Files
graphify/graphify/ruby_resolution.py
T
safishamsiandClaude Opus 4.8 13e2bddf4d fix(ruby): extract module/Struct/Class.new containers and resolve constant-receiver calls (#1640, #1634)
#1640 (node extraction): the extractor only created nodes for `class Foo`, so
plain `module Foo`, `Foo = Struct.new(...) do ... end`, `Foo = Class.new(Super)`
and `Result = Data.define(...)` produced no container node — their methods hung
off the file via `contains` with dot-less labels and no edge could target them.
`module` is now a container type (methods attach via `method`, nested modules
included), and a constant assignment whose RHS is Struct.new/Class.new/Data.define
synthesizes a class node named after the constant, attaches block-defined methods
to it, and emits an `inherits` edge for `Class.new(Super)`. Plain constant
assignments (MAX = 100, X = Foo.new) are untouched.

#1634 (resolution): constant-receiver singleton calls (`Service.call`,
`Model.where`, `SomeJob.perform_async`) emitted no edge, so a Zeitwerk-autoloaded
Rails app (no requires) had near-zero cross-file edges. resolve_ruby_member_calls
now handles a capitalized receiver with any callee: bind to the class's owned
singleton/instance method (`def self.call`) when present, else to the class node
itself so inherited/dynamic class methods (ActiveRecord where/find_by) still give
blast-radius. Namespaced receivers resolve by bare class name. The
single-owning-class god-node guard is kept — ambiguous receivers resolve to
nothing, never a wrong edge.

The two compound: PaymentProcessor#process -> TaxCalculator.rate_for needs the
module node (#1640) AND the resolver (#1634); both now land.

Full suite: 2893 passed, 3 skipped. Adversarial smoke confirms no false class
nodes from plain/multiple assignments and no self-loops on self-class calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:20:07 +01:00

154 lines
6.3 KiB
Python

"""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()
# A Ruby class/module container node is labelled with a bare constant
# (``Processor``, ``TaxCalculator``); methods end in ``()`` and files in ``.rb``.
# Lets us register method-less containers (a ``Class.new(StandardError)`` error
# class, an empty module) that have no `method` edge to be found by.
_BARE_CONST_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*$")
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)
# Also register class/module container nodes that own no `method` edge — a
# method-less `Class.new(StandardError)` or an empty module — so a constant
# receiver still resolves to a real node (#1640/#1634). External base stubs
# carry an empty source_file, so the `.rb` filter keeps them out.
for n in all_nodes:
nid = n.get("id")
sf = str(n.get("source_file", ""))
if nid and sf.endswith(".rb") and _BARE_CONST_RE.match(str(n.get("label", ""))):
class_def_nids.setdefault(_key(n.get("label", "")), []).append(str(nid))
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
# Constant receiver: `Processor.new` (instantiation) or `Service.call` /
# `Model.where` (singleton / class method). The bare method name would
# collide with unrelated same-named methods, so we resolve by the
# receiver's class under the single-owning-class god-node guard.
receiver = rc.get("receiver")
if receiver and str(receiver)[:1].isupper():
class_nid = _unique_class(str(receiver))
if class_nid is not None:
if callee == "new":
_emit(caller, class_nid, rc)
else:
# Emit to the singleton/instance method the class owns
# (`def self.call`, which the extractor indexes); otherwise
# to the class node itself, so inherited/dynamic class methods
# like ActiveRecord `where`/`find_by` still give correct
# blast-radius. An ambiguous receiver bails to nothing.
method_nid = method_index.get((class_nid, _key(str(callee))))
_emit(caller, method_nid or 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)