runtimeanalyz.ing

← All capabilities

feature_envy

CLI · LSP · MCP Pro

Find methods that belong on another class. A method that sends most of its calls to another object and few to its own. The report names the object to move it onto, using recorded call counts.

Enrollment app/models
def eligible_books
  done = completed_books
  curriculum.books_at(…)
    .reject { curriculum.archived?(_1) }
    .select { … }
end
level_for ( student Student )
books_at ( level Integer )
archived? ( book Book )
prerequisites ( book Book )
9 calls for every 1 to self
Curriculum move it here
completed_books
the 1
Enrollment self
Every call that eligible_books made in the recorded runs, grouped by receiver. Nearly all of them went to Curriculum, so the suggested move is to Curriculum, with the one value it still gets from Enrollment passed as an argument.

The problem

A method that spends most of its calls asking another object questions is in the wrong class. This is feature envy, and it breaks the single responsibility principle: one class does another’s job, and a change to the second class means editing the first.

This is Enrollment#eligible_books in the example app, app/models/enrollment.rb:24:

def eligible_books
  level = curriculum.level_for(student)
  curriculum.books_at(level)
            .reject { |book| curriculum.archived?(book) }
            .select { |book| curriculum.prerequisites(book).all? { |prerequisite| completed_books.include?(prerequisite) } }
end

Every line but one is about Curriculum, which you can see by reading this one method. Reading every method in a codebase to ask whose data it uses is impractical. A linter that counts receivers in the source cannot tell curriculum from any other local, and it does not know how often each call runs.

The fix

ra feature_envy lists the methods in a file that envy another object:

$ ra feature_envy app/models/enrollment.rb

feature_envy
· 1 envious method in app/models/enrollment.rb
⚠ Enrollment#eligible_books
  app/models/enrollment.rb:24
  4 calls to Curriculum for every 1 to itself (10 recorded calls)
    Curriculum level_for, books_at, archived?, prerequisites
    self completed_books
  it needs from Enrollment: completed_books (Set)
  → move it onto Curriculum   SRP

Move the method onto Curriculum and pass in the one value it still needs from Enrollment:

class Curriculum
  def eligible_books(student, completed:)
    books_at(level_for(student))
      .reject { |book| archived?(book) }
      .select { |book| prerequisites(book).all? { |pre| completed.include?(pre) } }
  end
end

class Enrollment
  def eligible_books = curriculum.eligible_books(student, completed: completed_books)
end

Curriculum now answers a question about its own books, and the four calls it used to receive from outside are calls to itself.

Where it shows up

  • On the command line, ra feature_envy runs on a file or a directory, and ra refactor includes it.
  • Over MCP, your agent gets the same report as structured blocks, for one file, one directory or the whole app, with the destination and the parameters the move needs.
  • In your editor (LSP), a hint on the method with the ratio and the destination is planned, along with a quick-fix that moves the method and leaves a delegating method behind.

How it works

  1. The recorder wraps every method your app defines, so each call is an edge from the calling method to a receiving class and method. For each method, feature_envy totals the calls it made in every recorded run, grouped by the class that received them.
  2. When another class receives at least three times the calls the method’s own class does, across at least three distinct messages, the method is flagged with that class as the destination.
  3. The calls the method still makes on its own object are what the moved method needs passed in. Return classes are recorded with each call and give their types: here completed_books returned a Set.

The counts come from recorded runs, so a call inside a loop counts once per iteration and a branch that did not run adds no calls. The ten calls above come from the one spec that exercises the method.

Run it on your app

The output on this page comes from our example Rails app, and it is regenerated whenever a tool changes. These are the commands behind it. On your own app, run ra record first, then put your own class, method or file in their place.

ra feature_envy app/models/enrollment.rb

The method is app/models/enrollment.rb:24.

Reading the result

  • It counts calls to methods your app defines. Calls to Ruby’s own classes, such as Array#select or Set#include?, are not recorded, and neither are instance variable reads. A method that works hard on its own state with plain Ruby can look more envious than it is.
  • Some envy is intended, because a presenter or a report exists to read another object. Refund#process in the example app is flagged, and it is a service whose job is to act on an invoice. The ratio is a hint and does not fail a build.

Every tool reads the same recording. What gets recorded and how far to trust an answer are on the questions page.

split_interface

CLI · LSP · MCP Pro

The role interfaces in a class. Groups a class's public methods by which callers used them in the recording, and lists each group as a role interface to extract.

See more →

relocate_file

CLI · MCP Pro

Which file should move where. Two files that call each other often and are directories apart. The report names the folder they belong in, weighted by recorded call traffic and the distance between them.

See more →

what_it_receives

CLI · LSP · MCP free

What a method receives. For each argument of a method, the classes it was given in recorded calls and the messages the method sent to it.

See more →