Skip to content

How to raise code quality at scale with ESLint

Introducing new ESLint rules to an existing project#

Why linting is important#

Linters offer a way of enforcing code quality and consistency in a codebase. They can help you catch bugs, enforce best practices, and ensure that your code is readable and maintainable.

Linters are also an automated way of enforcing practices which means that we can spend less time writing nitpicks in code reviews and more time thinking about the bigger picture like if the code is solving the right problem, whether or not it will be performant, etc.

Gunnar Morling’s visualisation of the code review pyramid nicely shows what we should be focussing on in code reviews.

Evan Smith points out in his article about Kind Engineering that people tend to be more open to receiving many nitpicks from a linter than from a dozen comments on a PR.

Choosing which rules to add#

How do we choose which rules to add to our ESLint configuration?

Determining which rules to enable individually can be time-consuming in terms of discussing and justifying whether or not you need the rule. So, generally it’s better to start with a config that defines a recommended set of rules. Not every rule in the recommended config will be applicable but they are a good starting point. It is much easier to disable specific rules than to enable them one by one.

You can see a list of recommended configs in my article on awesome linters.

If you are interested in looking at a production grade ESLint configuration, you can check out the SEEK skuba ESLint Config which extends the SEEK base ESLint Config.

It is important to note that even if you do not agree with the opinion of a rule, it is usually better to have consistency within the codebase rather than different code styles. For example, jest and vitest expose aliases for writing tests under the test and it functions. So, think about whether you really want to disable a rule from a recommended config.

// Exactly the same test
test('should add up two numbers', () => {
expect(sum(1, 1)).toBe(2);
});
it('should add up two numbers', () => {
expect(sum(1, 1)).toBe(2);
});

How to ignore existing violations#

The best time to introduce linting rules is at the start of a project, so that you do not have to worry about fixing a large number of issues later on. If you are working on an existing project, introducing a new recommended set of rules can be a daunting task because your first run of the linter may show hundreds or thousands of errors.

Do not be discouraged by this! Some of these errors can be fixed automatically using the —fix ESLint option.

Terminal window
npx eslint --fix file.ts

Not every issue will be fixable automatically, but this lets us focus on the issues that may require more thought.

From here, you can choose to change the severity of the rule to a warning or disable it entirely. For example,

{
"rules": {
"no-unused-vars": "warn",
"no-unsafe-argument": "off"
}
}

This is usually done because the effort to make the codebase compliant with the rule is high. However, I do not recommend this because there is significant value in having a linter that is able to enforce rules by failing a build in CI. Setting rules to warn tends to lead to new violations being introduced. So, ideally we would be able to ignore existing violations and enforce the rule for new changes.

So, I recommend using a tool called eslint-interactive which allow us to quickly deal with the remaining issues.

Terminal window
# Install eslint-interactive@10 for ESLint < v9
npm i eslint-interactive

You can start using eslint-interactive by running the following command:

Terminal window
npx eslint-interactive ./src

eslint-interactive will lint the files in the ./src directory, summarise the number of issues by rule and then allow you to select which rules you want to deal with first.

Terminal window
Linting done.
- 150 files (146 files passed, 4 files failed) checked.
- 4 problems (4 errors, 0 warning) found.
╔═══════════════════════════════════════╤═══════╤═════════╤════════════╤═════════════════╗
Rule Error Warning is fixable has suggestions
╟───────────────────────────────────────┼───────┼─────────┼────────────┼─────────────────╢
@typescript-eslint/no-empty-function 1 0 0 0
╟───────────────────────────────────────┼───────┼─────────┼────────────┼─────────────────╢
@typescript-eslint/no-unsafe-argument 1 0 0 0
╟───────────────────────────────────────┼───────┼─────────┼────────────┼─────────────────╢
@typescript-eslint/no-unused-vars 2 0 0 0
╚═══════════════════════════════════════╧═══════╧═════════╧════════════╧═════════════════╝
? Which rules would you like to apply action? … Select all you want with <space> key.
@typescript-eslint/no-empty-function
@typescript-eslint/no-unsafe-argument
@typescript-eslint/no-unused-vars

