Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: 'Dependency Review'
name: "Dependency Review"
on: [pull_request]

permissions:
Expand All @@ -8,7 +8,7 @@ jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
- name: "Checkout Repository"
uses: actions/checkout@v5
- name: 'Dependency Review'
- name: "Dependency Review"
uses: actions/dependency-review-action@v4
2 changes: 1 addition & 1 deletion .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache: "npm"

- name: Install dependencies
run: npm ci
Expand Down
82 changes: 82 additions & 0 deletions src/applySuppressions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const fs = require("node:fs");
const { dirname, isAbsolute, resolve } = require("node:path");

/** @typedef {import('eslint').ESLint.LintResult} LintResult */
/** @typedef {import('./options').Options} Options */
/**
* @typedef {Record<string, Record<string, { count: number }>>} SuppressedViolations
* @typedef {new (options: { filePath: string, cwd: string }) => { load: () => Promise<SuppressedViolations>, applySuppressions: (results: LintResult[], suppressions: SuppressedViolations) => { results: LintResult[], unused: SuppressedViolations } }} SuppressionsService
*/

/**
* Try to load SuppressionsService from ESLint..
* Returns null if not available, e.g. older ESLint versions.
* @returns {SuppressionsService | null} SuppressionsService instance or null
*/
function getSuppressionsService() {
// ESLint doesn't export SuppressionsService in package.json exports,
// so we need to resolve the path directly
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to open an issue in eslint repo, ideally they should export or provide API for such things

const eslintPath = require.resolve("eslint");
const eslintDir = eslintPath.replace(/\/lib\/api\.js$/, "");

try {
const { SuppressionsService } = require(
`${eslintDir}/lib/services/suppressions-service.js`,
);

return SuppressionsService;
} catch {
return null;
}
}

/**
* @param {LintResult[]} results results
* @param {Options} options options
* @returns {Promise<LintResult[]>} suppressed results
*/
async function applySuppressions(results, options) {
const SuppressionsService = getSuppressionsService();
if (!SuppressionsService) {
return results;
}

const { context } = options;
if (!context) {
return results;
}

const suppressionsLocation =
options.suppressionsLocation || "eslint-suppressions.json";
const filePath = isAbsolute(suppressionsLocation)
? suppressionsLocation
: resolve(context, suppressionsLocation);

// Only apply suppressions if the file exists
if (!fs.existsSync(filePath)) {
return results;
}

// cwd must be the directory containing the suppressions file,
// since paths in the file are relative to that location
const suppressionsCwd = dirname(filePath);

const suppressions = new SuppressionsService({
filePath,
cwd: suppressionsCwd,
});

try {
const suppressionData = await suppressions.load();
const { results: filteredResults } = suppressions.applySuppressions(
results,
suppressionData,
);
return filteredResults;
} catch {
// Return original results if loading/applying suppressions fails
return results;
}
}

module.exports = applySuppressions;
9 changes: 4 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,20 @@ class ESLintWebpackPlugin {
// this differentiates one from the other when being cached.
this.key = compiler.name || `${this.key}_${(compilerId += 1)}`;

const excludedFiles = parseFiles(
this.options.exclude || [],
this.getContext(compiler),
);
const context = this.getContext(compiler);
const excludedFiles = parseFiles(this.options.exclude || [], context);
const resourceQueries = arrify(this.options.resourceQueryExclude || []);
const excludedResourceQueries = resourceQueries.map((item) =>
item instanceof RegExp ? item : new RegExp(item),
);

const options = {
...this.options,
context,
exclude: excludedFiles,
resourceQueryExclude: excludedResourceQueries,
extensions: arrify(this.options.extensions),
files: parseFiles(this.options.files || "", this.getContext(compiler)),
files: parseFiles(this.options.files || "", context),
};

const foldersToExclude = this.options.exclude
Expand Down
4 changes: 4 additions & 0 deletions src/linter.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { dirname, isAbsolute, join } = require("node:path");

const ESLintError = require("./ESLintError");
const applySuppressions = require("./applySuppressions");
const { getESLint } = require("./getESLint");
const { arrify } = require("./utils");

Expand Down Expand Up @@ -232,6 +233,9 @@ async function linter(key, options, compilation) {

await cleanup();

// Apply suppressions from eslint-suppressions.json if available
results = await applySuppressions(results, options);

for (const result of results) {
crossRunResultStorage[result.filePath] = result;
}
Expand Down
1 change: 1 addition & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const schema = require("./options.json");
* @property {number | boolean=} threads number of worker threads
* @property {RegExp | RegExp[]=} resourceQueryExclude Specify the resource query to exclude
* @property {string=} configType config type
* @property {string=} suppressionsLocation path to suppressions file (relative to options.context)
*/

/** @typedef {PluginOptions & ESLintOptions} Options */
Expand Down
4 changes: 4 additions & 0 deletions src/options.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@
"threads": {
"description": "Default is false. Set to true for an auto-selected pool size based on number of cpus. Set to a number greater than 1 to set an explicit pool size. Set to false, 1, or less to disable and only run in main process.",
"anyOf": [{ "type": "number" }, { "type": "boolean" }]
},
"suppressionsLocation": {
"description": "Path to ESLint suppressions file. Must be relative to `options.context`. Defaults to 'eslint-suppressions.json'. Suppressions are applied if the file exists and ESLint >= 9.24.0 is installed.",
"type": "string"
}
}
}
1 change: 1 addition & 0 deletions test/fixtures/other-folder/suppressed-error-entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './suppressed-error';
2 changes: 2 additions & 0 deletions test/fixtures/other-folder/suppressed-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// This file has errors that should be suppressed
var foo = undefinedVariable
1 change: 1 addition & 0 deletions test/fixtures/subdir/suppressed-error-entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './suppressed-error';
2 changes: 2 additions & 0 deletions test/fixtures/subdir/suppressed-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// This file has errors that should be suppressed
var foo = undefinedVariable
1 change: 1 addition & 0 deletions test/fixtures/suppressed-error-entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require('./suppressed-error');
2 changes: 2 additions & 0 deletions test/fixtures/suppressed-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// This file has errors that should be suppressed
var foo = undefinedVariable
194 changes: 194 additions & 0 deletions test/suppressions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";

