runtimeanalyz.ing ● early access · sign-up open

← All capabilities

feature_envy

CLI · LSP · MCP free

Move a method to where it belongs. A method that sends most of its calls to another object and barely touches its own. It names the object to move it onto, from real 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 eligible_books made in the recorded runs, grouped by who received it. Nearly all of them went to Curriculum, so that's where the method belongs, taking the one thing it still asks Enrollment for as an argument.

The problem

A method belongs with the data it works on. When it spends its time asking another object questions, it’s in the wrong class. That’s feature envy, and it’s how single responsibility erodes: one class quietly does another’s job, and a change to the second now means editing the first.

class Enrollment
  def eligible_books
    done = completed_books
    level = curriculum.level_for(student)

    curriculum.books_at(level)
      .reject { |book| curriculum.archived?(book) }
      .select { |book| curriculum.prerequisites(book).all? { |pre| done.include?(pre) } }
  end
end

Every line but one is about Curriculum. Reading it, you can see that. Across a codebase, you can’t: nobody reads every method asking whose data it’s really using, and a linter counting receivers in the source can’t tell curriculum from any other local, or how often each call actually runs.

The fix

Ask which methods envy another object:

$ ra feature_envy app/models/enrollment.rb

Enrollment#eligible_books
  9 calls to Curriculum for every 1 to itself   (2,340 recorded calls)
    Curriculum   level_for, books_at, archived?, prerequisites
    self         completed_books
  move it onto Curriculum                                         SRP
  it needs from Enrollment: student (Student), completed_books (Set)

Move it, passing in the one thing 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

  • Your editor (LSP). The method name gets a hint with the ratio and the destination, and a quick-fix that moves it and leaves a delegating method behind.
  • Your agent (MCP). The agent asks for recommendations for one method, one class or the whole codebase, and gets the destination and the parameters the move needs.
  • The command line. ra feature_envy on a file or directory, or as part of ra refactor.

How it works

  1. Count by receiver. 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. Compare with self. When one other class receives far more of those calls than the method’s own class does, the method is flagged, with that class named as the destination.
  3. Work out what moves with it. The calls it still makes on its own object are what the moved method needs passed in. Argument classes, recorded with each call, give their types.

Because these are counts from real runs, a call inside a loop weighs what it actually costs, and a branch that never runs doesn’t count at all.

Limits

  • App methods only. Calls to Ruby’s own classes, like Array#select or Set#include?, aren’t recorded, and nor 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 the point. A presenter or a report exists to read another object. The ratio is evidence, not a verdict, so it’s a hint and never fails a build.
  • Only what ran. A branch no recorded run took isn’t in the counts.

split_interface

CLI · LSP · MCP free

Split a fat interface. A class whose callers each use a different slice of it. It names the role interfaces to split it into, measured from who actually calls what.

See more →

relocate_file

CLI · MCP free

Which file should move where. Two files that lean on each other constantly but sit directories apart. The graph names the folder they belong in, weighed by real call traffic and the distance between them.

See more →

what_it_receives

CLI · LSP · MCP free

What a method receives. The observed shape of each argument at a call site. The messages the method actually sends to what it's passed, not only the class it was given.

See more →