# Writing custom RuboCop rules in 2026

> Write and ship modern custom RuboCop cops in 2026: learn the new plugin system, requires_gem API, InternalAffairs best practices, safe autocorrection nuances, and design trade-offs for building robust, future-proof lint rules.

- Date: 2026-02-18T00:00:00.000Z
- Authors: Yaroslav Kurbatov, Travis Turner
- Categories: DX
- URL: https://evilmartians.com/chronicles/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 continue onward from 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`](https://github.com/standardrb/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`](https://github.com/rubocop/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`](https://github.com/rubocop/rubocop-thread_safety/blob/0e59b597d6ea683a3e71a0e1e68b3b1b8a1b65e0/lib/rubocop/thread_safety/plugin.rb).

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

```diff
-require:
+plugins:
   - rubocop-custom_plugin
```

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

Refer to [the offical documentation](https://docs.rubocop.org/rubocop/extensions.html) for details.

---

*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!* [Contact Evil Martians](https://evilmartians.com/contact-us)

---

## 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:

```yaml
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:

```shell
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:

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

The API also allows you to specify version constraints:

```ruby
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](https://github.com/rubocop/rubocop/blob/b93ff5b1b113c758f5333b7b812582de2145f6e3/lib/rubocop/config.rb#L373-L378) (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:

```shell
$ 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:

```ruby
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`.

Some official RuboCop plugins also follow the same pattern, for instance, [`rubocop-performance`](https://github.com/rubocop/rubocop-performance/blob/64cbb22f317dfa07eb3b2ed954c73023172e23be/lib/rubocop/cop/performance/io_readlines.rb#L32)

There's [an ongoing discussion](https://github.com/rubocop/rubocop/issues/14724) 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.

---

**Evil Martians help startups win** 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! [Contact Evil Martians](https://evilmartians.com/contact-us)
