fix(linter): do not infer lint tasks for projects without files to lint (#22944)

This commit is contained in:
Leosvel Pérez Espinosa 2024-04-23 17:38:08 +02:00 committed by GitHub
parent 739e2e7115
commit 1e4324fbce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 319 additions and 164 deletions

View File

@ -21,7 +21,7 @@ This will install the correct version of `@nx/eslint`.
### How @nx/eslint Infers Tasks
The `@nx/eslint` plugin will create a task for any project that has an ESLint configuration file present. Any of the following files will be recognized as an ESLint configuration file:
The `@nx/eslint` plugin will create a task for any project that has an ESLint configuration file present and files to lint. Any of the following files will be recognized as an ESLint configuration file:
- `.eslintrc`
- `.eslintrc.js`
@ -33,6 +33,8 @@ The `@nx/eslint` plugin will create a task for any project that has an ESLint co
Because ESLint applies configuration files to all subdirectories, the `@nx/eslint` plugin will also infer tasks for projects in subdirectories. So, if there is an ESLint configuration file in the root of the repository, every project will have an inferred ESLint task.
Even if a project has an ESLint configuration file, it will only have an inferred ESLint task if there are files to lint. Otherwise, the task will not be created. Therefore, if you don't want an ESLint task to be inferred for a particular project, make sure the project files are properly excluded from ESLint.
### View Inferred Tasks
To view inferred tasks for a project, open the [project details view](/concepts/inferred-tasks) in Nx Console or run `nx show project my-project --web` in the command line.

View File

@ -21,7 +21,7 @@ This will install the correct version of `@nx/eslint`.
### How @nx/eslint Infers Tasks
The `@nx/eslint` plugin will create a task for any project that has an ESLint configuration file present. Any of the following files will be recognized as an ESLint configuration file:
The `@nx/eslint` plugin will create a task for any project that has an ESLint configuration file present and files to lint. Any of the following files will be recognized as an ESLint configuration file:
- `.eslintrc`
- `.eslintrc.js`
@ -33,6 +33,8 @@ The `@nx/eslint` plugin will create a task for any project that has an ESLint co
Because ESLint applies configuration files to all subdirectories, the `@nx/eslint` plugin will also infer tasks for projects in subdirectories. So, if there is an ESLint configuration file in the root of the repository, every project will have an inferred ESLint task.
Even if a project has an ESLint configuration file, it will only have an inferred ESLint task if there are files to lint. Otherwise, the task will not be created. Therefore, if you don't want an ESLint task to be inferred for a particular project, make sure the project files are properly excluded from ESLint.
### View Inferred Tasks
To view inferred tasks for a project, open the [project details view](/concepts/inferred-tasks) in Nx Console or run `nx show project my-project --web` in the command line.

View File

@ -1,27 +1,14 @@
import 'nx/src/internal-testing-utils/mock-fs';
jest.mock(
'nx/src/utils/workspace-context',
(): Partial<typeof import('nx/src/utils/workspace-context')> => {
const glob = require('fast-glob');
return {
globWithWorkspaceContext(workspaceRoot: string, patterns: string[]) {
// This glob will operate on memfs thanks to 'nx/src/internal-testing-utils/mock-fs'
return glob.sync(patterns, { cwd: workspaceRoot });
},
};
}
);
import { CreateNodesContext } from '@nx/devkit';
import { vol } from 'memfs';
import { minimatch } from 'minimatch';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { createNodes } from './plugin';
describe('@nx/eslint/plugin', () => {
let context: CreateNodesContext;
let tempFs: TempFs;
beforeEach(async () => {
tempFs = new TempFs('eslint-plugin');
context = {
nxJsonConfiguration: {
// These defaults should be overridden by the plugin
@ -36,24 +23,22 @@ describe('@nx/eslint/plugin', () => {
production: ['!{projectRoot}/**/*.spec.ts'],
},
},
workspaceRoot: '',
workspaceRoot: tempFs.tempDir,
configFiles: [],
};
});
afterEach(() => {
vol.reset();
jest.resetModules();
tempFs.cleanup();
tempFs = null;
});
it('should not create any nodes when there are no eslint configs', async () => {
applyFilesToVolAndContext(
{
'package.json': `{}`,
'project.json': `{}`,
},
context
);
createFiles({
'package.json': `{}`,
'project.json': `{}`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
@ -64,13 +49,10 @@ describe('@nx/eslint/plugin', () => {
describe('root eslint config only', () => {
it('should not create any nodes for just a package.json and root level eslint config', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'package.json': `{}`,
},
context
);
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
@ -79,14 +61,14 @@ describe('@nx/eslint/plugin', () => {
`);
});
it('should not create a node for a root level eslint config when accompanied by a project.json, if no src directory is present', async () => {
applyFilesToVolAndContext(
{
'eslint.config.js': `module.exports = {};`,
'project.json': `{}`,
},
context
);
// TODO(leo): dynamic import of the flat config fails with jest:
// "TypeError: A dynamic import callback was invoked without --experimental-vm-modules"
// mocking the "eslint.config.js" file import is not working, figure out if there's a way
it.skip('should not create a node for a root level eslint config when accompanied by a project.json, if no src directory is present', async () => {
createFiles({
'eslint.config.js': `module.exports = {};`,
'project.json': `{}`,
});
// NOTE: It should set ESLINT_USE_FLAT_CONFIG to true because of the use of eslint.config.js
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
@ -98,14 +80,11 @@ describe('@nx/eslint/plugin', () => {
// Standalone Nx workspace style setup
it('should create a node for just a package.json and root level eslint config if accompanied by a src directory', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
},
context
);
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
@ -138,16 +117,44 @@ describe('@nx/eslint/plugin', () => {
`);
});
it('should create a node for a nested project (with a project.json and any lintable file) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
it('should not create a node for just a package.json and root level eslint config if accompanied by a src directory when all files are ignored (.eslintignore)', async () => {
createFiles({
'.eslintrc.json': `{}`,
'.eslintignore': `**/*`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
},
context
);
"projects": {},
}
`);
});
it('should not create a node for just a package.json and root level eslint config if accompanied by a src directory when all files are ignored (ignorePatterns in .eslintrc.json)', async () => {
createFiles({
'.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should create a node for a nested project (with a project.json and any lintable file) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
@ -180,15 +187,12 @@ describe('@nx/eslint/plugin', () => {
});
it('should create a node for a nested project (with a package.json and any lintable file) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
},
context
);
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
@ -221,19 +225,16 @@ describe('@nx/eslint/plugin', () => {
});
it('should not create a node for a nested project (with a package.json and no lintable files) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
},
context
);
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
@ -243,19 +244,46 @@ describe('@nx/eslint/plugin', () => {
});
it('should not create a node for a nested project (with a project.json and no lintable files) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
applyFilesToVolAndContext(
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
},
context
);
"projects": {},
}
`);
});
it('should not create a node for a nested project (with a project.json and all files ignored) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'apps/my-app/project.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should not create a node for a nested project (with a package.json and all files ignored) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'apps/my-app/package.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
@ -267,17 +295,14 @@ describe('@nx/eslint/plugin', () => {
describe('nested eslint configs only', () => {
it('should create appropriate nodes for nested projects without a root level eslint config', async () => {
applyFilesToVolAndContext(
{
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
},
context
);
createFiles({
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
@ -330,23 +355,56 @@ 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 () => {
createFiles({
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/.eslintignore': `**/*`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/.eslintignore': `**/*`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"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 () => {
createFiles({
'apps/my-app/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
});
describe('root eslint config and nested eslint configs', () => {
it('should create appropriate nodes for just a package.json and root level eslint config combined with nested eslint configs', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': `{}`,
'package.json': `{}`,
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
},
context
);
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
// NOTE: The nested projects have the root level config as an input to their lint targets
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
@ -404,15 +462,12 @@ describe('@nx/eslint/plugin', () => {
});
it('should create appropriate nodes for a nested project without its own eslint config but with an orphaned eslint config in its parent hierarchy', async () => {
applyFilesToVolAndContext(
{
'.eslintrc.json': '{}',
'apps/.eslintrc.json': '{}',
'apps/myapp/project.json': '{}',
'apps/myapp/index.ts': 'console.log("hello world")',
},
context
);
createFiles({
'.eslintrc.json': '{}',
'apps/.eslintrc.json': '{}',
'apps/myapp/project.json': '{}',
'apps/myapp/index.ts': 'console.log("hello world")',
});
// NOTE: The nested projects have the root level config as an input to their lint targets
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
@ -445,7 +500,58 @@ describe('@nx/eslint/plugin', () => {
}
`);
});
it('should handle multiple levels of nesting and ignored files correctly', async () => {
createFiles({
'.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/index.ts': 'console.log("hello world")',
'apps/myapp/nested/mylib/.eslintrc.json': JSON.stringify({
extends: '../../../../.eslintrc.json',
ignorePatterns: ['!**/*'], // include all files, create task
}),
'apps/myapp/nested/mylib/project.json': '{}',
'apps/myapp/nested/mylib/index.ts': 'console.log("hello world")',
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/myapp/nested/mylib": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"options": {
"cwd": "apps/myapp/nested/mylib",
},
},
},
},
},
}
`);
});
});
function createFiles(fileSys: Record<string, string>) {
tempFs.createFilesSync(fileSys);
// @ts-expect-error update otherwise readonly property for testing
context.configFiles = getMatchingFiles(Object.keys(fileSys));
}
});
function getMatchingFiles(allConfigFiles: string[]): string[] {
@ -454,15 +560,6 @@ function getMatchingFiles(allConfigFiles: string[]): string[] {
);
}
function applyFilesToVolAndContext(
fileSys: Record<string, string>,
context: CreateNodesContext
) {
vol.fromJSON(fileSys, '');
// @ts-expect-error update otherwise readonly property for testing
context.configFiles = getMatchingFiles(Object.keys(fileSys));
}
async function invokeCreateNodesOnMatchingFiles(
context: CreateNodesContext,
targetName: string

View File

@ -4,8 +4,9 @@ import {
CreateNodesResult,
TargetConfiguration,
} from '@nx/devkit';
import type { ESLint } from 'eslint';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { dirname, join, normalize, sep } from 'node:path';
import { combineGlobPatterns } from 'nx/src/utils/globs';
import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context';
import {
@ -28,48 +29,64 @@ export const createNodes: CreateNodes<EslintPluginOptions> = [
baseEsLintConfigFile,
baseEsLintFlatConfigFile,
]),
(configFilePath, options, context) => {
async (configFilePath, options, context) => {
options = normalizeOptions(options);
const configDir = dirname(configFilePath);
// Ensure that configFiles are set, e2e-run fails due to them being undefined in CI (does not occur locally)
// TODO(JamesHenry): Further troubleshoot this in CI
(context as any).configFiles = context.configFiles ?? [];
// Create a Set of all the directories containing eslint configs
const eslintRoots = new Set(context.configFiles.map(dirname));
const configDir = dirname(configFilePath);
// Create a Set of all the directories containing eslint configs, and a
// list of globs to exclude from child projects
const eslintRoots = new Set();
const nestedEslintRootPatterns: string[] = [];
for (const configFile of context.configFiles) {
const eslintRootDir = dirname(configFile);
eslintRoots.add(eslintRootDir);
const childProjectRoots = globWithWorkspaceContext(
if (eslintRootDir !== configDir && isSubDir(configDir, eslintRootDir)) {
nestedEslintRootPatterns.push(`${eslintRootDir}/**/*`);
}
}
const childProjectRoots = new Set<string>();
const ESLint = resolveESLintClass(isFlatConfig(configFilePath));
const projectFiles = globWithWorkspaceContext(
context.workspaceRoot,
[
'project.json',
'package.json',
'**/project.json',
'**/package.json',
].map((f) => join(configDir, f))
)
.map((f) => dirname(f))
.filter((childProjectRoot) => {
// Filter out projects under other eslint configs
let root = childProjectRoot;
// Traverse up from the childProjectRoot to either the workspaceRoot or the dir of this config file
while (root !== dirname(root) && root !== dirname(configFilePath)) {
if (eslintRoots.has(root)) {
return false;
}
root = dirname(root);
}
return true;
})
.filter((dir) => {
// Ignore project roots where the project does not contain any lintable files
const lintableFiles = globWithWorkspaceContext(context.workspaceRoot, [
join(dir, `**/*.{${options.extensions.join(',')}}`),
]);
return lintableFiles.length > 0;
});
].map((f) => join(configDir, f)),
nestedEslintRootPatterns.length ? nestedEslintRootPatterns : undefined
);
for (const projectFile of projectFiles) {
const childProjectRoot = dirname(projectFile);
if (childProjectRoots.has(childProjectRoot)) {
continue;
}
const uniqueChildProjectRoots = Array.from(new Set(childProjectRoots));
// Ignore project roots where the project does not contain any lintable files
const lintableFiles = globWithWorkspaceContext(
context.workspaceRoot,
[join(childProjectRoot, `**/*.{${options.extensions.join(',')}}`)],
nestedEslintRootPatterns.length ? nestedEslintRootPatterns : undefined
);
const eslint = new ESLint({
cwd: join(context.workspaceRoot, childProjectRoot),
});
for (const file of lintableFiles) {
if (!(await eslint.isPathIgnored(join(context.workspaceRoot, file)))) {
childProjectRoots.add(childProjectRoot);
break;
}
}
}
const uniqueChildProjectRoots = Array.from(childProjectRoots);
return {
projects: getProjectsUsingESLintConfig(
@ -118,6 +135,7 @@ function getProjectsUsingESLintConfig(
targets: buildEslintTargets(
eslintConfigs,
projectRoot,
context.workspaceRoot,
options,
isStandaloneWorkspace
),
@ -130,6 +148,7 @@ function getProjectsUsingESLintConfig(
function buildEslintTargets(
eslintConfigs: string[],
projectRoot: string,
workspaceRoot: string,
options: EslintPluginOptions,
isStandaloneWorkspace = false
) {
@ -153,6 +172,9 @@ function buildEslintTargets(
isRootProject ? '{projectRoot}/' : '{projectRoot}'
)
),
...(existsSync(join(workspaceRoot, projectRoot, '.eslintignore'))
? ['{projectRoot}/.eslintignore']
: []),
'{workspaceRoot}/tools/eslint-rules/**/*',
{ externalDependencies: ['eslint'] },
],
@ -181,3 +203,35 @@ function normalizeOptions(options: EslintPluginOptions): EslintPluginOptions {
return options;
}
function resolveESLintClass(useFlatConfig = false): typeof ESLint {
try {
if (!useFlatConfig) {
return require('eslint').ESLint;
}
return require('eslint/use-at-your-own-risk').FlatESLint;
} catch {
throw new Error('Unable to find ESLint. Ensure ESLint is installed.');
}
}
/**
* Determines if `child` is a subdirectory of `parent`. This is a simplified
* version that takes into account that paths are always relative to the
* workspace root.
*/
function isSubDir(parent: string, child: string): boolean {
if (parent === '.') {
return true;
}
parent = normalize(parent);
child = normalize(child);
if (!parent.endsWith(sep)) {
parent += sep;
}
return child.startsWith(parent);
}