Table of Contents
Background
When you add a new rule to ESLint, all existing code is checked against that rule. In a large-scale monorepo, every time you add a new rule, you need to fix a large amount of existing code, and the blast radius grows significantly.
For example, adding just one new rule can trigger errors in dozens or even hundreds of files. This makes the cost of introducing new rules too high, making it difficult to improve code quality.
Ideally, we want to lint only the modified files, reducing the blast radius while gradually improving quality.
The Problem
Each app’s CI workflow was running ESLint against the entire project.
- name: ESLint
run: yarn lint:app-a
The problems with this approach:
- When adding a new rule, all existing files become lint targets
- Bulk fixes to existing code are required, increasing the PR’s blast radius
- As a result, teams hesitate to introduce new rules
Approaches Considered
Approach A: Maintaining Two Separate Config Files
The first approach we considered was splitting the ESLint config into two files: one for existing rules and one for new rules.
.eslintrc.cjs ← Existing rules (lint all files)
.eslintrc.new-rules.cjs ← New rules (lint changed files only)
# Existing rules: Run against the entire project
- name: ESLint (existing rules)
run: yarn lint:app-a
# New rules: Run only on changed files
- name: ESLint (new rules)
run: npx eslint -c .eslintrc.new-rules.cjs $CHANGED_FILES
This approach has the advantage of maintaining full-project linting for existing rules while gradually introducing new rules. However, it presented several issues in practice:
- Config file management becomes complex: Two config files must always be kept in sync
- Rule promotion is required: When a new rule becomes applicable to the entire codebase, it must be moved from
.eslintrc.new-rules.cjsto.eslintrc.cjs - Hard to track which rules are where: Rules are scattered across two locations, increasing management overhead
Approach B: Apply All Rules to Changed Files Only (Adopted)
The next approach we considered was keeping a single config file but limiting the lint target to changed files only.
.eslintrc.cjs ← Contains both existing and new rules (lint changed files only)
The concern with this approach was “won’t code quality degrade if existing rules aren’t applied to all files?” However, we determined there would be no practical issues for the following reasons:
- Existing code already passes existing rules: Even without full-project linting, existing files won’t suddenly be in violation
- Unchanged files don’t need to be linted: Violations only occur when code is changed
- A single config file keeps management simple: Just add new rules to
.eslintrc.cjs
Ultimately, we prioritized simplicity of configuration and ease of operation and adopted Approach B.
Solution: Running ESLint Only on Changed Files in a PR
We used the GitHub API to get the list of changed files in a PR and modified the workflow to run ESLint only on those files.
- name: Get changed files and run ESLint
uses: actions/github-script@v7
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const filtered = files
.filter(f => ['added', 'modified', 'renamed', 'copied'].includes(f.status))
.filter(f => f.filename.startsWith('apps/app-a/src/'))
.filter(f => /\.(ts|tsx|js|jsx)$/.test(f.filename))
.map(f => f.filename);
if (filtered.length === 0) {
core.info('No matching files to lint.');
return;
}
await exec.exec('npx', [
'eslint',
'-c',
'apps/app-a/.eslintrc.cjs',
...filtered,
]);
Key Points
1. Fetching Changed Files via GitHub API
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
We use github.paginate to fetch the list of changed files in the PR. This handles pagination, so all files are retrieved even when there are many changes.
2. Filtering Target Files
const filtered = files
.filter((f) => ['added', 'modified', 'renamed', 'copied'].includes(f.status))
.filter((f) => f.filename.startsWith('apps/app-a/src/'))
.filter((f) => /\.(ts|tsx|js|jsx)$/.test(f.filename))
.map((f) => f.filename);
Filtering is performed in 3 stages:
| Filter | Purpose |
|---|---|
status check | Only target added, modified, renamed, or copied files (exclude deleted files) |
startsWith check | Only target files under the app’s src/ directory |
| Extension check | Only target .ts, .tsx, .js, .jsx files |
3. Skip When No Changed Files
if (filtered.length === 0) {
core.info('No matching files to lint.');
return;
}
If there are no target files, ESLint execution is skipped. For example, if only config files or documentation were changed, ESLint won’t run.
4. Running ESLint on Changed Files Only
await exec.exec('npx', [
'eslint',
'-c',
'apps/app-a/.eslintrc.cjs',
...filtered,
]);
Instead of yarn lint:app-a (entire project), we pass only the filtered files as arguments to ESLint. When applying to each app, only the src/ path and ESLint config file path need to be changed.
Verification
We verified the actual behavior with a test PR.
When adding a new ESLint rule:
- Changed files: ESLint errors were detected
- Unchanged files with issues: ESLint errors were not detected
This confirmed that CI passes without affecting existing code when adding new rules.
Summary
| Before | After | |
|---|---|---|
| Lint target | Entire project | Only changed files in the PR |
| Impact of new rules | All files need fixing | Only changed files need fixes |
| Existing code quality | Requires bulk remediation | Gradually improved on modification |
With this approach, we can add new ESLint rules without friction and gradually improve code quality over time.
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.