Skip to content

Ruby: constant-receiver singleton calls (Service.call, Model.where) never resolve — Rails codebases get near-zero cross-file edges #1634

Description

@krishnateja7

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:

  1. Const.new → edge to the class (callee == "new" is special-cased), and
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions