Writing custom RuboCop rules in 2026

Cover for Writing custom RuboCop rules in 2026

Five years ago, we published a blog post about writing custom RuboCop rules (called “cops”). Since then, RuboCop has had a major release with new features that can make your custom rules even better. Let’s revisit that post to see what’s changed and also uncover items left on the cutting room floor the first time, too.

Introducing the Plugin system

RuboCop now has a built-in plugin system that simplifies the process of creating and distributing custom cops. In the original article, we discussed a workaround for organizing custom rules outside of RuboCop’s core structure, but this approach is now obsolete. Instead, you can package your custom cops as a RuboCop plugin gem following the official plugin naming convention (rubocop-<plugin-name>).

RuboCop plugins are based on the lint_roller which aims to provide standardized publishing API for various Ruby tools.

To promote your RuboCop extension gem to a plugin, you’ll need to create a plugin.rb file (either with rubocop-extension-generator or manually). Once this is done, most likely your plugin will follow the standard structure that other RuboCop plugins use. For example, see how the rubocop-thread_safety plugin organizes its plugin.rb.

When using the gem, you should include its name in the plugins RuboCop configuration option:

-require:
+plugins:
   - rubocop-custom_plugin

Most of the official RuboCop extensions are already distributed as plugins.

Book a call

Hire Evil Martians

Playbook.com, Stackblitz.com, Fountain.com, Monograph.com–we joined the best-in-class startup teams running on Rails to speed up, scale up and win! Curious? Let's talk!

Using RuboCop’s internal ruleset

RuboCop provides a special, built-in plugin rubocop-internal_affairs specifically designed to lint the source code of the cops themselves. If you’re writing custom cops, you should enable this department to ensure your cop implementations follow best practices and that they are using RuboCop’s APIs correctly:

plugins:
  - rubocop-internal_affairs

One example of a cop from the department is InternalAffairs/OnSendWithoutOnCSend which checks that if you defined on_send, then you also defined on_csend, since in most cases these two method calls (via .some_method and safe navigation &.some_method) should be treated in the same way.

The department might also remind you of deprecated APIs: for instance, our previous blog post mentioned that custom cops should inherit from RuboCop::Cop::Cop; this is deprecated since RuboCop v2:

Offenses:

argument_name:4:15: C: InternalAffairs/InheritDeprecatedCopClass: Use Base instead of Cop.
      class ArgumentName < RuboCop::Cop::Cop
                           ^^^^^^^^^^^^^^^^^

Note that if you don’t use a separate gem for your custom rules with well-established naming conventions, you might need to configure Include property of the InternalAffairs department, since by default it is a glob lib/rubocop/cop/**/*.rb.

Using gem and Ruby runtime conditionals

For a long time, RuboCop has had the minimum_target_ruby_version and maximum_target_ruby_version (provided by RuboCop::Cop::TargetRubyVersion) APIs to specify which Ruby versions a cop is applicable. RuboCop 1.63 introduced a new API requires_gem to do the same for gems:

class RuboCop::Cop::Custom::ActiveJobSpecificCop
  requires_gem "activejob"
end

The API also allows you to specify version constraints:

requires_gem "activejob", "~> 8"
requires_gem "activejob", "~> 8", "!= 8.0.1"

By design, RuboCop performs analysis on a per-file basis. But to support these features, it has to parse and inspect the bundler lockfile (e.g. Gemfile.lock), which means that these pragmas will only work if the gem information is available in the lockfile.

If a gem is not listed in the bundle lockfile, the cop will not be fired.

The Nuances of Safe Autocorrection

RuboCop has rich autocorrection functionality. Autocorrections can be safe or unsafe. If your cop supports autocorrection, it’s important to indicate whether it is safe, so that developers running rubocop -a can trust the fixes won’t change program behavior, and those running rubocop -A are aware of potential risks.

One important thing to consider is most cases safe autocorrection is not an absolute category. Let’s take a look at two core cops, both of them are marked as “safe-autocorrectable”.

The first one is Layout/SpaceInsideRangeLiteral cop, which advises omitting spaces in range literals:

$ echo '(1.. 2)' | rubocop --stdin example-ranges.rb --only Layout/SpaceInsideRangeLiteral -a

Offenses:

example-ranges.rb:1:2: C: [Corrected] Layout/SpaceInsideRangeLiteral: Space inside range literal.
(1.. 2)
 ^^^^^

1 file inspected, 1 offense detected, 1 offense corrected
====================
(1..2)

Here, there’s absolutely no scenario where autocorrect will lead to an incompatible change, since, according to Ruby’s syntax range, literals are well-defined and removing spaces really changes nothing in the program’s behaviour, since it does not change the AST of the program.

The second one is Style/ClassCheck, which, by default (when enforced style is set to is_a?), prefers and autocorrects Kernel#kind_of? to Kernel#is_a?. RuboCop marks this cop as having safe autocorrection, and in practice this holds true for most codebases. The problem is, here the safety of the autocorrection isn’t bound to the language itself, since you can monkey-patch Kernel#kind_of? so that it isn’t functionally equal to the #is_a? (or vice versa).

This is something to pay close attention to, especially if you’re planning to publish your RuboCop plugin publicly and not just use it internally within your company. Once your cop is used in arbitrary codebases, assumptions that feel “safe” in your environment may no longer hold.

Following the RuboCop design

By design, RuboCop is a static analysis tool. This means that, conceptually, its behaviour should not depend on runtime information, i.e. information obtained from Ruby process the rubocop runs within, including RUBY_VERSION, Kernel.const_defined?, Array.instance_methods and so on. RuboCop’s runtime environment is separated from the processed source environment.

In reality, there are a couple of core cops which do depend on the data mentioned before. For instance, at the time of writing, the Lint/ShadowedException cop (which checks that rescue exceptions list does not contain subclasses), checks inheritance by performing Kernel.const_get.

Given this code:

begin
  perform_some_active_record_operation
rescue ActiveRecord::NotNullViolation, ActiveRecord::StatementInvalid => e
  Rails.error.report(e)
end

…you might miss that it can be simplified to rescue ActiveRecord::StatementInvalid, since it already covers ActiveRecord::NotNullViolation.

Whether RuboCop will be able to detect it depends if activerecord gem is loaded into the Ruby process. If you’re using bundler and rubocop is in the same group as activerecord (or if activerecord is in default group), RuboCop will have access to ActiveRecord’s constants and will be able to check if ActiveRecord::NotNullViolation < ActiveRecord::StatementInvalid.

There’s an ongoing discussion whether this should be considered a bug. That said, it’s up to you which of the two options to choose and each comes with its own trade-offs: accessing runtime information during static analysis may make linting somewhat unstable (depend on loaded gems), but it also might help to get some extra insights.

Book a call

Irina Nazarova CEO at Evil Martians

Playbook.com, Stackblitz.com, Fountain.com, Monograph.com–we joined the best-in-class startup teams running on Rails to speed up, scale up and win! Solving performance problems, shipping fast while ensuring maintainability of the application and helping with team upskill. We can confidently call ourselves the most skilled team in the world in working with startups on Rails. Curious? Let's talk!