Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Mod>` 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")
Expand Down
23 changes: 23 additions & 0 deletions tests/test_ruby_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."""
Expand Down
Loading