From 871ca91a1e8ece1182d68501b68d36f2bd7d9035 Mon Sep 17 00:00:00 2001 From: James Henry Date: Sat, 16 Mar 2024 00:29:13 +0400 Subject: [PATCH] fix(linter): refactor pcv3 plugin, expose configFiles on context (#21677) --- docs/generated/devkit/CreateNodes.md | 2 +- docs/generated/devkit/CreateNodesContext.md | 9 + packages/cypress/src/plugins/plugin.spec.ts | 1 + ...place-project-configuration-with-plugin.ts | 1 + .../src/utils/update-package-scripts.ts | 22 +- packages/eslint/src/plugins/plugin.spec.ts | 526 ++++++++++++++---- packages/eslint/src/plugins/plugin.ts | 186 ++++--- packages/eslint/src/utils/config-file.ts | 16 - packages/jest/src/plugins/plugin.spec.ts | 1 + packages/next/src/plugins/plugin.spec.ts | 2 + packages/nuxt/src/plugins/plugin.spec.ts | 2 + .../package-json-next-to-project-json.spec.ts | 1 + .../build-nodes/project-json.spec.ts | 1 + .../target-defaults-plugin.spec.ts | 7 + packages/nx/src/project-graph/file-utils.ts | 8 +- .../utils/project-configuration-utils.ts | 97 ++-- .../utils/retrieve-workspace-files.ts | 7 +- packages/nx/src/utils/ignore.ts | 2 +- packages/nx/src/utils/nx-plugin.ts | 6 +- .../playwright/src/plugins/plugin.spec.ts | 1 + packages/remix/src/plugins/plugin.spec.ts | 2 + packages/rollup/src/plugins/plugin.spec.ts | 2 + packages/storybook/src/plugins/plugin.spec.ts | 1 + packages/webpack/src/plugins/plugin.spec.ts | 1 + 24 files changed, 662 insertions(+), 242 deletions(-) diff --git a/docs/generated/devkit/CreateNodes.md b/docs/generated/devkit/CreateNodes.md index ed46857f91b7cd..766261c911cab4 100644 --- a/docs/generated/devkit/CreateNodes.md +++ b/docs/generated/devkit/CreateNodes.md @@ -1,6 +1,6 @@ # Type alias: CreateNodes\ -Ƭ **CreateNodes**\<`T`\>: readonly [projectFilePattern: string, createNodesFunction: CreateNodesFunction\] +Ƭ **CreateNodes**\<`T`\>: readonly [configFilePattern: string, createNodesFunction: CreateNodesFunction\] A pair of file patterns and [CreateNodesFunction](../../devkit/documents/CreateNodesFunction) diff --git a/docs/generated/devkit/CreateNodesContext.md b/docs/generated/devkit/CreateNodesContext.md index aeb424ba04a3a5..25c357b8103e0c 100644 --- a/docs/generated/devkit/CreateNodesContext.md +++ b/docs/generated/devkit/CreateNodesContext.md @@ -6,11 +6,20 @@ Context for [CreateNodesFunction](../../devkit/documents/CreateNodesFunction) ### Properties +- [configFiles](../../devkit/documents/CreateNodesContext#configfiles): string[] - [nxJsonConfiguration](../../devkit/documents/CreateNodesContext#nxjsonconfiguration): NxJsonConfiguration - [workspaceRoot](../../devkit/documents/CreateNodesContext#workspaceroot): string ## Properties +### configFiles + +• `Readonly` **configFiles**: `string`[] + +The subset of configuration files which match the createNodes pattern + +--- + ### nxJsonConfiguration • `Readonly` **nxJsonConfiguration**: [`NxJsonConfiguration`](../../devkit/documents/NxJsonConfiguration)\<`string`[] \| `"*"`\> diff --git a/packages/cypress/src/plugins/plugin.spec.ts b/packages/cypress/src/plugins/plugin.spec.ts index fb06b10d283130..4d66889bab4387 100644 --- a/packages/cypress/src/plugins/plugin.spec.ts +++ b/packages/cypress/src/plugins/plugin.spec.ts @@ -34,6 +34,7 @@ describe('@nx/cypress/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; }); diff --git a/packages/devkit/src/utils/replace-project-configuration-with-plugin.ts b/packages/devkit/src/utils/replace-project-configuration-with-plugin.ts index 6ee2949253b190..c018bafd01b9ef 100644 --- a/packages/devkit/src/utils/replace-project-configuration-with-plugin.ts +++ b/packages/devkit/src/utils/replace-project-configuration-with-plugin.ts @@ -48,6 +48,7 @@ export async function replaceProjectConfigurationsWithPlugin( const nodes = await createNodesFunction(configFile, pluginOptions, { workspaceRoot: tree.root, nxJsonConfiguration: readNxJson(tree), + configFiles, }); const node = nodes.projects[Object.keys(nodes.projects)[0]]; diff --git a/packages/devkit/src/utils/update-package-scripts.ts b/packages/devkit/src/utils/update-package-scripts.ts index 6896f56d69d353..46c4478e3a8fa0 100644 --- a/packages/devkit/src/utils/update-package-scripts.ts +++ b/packages/devkit/src/utils/update-package-scripts.ts @@ -25,11 +25,18 @@ export async function updatePackageScripts( const nxJson = readNxJson(tree); const [pattern, createNodes] = createNodesTuple; - const files = glob(tree, [pattern]); + const matchingFiles = glob(tree, [pattern]); - for (const file of files) { + for (const file of matchingFiles) { const projectRoot = getProjectRootFromConfigFile(file); - await processProject(tree, projectRoot, file, createNodes, nxJson); + await processProject( + tree, + projectRoot, + file, + createNodes, + nxJson, + matchingFiles + ); } } @@ -38,7 +45,8 @@ async function processProject( projectRoot: string, projectConfigurationFile: string, createNodesFunction: CreateNodesFunction, - nxJsonConfiguration: NxJsonConfiguration + nxJsonConfiguration: NxJsonConfiguration, + configFiles: string[] ) { const packageJsonPath = `${projectRoot}/package.json`; if (!tree.exists(packageJsonPath)) { @@ -52,7 +60,11 @@ async function processProject( const result = await createNodesFunction( projectConfigurationFile, {}, - { nxJsonConfiguration, workspaceRoot } + { + nxJsonConfiguration, + workspaceRoot, + configFiles, + } ); const targetCommands = getInferredTargetCommands(result); diff --git a/packages/eslint/src/plugins/plugin.spec.ts b/packages/eslint/src/plugins/plugin.spec.ts index 5435bd5b47b458..48b89c8736ee3d 100644 --- a/packages/eslint/src/plugins/plugin.spec.ts +++ b/packages/eslint/src/plugins/plugin.spec.ts @@ -1,23 +1,30 @@ +import 'nx/src/internal-testing-utils/mock-fs'; + +jest.mock( + 'nx/src/utils/workspace-context', + (): Partial => { + 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 { createNodes } from './plugin'; import { vol } from 'memfs'; - -jest.mock('fs', () => { - const memFs = require('memfs').fs; - return { - ...memFs, - existsSync: (p) => (p.endsWith('.node') ? true : memFs.existsSync(p)), - }; -}); +import { minimatch } from 'minimatch'; +import { createNodes } from './plugin'; describe('@nx/eslint/plugin', () => { - let createNodesFunction = createNodes[1]; let context: CreateNodesContext; beforeEach(async () => { context = { nxJsonConfiguration: { - // These defaults should be overridden by plugin + // These defaults should be overridden by the plugin targetDefaults: { lint: { cache: false, @@ -30,6 +37,7 @@ describe('@nx/eslint/plugin', () => { }, }, workspaceRoot: '', + configFiles: [], }; }); @@ -38,117 +46,433 @@ describe('@nx/eslint/plugin', () => { jest.resetModules(); }); - it('should create nodes with default configuration for nested project', () => { - const fileSys = { - 'apps/my-app/.eslintrc.json': `{}`, - 'apps/my-app/project.json': `{}`, - '.eslintrc.json': `{}`, - 'package.json': `{}`, - }; - vol.fromJSON(fileSys, ''); - const nodes = createNodesFunction( - 'apps/my-app/project.json', + it('should not create any nodes when there are no eslint configs', async () => { + applyFilesToVolAndContext( { - targetName: 'lint', + 'package.json': `{}`, + 'project.json': `{}`, }, context ); - - expect(nodes).toMatchInlineSnapshot(` + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` { - "projects": { - "apps/my-app": { - "targets": { - "lint": { - "cache": true, - "command": "eslint .", - "inputs": [ - "default", - "{workspaceRoot}/.eslintrc.json", - "{workspaceRoot}/apps/my-app/.eslintrc.json", - "{workspaceRoot}/tools/eslint-rules/**/*", - { - "externalDependencies": [ - "eslint", - ], + "projects": {}, + } + `); + }); + + 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 + ); + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": {}, + } + `); + }); + + 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 + ); + // NOTE: It should set ESLINT_USE_FLAT_CONFIG to true because of the use of eslint.config.js + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": {}, + } + `); + }); + + // 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 + ); + // NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": { + ".": { + "targets": { + "lint": { + "cache": true, + "command": "eslint ./src", + "inputs": [ + "default", + "^default", + "{projectRoot}/eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": ".", }, - ], - "options": { - "cwd": "apps/my-app", }, }, }, }, + } + `); + }); + + 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( + { + '.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 + ); + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": { + "apps/my-app": { + "targets": { + "lint": { + "cache": true, + "command": "eslint .", + "inputs": [ + "default", + "^default", + "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": "apps/my-app", + }, + }, + }, + }, + }, + } + `); + }); - it('should create nodes with default configuration for standalone project', () => { - const fileSys = { - 'apps/my-app/eslint.config.js': `module.exports = []`, - 'apps/my-app/project.json': `{}`, - 'eslint.config.js': `module.exports = []`, - 'src/index.ts': `console.log('hello world')`, - 'package.json': `{}`, - }; - vol.fromJSON(fileSys, ''); - const nodes = createNodesFunction( - 'package.json', - { - targetName: 'lint', - }, - context - ); + 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 + ); + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": { + "apps/my-app": { + "targets": { + "lint": { + "cache": true, + "command": "eslint .", + "inputs": [ + "default", + "^default", + "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": "apps/my-app", + }, + }, + }, + }, + }, + } + `); + }); - expect(nodes).toMatchInlineSnapshot(` - { - "projects": { - ".": { - "targets": { - "lint": { - "cache": true, - "command": "eslint ./src", - "inputs": [ - "default", - "{workspaceRoot}/eslint.config.js", - "{workspaceRoot}/tools/eslint-rules/**/*", - { - "externalDependencies": [ - "eslint", - ], + 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 + ); + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": {}, + } + `); + }); + + 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( + { + '.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 + ); + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": {}, + } + `); + }); + }); + + 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 + ); + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": { + "apps/my-app": { + "targets": { + "lint": { + "cache": true, + "command": "eslint .", + "inputs": [ + "default", + "^default", + "{projectRoot}/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": "apps/my-app", }, - ], - "options": { - "cwd": ".", - "env": { - "ESLINT_USE_FLAT_CONFIG": "true", + }, + }, + }, + "libs/my-lib": { + "targets": { + "lint": { + "cache": true, + "command": "eslint .", + "inputs": [ + "default", + "^default", + "{projectRoot}/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": "libs/my-lib", }, }, }, }, }, - }, - } - `); + } + `); + }); }); - it('should not create nodes if no src folder for root', () => { - const fileSys = { - 'apps/my-app/eslint.config.js': `module.exports = []`, - 'apps/my-app/project.json': `{}`, - 'eslint.config.js': `module.exports = []`, - 'package.json': `{}`, - }; - vol.fromJSON(fileSys, ''); - const nodes = createNodesFunction( - 'package.json', - { - targetName: 'lint', - }, - context - ); + 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 + ); + // NOTE: The nested projects have the root level config as an input to their lint targets + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": { + "apps/my-app": { + "targets": { + "lint": { + "cache": true, + "command": "eslint .", + "inputs": [ + "default", + "^default", + "{workspaceRoot}/.eslintrc.json", + "{projectRoot}/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": "apps/my-app", + }, + }, + }, + }, + "libs/my-lib": { + "targets": { + "lint": { + "cache": true, + "command": "eslint .", + "inputs": [ + "default", + "^default", + "{workspaceRoot}/.eslintrc.json", + "{projectRoot}/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": "libs/my-lib", + }, + }, + }, + }, + }, + } + `); + }); - expect(nodes).toMatchInlineSnapshot(`{}`); + 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 + ); + // NOTE: The nested projects have the root level config as an input to their lint targets + expect(await invokeCreateNodesOnMatchingFiles(context, 'lint')) + .toMatchInlineSnapshot(` + { + "projects": { + "apps/myapp": { + "targets": { + "lint": { + "cache": true, + "command": "eslint .", + "inputs": [ + "default", + "^default", + "{workspaceRoot}/.eslintrc.json", + "{workspaceRoot}/apps/.eslintrc.json", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": "apps/myapp", + }, + }, + }, + }, + }, + } + `); + }); }); }); + +function getMatchingFiles(allConfigFiles: string[]): string[] { + return allConfigFiles.filter((file) => + minimatch(file, createNodes[0], { dot: true }) + ); +} + +function applyFilesToVolAndContext( + fileSys: Record, + 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 +) { + const aggregateProjects: Record = {}; + for (const file of context.configFiles) { + const nodes = await createNodes[1](file, { targetName }, context); + Object.assign(aggregateProjects, nodes.projects); + } + return { + projects: aggregateProjects, + }; +} diff --git a/packages/eslint/src/plugins/plugin.ts b/packages/eslint/src/plugins/plugin.ts index ecc2f72eeff8c0..9b265f028a7514 100644 --- a/packages/eslint/src/plugins/plugin.ts +++ b/packages/eslint/src/plugins/plugin.ts @@ -1,110 +1,158 @@ -import { CreateNodes, TargetConfiguration } from '@nx/devkit'; -import { dirname, join } from 'path'; -import { readdirSync } from 'fs'; +import { + CreateNodes, + CreateNodesContext, + CreateNodesResult, + TargetConfiguration, +} from '@nx/devkit'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { combineGlobPatterns } from 'nx/src/utils/globs'; +import { globWithWorkspaceContext } from 'nx/src/utils/workspace-context'; import { ESLINT_CONFIG_FILENAMES, - findBaseEslintFile, + baseEsLintConfigFile, + baseEsLintFlatConfigFile, isFlatConfig, } from '../utils/config-file'; export interface EslintPluginOptions { targetName?: string; + extensions?: string[]; } +const DEFAULT_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'html', 'vue']; + export const createNodes: CreateNodes = [ - combineGlobPatterns(['**/project.json', '**/package.json']), + combineGlobPatterns([ + ...ESLINT_CONFIG_FILENAMES.map((f) => `**/${f}`), + baseEsLintConfigFile, + baseEsLintFlatConfigFile, + ]), (configFilePath, options, context) => { - const projectRoot = dirname(configFilePath); - options = normalizeOptions(options); - const eslintConfigs = getEslintConfigsForProject( - projectRoot, - context.workspaceRoot - ); - if (!eslintConfigs.length) { - return {}; - } + // 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); + + const childProjectRoots = 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; + }); + + const uniqueChildProjectRoots = Array.from(new Set(childProjectRoots)); return { - projects: { - [projectRoot]: { - targets: buildEslintTargets(eslintConfigs, projectRoot, options), - }, - }, + projects: getProjectsUsingESLintConfig( + configFilePath, + uniqueChildProjectRoots, + options, + context + ), }; }, ]; -function getEslintConfigsForProject( - projectRoot: string, - workspaceRoot: string -): string[] { - const detectedConfigs = new Set(); - const baseConfig = findBaseEslintFile(workspaceRoot); - if (baseConfig) { - detectedConfigs.add(baseConfig); - } - - let siblingFiles = readdirSync(join(workspaceRoot, projectRoot)); +function getProjectsUsingESLintConfig( + configFilePath: string, + childProjectRoots: string[], + options: EslintPluginOptions, + context: CreateNodesContext +): CreateNodesResult['projects'] { + const projects: CreateNodesResult['projects'] = {}; - if (projectRoot === '.') { - // If there's no src folder, it's not a standalone project - if (!siblingFiles.includes('src')) { - return []; - } - // If it's standalone but doesn't have eslint config, it's not a lintable - const config = siblingFiles.find((f) => + const rootEslintConfig = context.configFiles.find( + (f) => + f === baseEsLintConfigFile || + f === baseEsLintFlatConfigFile || ESLINT_CONFIG_FILENAMES.includes(f) - ); - if (!config) { - return []; + ); + + // Add a lint target for each child project without an eslint config, with the root level config as an input + for (const projectRoot of childProjectRoots) { + // If there's no src folder, it's not a standalone project, do not add the target at all + const isStandaloneWorkspace = + projectRoot === '.' && + existsSync(join(context.workspaceRoot, projectRoot, 'src')) && + existsSync(join(context.workspaceRoot, projectRoot, 'package.json')); + if (projectRoot === '.' && !isStandaloneWorkspace) { + continue; } - detectedConfigs.add(config); - return Array.from(detectedConfigs); - } - while (projectRoot !== '.') { - // if it has an eslint config it's lintable - const config = siblingFiles.find((f) => - ESLINT_CONFIG_FILENAMES.includes(f) - ); - if (config) { - detectedConfigs.add(`${projectRoot}/${config}`); - return Array.from(detectedConfigs); + + const eslintConfigs = [configFilePath]; + + if (rootEslintConfig && !eslintConfigs.includes(rootEslintConfig)) { + eslintConfigs.unshift(rootEslintConfig); } - projectRoot = dirname(projectRoot); - siblingFiles = readdirSync(join(workspaceRoot, projectRoot)); - } - // check whether the root has an eslint config - const config = readdirSync(workspaceRoot).find((f) => - ESLINT_CONFIG_FILENAMES.includes(f) - ); - if (config) { - detectedConfigs.add(config); - return Array.from(detectedConfigs); + + projects[projectRoot] = { + targets: buildEslintTargets( + eslintConfigs, + projectRoot, + options, + isStandaloneWorkspace + ), + }; } - return []; + + return projects; } function buildEslintTargets( eslintConfigs: string[], projectRoot: string, - options: EslintPluginOptions + options: EslintPluginOptions, + isStandaloneWorkspace = false ) { const isRootProject = projectRoot === '.'; const targets: Record = {}; const targetConfig: TargetConfiguration = { - command: `eslint ${isRootProject ? './src' : '.'}`, + command: `eslint ${isRootProject && isStandaloneWorkspace ? './src' : '.'}`, cache: true, options: { cwd: projectRoot, }, inputs: [ 'default', - ...eslintConfigs.map((config) => `{workspaceRoot}/${config}`), + // Certain lint rules can be impacted by changes to dependencies + '^default', + ...eslintConfigs.map((config) => + `{workspaceRoot}/${config}`.replace( + `{workspaceRoot}/${projectRoot}`, + isRootProject ? '{projectRoot}/' : '{projectRoot}' + ) + ), '{workspaceRoot}/tools/eslint-rules/**/*', { externalDependencies: ['eslint'] }, ], @@ -123,5 +171,13 @@ function buildEslintTargets( function normalizeOptions(options: EslintPluginOptions): EslintPluginOptions { options ??= {}; options.targetName ??= 'lint'; + + // Normalize user input for extensions (strip leading . characters) + if (Array.isArray(options.extensions)) { + options.extensions = options.extensions.map((f) => f.replace(/^\.+/, '')); + } else { + options.extensions = DEFAULT_EXTENSIONS; + } + return options; } diff --git a/packages/eslint/src/utils/config-file.ts b/packages/eslint/src/utils/config-file.ts index af6601092c5a20..f891764a1fd75d 100644 --- a/packages/eslint/src/utils/config-file.ts +++ b/packages/eslint/src/utils/config-file.ts @@ -14,22 +14,6 @@ export const ESLINT_CONFIG_FILENAMES = [ export const baseEsLintConfigFile = '.eslintrc.base.json'; export const baseEsLintFlatConfigFile = 'eslint.base.config.js'; -export function findBaseEslintFile(workspaceRoot = ''): string | null { - if (existsSync(joinPathFragments(workspaceRoot, baseEsLintConfigFile))) { - return baseEsLintConfigFile; - } - if (existsSync(joinPathFragments(workspaceRoot, baseEsLintFlatConfigFile))) { - return baseEsLintFlatConfigFile; - } - for (const file of ESLINT_CONFIG_FILENAMES) { - if (existsSync(joinPathFragments(workspaceRoot, file))) { - return file; - } - } - - return null; -} - export function isFlatConfig(configFilePath: string): boolean { return configFilePath.endsWith('.config.js'); } diff --git a/packages/jest/src/plugins/plugin.spec.ts b/packages/jest/src/plugins/plugin.spec.ts index 8595e35b320c27..b16c1e88e03587 100644 --- a/packages/jest/src/plugins/plugin.spec.ts +++ b/packages/jest/src/plugins/plugin.spec.ts @@ -20,6 +20,7 @@ describe('@nx/jest/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; await tempFs.createFiles({ diff --git a/packages/next/src/plugins/plugin.spec.ts b/packages/next/src/plugins/plugin.spec.ts index 0b1f5e6e662aa1..44bc72d19e795f 100644 --- a/packages/next/src/plugins/plugin.spec.ts +++ b/packages/next/src/plugins/plugin.spec.ts @@ -18,6 +18,7 @@ describe('@nx/next/plugin', () => { }, }, workspaceRoot: '', + configFiles: [], }; }); @@ -53,6 +54,7 @@ describe('@nx/next/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync( diff --git a/packages/nuxt/src/plugins/plugin.spec.ts b/packages/nuxt/src/plugins/plugin.spec.ts index 60b47d475a3b1f..12ce5693df64e7 100644 --- a/packages/nuxt/src/plugins/plugin.spec.ts +++ b/packages/nuxt/src/plugins/plugin.spec.ts @@ -38,6 +38,7 @@ describe('@nx/nuxt/plugin', () => { }, }, workspaceRoot: '', + configFiles: [], }; }); @@ -72,6 +73,7 @@ describe('@nx/nuxt/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync( diff --git a/packages/nx/src/plugins/project-json/build-nodes/package-json-next-to-project-json.spec.ts b/packages/nx/src/plugins/project-json/build-nodes/package-json-next-to-project-json.spec.ts index 688c85af1b4936..c4367d0066a0af 100644 --- a/packages/nx/src/plugins/project-json/build-nodes/package-json-next-to-project-json.spec.ts +++ b/packages/nx/src/plugins/project-json/build-nodes/package-json-next-to-project-json.spec.ts @@ -14,6 +14,7 @@ describe('nx project.json plugin', () => { context = { nxJsonConfiguration: {}, workspaceRoot: '/root', + configFiles: [], }; }); diff --git a/packages/nx/src/plugins/project-json/build-nodes/project-json.spec.ts b/packages/nx/src/plugins/project-json/build-nodes/project-json.spec.ts index 138be521698e9a..9d6addb0dbcee6 100644 --- a/packages/nx/src/plugins/project-json/build-nodes/project-json.spec.ts +++ b/packages/nx/src/plugins/project-json/build-nodes/project-json.spec.ts @@ -12,6 +12,7 @@ describe('nx project.json plugin', () => { context = { nxJsonConfiguration: {}, workspaceRoot: '/root', + configFiles: [], }; }); diff --git a/packages/nx/src/plugins/target-defaults/target-defaults-plugin.spec.ts b/packages/nx/src/plugins/target-defaults/target-defaults-plugin.spec.ts index 80a24485f2fc25..3bc0c97404225c 100644 --- a/packages/nx/src/plugins/target-defaults/target-defaults-plugin.spec.ts +++ b/packages/nx/src/plugins/target-defaults/target-defaults-plugin.spec.ts @@ -20,6 +20,7 @@ describe('target-defaults plugin', () => { }, }, workspaceRoot: '/root', + configFiles: [], }; }); @@ -109,6 +110,7 @@ describe('target-defaults plugin', () => { }, }, workspaceRoot: '/root', + configFiles: [], }) ).toMatchInlineSnapshot(` { @@ -156,6 +158,7 @@ describe('target-defaults plugin', () => { }, }, workspaceRoot: '/root', + configFiles: [], }) ).toMatchInlineSnapshot(` { @@ -200,6 +203,7 @@ describe('target-defaults plugin', () => { }, }, workspaceRoot: '/root', + configFiles: [], }) ).toMatchInlineSnapshot(`{}`); }); @@ -230,6 +234,7 @@ describe('target-defaults plugin', () => { }, }, workspaceRoot: '/root', + configFiles: [], }) ).toMatchInlineSnapshot(` { @@ -278,6 +283,7 @@ describe('target-defaults plugin', () => { }, }, workspaceRoot: '/root', + configFiles: [], }); const { targets } = result.projects['.']; @@ -321,6 +327,7 @@ describe('target-defaults plugin', () => { }, }, workspaceRoot: '/root', + configFiles: [], }); const { targets } = result.projects['.']; diff --git a/packages/nx/src/project-graph/file-utils.ts b/packages/nx/src/project-graph/file-utils.ts index c07c9edfe8221f..dc14266170db17 100644 --- a/packages/nx/src/project-graph/file-utils.ts +++ b/packages/nx/src/project-graph/file-utils.ts @@ -182,7 +182,7 @@ export { readNxJson, workspaceLayout } from '../config/configuration'; * TODO(v19): Remove this function. */ function getProjectsSyncNoInference(root: string, nxJson: NxJsonConfiguration) { - const projectFiles = retrieveProjectConfigurationPaths( + const allConfigFiles = retrieveProjectConfigurationPaths( root, getDefaultPluginsSync(root) ); @@ -199,11 +199,15 @@ function getProjectsSyncNoInference(root: string, nxJson: NxJsonConfiguration) { if (!pattern) { continue; } - for (const file of projectFiles) { + const matchingConfigFiles = allConfigFiles.filter((file) => + minimatch(file, pattern, { dot: true }) + ); + for (const file of matchingConfigFiles) { if (minimatch(file, pattern, { dot: true })) { let r = createNodes(file, options, { nxJsonConfiguration: nxJson, workspaceRoot: root, + configFiles: matchingConfigFiles, }) as CreateNodesResult; for (const node in r.projects) { const project = { diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.ts index 275cea0f6c853d..08c2c5f727819c 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.ts @@ -193,13 +193,13 @@ export type ConfigurationResult = { * Transforms a list of project paths into a map of project configurations. * * @param nxJson The NxJson configuration - * @param projectFiles A list of files identified as projects + * @param workspaceFiles A list of non-ignored workspace files * @param plugins The plugins that should be used to infer project configuration * @param root The workspace root */ export function buildProjectsConfigurationsFromProjectPathsAndPlugins( nxJson: NxJsonConfiguration, - projectFiles: string[], // making this parameter allows devkit to pick up newly created projects + workspaceFiles: string[], // making this parameter allows devkit to pick up newly created projects plugins: LoadedNxPlugin[], root: string = workspaceRoot ): Promise { @@ -222,54 +222,57 @@ export function buildProjectsConfigurationsFromProjectPathsAndPlugins( continue; } - for (const file of projectFiles) { - performance.mark(`${plugin.name}:createNodes:${file} - start`); - if (minimatch(file, pattern, { dot: true })) { - try { - let r = createNodes(file, options, { - nxJsonConfiguration: nxJson, - workspaceRoot: root, - }); + const matchingConfigFiles: string[] = workspaceFiles.filter( + minimatch.filter(pattern, { dot: true }) + ); - if (r instanceof Promise) { - pluginResults.push( - r - .catch((e) => { - performance.mark(`${plugin.name}:createNodes:${file} - end`); - throw new CreateNodesError( - `Unable to create nodes for ${file} using plugin ${plugin.name}.`, - e - ); - }) - .then((r) => { - performance.mark(`${plugin.name}:createNodes:${file} - end`); - performance.measure( - `${plugin.name}:createNodes:${file}`, - `${plugin.name}:createNodes:${file} - start`, - `${plugin.name}:createNodes:${file} - end` - ); - return { ...r, file, pluginName: plugin.name }; - }) - ); - } else { - performance.mark(`${plugin.name}:createNodes:${file} - end`); - performance.measure( - `${plugin.name}:createNodes:${file}`, - `${plugin.name}:createNodes:${file} - start`, - `${plugin.name}:createNodes:${file} - end` - ); - pluginResults.push({ - ...r, - file, - pluginName: plugin.name, - }); - } - } catch (e) { - throw new CreateNodesError( - `Unable to create nodes for ${file} using plugin ${plugin.name}.`, - e + for (const file of matchingConfigFiles) { + performance.mark(`${plugin.name}:createNodes:${file} - start`); + try { + let r = createNodes(file, options, { + nxJsonConfiguration: nxJson, + workspaceRoot: root, + configFiles: matchingConfigFiles, + }); + + if (r instanceof Promise) { + pluginResults.push( + r + .catch((e) => { + performance.mark(`${plugin.name}:createNodes:${file} - end`); + throw new CreateNodesError( + `Unable to create nodes for ${file} using plugin ${plugin.name}.`, + e + ); + }) + .then((r) => { + performance.mark(`${plugin.name}:createNodes:${file} - end`); + performance.measure( + `${plugin.name}:createNodes:${file}`, + `${plugin.name}:createNodes:${file} - start`, + `${plugin.name}:createNodes:${file} - end` + ); + return { ...r, file, pluginName: plugin.name }; + }) ); + } else { + performance.mark(`${plugin.name}:createNodes:${file} - end`); + performance.measure( + `${plugin.name}:createNodes:${file}`, + `${plugin.name}:createNodes:${file} - start`, + `${plugin.name}:createNodes:${file} - end` + ); + pluginResults.push({ + ...r, + file, + pluginName: plugin.name, + }); } + } catch (e) { + throw new CreateNodesError( + `Unable to create nodes for ${file} using plugin ${plugin.name}.`, + e + ); } } // If there are no promises (counter undefined) or all promises have resolved (counter === 0) diff --git a/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts b/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts index 271f4746b58eb1..6290fcd7578b53 100644 --- a/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts +++ b/packages/nx/src/project-graph/utils/retrieve-workspace-files.ts @@ -116,12 +116,12 @@ function _retrieveProjectConfigurations( plugins: LoadedNxPlugin[] ): Promise { const globPatterns = configurationGlobs(plugins); - const projectFiles = globWithWorkspaceContext(workspaceRoot, globPatterns); + const workspaceFiles = globWithWorkspaceContext(workspaceRoot, globPatterns); return createProjectConfigurations( workspaceRoot, nxJson, - projectFiles, + workspaceFiles, plugins ); } @@ -152,7 +152,8 @@ export async function retrieveProjectConfigurationsWithoutPluginInference( return projectsWithoutPluginCache.get(cacheKey); } - const projectFiles = globWithWorkspaceContext(root, projectGlobPatterns); + const projectFiles = + globWithWorkspaceContext(root, projectGlobPatterns) ?? []; const { projects } = await createProjectConfigurations( root, nxJson, diff --git a/packages/nx/src/utils/ignore.ts b/packages/nx/src/utils/ignore.ts index 05440219163d52..786af234aad03f 100644 --- a/packages/nx/src/utils/ignore.ts +++ b/packages/nx/src/utils/ignore.ts @@ -32,7 +32,7 @@ export function getIgnoredGlobs( } export function getAlwaysIgnore(root?: string) { - const paths = ['node_modules', '**/node_modules', '.git']; + const paths = ['node_modules', '**/node_modules', '.git', '.nx', '.vscode']; return root ? paths.map((x) => joinPathFragments(root, x)) : paths; } diff --git a/packages/nx/src/utils/nx-plugin.ts b/packages/nx/src/utils/nx-plugin.ts index 836dfe23337fb1..8080feee962019 100644 --- a/packages/nx/src/utils/nx-plugin.ts +++ b/packages/nx/src/utils/nx-plugin.ts @@ -50,6 +50,10 @@ import { TargetDefaultsPlugin } from '../plugins/target-defaults/target-defaults export interface CreateNodesContext { readonly nxJsonConfiguration: NxJsonConfiguration; readonly workspaceRoot: string; + /** + * The subset of configuration files which match the createNodes pattern + */ + readonly configFiles: string[]; } /** @@ -78,7 +82,7 @@ export interface CreateNodesResult { * A pair of file patterns and {@link CreateNodesFunction} */ export type CreateNodes = readonly [ - projectFilePattern: string, + configFilePattern: string, createNodesFunction: CreateNodesFunction ]; diff --git a/packages/playwright/src/plugins/plugin.spec.ts b/packages/playwright/src/plugins/plugin.spec.ts index 9c104564ba30d4..94471ba4bbac61 100644 --- a/packages/playwright/src/plugins/plugin.spec.ts +++ b/packages/playwright/src/plugins/plugin.spec.ts @@ -24,6 +24,7 @@ describe('@nx/playwright/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; }); diff --git a/packages/remix/src/plugins/plugin.spec.ts b/packages/remix/src/plugins/plugin.spec.ts index a2f798dbbdc390..0094174e94c306 100644 --- a/packages/remix/src/plugins/plugin.spec.ts +++ b/packages/remix/src/plugins/plugin.spec.ts @@ -34,6 +34,7 @@ describe('@nx/remix/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync( 'package.json', @@ -89,6 +90,7 @@ module.exports = { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync( diff --git a/packages/rollup/src/plugins/plugin.spec.ts b/packages/rollup/src/plugins/plugin.spec.ts index 82b4bca4ae4ade..69f3cc2f647f8b 100644 --- a/packages/rollup/src/plugins/plugin.spec.ts +++ b/packages/rollup/src/plugins/plugin.spec.ts @@ -25,6 +25,7 @@ describe('@nx/rollup/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync('package.json', JSON.stringify({ name: 'mylib' })); @@ -93,6 +94,7 @@ module.exports = config; }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync( diff --git a/packages/storybook/src/plugins/plugin.spec.ts b/packages/storybook/src/plugins/plugin.spec.ts index f372d0604f4011..e5cf37569f0098 100644 --- a/packages/storybook/src/plugins/plugin.spec.ts +++ b/packages/storybook/src/plugins/plugin.spec.ts @@ -17,6 +17,7 @@ describe('@nx/storybook/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync( 'my-app/project.json', diff --git a/packages/webpack/src/plugins/plugin.spec.ts b/packages/webpack/src/plugins/plugin.spec.ts index 12e087e0071dbd..d198a943581ea6 100644 --- a/packages/webpack/src/plugins/plugin.spec.ts +++ b/packages/webpack/src/plugins/plugin.spec.ts @@ -19,6 +19,7 @@ describe('@nx/webpack/plugin', () => { }, }, workspaceRoot: tempFs.tempDir, + configFiles: [], }; tempFs.createFileSync(