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`."""