From 86ecb769b694ecd7485eb8143a75966f748cbb8f Mon Sep 17 00:00:00 2001 From: vamsi pavan mahesh gunturu Date: Mon, 29 Jun 2026 09:40:22 +0100 Subject: [PATCH] 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) --- graphify/extract.py | 139 ++++++++++++++++++---- graphify/resolver_registry.py | 85 ++++++++++++++ graphify/ruby_resolution.py | 125 ++++++++++++++++++++ tests/test_language_resolvers.py | 74 ++++++++++++ tests/test_ruby_resolution.py | 195 +++++++++++++++++++++++++++++++ 5 files changed, 596 insertions(+), 22 deletions(-) create mode 100644 graphify/resolver_registry.py create mode 100644 graphify/ruby_resolution.py create mode 100644 tests/test_language_resolvers.py create mode 100644 tests/test_ruby_resolution.py diff --git a/graphify/extract.py b/graphify/extract.py index 61f8afb5..5fb3becc 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/graphify/resolver_registry.py b/graphify/resolver_registry.py new file mode 100644 index 00000000..b17478a7 --- /dev/null +++ b/graphify/resolver_registry.py @@ -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 _paths: try: _resolve_(...)`` 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) diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py new file mode 100644 index 00000000..d0e41dd3 --- /dev/null +++ b/graphify/ruby_resolution.py @@ -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) diff --git a/tests/test_language_resolvers.py b/tests/test_language_resolvers.py new file mode 100644 index 00000000..787c1d50 --- /dev/null +++ b/tests/test_language_resolvers.py @@ -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"}] diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py new file mode 100644 index 00000000..d11b1cf8 --- /dev/null +++ b/tests/test_ruby_resolution.py @@ -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"