mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 18:37:12 +00:00
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>
This commit is contained in:
committed by
safishamsi
parent
12193a89f1
commit
86ecb769b6
+117
-22
@@ -14,6 +14,12 @@ from typing import Any, Callable
|
||||
from .cache import load_cached, save_cached
|
||||
from .mcp_ingest import extract_mcp_config, is_mcp_config_path
|
||||
from .manifest_ingest import extract_package_manifest, is_package_manifest_path
|
||||
from .resolver_registry import (
|
||||
LanguageResolver,
|
||||
register as register_language_resolver,
|
||||
run_language_resolvers,
|
||||
)
|
||||
from .ruby_resolution import resolve_ruby_member_calls
|
||||
|
||||
# --- migrated to graphify/extractors/ (see graphify/extractors/MIGRATION.md) ---
|
||||
from graphify.extractors.base import ( # noqa: F401
|
||||
@@ -2391,6 +2397,63 @@ _SWIFT_CONFIG = LanguageConfig(
|
||||
import_handler=_import_swift,
|
||||
)
|
||||
|
||||
# ── Ruby local type inference (for member-call resolution) ─────────────────────
|
||||
|
||||
|
||||
def _ruby_new_class_name(node, source: bytes) -> str | None:
|
||||
"""Return ``ClassName`` if ``node`` is a ``ClassName.new(...)`` call, else None.
|
||||
|
||||
Only a bare capitalized constant receiver counts (``Processor.new``);
|
||||
namespaced (``A::B.new``) and dynamic receivers are intentionally ignored so
|
||||
the binding stays unambiguous.
|
||||
"""
|
||||
if node is None or node.type != "call":
|
||||
return None
|
||||
recv = node.child_by_field_name("receiver")
|
||||
meth = node.child_by_field_name("method")
|
||||
if recv is None or meth is None:
|
||||
return None
|
||||
if recv.type != "constant" or _read_text(meth, source) != "new":
|
||||
return None
|
||||
return _read_text(recv, source)
|
||||
|
||||
|
||||
def _ruby_local_class_bindings(body_node, source: bytes) -> dict[str, str | None]:
|
||||
"""Map ``local_var -> ClassName`` for ``var = ClassName.new`` within one Ruby
|
||||
method body, not descending into nested method definitions.
|
||||
|
||||
100%-confidence contract: a variable assigned more than once, or to anything
|
||||
other than a single ``Constant.new``, maps to ``None`` (ambiguous) so callers
|
||||
never resolve it. Only the certain single-binding case carries a type.
|
||||
"""
|
||||
bindings: dict[str, str | None] = {}
|
||||
boundary = {"method", "singleton_method"}
|
||||
|
||||
def visit(n) -> None:
|
||||
for child in n.children:
|
||||
if child.type in boundary:
|
||||
continue # nested method has its own scope
|
||||
if child.type == "assignment":
|
||||
left = child.child_by_field_name("left")
|
||||
right = child.child_by_field_name("right")
|
||||
if left is not None and left.type == "identifier":
|
||||
var = _read_text(left, source)
|
||||
cls = _ruby_new_class_name(right, source) if right is not None else None
|
||||
if cls is None:
|
||||
# assigned to something we can't type: poison if it was typed
|
||||
if var in bindings:
|
||||
bindings[var] = None
|
||||
elif var in bindings:
|
||||
if bindings[var] != cls:
|
||||
bindings[var] = None # reassigned to a different class
|
||||
else:
|
||||
bindings[var] = cls
|
||||
visit(child)
|
||||
|
||||
visit(body_node)
|
||||
return bindings
|
||||
|
||||
|
||||
# ── Generic extractor ─────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_generic(
|
||||
@@ -3567,6 +3630,10 @@ def _extract_generic(
|
||||
seen_helper_ref_pairs: set[tuple[str, str, str]] = set()
|
||||
seen_bind_pairs: set[tuple[str, str, str]] = set()
|
||||
raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract()
|
||||
# Ruby: per-method `var -> ClassName` table from `var = Const.new` bindings,
|
||||
# populated before walk_calls runs. Lets member-call raw_calls carry a
|
||||
# receiver_type so the cross-file pass resolves `var.method` by type (#ruby).
|
||||
ruby_var_types: dict[str, dict[str, str | None]] = {}
|
||||
|
||||
def _php_class_const_scope(n) -> str | None:
|
||||
scope = n.child_by_field_name("scope")
|
||||
@@ -3701,6 +3768,20 @@ def _extract_generic(
|
||||
raw = _read_text(type_node, source).split("<", 1)[0].strip()
|
||||
if raw:
|
||||
callee_name = raw.rsplit(".", 1)[-1]
|
||||
elif config.ts_module == "tree_sitter_ruby":
|
||||
# Ruby's `call` node carries `receiver` and `method` as direct
|
||||
# fields (no intermediate accessor node), so the generic accessor
|
||||
# model doesn't apply. Read them directly and capture a simple
|
||||
# receiver (`p` in `p.run`, `Processor` in `Processor.new`) so the
|
||||
# cross-file pass can resolve member calls by the receiver's type.
|
||||
meth = node.child_by_field_name("method")
|
||||
if meth is not None:
|
||||
callee_name = _read_text(meth, source)
|
||||
recv = node.child_by_field_name("receiver")
|
||||
if recv is not None:
|
||||
is_member_call = True
|
||||
if recv.type in ("identifier", "constant"):
|
||||
member_receiver = _read_text(recv, source)
|
||||
else:
|
||||
# Generic: get callee from call_function_field
|
||||
func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None
|
||||
@@ -3753,14 +3834,21 @@ def _extract_generic(
|
||||
})
|
||||
elif callee_name and not tgt_nid:
|
||||
# Callee not in this file — save for cross-file resolution in extract()
|
||||
raw_calls.append({
|
||||
rc_entry = {
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
"is_member_call": is_member_call,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
"receiver": swift_receiver or member_receiver,
|
||||
})
|
||||
}
|
||||
# Ruby: attach the receiver's inferred type from the method's
|
||||
# local `var = Const.new` bindings, when unambiguously known.
|
||||
if member_receiver and config.ts_module == "tree_sitter_ruby":
|
||||
rc_entry["receiver_type"] = ruby_var_types.get(
|
||||
caller_nid, {}
|
||||
).get(member_receiver)
|
||||
raw_calls.append(rc_entry)
|
||||
|
||||
# Helper function calls: config('foo.bar') → uses_config edge to "foo"
|
||||
if (callee_name and callee_name in config.helper_fn_names):
|
||||
@@ -3889,6 +3977,10 @@ def _extract_generic(
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
if config.ts_module == "tree_sitter_ruby":
|
||||
for caller_nid, body_node in function_bodies:
|
||||
ruby_var_types[caller_nid] = _ruby_local_class_bindings(body_node, source)
|
||||
|
||||
for caller_nid, body_node in function_bodies:
|
||||
walk_calls(body_node, caller_nid)
|
||||
|
||||
@@ -9494,6 +9586,23 @@ def _resolve_python_member_calls(
|
||||
})
|
||||
|
||||
|
||||
# Register the cross-file, language-specific member-call resolvers into the shared
|
||||
# registry (framework lives in graphify.resolver_registry). A new language plugs in
|
||||
# by adding one register() call below — no edits to extract()'s body. Order
|
||||
# preserved from the prior inlined wiring: Swift (#1356) before Python (#1446).
|
||||
register_language_resolver(
|
||||
LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls)
|
||||
)
|
||||
register_language_resolver(
|
||||
LanguageResolver("python_member_calls", frozenset({".py"}), _resolve_python_member_calls)
|
||||
)
|
||||
# Ruby type-aware member-call resolution (Class.new + typed var.method). Lives in
|
||||
# graphify.ruby_resolution; registered here as a second consumer of the framework.
|
||||
register_language_resolver(
|
||||
LanguageResolver("ruby_member_calls", frozenset({".rb"}), resolve_ruby_member_calls)
|
||||
)
|
||||
|
||||
|
||||
def extract_objc(path: Path) -> dict:
|
||||
"""Extract interfaces, implementations, protocols, methods, and imports from .m/.mm/.h files."""
|
||||
try:
|
||||
@@ -13528,26 +13637,12 @@ def extract(
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
# Cross-file Swift member-call resolution (#1356). Runs after the shared call
|
||||
# pass so node ids/caller_nids are final; additive (only receiver-typed calls
|
||||
# the shared pass skipped), with a single-definition god-node guard.
|
||||
swift_paths = [p for p in paths if p.suffix == ".swift"]
|
||||
if swift_paths:
|
||||
try:
|
||||
_resolve_swift_member_calls(per_file, all_nodes, all_edges)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Swift member-call resolution failed, skipping: %s", exc)
|
||||
|
||||
# Cross-file Python qualified class-method resolution (#1446). Same shape as the
|
||||
# Swift pass: additive, runs after id-disambiguation, single-definition guard.
|
||||
py_paths = [p for p in paths if p.suffix == ".py"]
|
||||
if py_paths:
|
||||
try:
|
||||
_resolve_python_member_calls(per_file, all_nodes, all_edges)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Python member-call resolution failed, skipping: %s", exc)
|
||||
# Cross-file, language-specific member-call resolution. Runs after the shared
|
||||
# call pass so node ids/caller_nids are final; each pass is additive (only the
|
||||
# receiver-typed/qualified calls the shared pass skipped) with its own
|
||||
# single-definition god-node guard. Registered in graphify.resolver_registry so
|
||||
# a new language plugs in without editing this body (#1356 Swift, #1446 Python).
|
||||
run_language_resolvers(paths, per_file, all_nodes, all_edges)
|
||||
|
||||
# Relativize source_file fields so paths are portable across machines (#555)
|
||||
for item in all_nodes + all_edges:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Registry for cross-file, language-specific resolution passes.
|
||||
|
||||
Some call/reference edges can only be resolved with language-specific knowledge
|
||||
(receiver typing, qualified member calls, framework conventions). Historically
|
||||
these ran as a hand-wired sequence of suffix-gated
|
||||
``if <lang>_paths: try: _resolve_<lang>(...)`` blocks at the tail of
|
||||
``extract.extract()``. That pattern is the de-facto extension point for
|
||||
per-language resolution; this module formalizes it so a new language plugs in by
|
||||
registering one ``LanguageResolver`` instead of editing ``extract()``'s body.
|
||||
|
||||
This module deliberately knows nothing about any specific language — languages
|
||||
register themselves (see ``extract.py``), keeping the dependency direction one
|
||||
way (``extract`` → ``resolver_registry``) and this seam small and reviewable on
|
||||
its own, separate from the multi-thousand-line ``extract`` module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LanguageResolver:
|
||||
"""One cross-file, language-specific resolution pass.
|
||||
|
||||
``resolve`` has the signature ``(per_file, all_nodes, all_edges) -> None`` and
|
||||
mutates ``all_nodes`` / ``all_edges`` in place, matching the existing
|
||||
member-call resolvers. ``suffixes`` gates activation: the pass runs only when
|
||||
the corpus contains at least one file with one of these extensions.
|
||||
"""
|
||||
|
||||
name: str
|
||||
suffixes: frozenset
|
||||
resolve: Callable
|
||||
|
||||
|
||||
# Module-level registry, populated by callers via register(). Ordered: resolvers
|
||||
# run in registration order, preserving any required sequencing between passes.
|
||||
_REGISTRY: list[LanguageResolver] = []
|
||||
|
||||
|
||||
def register(resolver: LanguageResolver) -> LanguageResolver:
|
||||
"""Append a resolver to the global registry and return it (for inline use)."""
|
||||
_REGISTRY.append(resolver)
|
||||
return resolver
|
||||
|
||||
|
||||
def registered_resolvers() -> list[LanguageResolver]:
|
||||
"""Return a copy of the registered resolvers, in registration order."""
|
||||
return list(_REGISTRY)
|
||||
|
||||
|
||||
def run_language_resolvers(
|
||||
paths: Sequence[Path],
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
*,
|
||||
resolvers: Sequence[LanguageResolver] | None = None,
|
||||
) -> None:
|
||||
"""Run every resolver whose suffix appears in ``paths``.
|
||||
|
||||
Behaviorally identical to the prior hand-wired sequence of suffix-gated,
|
||||
try/except-wrapped passes: same activation rule (suffix present), same
|
||||
failure handling (log a warning and continue to the next pass), same
|
||||
execution order (registration order).
|
||||
|
||||
``resolvers`` defaults to the global registry; tests pass an explicit list to
|
||||
exercise the driver in isolation.
|
||||
"""
|
||||
active = _REGISTRY if resolvers is None else resolvers
|
||||
suffixes_present = {p.suffix for p in paths}
|
||||
for resolver in active:
|
||||
if not (resolver.suffixes & suffixes_present):
|
||||
continue
|
||||
try:
|
||||
resolver.resolve(per_file, all_nodes, all_edges)
|
||||
except Exception as exc:
|
||||
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Type-aware cross-file resolution for Ruby member calls.
|
||||
|
||||
Ruby has no type annotations and reuses method names heavily, so resolving
|
||||
``obj.method()`` by globally-unique name is both lossy (drops on collision) and
|
||||
unsafe (can attach to the wrong same-named method). This resolver instead uses
|
||||
the receiver's *type*, inferred at extraction time from local
|
||||
``var = ClassName.new`` bindings and carried on each member-call raw_call as
|
||||
``receiver_type``.
|
||||
|
||||
It resolves two shapes, both 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``
|
||||
|
||||
Registered into graphify.resolver_registry and run by extract() after id
|
||||
disambiguation, so node ids and raw_call caller_nids are final.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _key(label: str) -> str:
|
||||
"""Normalize a class/method label to a comparison key (drop punctuation)."""
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
|
||||
|
||||
|
||||
def _ruby_raw_calls(per_file: list[dict]) -> list[dict]:
|
||||
calls: list[dict] = []
|
||||
for result in per_file:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
for rc in result.get("raw_calls", []):
|
||||
if not isinstance(rc, dict):
|
||||
continue
|
||||
sf = str(rc.get("source_file", ""))
|
||||
if sf.endswith(".rb"):
|
||||
calls.append(rc)
|
||||
return calls
|
||||
|
||||
|
||||
def resolve_ruby_member_calls(
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Resolve Ruby ``Class.new`` and typed ``var.method`` calls by receiver type.
|
||||
|
||||
Purely additive: only emits edges the shared (name-based) call pass skips
|
||||
because they are member calls. Each emission requires a single owning class
|
||||
(god-node guard) so an ambiguous class name resolves to nothing rather than a
|
||||
wrong edge.
|
||||
"""
|
||||
node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
|
||||
|
||||
# class label key -> [class node ids]; (class_node_id, method_key) -> method id
|
||||
class_def_nids: dict[str, list[str]] = {}
|
||||
method_index: dict[tuple[str, str], str] = {}
|
||||
for e in all_edges:
|
||||
if e.get("relation") != "method":
|
||||
continue
|
||||
src, tgt = e.get("source"), e.get("target")
|
||||
cnode = node_by_id.get(src)
|
||||
if cnode is not None:
|
||||
class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(str(src))
|
||||
tnode = node_by_id.get(tgt)
|
||||
if tnode is not None:
|
||||
method_index[(str(src), _key(tnode.get("label", "")))] = str(tgt)
|
||||
for k in list(class_def_nids):
|
||||
class_def_nids[k] = sorted(set(class_def_nids[k]))
|
||||
|
||||
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
||||
|
||||
def _unique_class(name: str) -> str | None:
|
||||
nids = class_def_nids.get(_key(name), [])
|
||||
return nids[0] if len(nids) == 1 else None
|
||||
|
||||
def _emit(caller: str, target: str, rc: dict[str, Any]) -> None:
|
||||
if not caller or not target or caller == target:
|
||||
return
|
||||
if (caller, target) in existing_pairs:
|
||||
return
|
||||
existing_pairs.add((caller, target))
|
||||
all_edges.append({
|
||||
"source": caller,
|
||||
"target": target,
|
||||
"relation": "calls",
|
||||
"context": "call",
|
||||
"confidence": "EXTRACTED",
|
||||
"confidence_score": 1.0,
|
||||
"source_file": rc.get("source_file", ""),
|
||||
"source_location": rc.get("source_location"),
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
for rc in _ruby_raw_calls(per_file):
|
||||
if not rc.get("is_member_call"):
|
||||
continue
|
||||
caller = str(rc.get("caller_nid", ""))
|
||||
callee = rc.get("callee")
|
||||
if not caller or not callee:
|
||||
continue
|
||||
|
||||
# `Processor.new` -> instantiation edge to the class.
|
||||
receiver = rc.get("receiver")
|
||||
if callee == "new" and receiver and str(receiver)[:1].isupper():
|
||||
class_nid = _unique_class(str(receiver))
|
||||
if class_nid is not None:
|
||||
_emit(caller, class_nid, rc)
|
||||
continue
|
||||
|
||||
# `p.run` where p's type is known -> edge to that class's method.
|
||||
receiver_type = rc.get("receiver_type")
|
||||
if not receiver_type:
|
||||
continue
|
||||
class_nid = _unique_class(str(receiver_type))
|
||||
if class_nid is None:
|
||||
continue
|
||||
method_nid = method_index.get((class_nid, _key(str(callee))))
|
||||
if method_nid is None:
|
||||
continue
|
||||
_emit(caller, method_nid, rc)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for the language resolver registry (graphify.resolver_registry).
|
||||
|
||||
The registry formalizes the previously hand-wired, suffix-gated cross-file
|
||||
resolution passes. These tests pin its contract so future languages can be
|
||||
registered with confidence: gating by suffix, in-order execution, in-place
|
||||
mutation, and fault isolation (a failing pass logs and is skipped, never
|
||||
aborting the build or blocking later passes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.resolver_registry import (
|
||||
LanguageResolver,
|
||||
registered_resolvers,
|
||||
run_language_resolvers,
|
||||
)
|
||||
|
||||
|
||||
def _make_resolver(name: str, suffix: str, log: list[str]) -> LanguageResolver:
|
||||
def _resolve(per_file, all_nodes, all_edges):
|
||||
log.append(name)
|
||||
return LanguageResolver(name, frozenset({suffix}), _resolve)
|
||||
|
||||
|
||||
def test_default_registry_contains_swift_then_python() -> None:
|
||||
# Importing extract registers its resolvers into the shared registry. Order
|
||||
# matters: it preserves the prior inlined wiring (Swift before Python).
|
||||
import graphify.extract # noqa: F401 (registers resolvers on import)
|
||||
|
||||
names = [r.name for r in registered_resolvers()]
|
||||
assert "swift_member_calls" in names
|
||||
assert "python_member_calls" in names
|
||||
assert names.index("swift_member_calls") < names.index("python_member_calls")
|
||||
|
||||
|
||||
def test_resolver_runs_only_when_suffix_present() -> None:
|
||||
log: list[str] = []
|
||||
resolvers = [_make_resolver("ruby", ".rb", log), _make_resolver("go", ".go", log)]
|
||||
run_language_resolvers([Path("a.rb")], [], [], [], resolvers=resolvers)
|
||||
assert log == ["ruby"] # go skipped: no .go file present
|
||||
|
||||
|
||||
def test_resolvers_run_in_given_order() -> None:
|
||||
log: list[str] = []
|
||||
resolvers = [_make_resolver("first", ".rb", log), _make_resolver("second", ".rb", log)]
|
||||
run_language_resolvers([Path("a.rb")], [], [], [], resolvers=resolvers)
|
||||
assert log == ["first", "second"]
|
||||
|
||||
|
||||
def test_failing_resolver_is_isolated() -> None:
|
||||
log: list[str] = []
|
||||
|
||||
def _boom(per_file, all_nodes, all_edges):
|
||||
raise RuntimeError("resolver blew up")
|
||||
|
||||
resolvers = [
|
||||
LanguageResolver("boom", frozenset({".rb"}), _boom),
|
||||
_make_resolver("after", ".rb", log),
|
||||
]
|
||||
# Must not raise, and the later resolver still runs.
|
||||
run_language_resolvers([Path("a.rb")], [], [], [], resolvers=resolvers)
|
||||
assert log == ["after"]
|
||||
|
||||
|
||||
def test_resolver_mutates_edges_in_place() -> None:
|
||||
def _add_edge(per_file, all_nodes, all_edges):
|
||||
all_edges.append({"source": "x", "target": "y", "relation": "calls"})
|
||||
|
||||
resolvers = [LanguageResolver("adder", frozenset({".rb"}), _add_edge)]
|
||||
edges: list[dict] = []
|
||||
run_language_resolvers([Path("a.rb")], [], [], edges, resolvers=resolvers)
|
||||
assert edges == [{"source": "x", "target": "y", "relation": "calls"}]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user