From e859eb39f8055ab55cff01bdea16ef9030ee6f60 Mon Sep 17 00:00:00 2001 From: rasmi Date: Thu, 21 May 2026 00:30:11 -0400 Subject: [PATCH 1/3] [Tests] add parser version guards for ESLint v10 (TS_NEW / ANGULAR / BABEL_NEW) --- tests/dep-time-travel.sh | 15 +++++ tests/src/rule-tester.js | 71 ++++++++++++++++++++++-- tests/src/rules/first.js | 4 +- tests/src/rules/group-exports.js | 5 +- tests/src/rules/namespace.js | 12 ++-- tests/src/rules/no-empty-named-blocks.js | 12 ++-- tests/src/rules/no-unused-modules.js | 6 +- tests/src/rules/order.js | 10 ++-- tests/src/utils.js | 30 +++++++++- 9 files changed, 135 insertions(+), 30 deletions(-) diff --git a/tests/dep-time-travel.sh b/tests/dep-time-travel.sh index f7a86d07f0..287ede50ce 100755 --- a/tests/dep-time-travel.sh +++ b/tests/dep-time-travel.sh @@ -58,3 +58,18 @@ if [ "${ESLINT_VERSION}" = '8' ]; then echo "Build self" npm run build fi + +# ESLint 10 requires newer parsers +if [[ "$ESLINT_VERSION" -ge "10" ]]; then + echo "Installing @typescript-eslint/parser v8 for ESLint 10..." + npm i --no-save @typescript-eslint/parser@8 + + echo "Installing @angular-eslint/template-parser v21.3.0+ for ESLint 10..." + npm i --no-save '@angular-eslint/template-parser@^21.3.0' + + # @babel/eslint-parser v8 requires Node ^20.19.0 || >=22.12.0; v7 doesn't support ESLint 10 + if [[ "$TRAVIS_NODE_VERSION" -eq "20" ]] || [[ "$TRAVIS_NODE_VERSION" -ge "22" ]]; then + echo "Installing @babel/eslint-parser v8 for ESLint 10..." + npm i --no-save @babel/core@'^8.0.0-rc.2' @babel/eslint-parser@'^8.0.0-rc.2' + fi +fi diff --git a/tests/src/rule-tester.js b/tests/src/rule-tester.js index 103f2fd6fe..6e8820151d 100644 --- a/tests/src/rule-tester.js +++ b/tests/src/rule-tester.js @@ -10,17 +10,60 @@ export function withoutAutofixOutput(test) { class FlatCompatRuleTester { constructor(testerConfig = { parserOptions: { sourceType: 'script' } }) { + this._constructorConfig = testerConfig; this._tester = new RuleTester(FlatCompatRuleTester._flatCompat(testerConfig)); } + // Skip tests that reference a parser that resolved to `false` (unavailable for this ESLint version) + static _hasUnavailableParser(t) { + return t && typeof t === 'object' && 'parser' in t && t.parser === false; + } + + // Check if a parser resolved to `false` (unavailable for this ESLint version) + static _isUnavailableParser(parser) { + return parser === false; + } + run(ruleName, rule, tests) { + // Skip entire suite if the constructor parser is unavailable in this ESLint version + if (FlatCompatRuleTester._isUnavailableParser( + this._constructorConfig && this._constructorConfig.parser, + )) { + return; + } + + // Pass constructor parser so per-test configs can inherit babel options + const ctorParser = this._constructorConfig && this._constructorConfig.parser; + this._tester.run(ruleName, rule, { - valid: tests.valid.map((t) => FlatCompatRuleTester._flatCompat(t)), - invalid: tests.invalid.map((t) => FlatCompatRuleTester._flatCompat(t)), + valid: tests.valid + .filter((t) => !FlatCompatRuleTester._hasUnavailableParser(t)) + .map((t) => FlatCompatRuleTester._flatCompat(t, ctorParser)), + invalid: tests.invalid + .filter((t) => !FlatCompatRuleTester._hasUnavailableParser(t)) + .map((t) => FlatCompatRuleTester._flatCompat(t, ctorParser)), }); } - static _flatCompat(config) { + // @babel/eslint-parser requires explicit config; babel-eslint enabled all syntax by default. + // When tests use @babel/eslint-parser, inject the equivalent config. + static _babelParserOptions(parser) { + if (typeof parser !== 'string' || !parser.includes('@babel/eslint-parser')) { + return null; + } + return { + requireConfigFile: false, + babelOptions: { + configFile: false, + babelrc: false, // the project's .babelrc is for babel-register (Babel 6 build toolchain), not for parsing test snippets + parserOpts: { + plugins: ['flow', ['decorators', { decoratorsBeforeExport: true }], 'exportDefaultFrom'], + }, + }, + }; + } + + static _flatCompat(config, ctorParser) { if (!config || !usingFlatConfig || typeof config !== 'object') { return config; } @@ -29,6 +72,23 @@ class FlatCompatRuleTester { const { ecmaVersion, sourceType, ...remainingParserOptions } = parserOptions; const parserObj = typeof parser === 'string' ? require(parser) : parser; + // Inject babelOptions if either this test or the constructor uses @babel/eslint-parser. + // Deep-merge babelOptions so test-specific values override defaults but babelrc/configFile survive. + const babelOpts = FlatCompatRuleTester._babelParserOptions(parser) + || FlatCompatRuleTester._babelParserOptions(ctorParser); + let flatParserOptions; + if (babelOpts) { + const { babelOptions: defaultBabelOptions, ...defaultRest } = babelOpts; + const { babelOptions: testBabelOptions, ...testRest } = remainingParserOptions; + flatParserOptions = { + ...defaultRest, + ...testRest, + babelOptions: { ...defaultBabelOptions, ...testBabelOptions }, + }; + } else { + flatParserOptions = { ...remainingParserOptions }; + } + return { ...remainingConfig, languageOptions: { @@ -36,9 +96,8 @@ class FlatCompatRuleTester { ...parserObj ? { parser: parserObj } : {}, ...ecmaVersion ? { ecmaVersion } : {}, ...sourceType ? { sourceType } : {}, - parserOptions: { - ...remainingParserOptions, - }, + // Only set parserOptions if non-empty, to avoid overriding constructor parserOptions + ...Object.keys(flatParserOptions).length > 0 ? { parserOptions: flatParserOptions } : {}, }, }; } diff --git a/tests/src/rules/first.js b/tests/src/rules/first.js index 52b71db861..ddca13d936 100644 --- a/tests/src/rules/first.js +++ b/tests/src/rules/first.js @@ -1,4 +1,4 @@ -import { test, getTSParsers, testVersion } from '../utils'; +import { test, getTSParsers, testVersion, parsers } from '../utils'; import fs from 'fs'; import path from 'path'; @@ -26,7 +26,7 @@ ruleTester.run('first', rule, { testVersion('>= 7', () => ({ // issue #2210 code: String(fs.readFileSync(path.join(__dirname, '../../files/component.html'))), - parser: require.resolve('@angular-eslint/template-parser'), + parser: parsers.ANGULAR, })), ), invalid: [ diff --git a/tests/src/rules/group-exports.js b/tests/src/rules/group-exports.js index 6f05bc866b..0d6693729b 100644 --- a/tests/src/rules/group-exports.js +++ b/tests/src/rules/group-exports.js @@ -1,7 +1,6 @@ -import { test } from '../utils'; +import { test, parsers } from '../utils'; import { RuleTester } from '../rule-tester'; import rule from 'rules/group-exports'; -import { resolve } from 'path'; import { default as babelPresetFlow } from 'babel-preset-flow'; /* eslint-disable max-len */ @@ -11,7 +10,7 @@ const errors = { }; /* eslint-enable max-len */ const ruleTester = new RuleTester({ - parser: resolve(__dirname, '../../../node_modules/babel-eslint'), + parser: parsers.BABEL_OLD, parserOptions: { babelOptions: { configFile: false, diff --git a/tests/src/rules/namespace.js b/tests/src/rules/namespace.js index 96c7d91559..703bf65bca 100644 --- a/tests/src/rules/namespace.js +++ b/tests/src/rules/namespace.js @@ -1,4 +1,4 @@ -import { test, SYNTAX_CASES, getTSParsers, testVersion, testFilePath, parsers } from '../utils'; +import { test, SYNTAX_CASES, getTSParsers, testVersion, testFilePath, parsers, typescriptEslintParserSatisfies } from '../utils'; import { RuleTester } from '../rule-tester'; import flatMap from 'array.prototype.flatmap'; @@ -143,8 +143,9 @@ const valid = [ }), // Typescript - ...flatMap(getTSParsers(), (parser) => [ - test({ + ...flatMap(getTSParsers(), (parser) => [].concat( + // @typescript-eslint/parser v8 changed how dotted namespace declarations are resolved + !typescriptEslintParserSatisfies('< 8') ? [] : test({ code: ` import * as foo from "./typescript-declare-nested" foo.bar.MyFunction() @@ -182,7 +183,7 @@ const valid = [ 'import/resolver': { 'eslint-import-resolver-typescript': true }, }, }), - ]), + )), ...SYNTAX_CASES, @@ -342,7 +343,8 @@ const invalid = [].concat( /////////////////////// // deep dereferences // ////////////////////// -[['deep', require.resolve('espree')], ['deep-es7', parsers.BABEL_OLD]].forEach(function ([folder, parser]) { // close over params +[['deep', parsers.ESPREE], ['deep-es7', parsers.BABEL_OLD]].forEach(function ([folder, parser]) { // close over params + if (!parser) { return; } valid.push( test({ parser, code: `import * as a from "./${folder}/a"; console.log(a.b.c.d.e)` }), test({ parser, code: `import { b } from "./${folder}/a"; console.log(b.c.d.e)` }), diff --git a/tests/src/rules/no-empty-named-blocks.js b/tests/src/rules/no-empty-named-blocks.js index d9514a845b..0ed66ee389 100644 --- a/tests/src/rules/no-empty-named-blocks.js +++ b/tests/src/rules/no-empty-named-blocks.js @@ -1,4 +1,4 @@ -import { parsers, test } from '../utils'; +import { parsers, test, typescriptEslintParserSatisfies } from '../utils'; import { RuleTester } from '../rule-tester'; @@ -34,12 +34,13 @@ ruleTester.run('no-empty-named-blocks', rule, { test({ code: `import * as Namespace from 'mod';` }), // Typescript - parsers.TS_NEW ? [ + parsers.TS_NEW ? [].concat( test({ code: `import type Default from 'mod';`, parser: parsers.TS_NEW }), test({ code: `import type { Named } from 'mod';`, parser: parsers.TS_NEW }), - test({ code: `import type Default, { Named } from 'mod';`, parser: parsers.TS_NEW }), + // `import type Default, { Named }` syntax was removed in TypeScript 5.0 / @typescript-eslint/parser v8 + !typescriptEslintParserSatisfies('< 8') ? [] : test({ code: `import type Default, { Named } from 'mod';`, parser: parsers.TS_NEW }), test({ code: `import type * as Namespace from 'mod';`, parser: parsers.TS_NEW }), - ] : [], + ) : [], // Flow test({ code: `import typeof Default from 'mod'; // babel old`, parser: parsers.BABEL_OLD }), @@ -88,7 +89,8 @@ ruleTester.run('no-empty-named-blocks', rule, { ], parsers.TS_NEW, ), - test({ + // `import type Default, {}` syntax was removed in TypeScript 5.0 / @typescript-eslint/parser v8 + !typescriptEslintParserSatisfies('< 8') ? [] : test({ code: `import type Default, {} from 'mod';`, output: `import type Default from 'mod';`, parser: parsers.TS_NEW, diff --git a/tests/src/rules/no-unused-modules.js b/tests/src/rules/no-unused-modules.js index c24c628b1b..1bb6fc7622 100644 --- a/tests/src/rules/no-unused-modules.js +++ b/tests/src/rules/no-unused-modules.js @@ -18,7 +18,7 @@ try { // TODO: figure out why these tests fail in eslint 4 and 5 const isESLint4TODO = semver.satisfies(eslintPkg.version, '^4 || ^5'); -const isESLint9 = semver.satisfies(eslintPkg.version, '>=9'); +const isESLint9Only = semver.satisfies(eslintPkg.version, '>=9 <10'); const ruleTester = new RuleTester(); const typescriptRuleTester = new RuleTester(typescriptConfig); @@ -1488,7 +1488,9 @@ describe('parser ignores prefixes like BOM and hashbang', () => { }); }); -(isESLint9 ? describe : describe.skip)('with eslint 9+', () => { +// On v9, FileEnumerator requires an .eslintrc even under flat config; verify the error message. +// On v10, FileEnumerator is gone and listFilesWithNodeFs doesn't need .eslintrc, so this is moot. +(isESLint9Only ? describe : describe.skip)('with eslint 9 (FileEnumerator + flat config)', () => { it('provides meaningful error when eslintrc is not present', () => { const tmp = require('tmp'); diff --git a/tests/src/rules/order.js b/tests/src/rules/order.js index 9b0a2a63ff..b538990c98 100644 --- a/tests/src/rules/order.js +++ b/tests/src/rules/order.js @@ -1,16 +1,15 @@ -import { test, getTSParsers, getNonDefaultParsers, testFilePath, parsers } from '../utils'; +import { test, getTSParsers, getNonDefaultParsers, testFilePath, parsers, typescriptEslintParserSatisfies } from '../utils'; import { RuleTester, withoutAutofixOutput } from '../rule-tester'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; import flatMap from 'array.prototype.flatmap'; -import { resolve } from 'path'; import isCoreModule from 'is-core-module'; import { default as babelPresetFlow } from 'babel-preset-flow'; const ruleTester = new RuleTester(); const flowRuleTester = new RuleTester({ - parser: resolve(__dirname, '../../../node_modules/babel-eslint'), + parser: parsers.BABEL_OLD, parserOptions: { babelOptions: { configFile: false, @@ -5097,7 +5096,8 @@ context('TypeScript', function () { ], }), // named import order - test({ + // `import type Default, { Named }` syntax was removed in TypeScript 5.0 / @typescript-eslint/parser v8 + ...!typescriptEslintParserSatisfies('< 8') ? [] : [test({ code: ` import { type Z, A } from "./Z"; import type N, { E, D } from "./Z"; @@ -5118,7 +5118,7 @@ context('TypeScript', function () { { message: '`D` import should occur before import of `E`' }, { message: '`G` import should occur before import of `L`' }, ], - }), + })], test({ code: ` const { B, /* Hello World */ A } = require("./Z"); diff --git a/tests/src/utils.js b/tests/src/utils.js index 24d5504a71..c2bf4a72bf 100644 --- a/tests/src/utils.js +++ b/tests/src/utils.js @@ -9,8 +9,34 @@ import 'babel-eslint'; export const parsers = { ESPREE: require.resolve('espree'), TS_OLD: semver.satisfies(eslintPkg.version, '>=4.0.0 <6.0.0') && semver.satisfies(typescriptPkg.version, '<4') && require.resolve('typescript-eslint-parser'), - TS_NEW: semver.satisfies(eslintPkg.version, '> 5') && require.resolve('@typescript-eslint/parser'), - BABEL_OLD: require.resolve('babel-eslint'), + TS_NEW: semver.satisfies(eslintPkg.version, '> 5') && (() => { + try { + const parserPkg = require('@typescript-eslint/parser/package.json'); + // @typescript-eslint/parser v8.56+ supports ESLint v10; older versions do not + if (semver.major(eslintPkg.version) >= 10 && !semver.satisfies(parserPkg.version, '>=8.56.0')) { + return false; + } + return require.resolve('@typescript-eslint/parser'); + } catch (e) { + return false; + } + })(), + // babel-eslint is unmaintained and is not compatible with ESLint v10+; tests that need + // babel parsing on v10 should use parsers.BABEL_NEW (`@babel/eslint-parser`) explicitly. + BABEL_OLD: semver.major(eslintPkg.version) < 10 ? require.resolve('babel-eslint') : false, + BABEL_NEW: (() => { + try { return require.resolve('@babel/eslint-parser'); } catch (e) { return false; } + })(), + // used by first.js for issue #2210; v21.3.0+ supports ESLint v10, v13 does not + ANGULAR: (() => { + try { + const parserPkg = require('@angular-eslint/template-parser/package.json'); + if (semver.major(eslintPkg.version) >= 10 && !semver.satisfies(parserPkg.version, '>=21.3.0')) { + return false; + } + return require.resolve('@angular-eslint/template-parser'); + } catch (e) { return false; } + })(), }; export function tsVersionSatisfies(specifier) { From 7d22beee597cf2e460bbb2f8e0c7db658eba44b6 Mon Sep 17 00:00:00 2001 From: rasmi Date: Thu, 21 May 2026 00:32:05 -0400 Subject: [PATCH 2/3] [New] add eslint v10 support --- .github/workflows/eslint-8+.yml | 9 +++ package.json | 4 +- src/core/sourceType.js | 6 +- src/exportMap/childContext.js | 2 +- src/exportMap/typescript.js | 9 ++- src/rules/no-unused-modules.js | 107 +++++++++++++++++++++++++++++++- src/rules/order.js | 16 ++++- src/scc.js | 3 +- tests/src/core/sourceType.js | 25 ++++++++ tests/src/rule-tester.js | 19 +++++- utils/contextCompat.js | 4 ++ utils/declaredScope.js | 2 +- utils/parse.js | 2 +- 13 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 tests/src/core/sourceType.js diff --git a/.github/workflows/eslint-8+.yml b/.github/workflows/eslint-8+.yml index ff79a5fa27..ea3d1adb19 100644 --- a/.github/workflows/eslint-8+.yml +++ b/.github/workflows/eslint-8+.yml @@ -36,9 +36,18 @@ jobs: - macos-latest node-version: ${{ fromJson(needs.matrix.outputs.latest) }} eslint: + - 10 - 9 - 8 exclude: + - node-version: 17 + eslint: 10 + - node-version: 16 + eslint: 10 + - node-version: 14 + eslint: 10 + - node-version: 12 + eslint: 10 - node-version: 16 eslint: 9 - node-version: 14 diff --git a/package.json b/package.json index befbb62fb7..04f2adf1be 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "chai": "^4.3.10", "cross-env": "^4.0.0", "escope": "^3.6.0", - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 || ^10", "eslint-doc-generator": "^1.6.1", "eslint-import-resolver-node": "file:./resolvers/node", "eslint-import-resolver-typescript": "^1.0.2 || ^1.1.1", @@ -111,7 +111,7 @@ "typescript-eslint-parser": "^15 || ^20 || ^22" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 || ^10" }, "dependencies": { "@rtsao/scc": "^1.1.0", diff --git a/src/core/sourceType.js b/src/core/sourceType.js index 5ff92edc97..e83ef71bc0 100644 --- a/src/core/sourceType.js +++ b/src/core/sourceType.js @@ -3,10 +3,10 @@ * @returns 'module' | 'script' | 'commonjs' | undefined */ export default function sourceType(context) { - if ('sourceType' in context.parserOptions) { - return context.parserOptions.sourceType; - } if ('languageOptions' in context && context.languageOptions) { return context.languageOptions.sourceType; } + if (context.parserOptions && 'sourceType' in context.parserOptions) { + return context.parserOptions.sourceType; + } } diff --git a/src/exportMap/childContext.js b/src/exportMap/childContext.js index 8994ac206a..1cada66edc 100644 --- a/src/exportMap/childContext.js +++ b/src/exportMap/childContext.js @@ -48,7 +48,7 @@ export default function childContext(path, context) { return { cacheKey: optionsToken + settingsHash + String(path), settings, - parserOptions, + parserOptions: parserOptions || languageOptions && languageOptions.parserOptions, parserPath, path, languageOptions, diff --git a/src/exportMap/typescript.js b/src/exportMap/typescript.js index 7db4356da8..e7d2cb7c24 100644 --- a/src/exportMap/typescript.js +++ b/src/exportMap/typescript.js @@ -7,7 +7,10 @@ const tsconfigCache = new Map(); function readTsConfig(context) { const tsconfigInfo = tsConfigLoader({ - cwd: context.parserOptions && context.parserOptions.tsconfigRootDir || process.cwd(), + cwd: (context.parserOptions + ? context.parserOptions.tsconfigRootDir + : context.languageOptions && context.languageOptions.parserOptions && context.languageOptions.parserOptions.tsconfigRootDir + ) || process.cwd(), getEnv: (key) => process.env[key], }); try { @@ -31,7 +34,9 @@ function readTsConfig(context) { export function isEsModuleInterop(context) { const cacheKey = hashObject({ - tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir, + tsconfigRootDir: context.parserOptions + ? context.parserOptions.tsconfigRootDir + : context.languageOptions && context.languageOptions.parserOptions && context.languageOptions.parserOptions.tsconfigRootDir, }).digest('hex'); let tsConfig = tsconfigCache.get(cacheKey); if (typeof tsConfig === 'undefined') { diff --git a/src/rules/no-unused-modules.js b/src/rules/no-unused-modules.js index 86302a0ea6..07d9c35284 100644 --- a/src/rules/no-unused-modules.js +++ b/src/rules/no-unused-modules.js @@ -13,6 +13,7 @@ import readPkgUp from 'eslint-module-utils/readPkgUp'; import values from 'object.values'; import includes from 'array-includes'; import flatMap from 'array.prototype.flatmap'; +import minimatch from 'minimatch'; import ExportMapBuilder from '../exportMap/builder'; import recursivePatternCapture from '../exportMap/patternCapture'; @@ -49,6 +50,102 @@ function requireFileEnumerator() { return FileEnumerator; } +/** + * Walk a directory collecting file paths that match the given extensions. + * Skips node_modules and dot-directories. Recurses unless `recursive` is `false`. + * @param {string} dir - directory to walk + * @param {string[]} extensions - list of supported file extensions + * @param {string[]} results - accumulator for matched file paths + * @param {object} fs - Node.js fs module + * @param {Function} join - path.join + * @param {Function} extname - path.extname + * @param {boolean} [recursive] - if `false`, do not descend into subdirectories + * @returns {string[]} list of matched file paths + */ +function walkDirectory(dir, extensions, results, fs, join, extname, recursive) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + return results; + } + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.name[0] === '.' || entry.name === 'node_modules') { + continue; + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (recursive !== false) { + walkDirectory(fullPath, extensions, results, fs, join, extname, recursive); + } + } else if (entry.isFile() && extensions.indexOf(extname(fullPath)) > -1) { + results.push(fullPath); + } + } + + return results; +} + +/** + * List files with Node.js fs + minimatch — the last tier of listFilesToProcess, + * used when neither FileEnumerator nor the legacy APIs are requireable. + * @param {string[]} src - list of file paths, directories, or glob patterns + * @param {string[]} extensions - list of supported file extensions + * @returns {string[]} list of matched file paths + */ +function listFilesWithNodeFs(src, extensions) { + const fs = require('fs'); + const { join, resolve, extname } = require('path'); + const isGlob = require('is-glob'); + + extensions = extensions.map((ext) => ext.startsWith('.') ? ext : `.${ext}`); + const results = []; + + const { Minimatch, GLOBSTAR } = minimatch; + const minimatchOpts = { dot: true, matchBase: true }; + + src.forEach((pattern) => { + if (isGlob(pattern)) { + // Expand braces, then take the base from the parsed pattern's leading + // literal segments, mirroring how ESLint's FileEnumerator resolves globs. + minimatch.braceExpand(pattern).forEach((expanded) => { + const mm = new Minimatch(resolve(expanded), minimatchOpts); + const segments = mm.set[0] || []; + const baseParts = []; + while (baseParts.length < segments.length && typeof segments[baseParts.length] === 'string') { + baseParts.push(segments[baseParts.length]); + } + const base = baseParts.join('/') || '/'; + const globPart = segments.slice(baseParts.length); + // `src/*.js` stays in `src/`; `src/**/*.js` recurses. + const recursive = globPart.length > 1 || globPart.indexOf(GLOBSTAR) !== -1; + const allFiles = walkDirectory(base, extensions, [], fs, join, extname, recursive); + allFiles.forEach((file) => { + if (mm.match(file)) { + results.push(file); + } + }); + }); + } else { + const resolved = resolve(pattern); + try { + const stat = fs.statSync(resolved); + if (stat.isDirectory()) { + walkDirectory(resolved, extensions, results, fs, join, extname); + } else if (stat.isFile() && extensions.indexOf(extname(resolved)) > -1) { + results.push(resolved); + } + } catch (e) { + // Path doesn't exist, skip it + } + } + }); + + return results; +} + /** * Given a FileEnumerator class, instantiate and load the list of files. * @param FileEnumerator the `FileEnumerator` class from `eslint`'s internal api @@ -162,7 +259,15 @@ function listFilesToProcess(src, extensions) { return listFilesUsingFileEnumerator(FileEnumerator, src, extensions); } // If not, then we can try even older versions of this capability (listFilesToProcess) - return listFilesWithLegacyFunctions(src, extensions); + try { + return listFilesWithLegacyFunctions(src, extensions); + } catch (e) { + // If legacy functions are also unavailable (ESLint v10+), use Node.js fs as a fallback + if (e.code === 'MODULE_NOT_FOUND' || e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { + return listFilesWithNodeFs(src, extensions); + } + throw e; + } } const EXPORT_DEFAULT_DECLARATION = 'ExportDefaultDeclaration'; diff --git a/src/rules/order.js b/src/rules/order.js index 579dbb0444..0483e3f02a 100644 --- a/src/rules/order.js +++ b/src/rules/order.js @@ -24,11 +24,23 @@ function reverse(array) { return array.map((v) => ({ ...v, rank: -v.rank })).reverse(); } +function getTokenOrCommentAfterCompat(sourceCode, nodeOrToken) { + return sourceCode.getTokenOrCommentAfter + ? sourceCode.getTokenOrCommentAfter(nodeOrToken) + : sourceCode.getTokenAfter(nodeOrToken, { includeComments: true }); +} + +function getTokenOrCommentBeforeCompat(sourceCode, nodeOrToken) { + return sourceCode.getTokenOrCommentBefore + ? sourceCode.getTokenOrCommentBefore(nodeOrToken) + : sourceCode.getTokenBefore(nodeOrToken, { includeComments: true }); +} + function getTokensOrCommentsAfter(sourceCode, node, count) { let currentNodeOrToken = node; const result = []; for (let i = 0; i < count; i++) { - currentNodeOrToken = sourceCode.getTokenOrCommentAfter(currentNodeOrToken); + currentNodeOrToken = getTokenOrCommentAfterCompat(sourceCode, currentNodeOrToken); if (currentNodeOrToken == null) { break; } @@ -41,7 +53,7 @@ function getTokensOrCommentsBefore(sourceCode, node, count) { let currentNodeOrToken = node; const result = []; for (let i = 0; i < count; i++) { - currentNodeOrToken = sourceCode.getTokenOrCommentBefore(currentNodeOrToken); + currentNodeOrToken = getTokenOrCommentBeforeCompat(sourceCode, currentNodeOrToken); if (currentNodeOrToken == null) { break; } diff --git a/src/scc.js b/src/scc.js index c2b2c637dc..9bb90420de 100644 --- a/src/scc.js +++ b/src/scc.js @@ -20,8 +20,9 @@ export default class StronglyConnectedComponentsBuilder { static for(context) { const settingsHash = hashObject({ settings: context.settings, - parserOptions: context.parserOptions, + parserOptions: context.parserOptions || context.languageOptions && context.languageOptions.parserOptions, parserPath: context.parserPath, + languageOptions: context.languageOptions, }).digest('hex'); const cacheKey = context.path + settingsHash; if (cache.has(cacheKey)) { diff --git a/tests/src/core/sourceType.js b/tests/src/core/sourceType.js new file mode 100644 index 0000000000..79a222d81c --- /dev/null +++ b/tests/src/core/sourceType.js @@ -0,0 +1,25 @@ +import { expect } from 'chai'; + +import sourceType from 'core/sourceType'; + +describe('sourceType', function () { + it('reads sourceType from languageOptions (flat config)', function () { + expect(sourceType({ languageOptions: { sourceType: 'module' } })).to.equal('module'); + }); + + it('reads sourceType from parserOptions (legacy eslintrc)', function () { + expect(sourceType({ parserOptions: { sourceType: 'module' } })).to.equal('module'); + }); + + it('prefers languageOptions when both are present', function () { + expect(sourceType({ + languageOptions: { sourceType: 'module' }, + parserOptions: { sourceType: 'script' }, + })).to.equal('module'); + }); + + it('does not throw when parserOptions is absent (ESLint v10 flat config)', function () { + expect(() => sourceType({ languageOptions: { sourceType: 'module' } })).not.to.throw(); + expect(sourceType({})).to.equal(undefined); + }); +}); diff --git a/tests/src/rule-tester.js b/tests/src/rule-tester.js index 6e8820151d..43473d46c2 100644 --- a/tests/src/rule-tester.js +++ b/tests/src/rule-tester.js @@ -3,6 +3,7 @@ import { version as eslintVersion } from 'eslint/package.json'; import semver from 'semver'; export const usingFlatConfig = semver.major(eslintVersion) >= 9; +const eslintV10 = semver.major(eslintVersion) >= 10; export function withoutAutofixOutput(test) { return { ...test, ...usingFlatConfig || { output: test.code } }; @@ -41,10 +42,26 @@ class FlatCompatRuleTester { .map((t) => FlatCompatRuleTester._flatCompat(t, ctorParser)), invalid: tests.invalid .filter((t) => !FlatCompatRuleTester._hasUnavailableParser(t)) - .map((t) => FlatCompatRuleTester._flatCompat(t, ctorParser)), + .map((t) => FlatCompatRuleTester._flatCompatInvalid(t, ctorParser)), }); } + static _flatCompatInvalid(config, ctorParser) { + const converted = FlatCompatRuleTester._flatCompat(config, ctorParser); + + // ESLint v10 removed the 'type' property from invalid test case error assertions + if (eslintV10 && converted && typeof converted === 'object' && Array.isArray(converted.errors)) { + converted.errors = converted.errors.map((error) => { + if (error && typeof error === 'object') { + return Object.fromEntries(Object.entries(error).filter(([key]) => key !== 'type')); + } + return error; + }); + } + + return converted; + } + // @babel/eslint-parser requires explicit config; babel-eslint enabled all syntax by default. // When tests use @babel/eslint-parser, inject the equivalent config. static _babelParserOptions(parser) { diff --git a/utils/contextCompat.js b/utils/contextCompat.js index b1bdc598ef..e222d05ec7 100644 --- a/utils/contextCompat.js +++ b/utils/contextCompat.js @@ -35,6 +35,10 @@ function getFilename(context) { /** @type {import('./contextCompat').getPhysicalFilename} */ function getPhysicalFilename(context) { + if ('physicalFilename' in context) { + return context.physicalFilename; + } + if (context.getPhysicalFilename) { return context.getPhysicalFilename(); } diff --git a/utils/declaredScope.js b/utils/declaredScope.js index aa3e38b47a..150efc0fac 100644 --- a/utils/declaredScope.js +++ b/utils/declaredScope.js @@ -6,7 +6,7 @@ const { getScope } = require('./contextCompat'); /** @type {import('./declaredScope').default} */ exports.default = function declaredScope(context, name, node) { - const references = (node ? getScope(context, node) : context.getScope()).references; + const references = getScope(context, node).references; const reference = references.find((x) => x.identifier.name === name); if (!reference || !reference.resolved) { return undefined; } return reference.resolved.scope.type; diff --git a/utils/parse.js b/utils/parse.js index fb0fba7b93..a3ca95339e 100644 --- a/utils/parse.js +++ b/utils/parse.js @@ -114,7 +114,7 @@ exports.default = function parse(path, content, context) { // ESLint in "flat" mode only sets context.languageOptions.parserOptions const languageOptions = context.languageOptions; - let parserOptions = languageOptions && languageOptions.parserOptions || context.parserOptions; + let parserOptions = languageOptions && languageOptions.parserOptions || context.parserOptions || {}; const parserOrPath = getParser(path, context); if (!parserOrPath) { throw new Error('parserPath or languageOptions.parser is required!'); } From 233fe0469f8e2cd92faec2ad5cc50427aa1ff6bf Mon Sep 17 00:00:00 2001 From: rasmi Date: Fri, 22 May 2026 00:21:37 -0400 Subject: [PATCH 3/3] [Fix] `no-unused-modules`: fall back to `listFilesWithNodeFs` on flat config without .eslintrc (#3079) --- src/rules/no-unused-modules.js | 16 ++-------------- tests/src/rules/no-unused-modules.js | 20 +++++++++++--------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/rules/no-unused-modules.js b/src/rules/no-unused-modules.js index 07d9c35284..1432d4fb44 100644 --- a/src/rules/no-unused-modules.js +++ b/src/rules/no-unused-modules.js @@ -182,25 +182,13 @@ function listFilesUsingFileEnumerator(FileEnumerator, src, extensions) { ({ filePath, ignored }) => ({ filename: filePath, ignored }), ); } catch (e) { - // If we're using flat config, and FileEnumerator throws due to a lack of eslintrc, - // then we want to throw an error so that the user knows about this rule's reliance on - // the legacy config. + // #3079: flat config without .eslintrc — use the `listFilesWithNodeFs` fallback. if ( isUsingFlatConfig && e.message.includes('No ESLint configuration found') ) { - throw new Error(` -Due to the exclusion of certain internal ESLint APIs when using flat config, -the import/no-unused-modules rule requires an .eslintrc file to know which -files to ignore (even when using flat config). -The .eslintrc file only needs to contain "ignorePatterns", or can be empty if -you do not want to ignore any files. - -See https://github.com/import-js/eslint-plugin-import/issues/3079 -for additional context. -`); + return listFilesWithNodeFs(src, extensions); } - // If this isn't the case, then we'll just let the error bubble up throw e; } } diff --git a/tests/src/rules/no-unused-modules.js b/tests/src/rules/no-unused-modules.js index 1bb6fc7622..d239f65698 100644 --- a/tests/src/rules/no-unused-modules.js +++ b/tests/src/rules/no-unused-modules.js @@ -1488,10 +1488,9 @@ describe('parser ignores prefixes like BOM and hashbang', () => { }); }); -// On v9, FileEnumerator requires an .eslintrc even under flat config; verify the error message. -// On v10, FileEnumerator is gone and listFilesWithNodeFs doesn't need .eslintrc, so this is moot. +// #3079: v9 flat config without .eslintrc — rule runs via `listFilesWithNodeFs` instead of throwing. (isESLint9Only ? describe : describe.skip)('with eslint 9 (FileEnumerator + flat config)', () => { - it('provides meaningful error when eslintrc is not present', () => { + it('runs against flat config without an .eslintrc and reports unused exports', () => { const tmp = require('tmp'); // Create temp directory outside of project root @@ -1500,8 +1499,6 @@ describe('parser ignores prefixes like BOM and hashbang', () => { // Copy example project to temp directory fs.cpSync(path.join(process.cwd(), 'examples/v9'), tempDir.name, { recursive: true }); - let errorMessage = ''; - // Build the plugin try { execSync('npm run build'); @@ -1510,14 +1507,19 @@ describe('parser ignores prefixes like BOM and hashbang', () => { } // Install the plugin and run the lint command in the temp directory + let stdout = ''; + let stderr = ''; try { - execSync(`npm install -D "${process.cwd()}" && npm run lint`, { cwd: tempDir.name }); + stdout = execSync(`npm install -D "${process.cwd()}" && npm run lint`, { cwd: tempDir.name }).toString(); } catch (error) { - errorMessage = error.stderr.toString(); + stdout = error.stdout ? error.stdout.toString() : ''; + stderr = error.stderr ? error.stderr.toString() : ''; } + const output = stdout + stderr; - // Verify that the error message is as expected - expect(errorMessage).to.contain('the import/no-unused-modules rule requires an .eslintrc file'); + // Verify the rule runs (does not throw the legacy eslintrc error) and reports the unused export + expect(output).to.not.contain('requires an .eslintrc file'); + expect(output).to.contain('exports-unused.js'); // Cleanup tempDir.removeCallback();