RuboCop 1.91: The Journey to 2.0 Begins
RuboCop 1.91 is out! It has the usual mix of new cops, new options and a
small mountain of bug fixes, but the feature I want to talk about today is a single
flag - --preview. It’s the first real step on our (long) journey to RuboCop 2.0.
Since the beginning of time…
…certain people have been unhappy with RuboCop’s defaults. I wrote almost exactly
that sentence six years ago,
right before RuboCop 1.0, when I ran a survey to work out which of our defaults were
wrong. Some good came out of it - that’s how Layout/LineLength ended up at 120 -
but a survey has an obvious weakness. It asks people what they prefer, and people
are terrible at reporting what they actually do.
So this time I went looking for evidence instead of opinions. I built
a small tool that grabs the 500 most-starred Ruby repos on GitHub,
resolves whatever .rubocop.yml they ship (following inherit_from chains and
popular inherit_gem bases like standard and rubocop-rails-omakase), and tallies
what they change about our defaults. 233 of them have a config, and
the results were interesting in various ways:
Style/Documentationis turned off outright by 53% of them. It’s the most disabled cop by a wide margin.- Seven of the ten most disabled cops are
Metricscops. - Our
Metrics/MethodLengthdefault is 10; the median project that touches it setsAbcSizegoes from 17 to 28.ClassLengthgoes from 100 to 300.
- 91% of the projects that set
Style/StringLiteralspickdouble_quotes- and all three of the big shared configs people inherit wholesale already default to it.
Note: people write a line of config only when they disagree with us, so happy users are invisible here. This shows how far opinionated projects drift from us, not how everyone feels. Evidence, not proof. Still - when a third of the most visible Ruby projects out there disable a cop, that cop isn’t teaching anyone anything. It’s just noise people have learned to mute.
Knowing is the easy part
Knowing which defaults are “wrong” and being able to change them are two very
different things. We follow SemVer (semi-)religiously, and changing a default - a
cop’s Enabled state, a Max threshold, an EnforcedStyle - is a breaking change
that has to wait for a major release. That’s exactly the promise we made in
RuboCop 1.0, and I have no intention of
walking it back.
Same story for a cop that starts reporting a case it used to miss, or corrects one
differently. Those aren’t new cops, so pending is no help - they change results for
everyone who already has the cop enabled.
Which leaves us with a lovely little trap. Everything interesting piles up in a queue waiting for a major release; the major release gets scarier the longer the queue grows; so it gets postponed; so the queue grows some more. You can see where this ends. In the 1.0 post I wrote that RuboCop 2.0 would “probably arrive in less than 8 years”, and we’re six years in. I’d very much like to beat my own estimate. I really do!
Enter preview (mode)
Preview mode is our way out of that trap. It’s an opt-in channel where the next
major release’s behavior lives until that release ships:
AllCops:
Preview: true
Or, for a single run:
$ rubocop --preview
It gates three kinds of things. A cop’s entry in the default configuration can now
carry a Preview section holding the defaults it’s expected to adopt in 2.0:
Style/Documentation:
Enabled: true
Preview:
Enabled: false
A cop can also hide an unstable behavior change behind a preview? check and ship
it in a minor release. And a cop that’s too speculative to even be pending can ship
as Enabled: preview. In all three cases your own configuration still wins over the
default one, exactly as it always has.
The line between preview and pending is worth keeping straight, as both mean
“not on by default”. When a new cop gets introduced it’s usually pending (and
sometimes disabled) - a marker for a pending decision (from you or us). If we’re asking you to
help us find out if something is a good idea, it’s preview.
I know that’s a bit confusing right now and perhaps down the road preview will
replace pending completely for simplicity’s sake. That’s to be determined at this
point. Some ideas and thoughts on this front are most welcome!
One thing to be clear about: preview behavior is unstable and can change or be withdrawn in any release. That’s the whole point. It makes trying something out cheap for us, and telling us we got it wrong cheap for you.
I’d love to tell you the idea was original, but it wasn’t. It came out of an
afternoon spent looking at how other linters handle this problem, and they had all
landed in much the same place. Ruff has had a preview mode for a while
now - one global switch gating both preview-only rules and changed behavior inside
existing ones - and we copied that model wholesale. Clippy parks its
unfinished and contested lints in a nursery group that stays off until they’ve
earned their keep. Both admit the same thing: if the only way to try something is to
ship it to everyone at once, you’ll never try anything.
What’s in the box today
1.91 ships the mechanism and a first batch of proposed 2.0 defaults, taken straight from that corpus data. Flip preview on today and you get:
- The entire
Metricsdepartment off. Not because measuring complexity is a bad idea, but because our thresholds are so far off that people disable the cops rather than argue with them. I’d rather ship no opinion than a bad one. - Roughly two dozen contested
Style,NamingandLayoutcops off -Style/IfUnlessModifier,Style/GuardClause,Style/ClassAndModuleChildren,Style/DoubleNegation,Style/FrozenStringLiteralComment,Style/BlockDelimitersand friends. They’re all near the top of the disable leaderboard. Style/Documentationoff, obviously.- The community’s
EnforcedStylewherever it differs from ours:double_quotesforStyle/StringLiterals,bracketsforStyle/SymbolArrayandStyle/WordArray, andconsistent/indented/with_fixed_indentationacross the multiline indentation family. AllCops: FailLevel: warning, so style offenses get reported without failing your build. More on this one in a moment.Excludemerging with the default configuration and with inherited files instead of replacing them - the fix for #9325, one of our oldest configuration papercuts.
That’s 34 cops switched off and 17 defaults adjusted. A start, not a destination.
A less prescriptive RuboCop
I want RuboCop 2.0 to ship a much smaller and much less prescriptive default configuration than 1.x does.
This isn’t a new idea for me. Back in the 10th anniversary post I admitted that “we might have gone overboard at times in our desire to improve Ruby codebases”, and I listed bundling cops into presets as one of the things I wanted for 2.0, to address “the common complaint that RuboCop checks for way too many things out of the box”. Four years later that complaint hasn’t gone anywhere, and now I’ve got the numbers to back it up.
There’s a slightly embarrassing part to this story too. It wasn’t until I was digging through that data that it hit me how many cops we ship these days. 613. I’ve been maintaining this project for fourteen years and that number genuinely shocked me. Sure, I knew it was a lot - I’d just never looked at the figure, and once you have, it’s hard to unsee. That’s what got me focused on making the default configuration leaner.
RuboCop grew up as the “enforcement” arm of the Ruby Style Guide, and for many years “is it in the style guide?” was a good enough answer to “should it be on by default?”. I still stand firmly behind the style guide. But the tool has outgrown that framing - that’s an awful lot of opinions to hand someone on their first day, and the data says most people don’t want most of them. Out of the box RuboCop should catch real problems and stay out of your way on matters of taste. If you want the full sermon, you should have to ask for it.
Sure, this sounds a lot like Standard, and I have no problem admitting
they got there before we did. The popularity of Standard and rubocop-rails-omakase
was the community telling us something, and we took our sweet time listening. The
difference is that RuboCop will stay exactly as configurable as it’s always been. I’m
not taking away any knobs, I’m changing which ones start in the “on” position.1 If
your project loves our current defaults, you’ll be able to keep every last one of them.
Preview isn’t the only thing in motion here, either. The presets work I promised in that anniversary post is moving again - #14005 has the discussion and #14994 a first prototype - and I see the two as related rather than competing. Preview asks “is this particular default wrong?”. Presets ask the broader “how many cops should be running at all?”, by letting you pick a bundle - something essential, something recommended, the full style guide - instead of arguing over several hundred separate decisions. And the preview feedback feeds straight into that: every cop you tell me you don’t miss is a cop that belongs in a stricter tier, and every one you yell at me about belongs in the baseline. I’d like to land both for 2.0.
The severity work in this release is part of the same push. A cop’s default severity
now comes from its department, so Security cops report as warning and Metrics
cops as refactor. Combined with the new AllCops: FailLevel - the configuration
equivalent of --fail-level, which somehow we never had - you finally get a better
dial than “everything fails the build equally”:
AllCops:
FailLevel: warning
With that, only Lint and Security offenses (plus anything you’ve bumped yourself)
fail the run. Everything else still gets printed, it just doesn’t paint your CI red.
The rest of the release
Preview stole the show, but there’s plenty else in here - and a few of these came out of that same survey of the competition:
--diffprints a unified diff of what autocorrection would change, without touching a single file. This is the CI check I’ve wanted for ages - the build fails with the patch that would fix it sitting right there in the log, andrubocop --diff --format quiet | git applygets you that patch locally.--changedinspects only the files git says changed, defaulting to a diff againstHEAD(any revision works, and untracked files count). It pairs beautifully with--diff.- There’s a SARIF formatter now, so
rubocop --format sarif --out rubocop.sariffeeds straight into GitHub code scanning and your offenses show up annotated on the pull request diff. Lint/MisplacedMagicCommentis a new cop that flags magic comments Ruby silently ignores - an# encoding:comment that isn’t on the first line, a# frozen_string_literal: truethat drifted below arequire, a magic comment that pushed a shebang off line one. Quietly doing nothing is the worst failure mode a magic comment can have.- Two new directives.
# rubocop:enable-nextre-enables a cop for exactly one statement, with no closing directive to maintain.# rubocop:nextcovers the same statement scope but takespush-style+/-arguments, for the rare case that needs toggles in both directions at once.Style/DirectiveScopewill convert your existing pairs to the tighter forms. Lint/CopDirectiveSyntaxis enabled by default now, so a directive that silently suppresses nothing gets reported instead of fooling you for years. It absorbedStyle/DoubleCopDisableDirective, which is gone.Style/DisableCopsWithinSourceCodeDirectivegained anAllowedDirectivesoption, so you can ban hand-writtendisablecomments while still letting generatedrubocop:todoentries through. Its cop lists match department names too now.- And a mountain of bug fixes - a long parade of infinite loops between cops squashed, more crashes and bad autocorrections than I care to admit to, and two result cache bugs, one of which could serve one file’s cached results for another file. The release notes have the full list.
Epilogue
The most useful thing you can do right now is run rubocop --preview on your codebase
and tell us what you think. Which of those disabled cops do you actually miss? Which
EnforcedStyle did we get wrong? What did we leave switched on that should have gone?
That’s the whole reason preview exists - I’d much rather find out now than on the day
2.0 ships. Open an issue, comment on the defaults
discussion, or just yell about it on the internet. I’ll find it.
Big thanks to everyone who contributed to this release! viralpraxis and Starlexxx in particular did a huge amount of the bug-fixing this cycle, and Koichi keeps being Koichi. And thanks to everyone who’s been arguing with our defaults all these years - turns out you were mostly right.
That’s all I have for you today. Keep hacking!
-
Famous last words, I’m well aware. ↩