import pack from "./utils/pack";

const testDir = join(__dirname, "fixtures");

describe("suppressions", () => {
const suppressionsFile = join(testDir, "eslint-suppressions.json");

afterEach(() => {
if (existsSync(suppressionsFile)) {
unlinkSync(suppressionsFile);
}
});

it("should report errors when no suppressions file exists", async () => {
const compiler = pack("suppressed-error", {
cwd: testDir,
});

const stats = await compiler.runAsync();
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(true);
});

it("should suppress errors when suppressions file exists", async () => {
// Create suppressions file that matches the violations in suppressed-error.js
// The file has: var foo = undefinedVariable
// Which triggers: no-var (error), no-undef (error), no-unused-vars (error)
const suppressions = {
"suppressed-error.js": {
"no-var": { count: 1 },
"no-undef": { count: 1 },
"no-unused-vars": { count: 1 },
},
};

writeFileSync(suppressionsFile, JSON.stringify(suppressions, null, 2));

const compiler = pack("suppressed-error", {
cwd: testDir,
});

const stats = await compiler.runAsync();
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
});

it("should support custom suppressionsLocation option", async () => {
const customSuppressionsFile = join(testDir, "custom-suppressions.json");

const suppressions = {
"suppressed-error.js": {
"no-var": { count: 1 },
"no-undef": { count: 1 },
"no-unused-vars": { count: 1 },
},
};

writeFileSync(
customSuppressionsFile,
JSON.stringify(suppressions, null, 2),
);

try {
const compiler = pack("suppressed-error", {
cwd: testDir,
suppressionsLocation: "custom-suppressions.json",
});

const stats = await compiler.runAsync();
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
} finally {
if (existsSync(customSuppressionsFile)) {
unlinkSync(customSuppressionsFile);
}
}
});

it("should still report unsuppressed errors", async () => {
// Only suppress some of the violations, not suppressing no-undef and
// no-unused-vars
const suppressions = {
"suppressed-error.js": {
"no-var": { count: 1 },
},
};

writeFileSync(suppressionsFile, JSON.stringify(suppressions, null, 2));

const compiler = pack("suppressed-error", {
cwd: testDir,
});

const stats = await compiler.runAsync();
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(true);
});

describe("with context as subdirectory", () => {
const subdirTestDir = join(testDir, "subdir");
const parentSuppressionsFile = join(testDir, "eslint-suppressions.json");

afterEach(() => {
if (existsSync(parentSuppressionsFile)) {
unlinkSync(parentSuppressionsFile);
}
});

it("should suppress errors with suppressionsLocation pointing to parent directory", async () => {
// Suppressions file is at test/fixtures/eslint-suppressions.json
// Context is test/fixtures/subdir/
// suppressionsLocation is ../eslint-suppressions.json
//
// Paths in suppressions file are relative to the suppressions file location (test/fixtures/)
const suppressions = {
"subdir/suppressed-error.js": {
"no-var": { count: 1 },
"no-undef": { count: 1 },
"no-unused-vars": { count: 1 },
},
};

writeFileSync(
parentSuppressionsFile,
JSON.stringify(suppressions, null, 2),
);

const compiler = pack("subdir/suppressed-error", {
context: subdirTestDir,
suppressionsLocation: "../eslint-suppressions.json",
});

const stats = await compiler.runAsync();
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
});

it("should suppress errors with absolute suppressionsLocation path", async () => {
const suppressions = {
"subdir/suppressed-error.js": {
"no-var": { count: 1 },
"no-undef": { count: 1 },
"no-unused-vars": { count: 1 },
},
};

writeFileSync(
parentSuppressionsFile,
JSON.stringify(suppressions, null, 2),
);

const compiler = pack("subdir/suppressed-error", {
context: subdirTestDir,
suppressionsLocation: parentSuppressionsFile, // Absolute path
});

const stats = await compiler.runAsync();
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
});

it("should report errors when suppressionsLocation is relative but file does not exist", async () => {
const compiler = pack("subdir/suppressed-error", {
context: subdirTestDir,
suppressionsLocation: "../nonexistent-suppressions.json",
});

const stats = await compiler.runAsync();
expect(stats.hasErrors()).toBe(true);
});
});

describe("with context omitted (defaults to webpack context)", () => {
it("should suppress errors with default suppressionsLocation", async () => {
const suppressions = {
"suppressed-error.js": {
"no-var": { count: 1 },
"no-undef": { count: 1 },
"no-unused-vars": { count: 1 },
},
};

writeFileSync(suppressionsFile, JSON.stringify(suppressions, null, 2));

const compiler = pack("suppressed-error", {});
const stats = await compiler.runAsync();
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
});
});
});
Loading