From 01a41726e76aa6dcdaad65f5a72d07bd15ddc0fa Mon Sep 17 00:00:00 2001 From: Nicklas Gummesson Date: Fri, 5 Dec 2025 01:25:21 +0100 Subject: [PATCH 1/2] Fix errors despite bulk suppressions --- .github/workflows/dependency-review.yml | 6 +- .github/workflows/nodejs.yml | 2 +- src/applySuppressions.js | 82 ++++++++ src/index.js | 9 +- src/linter.js | 4 + src/options.js | 1 + src/options.json | 4 + .../other-folder/suppressed-error-entry.js | 1 + .../fixtures/other-folder/suppressed-error.js | 2 + .../fixtures/subdir/suppressed-error-entry.js | 1 + test/fixtures/subdir/suppressed-error.js | 2 + test/fixtures/suppressed-error-entry.js | 1 + test/fixtures/suppressed-error.js | 2 + test/suppressions.test.js | 194 ++++++++++++++++++ types/applySuppressions.d.ts | 34 +++ types/options.d.ts | 5 + 16 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 src/applySuppressions.js create mode 100644 test/fixtures/other-folder/suppressed-error-entry.js create mode 100644 test/fixtures/other-folder/suppressed-error.js create mode 100644 test/fixtures/subdir/suppressed-error-entry.js create mode 100644 test/fixtures/subdir/suppressed-error.js create mode 100644 test/fixtures/suppressed-error-entry.js create mode 100644 test/fixtures/suppressed-error.js create mode 100644 test/suppressions.test.js create mode 100644 types/applySuppressions.d.ts diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 8461b45..21aaefe 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,4 +1,4 @@ -name: 'Dependency Review' +name: "Dependency Review" on: [pull_request] permissions: @@ -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 diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 6186f56..0f5323f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -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 diff --git a/src/applySuppressions.js b/src/applySuppressions.js new file mode 100644 index 0000000..b2f45a6 --- /dev/null +++ b/src/applySuppressions.js @@ -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>} SuppressedViolations + * @typedef {new (options: { filePath: string, cwd: string }) => { load: () => Promise, 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 + 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} 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; diff --git a/src/index.js b/src/index.js index 7c53bcf..c5025fb 100644 --- a/src/index.js +++ b/src/index.js @@ -35,10 +35,8 @@ 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), @@ -46,10 +44,11 @@ class ESLintWebpackPlugin { 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 diff --git a/src/linter.js b/src/linter.js index 3121cef..4472225 100644 --- a/src/linter.js +++ b/src/linter.js @@ -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"); @@ -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; } diff --git a/src/options.js b/src/options.js index 77761d8..56a215a 100644 --- a/src/options.js +++ b/src/options.js @@ -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 */ diff --git a/src/options.json b/src/options.json index 298427c..a61e80a 100644 --- a/src/options.json +++ b/src/options.json @@ -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" } } } diff --git a/test/fixtures/other-folder/suppressed-error-entry.js b/test/fixtures/other-folder/suppressed-error-entry.js new file mode 100644 index 0000000..91b3cae --- /dev/null +++ b/test/fixtures/other-folder/suppressed-error-entry.js @@ -0,0 +1 @@ +import './suppressed-error'; diff --git a/test/fixtures/other-folder/suppressed-error.js b/test/fixtures/other-folder/suppressed-error.js new file mode 100644 index 0000000..ec5e984 --- /dev/null +++ b/test/fixtures/other-folder/suppressed-error.js @@ -0,0 +1,2 @@ +// This file has errors that should be suppressed +var foo = undefinedVariable diff --git a/test/fixtures/subdir/suppressed-error-entry.js b/test/fixtures/subdir/suppressed-error-entry.js new file mode 100644 index 0000000..91b3cae --- /dev/null +++ b/test/fixtures/subdir/suppressed-error-entry.js @@ -0,0 +1 @@ +import './suppressed-error'; diff --git a/test/fixtures/subdir/suppressed-error.js b/test/fixtures/subdir/suppressed-error.js new file mode 100644 index 0000000..ec5e984 --- /dev/null +++ b/test/fixtures/subdir/suppressed-error.js @@ -0,0 +1,2 @@ +// This file has errors that should be suppressed +var foo = undefinedVariable diff --git a/test/fixtures/suppressed-error-entry.js b/test/fixtures/suppressed-error-entry.js new file mode 100644 index 0000000..43beb3a --- /dev/null +++ b/test/fixtures/suppressed-error-entry.js @@ -0,0 +1 @@ +require('./suppressed-error'); diff --git a/test/fixtures/suppressed-error.js b/test/fixtures/suppressed-error.js new file mode 100644 index 0000000..ec5e984 --- /dev/null +++ b/test/fixtures/suppressed-error.js @@ -0,0 +1,2 @@ +// This file has errors that should be suppressed +var foo = undefinedVariable diff --git a/test/suppressions.test.js b/test/suppressions.test.js new file mode 100644 index 0000000..efe1469 --- /dev/null +++ b/test/suppressions.test.js @@ -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); + }); + }); +}); diff --git a/types/applySuppressions.d.ts b/types/applySuppressions.d.ts new file mode 100644 index 0000000..f8c8271 --- /dev/null +++ b/types/applySuppressions.d.ts @@ -0,0 +1,34 @@ +export = applySuppressions; +/** + * @param {LintResult[]} results results + * @param {Options} options options + * @returns {Promise} suppressed results + */ +declare function applySuppressions( + results: LintResult[], + options: Options, +): Promise; +declare namespace applySuppressions { + export { LintResult, Options, SuppressedViolations, SuppressionsService }; +} +type LintResult = import("eslint").ESLint.LintResult; +type Options = import("./options").Options; +type SuppressedViolations = Record< + string, + Record< + string, + { + count: number; + } + > +>; +type SuppressionsService = new (options: { filePath: string; cwd: string }) => { + load: () => Promise; + applySuppressions: ( + results: LintResult[], + suppressions: SuppressedViolations, + ) => { + results: LintResult[]; + unused: SuppressedViolations; + }; +}; diff --git a/types/options.d.ts b/types/options.d.ts index 1a2c79f..b16d1bc 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -80,6 +80,10 @@ export type PluginOptions = { * config type */ configType?: string | undefined; + /** + * path to suppressions file (relative to options.context) + */ + suppressionsLocation?: string | undefined; }; export type Options = PluginOptions & ESLintOptions; /** @@ -118,6 +122,7 @@ export function getESLintOptions(loaderOptions: Options): ESLintOptions; * @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 */ /** From fde678fa188640a3da6785aaeec5d04a84563933 Mon Sep 17 00:00:00 2001 From: Nicklas Gummesson Date: Fri, 20 Feb 2026 18:32:22 +0100 Subject: [PATCH 2/2] Address code review request Make comment into a TODO to explain and motivate fix for workaround in the future. --- src/applySuppressions.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/applySuppressions.js b/src/applySuppressions.js index b2f45a6..bfc7805 100644 --- a/src/applySuppressions.js +++ b/src/applySuppressions.js @@ -14,8 +14,9 @@ const { dirname, isAbsolute, resolve } = require("node:path"); * @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 + // TODO: Migrate to official API once available, ESLint doesn't export SuppressionsService yet. + // Upstream issue: https://github.com/eslint/eslint/issues/19603 + // RFC (adds `applySuppressions` to ESLint constructor): https://github.com/eslint/rfcs/pull/142 const eslintPath = require.resolve("eslint"); const eslintDir = eslintPath.replace(/\/lib\/api\.js$/, "");