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/Documentation is 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 Metrics cops.
  • Our Metrics/MethodLength default is 10; the median project that touches it sets
    1. AbcSize goes from 17 to 28. ClassLength goes from 100 to 300.
  • 91% of the projects that set Style/StringLiterals pick double_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 Metrics department 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, Naming and Layout cops off - Style/IfUnlessModifier, Style/GuardClause, Style/ClassAndModuleChildren, Style/DoubleNegation, Style/FrozenStringLiteralComment, Style/BlockDelimiters and friends. They’re all near the top of the disable leaderboard.
  • Style/Documentation off, obviously.
  • The community’s EnforcedStyle wherever it differs from ours: double_quotes for Style/StringLiterals, brackets for Style/SymbolArray and Style/WordArray, and consistent/indented/with_fixed_indentation across the multiline indentation family.
  • AllCops: FailLevel: warning, so style offenses get reported without failing your build. More on this one in a moment.
  • Exclude merging 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:

  • --diff prints 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, and rubocop --diff --format quiet | git apply gets you that patch locally.
  • --changed inspects only the files git says changed, defaulting to a diff against HEAD (any revision works, and untracked files count). It pairs beautifully with --diff.
  • There’s a SARIF formatter now, so rubocop --format sarif --out rubocop.sarif feeds straight into GitHub code scanning and your offenses show up annotated on the pull request diff.
  • Lint/MisplacedMagicComment is a new cop that flags magic comments Ruby silently ignores - an # encoding: comment that isn’t on the first line, a # frozen_string_literal: true that drifted below a require, 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-next re-enables a cop for exactly one statement, with no closing directive to maintain. # rubocop:next covers the same statement scope but takes push-style +/- arguments, for the rare case that needs toggles in both directions at once. Style/DirectiveScope will convert your existing pairs to the tighter forms.
  • Lint/CopDirectiveSyntax is enabled by default now, so a directive that silently suppresses nothing gets reported instead of fooling you for years. It absorbed Style/DoubleCopDisableDirective, which is gone.
  • Style/DisableCopsWithinSourceCodeDirective gained an AllowedDirectives option, so you can ban hand-written disable comments while still letting generated rubocop:todo entries 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!

  1. Famous last words, I’m well aware.