RuboCop 1.89: Project-Wide Analysis with Rubydex
RuboCop 1.89 is out, and it comes with the usual assortment of new cops and bug fixes. But there’s one theme running through this release that I’m particularly excited about, and that’s what I want to talk about today: RuboCop is finally learning to look beyond a single file.
The single-file curse (limitation)
For its entire existence RuboCop has analyzed your code one file at a time. That’s not an accident - it’s a deliberate design choice that has served the project extremely well. A file-at-a-time analyzer is simple, it’s fast, and it parallelizes trivially across all your cores. It’s a big part of why RuboCop can lint a huge codebase in a few seconds.
The catch is that a cop looking at a single file is essentially working with blinders on. It has no idea what’s defined in the rest of your project. And that turns out to be a real limitation for a whole class of checks:
- Is this method a duplicate of one defined in another file? No way to tell.
- Does this constant actually refer to what I think it does, given the surrounding namespaces? Can’t resolve it without the whole picture.
- Does this class really need a
supercall in its constructor, or does none of its ancestors defineinitialize? RuboCop can’t see the ancestry, so it has to play it safe.
Faced with questions like these, cops have only ever had two options: bail out and
say nothing (a false negative), or guess and risk yelling at perfectly good code
(a false positive). Neither is great. Over the years we’ve papered over this with
configuration knobs - things like AllowedParentClasses for Lint/MissingSuper,
where you manually list the base classes RuboCop can’t see for itself. It works,
but it’s the kind of busywork a tool should be doing for you.
Enter rubydex
rubydex is a library from Shopify that does exactly what RuboCop historically couldn’t: it builds a project-wide index of your code - a graph of every declaration and reference, with the ancestry and resolution worked out. Give a cop access to that index and suddenly it can answer all those cross-file questions with confidence.
We introduced optional support for rubydex back in 1.87, and 1.89 is where it really starts to pay off.
I want to stress the word optional here. rubydex is a separate gem with native (Rust) extensions, and using it means paying for an indexing pass and some extra memory. That’s a perfectly reasonable trade for the extra smarts, but it’s not a trade everyone wants to make, and it shouldn’t be forced on anyone. RuboCop’s default experience should stay lean and dependency-light - no native code, no index, no surprises. So rubydex is opt-in, and I fully expect it’ll stay that way for the foreseeable future. If you want the cross-file superpowers, you flip a switch; if you don’t, nothing changes for you.
Turning it on is two steps. Add the gem to your Gemfile:
gem 'rubydex', require: false
And enable the flag in your .rubocop.yml:
AllCops:
UseProjectIndex: true
Everything below happens automatically once the index is available - there’s no per-cop opt-in. And if the gem isn’t installed, RuboCop just prints a friendly warning and falls back to its usual file-local behavior.
What the index unlocks in 1.89
This release wires the index into a good chunk of the cop library. Here’s a tour of what you get.
There are a few brand-new cops that simply couldn’t exist without a project-wide view:
Lint/NameTypois my personal favorite - it catches typos in constant and method names by checking them against everything defined in your project and suggesting the closest match. SoServices::UserCraetor.newgets flagged with a helpful “did you meanUserCreator?”, and the same goes for a fat-fingered method call likeReport.generate_sumary. It’s careful to only speak up when it’s confident (the name has to be unresolvable and have a close sibling), so it stays quiet on genuinely external names.Lint/DeprecatedReferenceflags calls and constant references to any API you’ve marked with a YARD@deprecatedtag, anywhere in the project. Tag a method as deprecated in one file, and every stray use of it elsewhere lights up. It’s a lovely way to drive a gradual migration off some old code.Lint/UnusedPrivateMethoddoes project-wide dead-code detection - it reports private methods that are never referenced anywhere in the indexed project. It’s disabled by default and best used as an occasional sweep, since it can’t see reflective calls, but it’s great for a spring cleaning.
A whole bunch of existing cops got sharper too, either catching things they used to miss or backing off where they used to nag:
Lint/DuplicateMethodsnow catches duplicates that live in different files - the same method defined twice across two files was previously invisible to it. If you’re deliberately redefining a method (a monkey patch, say), the self-alias trick (alias foo fooright before the redefinition) signals your intent and keeps things quiet.Lint/ConstantReassignmentlikewise catches a constant that’s reassigned in another file, not just within the current one.Lint/MissingSuperno longer nags you about a missingsuperwhen the index can prove that none of the class’s ancestors actually definesinitialize- in that casesuperwould only reach the no-opObject#initialize. This is the one that makes thoseAllowedParentClasseslists mostly unnecessary; RuboCop figures out your abstract base classes on its own now.Lint/ConstantResolutionused to be so noisy it was practically unusable without a pile ofOnly/Ignoreconfiguration. With the index it reports only genuinely ambiguous constants - ones that resolve differently through the surrounding nesting than they would fully qualified - which finally makes it practical to turn on.Lint/InheritExceptionnow spots classes that inherit fromExceptionindirectly, through a parent class defined elsewhere in your project.Style/Documentationaccepts a reopened class or module as documented as long as any of its definition sites carries a doc comment, so you’re not forced to repeat yourself at every reopening.- A cluster of cops learned to stop producing false positives once they can see the
whole picture:
Style/MissingRespondToMissingaccepts arespond_to_missing?defined in another reopening of the class;Style/StaticClassleaves alone classes that are subclassed elsewhere;Style/RedundantConstantBasecan now flag a redundant leading::inside a namespace when it proves the constant resolves identically without it; andNaming/PredicatePrefixandNaming/AccessorMethodNamestop suggesting renames for methods that override an ancestor defined elsewhere in the project. Style/ClassAndModuleChildrenuses the index to make its (unsafe) autocorrection a lot more reliable - it resolves the real namespace kind (classvsmodule) instead of guessing, and skips compacting a definition when doing so would blow up with aNameErrorat load time.
And a couple of things happening under the hood are worth calling out:
- The index now always covers your whole project, regardless of which files a particular run happens to inspect. That means linting a single file reports the same cross-file offenses as a full run - no more results that depend on how you invoked RuboCop.
- If you want ancestry chains that reach into your gems to resolve too (think a
model inheriting from a framework base class), the new
AllCops/ProjectIndexIncludesGemsoption indexes your bundle’s sources as well. On RuboCop’s own repo that took the share of classes with a fully resolvable ancestry from about 20% to about 97%, at the cost of some extra memory. - The language server got smarter about the index - it now builds it once per session and reuses it across requests, rebuilding only when you save a file, so editing a buffer doesn’t pay the indexing cost on every keystroke.
Still early days
I don’t want to oversell any of this. We’re at the very beginning of RuboCop’s cross-file journey, and there’s a lot we haven’t tackled yet. The list of checks that would benefit from a project-wide view is long, and I suspect the most interesting applications are still ahead of us. rubydex itself is young and evolving, and so is our integration with it.
But I think it’s a genuinely promising start. A year ago the idea of RuboCop catching a constant typo across files, or reasoning about a class’s real ancestry, would have been a non-starter. Now it’s a config flag away.
If any of this sounds useful, I’d love for you to give it a try - add rubydex to
your Gemfile, flip UseProjectIndex on, and see what it turns up in your
codebase. And please tell us how it goes. The feature is still marked
experimental, and real-world feedback is exactly what we need to figure out where
to take it next. Bug reports, false positives, ideas for new cross-file cops - all
of it is welcome.
Big thanks to everyone who has been kicking the tires on the project index and reporting their findings. Here’s to seeing beyond a single file.
Keep hacking!