After selecting a rule, you can explore the issues that have been found and choose an action to take.

Terminal window
Which rules would you like to apply action? · @typescript-eslint/no-empty-function
? Which action do you want to do?
🔎 Display details of lint results
🔧 Run `eslint --fix` (disabled)
🔧 Disable per line
🔧 Disable per file
🔧 Convert error to warning per file
🔧 Apply suggestions (experimental, for experts) (disabled)
🔧 Make forcibly fixable and run `eslint --fix` (experimental, for experts)
↩️ Reselect rules

I have found that the most useful option is to Disable per line which will add a // eslint-disable-line comment to the line that is causing the issue. This allows us to ignore the issue for now and come back to it later. We can also include additional information in the comment such as a JIRA ticket number or a TODO which will make it easier to find the issue later.

Terminal window
Which action do you want to do? · disablePerLine
Leave a code comment with your reason for fixing (Optional) · TODO JIRA-123
Where would you like to position the code comment? · sameLine
Fixing done.

eslint-interactive will write out the changes to the files.

// eslint-disable-next-line unicorn/no-await-expression-member -- TODO JIRA-123

This approach allows us to quickly introduce new rulesets, ignore existing issues and gain the benefits of using those stricter rules for future changes. As we work on the codebase, we can gradually fix the existing issues and remove the eslint-disable-line comments.

To ensure that our eslint-disable-line comments are actively being used to suppress issues, we can use the —report-unused-disable-directives ESLint option.

Terminal window
npx eslint --fix --report-unused-disable-directives file.ts

Paired with the --fix flag, ESLint will automatically remove any unused eslint-disable-line comments.

Fixing existing violations#

One of the downsides of gradually chipping away at existing issues is that for large existing codebases with thousands of issues, it can take a very long time to make the codebase compliant with the new rules. This is because we are limited to developers resolving issues one by one.

So, how can we scale up our efforts to fix existing issues?

Using ESLint suggestions#

Sometimes, an ESLint rule will have some suggestions on how to resolve an issue but the fix is not always correct. So, some level of human oversight is required. Usually, these suggestions can be applied in an IDE but how do we apply them to a large number of issues at once?

You can use eslint-interactive to apply suggestions. When you select a rule to apply an action to, you can select Apply suggestions if there are applicable suggestions. It’s a good idea to first review the suggestions to ensure that they are correct by first selecting 🔎 Display details of lint results.

If we want to roll out fixes using suggestions over a large number of projects, using the interactive CLI is inefficient. Instead, we can use the eslint-interactive’s Programmable API.

A good approach I have found is to explore the suggestions provided by the rules using eslint-interactive and whitelist the rules with suggestions that I trust. Then, I can use a script to roll out the fixes using the suggestions quickly.

Script for applying whitelisted suggestions
import {
Core,
type ESLintOptions,
type SuggestionFilter,
} from 'eslint-interactive';
const core = new Core({
patterns: ['src', 'spec'],
eslintOptions: {
type: 'flat',
} as ESLintOptions,
});
const results = await core.lint();
for (const result of results) {
for (const message of result.messages.filter((message) =>
Boolean(message.suggestions),
)) {
console.log(message.ruleId, message.message);
for (const suggestion of message.suggestions ?? []) {
console.log(suggestion.fix);
}
}
}
const suggestionFilter: SuggestionFilter = (
suggestions,
_message,
_context,
) => {
// The rule may return multiple suggestions. Pick the first one.
return suggestions[0];
};
// Whitelist rules with suggestions that should be applied automatically
const ESLINT_RULE_WHITELIST = [
'@typescript-eslint/require-await',
'unicorn/text-encoding-identifier-case',
'no-useless-escape',
'unicorn/prefer-number-properties',
];
await core.applySuggestions(results, ESLINT_RULE_WHITELIST, suggestionFilter);

Using codemods#

