Adding New Lint Rules Without the Fuss
Tags: javascript, github, codequality, linting
Published: 2020-11-15 15:57:38

Linters are great for maintaining code quality and encoding team conventions, but how do you add new rules that your codebase currently violates? If there's a handful of violations or the violations can be autofixed, then it may be easy to fix them before adding the rule, but what if there are hundreds of them?

## Case Study

Suppose we've already set up CI for linting and want to add the ESLint rule [`import/extensions`](https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/extensions.md) to ensure that every import has a file extension. Let's walk through some options at our disposal and consider the pros and cons of each option.

## Option 1: Fix Every Violation

The first option available is to fix every violation that comes up from the new lint rule.

### The Setup

First, add the new rule:

```diff
diff --git a/.eslintrc.json b/.eslintrc.json
   ...
   "rules": {
+    "import/extensions": ["error", "always"]
   }
```

There's now lint errors and we can't merge to our main branch without failing CI, so we fix every error before merging. While time consuming, this process is straightforward. You go through each lint violation in the codebase and fix it - in this case, by adding a file extension to every import that is missing one.

### Pros

The codebase is 100% adhering to the new rule! There are no lint violations, and everyone in the future will follow this rule in their changes or face the wrath of a failing build. This strategy is awesome when there's time and motivation to get it done.

### Cons

When there are hundreds of warnings that can't be autofixed, this strategy will postpone or prevent you from getting value out of new rules.

## Option 2: Make the New Rule a Warning

What about adding the new rule as a warning instead of an error?

### The Setup

First, add our new rule:

```diff
diff --git a/.eslintrc.json b/.eslintrc.json
   ...
   "rules": {
+    "import/extensions": ["warn", "always"]
   }
```

**and we're done!**

### Pros

Setup was super easy - there's now a new lint rule that developers will see in their editors if they use an ESLint plugin.

### Cons

There's nothing _really_ enforcing the new rule. It's just a warning, and there may be hundreds of other warnings in the codebase. Warnings will pile up without developers noticing them.

#### Mitigations

ESLint has a CLI option [`--max-warnings`](https://eslint.org/docs/user-guide/command-line-interface#options) which enforces a max number of warnings. Unfortunately, as you fix any existing warnings you have to keep this up to date, otherwise each fix gives slack for future warnings.

## Option 3: Suppress the ESLint Errors

We could suppress the existing violations to enforce the new rule going forward while avoiding the immediate cost of fixing existing issues.

### The Setup

We'll add the new rule, and then add `eslint-disable-next-line` for each lint violation.

First, add the lint changes to `.eslintrc.json`, same as option 1:

```diff
diff --git a/.eslintrc.json b/.eslintrc.json
   ...
   "rules": {
+    "import/extensions": ["error", "always"]
   }
```

Then run [`suppress-eslint-errors`](https://www.npmjs.com/package/suppress-eslint-errors). The `suppress-eslint-errors` package notes that you may have to manually fix some of the suppressions it adds. If your setup doesn't involve ESLint, you'll need to find an alternative to `suppress-eslint-errors`, or may have to do this part manually.

```sh
npx suppress-eslint-errors src/**/*.{ts,tsx} --message="TODO: add extension"
```

```diff
diff --git a/src/App.test.tsx b/src/App.test.tsx
 import { render, screen } from '@testing-library/react
+// TODO: add extension
+// eslint-disable-next-line import/extensions
 import App from './App';
```

> See [this example of a suppress violations diff](https://github.com/dcwither/marginal-linting-example/commit/f6de3211294f9751feb1a7ecc6df5c241830d38d) for the changes needed to add this to a small project.

### Pros

Suppressing existing failures keeps our lint warnings clean and allows us to enforce future changes don't violate the new rule. You can go back and systematically fix suppressed violations in smaller chunks.

### Cons

The lines suppressing warnings reduce the signal-to-noise ratio of the code. It can also make it seem ok to add `eslint-disable` whenever a developer writes code that violates lint rules, reducing the effectiveness of linting.

## Option 4: Only Lint New Changes with New Rules

With a little extra work and a slightly more complicated setup, we can achieve linting that will ignore existing violations, while keeping us accountable in new changes. I like to call this **marginal linting**.

Using a tool like [reviewdog](https://github.com/reviewdog/reviewdog) (or [pronto](https://github.com/prontolabs/pronto) if you like ruby), we can set up GitHub checks to annotate our PRs with any lint violations.

### The Setup

We'll have two separate ESLint configs now. The "main" ESLint config (`.eslintrc.json`) adds the new rule. This is our default config which we run in editors as well as in reviewdog.

First, add the lint changes to `.eslintrc.json`, same as option 1.

```diff
diff --git a/.eslintrc.json b/.eslintrc.json
   ...
   "rules": {
+    "import/extensions": ["error", "always"]
   }
```

Our second ESLint config will intentionally disable the newly added rule in CI. Target it in the lint workflow with `eslint --config=.eslint-ci.json`.

```json
// .eslintrc-ci.json
{
  "extends": ".eslintrc.json",
  "rules": {
    "import/extensions": "off"
  }
}
```

Add a new GitHub workflow using the [reviewdog eslint action](https://github.com/reviewdog/action-eslint) to execute our new rules on pull requests.

```yml
# .github/workflows/reviewdog.yml
# Modified from reviewdog action eslint README
# https://github.com/reviewdog/action-eslint#githubworkflowsreviewdogyml
name: reviewdog
on: [pull_request]
jobs:
  eslint:
    name: runner / eslint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Lint Typescript Changes 👕
        uses: reviewdog/action-eslint@v1
        with:
          reporter: github-pr-check
          eslint_flags: "--config=.eslintrc.json src/**/*.{ts,tsx}"
```

> See [this example reviewdog setup diff](https://github.com/dcwither/marginal-linting-example/commit/29310a40438f387f042e7db85d5b4124b7183a0a) for the changes needed to add this to a small project.

### The Result

We now receive warnings in our pull requests whenever our changes violate any lint rules, including our existing ones.

![ESLint error annotation appearing in GitHub pull request](https://dev-to-uploads.s3.amazonaws.com/i/phrsb924pyfu44xd784y.png)

### Pros

Making `.eslintrc.json` the more restrictive configuration ensures that any new integrations will follow it by default. Any use of `.eslintrc-ci.json` can be explicitly specified such as in CI.

This setup has the added benefit of including code review integration, which can be beneficial regardless of new lint rules. It also means that any new rules require a two line change: one for the lint rule in `.eslintrc.json` and another to disable it in `.eslintrc-ci.json`.

### Cons

The setup for this option is more involved, and adds a new technology to the CI stack. The build time for this task in a new `create-react-app` was 3 minutes, and could increase depending on the project size.

## Conclusion

While it's nice to have a 100% compliant codebase, it might not be possible to fix every violation immediately. Minimizing the effort of adding new lint rules helps ensure your team can adopt and enforce best practices moving forward.

Running a script to disable lint errors for new rules can quickly fix the issue but remains the same effort for every future lint rule. Adopting two lint configs, while requiring a slightly more complex initial setup, provides the same benefit and allows you to add new rules easily. Integrating it with reviewdog or pronto makes encouraging the new practices even easier in code review.