fix(linter): speed up inferred plugin node processing (#31281)

This PR improves the **createNodes** function of eslint's inferred
plugin by making two pragmatic choices:
- reusing the ESLint between config file's runs instead of recreating
the new one every time
- skipping ignored files checks for projects that already have eslint
config file

## Results of benchmarks on customer's repo:

### Without ESLint plugin
- create-project-graph-async - avg. 11739.1326225 -> 11 seconds
### With current ESLint plugin
- create-project-graph-async - avg. 98005.0965135 -> 98 seconds
### With modified ESLint plugin
- create-project-graph-async - avg. 13225.073817  -> 13 seconds
  - (@nx/eslint/plugin:createNodes - 2206.96497, 16.69%)


## Current Behavior
<!-- This is the behavior we have today -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Miroslav Jonaš 2025-05-22 15:59:25 +02:00 committed by GitHub
parent 3a33d5f54f
commit d78782da49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 60 additions and 48 deletions

View File

@ -518,43 +518,45 @@ describe('@nx/eslint/plugin', () => {
`); `);
}); });
it('should not create nodes for nested projects without a root level eslint config when all files are ignored (.eslintignore)', async () => { // This is intentionally disabled, since we should always create a node for project that contains eslint config
createFiles({ // it('should not create nodes for nested projects without a root level eslint config when all files are ignored (.eslintignore)', async () => {
'apps/my-app/.eslintrc.json': `{}`, // createFiles({
'apps/my-app/.eslintignore': `**/*`, // 'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`, // 'apps/my-app/.eslintignore': `**/*`,
'apps/my-app/index.ts': `console.log('hello world')`, // 'apps/my-app/project.json': `{}`,
'libs/my-lib/.eslintrc.json': `{}`, // 'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintignore': `**/*`, // 'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`, // 'libs/my-lib/.eslintignore': `**/*`,
'libs/my-lib/index.ts': `console.log('hello world')`, // 'libs/my-lib/project.json': `{}`,
}); // 'libs/my-lib/index.ts': `console.log('hello world')`,
expect( // });
await invokeCreateNodesOnMatchingFiles(context, { targetName: 'lint' }) // expect(
).toMatchInlineSnapshot(` // await invokeCreateNodesOnMatchingFiles(context, { targetName: 'lint' })
{ // ).toMatchInlineSnapshot(`
"projects": {}, // {
} // "projects": {},
`); // }
}); // `);
// });
it('should not create nodes for nested projects without a root level eslint config when all files are ignored (ignorePatterns in .eslintrc.json)', async () => { // This is intentionally disabled, since we should always create a node for project that contains eslint config
createFiles({ // it('should not create nodes for nested projects without a root level eslint config when all files are ignored (ignorePatterns in .eslintrc.json)', async () => {
'apps/my-app/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`, // createFiles({
'apps/my-app/project.json': `{}`, // 'apps/my-app/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'apps/my-app/index.ts': `console.log('hello world')`, // 'apps/my-app/project.json': `{}`,
'libs/my-lib/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`, // 'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/project.json': `{}`, // 'libs/my-lib/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'libs/my-lib/index.ts': `console.log('hello world')`, // 'libs/my-lib/project.json': `{}`,
}); // 'libs/my-lib/index.ts': `console.log('hello world')`,
expect( // });
await invokeCreateNodesOnMatchingFiles(context, { targetName: 'lint' }) // expect(
).toMatchInlineSnapshot(` // await invokeCreateNodesOnMatchingFiles(context, { targetName: 'lint' })
{ // ).toMatchInlineSnapshot(`
"projects": {}, // {
} // "projects": {},
`); // }
}); // `);
// });
}); });
describe('root eslint config and nested eslint configs', () => { describe('root eslint config and nested eslint configs', () => {
@ -721,7 +723,6 @@ describe('@nx/eslint/plugin', () => {
it('should handle multiple levels of nesting and ignored files correctly', async () => { it('should handle multiple levels of nesting and ignored files correctly', async () => {
createFiles({ createFiles({
'.eslintrc.json': '{ "root": true, "ignorePatterns": ["**/*"] }', '.eslintrc.json': '{ "root": true, "ignorePatterns": ["**/*"] }',
'apps/myapp/.eslintrc.json': '{ "extends": "../../.eslintrc.json" }', // no lintable files, don't create task
'apps/myapp/project.json': '{}', 'apps/myapp/project.json': '{}',
'apps/myapp/index.ts': 'console.log("hello world")', 'apps/myapp/index.ts': 'console.log("hello world")',
'apps/myapp/nested/mylib/.eslintrc.json': JSON.stringify({ 'apps/myapp/nested/mylib/.eslintrc.json': JSON.stringify({

View File

@ -22,6 +22,7 @@ import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { combineGlobPatterns } from 'nx/src/utils/globs'; import { combineGlobPatterns } from 'nx/src/utils/globs';
import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context'; import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context';
import { gte } from 'semver'; import { gte } from 'semver';
import type { ESLint as ESLintType } from 'eslint';
import { import {
baseEsLintConfigFile, baseEsLintConfigFile,
BASE_ESLINT_CONFIG_FILENAMES, BASE_ESLINT_CONFIG_FILENAMES,
@ -186,6 +187,7 @@ const internalCreateNodes = async (
}; };
const internalCreateNodesV2 = async ( const internalCreateNodesV2 = async (
ESLint: typeof ESLintType,
configFilePath: string, configFilePath: string,
options: EslintPluginOptions, options: EslintPluginOptions,
context: CreateNodesContextV2, context: CreateNodesContextV2,
@ -195,10 +197,6 @@ const internalCreateNodesV2 = async (
hashByRoot: Map<string, string> hashByRoot: Map<string, string>
): Promise<CreateNodesResult> => { ): Promise<CreateNodesResult> => {
const configDir = dirname(configFilePath); const configDir = dirname(configFilePath);
const ESLint = await resolveESLintClass({
useFlatConfigOverrideVal: isFlatConfig(configFilePath),
});
const eslintVersion = ESLint.version; const eslintVersion = ESLint.version;
const projects: CreateNodesResult['projects'] = {}; const projects: CreateNodesResult['projects'] = {};
@ -212,15 +210,21 @@ const internalCreateNodesV2 = async (
return; return;
} }
const eslint = new ESLint({
cwd: join(context.workspaceRoot, projectRoot),
});
let hasNonIgnoredLintableFiles = false; let hasNonIgnoredLintableFiles = false;
for (const file of lintableFilesPerProjectRoot.get(projectRoot) ?? []) { if (configDir !== projectRoot || projectRoot === '.') {
if (!(await eslint.isPathIgnored(join(context.workspaceRoot, file)))) { const eslint = new ESLint({
hasNonIgnoredLintableFiles = true; cwd: join(context.workspaceRoot, projectRoot),
break; });
for (const file of lintableFilesPerProjectRoot.get(projectRoot) ?? []) {
if (
!(await eslint.isPathIgnored(join(context.workspaceRoot, file)))
) {
hasNonIgnoredLintableFiles = true;
break;
}
} }
} else {
hasNonIgnoredLintableFiles = true;
} }
if (!hasNonIgnoredLintableFiles) { if (!hasNonIgnoredLintableFiles) {
@ -286,9 +290,16 @@ export const createNodesV2: CreateNodesV2<EslintPluginOptions> = [
projectRoots.map((r, i) => [r, hashes[i]]) projectRoots.map((r, i) => [r, hashes[i]])
); );
try { try {
if (eslintConfigFiles.length === 0) {
return [];
}
const ESLint = await resolveESLintClass({
useFlatConfigOverrideVal: isFlatConfig(eslintConfigFiles[0]),
});
return await createNodesFromFiles( return await createNodesFromFiles(
(configFile, options, context) => (configFile, options, context) =>
internalCreateNodesV2( internalCreateNodesV2(
ESLint,
configFile, configFile,
options, options,
context, context,