Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
9 changes: 9 additions & 0 deletions .github/workflows/eslint-8+.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,18 @@ jobs:
- macos-latest
node-version: ${{ fromJson(needs.matrix.outputs.latest) }}
eslint:
- 10
- 9
- 8
exclude:
- node-version: 17
Copy link
Copy Markdown
Contributor

@fernandopasik fernandopasik Mar 11, 2026

Choose a reason for hiding this comment

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

probably need to exclude node 18 as well since eslint 10 supports from node 20
https://github.com/eslint/eslint/releases/tag/v10.0.0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The rest of the test suite still runs and passes on Node 18 & 19 + eslint 10, so I left these in, but defer to @ljharb! See e68ea07, it's just the @babel/eslint-parser-dependent ones that are skipped.

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
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -91,6 +91,8 @@
"eslint-plugin-eslint-plugin": "^2.3.0",
"eslint-plugin-import": "2.x",
"eslint-plugin-json": "^2.1.2",
"eslint-plugin-jsonc": "^2.21.1",
"espree": "^9",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why is this pin needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

espree is normally hoisted transitively from eslint, but installing eslint-plugin-jsonc@^3 for v10 via dep-time-travel.sh de-hoists it, breaking require.resolve('espree') in utils.js (see failed test run here). I used ^9 to avoid v11, which uses Array.prototype.at() and dies on node 12/14 (node 12 failure, node failure). v9 still works for the tests that use parsers.ESPREE.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These become unnecessary if we use a fixture as in #3252

"find-babel-config": "=1.2.0",
"fs-copy-file-sync": "^1.1.1",
"glob": "^7.2.3",
Expand All @@ -112,7 +114,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",
Expand Down
6 changes: 3 additions & 3 deletions src/core/sourceType.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion src/exportMap/childContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/exportMap/specifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export default function processSpecifier(specifier, astNode, exportMap, namespac
}));
return;
case 'ExportAllDeclaration':
exportMap.namespace.set(specifier.exported.name || specifier.exported.value, namespace.add(exportMeta, specifier.source.value));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this means it's not longer doing namespace.add - can you elaborate on this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

namespace.add was a no-op here -- it keys off identifier.name, but was passed specifier.source.value (the module string), so it never set a getter, just returned the bare {}. Updated code sets the lazy getter directly, same as ExportNamespaceSpecifier, so export * as ns resolves.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved to #3250

exportMap.namespace.set(specifier.exported.name || specifier.exported.value, Object.defineProperty(exportMeta, 'namespace', {
get() { return namespace.resolveImport(specifier.source.value); },
}));
return;
case 'ExportSpecifier':
if (!astNode.source) {
Expand Down
9 changes: 7 additions & 2 deletions src/exportMap/typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment thread
ljharb marked this conversation as resolved.
getEnv: (key) => process.env[key],
});
try {
Expand All @@ -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') {
Expand Down
5 changes: 4 additions & 1 deletion src/exportMap/visitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ export default class ImportExportVisitorBuilder {
},
ExportAllDeclaration() {
const getter = captureDependency(astNode, astNode.exportKind === 'type', this.remotePathResolver, this.exportMap, this.context, this.thunkFor);
if (getter) { this.exportMap.dependencies.add(getter); }
if (astNode.exported) {
// `export * as ns from './mod'` — named namespace, not a star-export
processSpecifier(astNode, astNode.exported, this.exportMap, this.namespace);
} else if (getter) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Previously, export * as ns from './mod' would both add the dependency and process the specifier. Now it only processes the specifier, skipping dependencies.add(getter). This means export * as ns from './mod' won't be tracked as a dependency of the current module, which could affect cycle detection or re-export tracking.

Is this intentional? If so, does the lazy getter in specifier.js fully compensate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Cycle detection reads m.imports (still populated by captureDependency), not m.dependencies, so the import is still tracked. m.dependencies is only populated for export * from, not export * as ns. The lazy getter resolves ns.namespace to ./mod's ExportMap on access (matching the existing pattern).

LMK if I should comment inline or update the "ExportMap export * as fix" section in the PR to clarify/note this!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Broken out into #3250

// `export * from './mod'` — star-export flattens into current module
this.exportMap.dependencies.add(getter);
}
},
/** capture namespaces in case of later export */
Expand Down
91 changes: 90 additions & 1 deletion src/rules/no-unused-modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -147,6 +148,86 @@ function listFilesWithLegacyFunctions(src, extensions) {
}
}

