From 415302b64926daa38331adfcd81c69ebd3b34130 Mon Sep 17 00:00:00 2001 From: Synvoya <16019863+Synvoya@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:14:24 +1000 Subject: [PATCH] fix(ruby): emit mixes_in for include/prepend inside `class << self` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `include`/`prepend` inside a `class << self` block injects the module's instance methods as the enclosing class's own singleton (class) methods — the very mixin relationship `extend ` expresses in the class body, which the extractor already captures (#1668). The mixin scan only walked the class/module body's direct children, so the `class << self` form was silently dropped and emitted no `mixes_in` edge. Scan `class << self` blocks as well, reusing the existing constant-argument filter and the single-owner resolver guard. Only `self` counts: `class << other` opens a different object's singleton, not the enclosing class's, so an include there stays unlinked. --- graphify/extractors/engine.py | 20 ++++++++++++++++++-- tests/test_ruby_resolution.py | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 90c95cc49..1778e227d 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2527,10 +2527,26 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: # in another file, so defer resolution to the cross-file Ruby # resolver (reusing the #1634 candidate logic and the #1640 module # nodes as targets). Only bare/namespaced constant arguments count; - # `extend self`, `include some_var`, etc. are skipped. + # `extend self`, `include some_var`, etc. are skipped. A `class << + # self` block is scanned too: an `include`/`prepend` there injects + # the module's methods as the class's own singleton (class) methods + # — the same relationship `extend ` expresses in the body — so + # it must mix in likewise. `class << other` opens a different + # object's singleton, not the enclosing class's, so only `self` + # counts. _rb_body = _find_body(node, config) if _rb_body is not None: - for _stmt in _rb_body.children: + _rb_mixin_stmts = list(_rb_body.children) + for _sc in _rb_body.children: + if _sc.type != "singleton_class": + continue + _sc_value = _sc.child_by_field_name("value") + if _sc_value is None or _sc_value.type != "self": + continue + _sc_body = _sc.child_by_field_name("body") + if _sc_body is not None: + _rb_mixin_stmts.extend(_sc_body.children) + for _stmt in _rb_mixin_stmts: if _stmt.type != "call" or _stmt.child_by_field_name("receiver") is not None: continue _m = _stmt.child_by_field_name("method") diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index 7d402524b..9335dd954 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -350,6 +350,29 @@ def test_mixin_is_not_emitted_as_calls_edge(tmp_path: Path) -> None: assert ("K", "C") in _mixes_in(g) +def test_include_in_singleton_class_emits_mixes_in(tmp_path: Path) -> None: + # `class << self; include M; end` injects M's instance methods as the class's + # own singleton (class) methods — the very relationship `extend M` expresses + # in the class body. The body-only mixin scan walked the singleton block's + # `include` right past, so this canonical class-method idiom emitted no edge. + _write(tmp_path, "concern.rb", "module Announceable\n def announce; end\nend\n") + _write(tmp_path, "broadcaster.rb", + "class Broadcaster\n class << self\n include Announceable\n end\nend\n") + g = extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False) + assert ("Broadcaster", "Announceable") in _mixes_in(g) + + +def test_include_in_other_object_singleton_class_emits_no_mixin(tmp_path: Path) -> None: + # `class << obj` opens *another object's* singleton class, not the enclosing + # class's — an include there does not mix the module into that class, so no + # edge (only `class << self` is a class-level mixin). + _write(tmp_path, "m.rb", "module M\n def m; end\nend\n") + _write(tmp_path, "c.rb", + "class C\n obj = Object.new\n class << obj\n include M\n end\nend\n") + mix = _mixes_in(extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False)) + assert ("C", "M") not in mix + + def test_rake_files_extract_and_resolve_like_rb(tmp_path): """#1784: `.rake` files are plain Ruby and must route to the Ruby extractor and participate in Ruby cross-file resolution exactly like `.rb`."""