September 11, 2019

Making friends with RuboCop

At Memory.ai, we started using RuboCop heavily. This is a story of how we integrated RuboCop into our existing app.

This is not an introductory post to RuboCop. Check out what RuboCop is before diving into our experience report.

We started with rubocop, rubocop-performance, rubocop-rails and rubocop-rspec gems. We enabled all cops by default, this was our initial configuration in rubocop.yml

require:
  - rubocop-rspec
  - rubocop-rails
  - rubocop-performance

AllCops:
  EnabledByDefault: true
  TargetRubyVersion: 2.6.3
  Exclude:
    - 'app/views/**/*'
    - 'db/**/*'
    - 'bin/**/*'
    - 'csv/**/*'
    - 'slate/**/*'
    - 'vendor/bundle/**/*'
    - 'node_modules/**/*'

If you enable all the cops that come with RuboCop in an existing project, you will get tons and tons of warnings and offenses. This was pretty evident when we ran rubocop after adding above configuration.

Work in progress

For existing projects, there is a better way to integrate RuboCop. RuboCop provides a file .rubocop_todo.yml which records all the offenses from our codebase. We can fix these offenses one by one as we touch that particular piece of code. So we don't have to fix everything to start with.

To start using the .rubocop_todo.yml, perform following steps.

bundle exec rubocop --auto-gen-config
Added inheritance from `.rubocop_todo.yml` in `.rubocop.yml`.
Phase 1 of 2: run Metrics/LineLength cop
Inspecting 38 files
CC....C..C..C...C..CCC..CC....CC..C..C

38 files inspected, 40 offenses detected
Created .rubocop_todo.yml.
Phase 2 of 2: run all cops
Inspecting 38 files
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

38 files inspected, 104 offenses detected
Created .rubocop_todo.yml.

You will see similar output but number of offenses might be overwhelmingly large or extremely less depending on the state of your codebase :)

This command did three things.

  • Inherited .rubocop.yml from .rubocop_todo.yml(We will come back to it later)
  • Ran rubocop on all of our code
  • Generated a new file .rubocop_todo.yml

Let's see contents of the .rubocop_todo.yml. It has recorded all the offenses present in our code. Let's see example of one of the cop for better understanding.

# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: TreatCommentsAsGroupSeparators, Include.
# Include: **/*.gemfile, **/Gemfile, **/gems.rb
Bundler/OrderedGems:
  Exclude:
    - 'Gemfile'

This little piece of code tells us that our codebase has one offense for Bundler/OrderedGems cop and how we can fix it. But more importantly, it marks the file where this offense is present as excluded from the list of files on which RuboCop will be run.

What does this mean? Well let's try to run rubocop on the entire project now.

▶ bundle exec rubocop
Inspecting 38 files
......................................

38 files inspected, no offenses detected

Woah! Our code is clean, it passes all the cops, time for party!

Well, not really. This is where the comment Inherited .rubocop.yml from .rubocop_todo.yml needs to be looked at.

▶ cat .rubocop.yml
inherit_from: .rubocop_todo.yml

.rubocop_todo.yml records all the offenses of our codebase and also excludes those files from the list on which rubocop will be run.

rubocop.yml inherits from .rubocop_todo.yml so it also excludes those files from the list on which rubocop will be run.

When we run bundle exec rubocop, it picks up configuration from .rubocop.yml which in turn picks up the configuration from .rubocop_todo.yml which has excluded all the files which have offenses. All of this results into a green output from bundle exec rubocop command.

So we still have offensive code that RuboCop does not like, but we have a strategy to fix it incrementally rather than fixing it all at once.

Pro Tip: Always generate the .rubocop_todo.yml when you are integrating Rubocop into an existing project.

Git workflow

Next natural step was to act on the .rubocop_todo.yml file and fix the offenses as we touch the existing offensive code and make sure that the new code that we write is following the cops.

We decided to add a git hook which will run RuboCop on staged changes. RuboCop provides an option --safe-autocorrect which automatically corrects certain pieces of code based on whether a particular cop is considered safe for autocorrect or not. We leveraged this in our git commit hook so that developers don't have to worry about manually fixing all the offenses. When the machine can do it, why not?

We used husky and lint-staged npm packages to achieve this.

npm install --save-dev lint-staged husky

Add following in package.json

{
  "scripts": {
    "precommit": "lint-staged"
  },
  "lint-staged": {
    {app,spec}/**/*.rb": [
      "bundle exec rubocop --safe-autocorrect",
      "git add"
    ]
  },
  "devDependencies": {
    "husky": "^0.13.4",
    "lint-staged": "^3.6.0"
  }
}

This configuration makes sure that whenever a developer tries to commit code the offenses in that code which are deemed safe by RuboCop and the cops which support Auto Correct mechanism, are already fixed. After this our developers did not have to do anything extra apart from fixing offenses which are not considered safe by RuboCop manually.

Work in progress continues

Once the autocorrect git hook was in action, our list of offenses started coming down. We had to regenerate the .rubocop_todo.yml in between to get the accurate list of offenses. We decided against adding regeneration of .rubocop_todo.yml into Git hook as it was slow in our case.

.rubocop_todo.yml can be regenerated from the same command that generated it.

Happiness or Pain?

After all of this, we were expecting the offensive code to go down and code quality to improve day by day without any bugs. But we faced few challenges.

The autocorrect hook did some unexpected changes to our code in the process.

We had a piece of code which has a method update_attributes. rubocop-rails gem has a cop ActiveRecord/Aliases which changes calls to update_attributes with update. Though this change should happen only on Active Record models, the cop does not check whether the method is called on Active Record model or not. So in our case, it changed the call to custom update_attributes to update but did not change the method definition. It remained update_attributes. This resulted into error. As we had enabled safe-autocorrect Git hook, the developers came to know of this only when the build failed in CI.

We also faced issues with few other cops such as Rails/SaveBang and Style/StringHashKeys where they changed the code which was supposed to be not changed.

In the end, we decided to remove the safe autocorrect hook and relied on manual fixing of the offensive code.

Contributing back

When we faced the issues related to some of the cops which were not really safe for the safe autocorrect option, we tried to fix them by submitting patches to RuboCop.

https://github.com/rubocop-hq/rubocop-rails/pull/101

https://github.com/rubocop-hq/rubocop-rails/pull/98

https://github.com/rubocop-hq/rubocop/pull/7312

I will encourage everyone to do the same as it makes RuboCop better for everyone.

In conclusion, I will list down what we learned from this adoption.

  • Incremental adoption is key.
  • Autocorrect can be tricky, depends on your test coverage and lot of other factors, so don't use it if you don't have to.
  • Disable the cops that your team does not agree upon. We disabled cops such as RSpec/AnyInstance, RSpec/ExpectInHook, RSpec/AlignRightLetBrace
  • Contribute back to the community with your findings, code fixes so that critical tools such as RuboCop become better for everyone.

Want to know how we do Ruby and Rails at Memory.ai, subscribe to my newsletter here.