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>
This commit is contained in:
safishamsi
2026-07-04 12:20:07 +01:00
co-authored by Claude Opus 4.8
parent 5737388f17
commit 13e2bddf4d
4 changed files with 234 additions and 4 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: Ruby plain modules and `Struct.new` / `Class.new` / `Data.define` constant assignments now get container nodes (#1640, thanks @krishnateja7). The extractor only created nodes for `class Foo`, so `module Foo` (utility/`module_function` modules), `Foo = Struct.new(...) do ... end`, `Foo = Class.new(StandardError)`, and `Result = Data.define(...)` produced no node at all — their methods hung off the file via `contains` with dot-less labels, and no edge could ever target them. `module` is now a container type (methods attach via `method` like a class, nested modules included), and a constant assignment whose RHS is one of those factories 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.
- Fix: Ruby constant-receiver singleton calls now resolve cross-file (#1634, thanks @krishnateja7). `Service.call`, `Model.where`, `SomeJob.perform_async` — the dominant Rails idiom — emitted no `calls` edge, so with Zeitwerk autoloading (no `require`s) a Rails app had essentially no cross-file edges and `affected`/`path` came up empty. `resolve_ruby_member_calls` now handles a capitalized (constant) receiver with any callee: it binds to the class's singleton/instance method when one is owned (`def self.call`, which the extractor indexes), else to the class node itself so inherited/dynamic class methods (ActiveRecord `where`/`find_by`) still give correct blast-radius. Namespaced receivers (`Billing::Processor.call`) resolve by the bare class name. The single-owning-class god-node guard is kept throughout — an ambiguous receiver resolves to nothing rather than a wrong edge.
- Fix: Apex `interface X extends A, B` now emits an `extends` edge per parent (#1645, thanks @Synvoya). The interface regex captured the parent list in group 2, but the handler only read the interface name (group 1), so multiple-inheritance parents were dropped and only the `contains` edge survived. The interface branch now iterates the parent list and resolves each the same way the class branch already does.
- Fix: Kotlin interface delegation (`class Foo : Bar by baz`) now emits the `implements` edge (#1644, thanks @Synvoya). The `by` form wraps the delegated interface in an `explicit_delegation` node, so neither the `constructor_invocation` nor the bare `user_type` branch fired and the edge was silently dropped. The delegation-specifier loop now unwraps `explicit_delegation` to its `user_type` (generic-argument recovery still runs), so idiomatic Kotlin delegation shows up in the graph.
- Fix: a malformed semantic chunk no longer crashes `extract` and discards every successful chunk (#1631, thanks @ssazy). When an LLM returned a well-formed object whose `edges` (or `nodes`/`hyperedges`) array carried a stray non-dict entry — a nested list where an edge object belongs — the AST+semantic merge and the semantic-cache write both called `.get()` per entry and raised `AttributeError: 'list' object has no attribute 'get'`. On a 34-chunk run where 33 succeeded, that meant no `graph.json` was written and the cache write failed too, so a re-run re-extracted everything. `_parse_llm_json` now sanitizes each fragment at the single parse chokepoint (keeping only dict entries and coercing a non-list value to `[]`), so the cache writer, the adaptive-retry merge, and the CLI merge are all protected in one place.
+104 -1
View File
@@ -2993,7 +2993,12 @@ _CPP_CONFIG = LanguageConfig(
_RUBY_CONFIG = LanguageConfig(
ts_module="tree_sitter_ruby",
class_types=frozenset({"class"}),
# `module Foo` is a container node just like `class Foo` in tree-sitter's
# Ruby grammar (name in a `constant` child, body in `body_statement`), so it
# gets a node and its methods attach via `method` (#1640). Without it, plain
# utility/`module_function` modules produced no node and their methods hung
# off the file via `contains` with dot-less labels.
class_types=frozenset({"class", "module"}),
function_types=frozenset({"method", "singleton_method"}),
import_types=frozenset(),
call_types=frozenset({"call"}),
@@ -3283,6 +3288,92 @@ def _ruby_local_class_bindings(body_node, source: bytes) -> dict[str, str | None
return bindings
def _ruby_const_last_name(node, source: bytes) -> str:
"""Last constant of a ``constant`` or ``scope_resolution`` (``A::B::C`` -> ``C``)."""
if node is None:
return ""
if node.type == "constant":
return _read_text(node, source)
if node.type == "scope_resolution":
consts = [c for c in node.children if c.type == "constant"]
if consts:
return _read_text(consts[-1], source)
return ""
# `Const = <factory>(...)` shapes that define a lightweight class named after the
# constant. tree-sitter parses each as an `assignment`, not a `class`, so the
# generic class branch never saw them (#1640).
_RUBY_CLASS_FACTORIES = frozenset({("Struct", "new"), ("Class", "new"), ("Data", "define")})
def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node, add_edge, walk,
callable_def_nids: set) -> bool:
"""Ruby: a constant assignment whose RHS is ``Struct.new(...)``,
``Class.new(Super)`` or ``Data.define(...)`` defines a class named after the
constant (#1640). Synthesize the class node, attach block-defined methods via
``method`` (by recursing the block with the new node as parent), and emit an
``inherits`` edge for ``Class.new(Super)``. Returns True if handled.
"""
if node.type != "assignment":
return False
left = node.child_by_field_name("left")
right = node.child_by_field_name("right")
if left is None or right is None or left.type != "constant" or right.type != "call":
return False
recv = right.child_by_field_name("receiver")
meth = right.child_by_field_name("method")
if recv is None or meth is None or recv.type != "constant":
return False
if (_read_text(recv, source), _read_text(meth, source)) not in _RUBY_CLASS_FACTORIES:
return False
const_name = _read_text(left, source)
if not const_name:
return False
line = node.start_point[0] + 1
class_nid = _make_id(stem, const_name)
add_node(class_nid, const_name, line)
callable_def_nids.add(class_nid) # a class is callable (its constructor)
# Mirror the generic class branch: containment always hangs off the file node.
add_edge(file_nid, class_nid, "contains", line)
# `Class.new(Super)` — the first positional constant argument is the superclass.
if _read_text(recv, source) == "Class":
args = next((c for c in right.children if c.type == "argument_list"), None)
if args is not None:
for arg in args.children:
if arg.type in ("constant", "scope_resolution"):
base = _ruby_const_last_name(arg, source)
if base:
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
base_nid = _make_id(base)
if base_nid not in seen_ids:
nodes.append({
"id": base_nid, "label": base,
"file_type": "code", "source_file": "",
"source_location": "",
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
break
# Recurse the do/brace block so block-defined methods attach to the class.
# The block wraps its statements in a `body_statement` (like a class body);
# descend into it so the method handler sees parent_class_nid — otherwise the
# default recurse resets the parent to None and the method hangs off the file
# with a dot-less label.
block = next((c for c in right.children if c.type in ("do_block", "block")), None)
if block is not None:
body = next((c for c in block.children if c.type == "body_statement"), block)
for child in body.children:
walk(child, parent_class_nid=class_nid)
return True
# ── Generic extractor ─────────────────────────────────────────────────────────
def _extract_generic(
@@ -4658,6 +4749,13 @@ def _extract_generic(
ensure_named_node):
return
if config.ts_module == "tree_sitter_ruby":
if _ruby_extra_walk(node, source, file_nid, stem, str_path,
nodes, edges, seen_ids, function_bodies,
parent_class_nid, add_node, add_edge, walk,
callable_def_nids):
return
# Python's `@property` / `@staticmethod` / `@classmethod` wrap the
# inner function_definition in a `decorated_definition` node. The
# default recurse below clears parent_class_nid, which would cause the
@@ -5042,6 +5140,11 @@ def _extract_generic(
is_member_call = True
if recv.type in ("identifier", "constant"):
member_receiver = _read_text(recv, source)
elif recv.type == "scope_resolution":
# Namespaced receiver `Billing::Processor.call` — capture the
# last constant so cross-file resolution can bind it by the
# bare class name (the god-node guard bails if ambiguous).
member_receiver = _ruby_const_last_name(recv, source) or None
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
+31 -3
View File
@@ -28,6 +28,13 @@ def _key(label: str) -> str:
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:
@@ -69,6 +76,15 @@ def resolve_ruby_member_calls(
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]))
@@ -104,12 +120,24 @@ def resolve_ruby_member_calls(
if not caller or not callee:
continue
# `Processor.new` -> instantiation edge to the class.
# 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 callee == "new" and receiver and str(receiver)[:1].isupper():
if receiver and str(receiver)[:1].isupper():
class_nid = _unique_class(str(receiver))
if class_nid is not None:
_emit(caller, class_nid, rc)
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.
+97
View File
@@ -193,3 +193,100 @@ def test_class_new_creates_instantiation_edge(tmp_path: Path) -> None:
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