diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7e44d..19634a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/graphify/extract.py b/graphify/extract.py index a5859f5..3f943e4 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 = (...)` 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 diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index d0e41dd..5823ecf 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -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. diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index d11b1cf..adc7757 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -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