def eligible_books
done = completed_books
curriculum.books_at(…)
.reject { curriculum.archived?(_1) }
.select { … }
end 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_envyruns on a file or a directory, andra refactorincludes 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
- 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_envytotals the calls it made in every recorded run, grouped by the class that received them. - 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.
- 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_booksreturned aSet.
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#selectorSet#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#processin 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.
Related tools
split_interface
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
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
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 →