/**
* Walk a directory recursively, collecting file paths that match the given extensions.
* Skips node_modules and dot-directories.
* @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
* @returns {string[]} list of matched file paths
*/
function walkDirectory(dir, extensions, results, fs, join, extname) {
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()) {
walkDirectory(fullPath, extensions, results, fs, join, extname);
} else if (entry.isFile() && extensions.indexOf(extname(fullPath)) > -1) {
results.push(fullPath);
}
}

return results;
}

/**
* List files using Node.js fs and minimatch as a fallback when FileEnumerator
* and legacy ESLint APIs are unavailable (ESLint v10+).
* @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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why wouldn't this function work on eslint 9?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It would work, just not reached as a fallback. Updated comment to clarify!

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 = [];

src.forEach((pattern) => {
if (isGlob(pattern)) {
// For glob patterns, walk the base directory and filter with minimatch
// Extract the base directory from the glob (everything before the first glob character)
const base = pattern.replace(/[*?{[].*/g, '').replace(/\/[^/]*$/, '') || '.';
const resolvedBase = resolve(base);
const allFiles = walkDirectory(resolvedBase, extensions, [], fs, join, extname);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The glob base extraction pattern.replace(/[*?{[].*/g, '').replace(/\/[^/]*$/, '') || '.' could be slow for patterns like *.js or {a,b}/*.js - it falls back to . which walks the entire project tree.

Also, minimatch doesn't handle all glob edge cases (negation, brace expansion) the same way ESLint's FileEnumerator did. Has this been tested with real-world src patterns?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is it okay to just use glob here for the sake of reliability, or do you want to avoid the additional dependency/chaos/requirements imposed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can also use glob-parent if preferred?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can use glob if you use a version that's old enough to support the same node versions as us.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed the base extraction (and brace expansion / recursion) so it no longer falls back to . and walks the whole tree (using the same minimatch options as FileEnumerator). It's not 1:1 with FileEnumerator as it will skip dotfile dirs and symlinks, is this important to address here?

allFiles.forEach((file) => {
if (minimatch(file, resolve(pattern)) || minimatch(file, pattern)) {
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 src pattern and list of supported extensions, return a list of files to process
* with this rule.
Expand All @@ -162,7 +243,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';
Expand Down
16 changes: 14 additions & 2 deletions src/rules/order.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/scc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
22 changes: 22 additions & 0 deletions tests/dep-time-travel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,25 @@ 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..."
Comment thread
ljharb marked this conversation as resolved.
npm i --no-save @babel/core@'^8.0.0-rc.2' @babel/eslint-parser@'^8.0.0-rc.2'
fi

# eslint-plugin-jsonc v3 supports ESLint 10; v2 (the default in package.json) does not.
# v3 requires Node ^20.19.0 || ^22.13.0 || >=24 — install only on supported Node majors.
if [[ "$TRAVIS_NODE_VERSION" -eq "20" ]] || [[ "$TRAVIS_NODE_VERSION" -eq "22" ]] || [[ "$TRAVIS_NODE_VERSION" -ge "24" ]]; then
echo "Installing eslint-plugin-jsonc v3 for ESLint 10..."
npm i --no-save eslint-plugin-jsonc@^3
fi
fi
28 changes: 0 additions & 28 deletions tests/files/just-json-files/eslint.config.js

This file was deleted.

17 changes: 17 additions & 0 deletions tests/files/just-json-files/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import jsoncPlugin from 'eslint-plugin-jsonc';

export default [
...jsoncPlugin.configs['flat/recommended-with-json'],
{
files: ['tests/files/just-json-files/*.json'],
rules: {
'import/no-unused-modules': [
'error',
{
missingExports: false,
unusedExports: true,
},
],
},
},
];
Loading
Loading