Codemods are programs that use code to refactor your code. One of the most popular ways to write codemods for JavsScript has been to use jscodeshift. However, this has required learning the underlying Abstract Syntax Tree (AST) of JavaScript, how to manipulate the ASTs and writing tests for your codemods is often more work than expected.

I have found that using grit is a much nicer experience. grit allows you to mostly write your codemods using JavaScript syntax while taking advantage of matching against the underlying AST. grit is also incredibly fast.

One instance where I have found grit to be useful has been to refactor default exports to named exports.

I can enforce this using ESLint, with no-restricted-exports

{
'no-restricted-exports': ['error', { restrictDefaultExports: { direct: true } }],
}

However, it would be very time consuming to manually refactor all the default exports to named exports. Fortunately, grit has a standard library pattern to migrate default imports to named imports. So, I can run the following command to apply the codemod.

Terminal window
grit apply migrate_default_imports

I was also able to write my own simpler grit pattern which was curated for my codebases.

Terminal window
or {
`export default $export` => `export { $export }`,
`import $alias from $source` => `import { $alias } from $source` where {
and {
$alias <: not contains `{ $imports }`,
$alias <: not r"\* as .*",
$source <: r".\..*",
$source <: not r".*json."
}
}
}

This pattern replaces named default exports with a named export.

// Before
const hello = () => {
console.log('hello');
};
export default hello;
// After
const hello = () => {
console.log('hello');
};
export { hello };

It also replaces default imports with named imports and ensures that other imports are unaffected.

// Before
import hello from './hello';
import 'aws-sdk-client-mock';
import { world } from './world';
import * as stream from './stream';
import me from 'me';
import schema from '../../validation/schema.json';
// After
import { hello } from './hello';
import 'aws-sdk-client-mock';
import { world } from './world';
import * as stream from './stream';
import me from 'me';
import schema from '../../validation/schema.json';

Another great part about grit is that code snippets included above can be used as tests to ensure that the codemod is working as expected. This is much simpler than setting up files, running the codemod and comparing the modified files with a correctly modified file.

Using ESLint information to guide LLMs#

I recently came across an article called How to Fix ESLint Violations with AI Assistance and was inspired by the success of Slack migrating from Enzyme to React Testing Library using codemods and LLMs. I have been using GitHub Copilot for a while now but one of the limitations with Copilot has been that I cannot use it programmatically.

Could we use LLMs to help fix ESLint issues at scale?

My first attempt was to use node-llama-cpp which lets you easily run LLMs locally with a nice JavaScript API. For most of my experiments I used llama3.1 8B which is a a lightweight open-source model by Meta that was the best I could run on my local machine.

I would generate a list of ESLint issues using eslint-interactive in JSON format and then give the LLM the ability to read the ESLint issues using the function calling feature of node-llama-cpp.

Terminal window
npx eslint-interactive --format json

Unfortunately, I ran into one big deal breaker: it was very difficult to get the LLM to generate fixes in a suitable format. It was especially important that any fixes that were created could be easily inserted into the original code.

Next, I came across aider, an AI pair programming CLI tool that hooks into many different LLMs and is specifically designed to handle structuring and inserting the LLM outputs into your codebase. aider also has a programmatic API which means that we can use it to fix ESLint issues at scale.

Some details about how I setup aider

  1. Install aider using pipx:

    Terminal window
    pipx install aider-chat
  2. I used ollama to handle running llama3.1 8B locally. You can install ollama using Homebrew:

    Terminal window
    brew install ollama
  3. You will need to run ollama in a separate terminal window.

    Terminal window
    ollama serve
  4. And pull the model that you wish to use:

    Terminal window
    ollama pull llama3.1
  5. You can interactively test aider with llama3.1 8B by running and seeing how it reacts to the prompt: “Hello”:

    Terminal window
    OLLAMA_API_BASE=http://127.0.0.1:11434 aider --model ollama/llama3.1

My strategy for using aider was:

Use eslint-interactive to generate a list of ESLint issues.

import { Core, type ESLintOptions } from 'eslint-interactive';
const core = new Core({
patterns: ['src', 'spec'],
eslintOptions: {
type: 'flat',
} as ESLintOptions,
});
const results = await core.lint();

