Summary
Ruby singleton-style calls on a constant receiver — ClassName.method — never produce cross-file calls edges. This is the dominant idiom in Rails codebases (SettleService.call, Model.find_or_initialize_by, SomeJob.perform_async, SomeChannel.broadcast_*), and because Rails autoloading (Zeitwerk) means Ruby files carry no require/import statements either, a Rails app ends up with essentially no cross-file edges at all: classes become islands connected only by contains/method/inherits.
Environment
- graphifyy 0.9.5 (uv tool install), Python 3.10, macOS arm64
- Target repo: a mid-size Rails 8 API + React/TS monorepo (~2,000 extracted files → 11,077 nodes / 16,392 edges)
Repro (minimal)
# app/services/processor.rb
class Processor
def self.call; end
end
# app/services/runner.rb
class Runner
def run
Processor.call # no edge emitted
Processor.new # edge emitted (works)
end
end
graphify update . then graphify path "Runner" "Processor" → path found only via the .new line; comment it out and there is no connection, even though Processor.call is right there and Processor + its .call() singleton method both exist as nodes.
Measured impact on a real Rails app
graphify affected "<service class>" --depth 2 → "No affected nodes found" for a service invoked from 10+ files (controllers, jobs, sibling services) — every call site is Namespace::Service.call(...).
graphify path between a Sidekiq job and the ActiveRecord model it writes → no path.
- Meanwhile the TS side of the same monorepo is excellent (import edges):
affected on a hook returned 80 dependents vs 85 by exhaustive grep.
Root cause
graphify/ruby_resolution.py (resolve_ruby_member_calls) intentionally resolves only two shapes:
Const.new → edge to the class (callee == "new" is special-cased), and
var.method where var's type was inferred from a local var = Const.new binding (receiver_type).
A member call with a capitalized (constant) receiver and any callee other than new falls through both branches, so Service.call / Model.where / Job.perform_async emit nothing — even when the receiver resolves to a unique class node and the callee matches an extracted singleton_method node.
Proposed fix (happy to PR)
Extend resolve_ruby_member_calls with a third shape, keeping your existing bail-on-ambiguity guard:
receiver is capitalized and callee != "new" → _unique_class(receiver);
- if
method_index[(class_nid, _key(callee))] exists → emit calls to that method node (covers def self.x singleton methods, which the extractor already indexes);
- else → emit
calls to the class node itself (covers inherited/dynamic class methods like ActiveRecord's where/find_by, where a class-level edge still gives correct blast-radius).
- Optionally: carry the full
scope_resolution text for namespaced receivers (Billing::Processor.call) so scoped receivers can resolve exactly even when the bare class name is ambiguous — bare ambiguous receivers keep bailing as today.
The class-node fallback matches the same conservative philosophy as the existing Const.new branch (single owning class or nothing), so it shouldn't reintroduce anything like #993's phantom edges.
If you're open to it I can put together a PR with tests along these lines.
Summary
Ruby singleton-style calls on a constant receiver —
ClassName.method— never produce cross-filecallsedges. This is the dominant idiom in Rails codebases (SettleService.call,Model.find_or_initialize_by,SomeJob.perform_async,SomeChannel.broadcast_*), and because Rails autoloading (Zeitwerk) means Ruby files carry norequire/import statements either, a Rails app ends up with essentially no cross-file edges at all: classes become islands connected only bycontains/method/inherits.Environment
Repro (minimal)
graphify update .thengraphify path "Runner" "Processor"→ path found only via the.newline; comment it out and there is no connection, even thoughProcessor.callis right there andProcessor+ its.call()singleton method both exist as nodes.Measured impact on a real Rails app
graphify affected "<service class>" --depth 2→ "No affected nodes found" for a service invoked from 10+ files (controllers, jobs, sibling services) — every call site isNamespace::Service.call(...).graphify pathbetween a Sidekiq job and the ActiveRecord model it writes → no path.affectedon a hook returned 80 dependents vs 85 by exhaustive grep.Root cause
graphify/ruby_resolution.py(resolve_ruby_member_calls) intentionally resolves only two shapes:Const.new→ edge to the class (callee == "new"is special-cased), andvar.methodwherevar's type was inferred from a localvar = Const.newbinding (receiver_type).A member call with a capitalized (constant) receiver and any callee other than
newfalls through both branches, soService.call/Model.where/Job.perform_asyncemit nothing — even when the receiver resolves to a unique class node and the callee matches an extractedsingleton_methodnode.Proposed fix (happy to PR)
Extend
resolve_ruby_member_callswith a third shape, keeping your existing bail-on-ambiguity guard:receiveris capitalized andcallee != "new"→_unique_class(receiver);method_index[(class_nid, _key(callee))]exists → emitcallsto that method node (coversdef self.xsingleton methods, which the extractor already indexes);callsto the class node itself (covers inherited/dynamic class methods like ActiveRecord'swhere/find_by, where a class-level edge still gives correct blast-radius).scope_resolutiontext for namespaced receivers (Billing::Processor.call) so scoped receivers can resolve exactly even when the bare class name is ambiguous — bare ambiguous receivers keep bailing as today.The class-node fallback matches the same conservative philosophy as the existing
Const.newbranch (single owning class or nothing), so it shouldn't reintroduce anything like #993's phantom edges.If you're open to it I can put together a PR with tests along these lines.