Filter the issues to only include issues that I trust aider to fix.

const filteredResults = results.filter((result) =>
result.messages.some(
(message) => message.ruleId === 'unicorn/no-await-expression-member',
),
);

Construct a prompt for aider to fix one file and one rule at a time.

function convertToRelativePath(absolutePath: string): string {
const currentWorkingDirectory = process.cwd();
return path.relative(currentWorkingDirectory, absolutePath);
}
const ruleToBasePromptGuidance: Record<string, string> = {
'unicorn/no-await-expression-member':
'This means that we need to await the promise and then destructure on a separate line.',
};
const createPromptWithRelativeFilePaths = ({
eslintResults,
ruleId,
}: {
eslintResults: ESLint.LintResult[];
ruleId: string;
}) => {
return eslintResults
.filter((result) =>
result.messages.some((message) => message.ruleId === ruleId),
)
.map((result) => {
const relativeFilePath = convertToRelativePath(result.filePath);
const basePrompt = `The file ${relativeFilePath} has the ESLint issue: ${ruleId}. ${ruleToBasePromptGuidance[ruleId]} Specific information about
where the issues occur are as follows:`;
const messageIssues = result.messages
.filter((message) => message.ruleId === ruleId)
.map((message) => {
return `${message.message} at line ${message.line} and column ${message.column}`;
});
return {
prompt: `${basePrompt}\n${messageIssues.join('\n')}`,
relativeFilePath,
};
});
};

This results in a prompt that looks like:

The file src/file.ts has the ESLint issue: unicorn/no-await-expression-member. This means that we need to await the promise and then destructure on a separate line. Specific information about where the issues occur are as follows:

Do not access a member directly from an await expression. at line 14 and column 58

Do not access a member directly from an await expression. at line 25 and column 58

I can then use zx to run aider CLI from JavaScript.

import { $ } from 'zx';
const promptWithRelativeFilePaths = createPromptWithRelativeFilePaths({
eslintResults: results,
ruleId: 'unicorn/no-await-expression-member',
});
for await (const { prompt, relativeFilePath } of promptWithRelativeFilePaths) {
console.log('User:', prompt);
const response =
await $`OLLAMA_API_BASE=http://127.0.0.1:11434 aider ${relativeFilePath} --message "${prompt}" --model ollama/llama3.1 --no-auto-commits --yes --subtree-only`;
console.log('AI:', response.stdout);
}

A couple of notes about the options passed into aider:

  1. By passing in the relativeFilePath, aider will only modify that file. aider still has context about the rest of the codebase so we do not need add the rest of the files explicitly.
  2. --message is the prompt that is given to the LLM. Unfortunately, it does not seem possible to aider multiple prompts through the CLI.
  3. --no-auto-commits is important because we want to review the changes before they are committed. By default, aider will commit the changes it has made with an appropriate commit message.
  4. --yes is used to automatically accept any prompt that aider gives which is useful for running aider programmatically given that we cannot interactively respond to aider.
  5. --subtree-only is useful for large monorepos and ensures that aider is only looking at files in the current working directory rather than from the git root. This also enables using relative file paths from the current working directory rather than from the git root.

I had some success with this approach but I found that there were some issues that llama3.1 8B was not able to adequately fix. Given that aider has good support for many different LLMs, investigating the performance of the leading LLMs would be a good next step.

Overall#

This article is not about providing a single silver bullet to fix all ESLint issues in a codebase. But, hopefully by using a combination of eslint-interactive, grit, aider and other tools, you can vastly reduce the number of issues that require human intervention and focus on delivering business value.

I think it’s important to note that choosing which tools you want to use to resolve a specific set of ESLint rules will be very important in determining the success of your efforts.

It is also important to remember that it is acceptable to have existing violations that are not fixed immediately. It is usually not worthwhile investing in the developer effort to make a codebase fully compliant but it is absolutely worthwhile ensuring that future changes are less bug-prone, more consistent and more maintainable.