diff --git a/e2e/eslint/src/linter.test.ts b/e2e/eslint/src/linter.test.ts index 6b506f2d32..904413ee1f 100644 --- a/e2e/eslint/src/linter.test.ts +++ b/e2e/eslint/src/linter.test.ts @@ -764,6 +764,45 @@ describe('Linter', () => { expect(e2eEslint.overrides[0].extends).toBeUndefined(); }); }); + + describe('Project Config v3', () => { + let myapp; + + beforeEach(() => { + myapp = uniq('myapp'); + newProject({ + name: uniq('eslint'), + unsetProjectNameAndRootFormat: false, + }); + }); + + it('should lint example app', () => { + runCLI( + `generate @nx/react:app ${myapp} --directory apps/${myapp} --unitTestRunner=none --bundler=vite --e2eTestRunner=cypress --style=css --no-interactive --projectNameAndRootFormat=as-provided`, + { env: { NX_PCV3: 'true' } } + ); + + let lintResults = runCLI(`lint ${myapp}`); + expect(lintResults).toContain( + `Successfully ran target lint for project ${myapp}` + ); + lintResults = runCLI(`lint ${myapp}-e2e`); + expect(lintResults).toContain( + `Successfully ran target lint for project ${myapp}-e2e` + ); + + const { targets } = readJson(`apps/${myapp}/project.json`); + expect(targets.lint).not.toBeDefined(); + + const { plugins } = readJson('nx.json'); + expect(plugins).toContainEqual({ + plugin: '@nx/eslint/plugin', + options: { + targetName: 'lint', + }, + }); + }); + }); }); /** 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 dd135758ae..217d3ce28c 100644 --- a/packages/devkit/src/utils/replace-project-configuration-with-plugin.ts +++ b/packages/devkit/src/utils/replace-project-configuration-with-plugin.ts @@ -134,7 +134,7 @@ function removeConfigurationDefinedByPlugin( for (const [optionName, optionValue] of Object.entries( targetFromProjectConfig.options ?? {} )) { - if (targetFromCreateNodes.options[optionName] === optionValue) { + if (equals(targetFromCreateNodes.options[optionName], optionValue)) { delete targetFromProjectConfig.options[optionName]; } } @@ -167,6 +167,16 @@ function removeConfigurationDefinedByPlugin( } } +function equals(a: T, b: T) { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => v === b[i]); + } + if (typeof a === 'object' && typeof b === 'object') { + return hashObject(a) === hashObject(b); + } + return a === b; +} + function shouldRemoveArrayProperty( arrayValuesFromProjectConfiguration: (object | string)[], arrayValuesFromCreateNodes: (object | string)[] diff --git a/packages/eslint/package.json b/packages/eslint/package.json index f9ba1dac67..ea36cb9dfa 100644 --- a/packages/eslint/package.json +++ b/packages/eslint/package.json @@ -27,16 +27,16 @@ "requirements": {}, "migrations": "./migrations.json" }, - "executors": "./executors.json", "generators": "./generators.json", + "executors": "./executors.json", "peerDependencies": { "eslint": "^8.0.0", "js-yaml": "4.1.0" }, "dependencies": { - "tslib": "^2.3.0", "@nx/devkit": "file:../devkit", "@nx/js": "file:../js", + "tslib": "^2.3.0", "typescript": "~5.2.2" }, "peerDependenciesMeta": { diff --git a/packages/eslint/plugin.ts b/packages/eslint/plugin.ts new file mode 100644 index 0000000000..3432a331aa --- /dev/null +++ b/packages/eslint/plugin.ts @@ -0,0 +1 @@ +export { createNodes, EslintPluginOptions } from './src/plugins/plugin'; diff --git a/packages/eslint/src/generators/init/__snapshots__/init.spec.ts.snap b/packages/eslint/src/generators/init/__snapshots__/init.spec.ts.snap index 5ffca0bd96..3fb18f7e0a 100644 --- a/packages/eslint/src/generators/init/__snapshots__/init.spec.ts.snap +++ b/packages/eslint/src/generators/init/__snapshots__/init.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`@nx/eslint:init --linter eslint should generate the global eslint config 1`] = ` +exports[`@nx/eslint:init should generate the global eslint config 1`] = ` "{ "root": true, "ignorePatterns": [ diff --git a/packages/eslint/src/generators/init/init.spec.ts b/packages/eslint/src/generators/init/init.spec.ts index 95f5a44d53..69d46b6b0a 100644 --- a/packages/eslint/src/generators/init/init.spec.ts +++ b/packages/eslint/src/generators/init/init.spec.ts @@ -1,5 +1,5 @@ import { Linter } from '../utils/linter'; -import { readJson, Tree } from '@nx/devkit'; +import { NxJsonConfiguration, readJson, Tree, updateJson } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { lintInitGenerator } from './init'; @@ -10,45 +10,91 @@ describe('@nx/eslint:init', () => { tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); }); - describe('--linter', () => { - describe('eslint', () => { - it('should generate the global eslint config', async () => { - await lintInitGenerator(tree, { - linter: Linter.EsLint, - }); + it('should generate the global eslint config', async () => { + await lintInitGenerator(tree, { + linter: Linter.EsLint, + }); - expect(tree.read('.eslintrc.json', 'utf-8')).toMatchSnapshot(); - expect(tree.read('.eslintignore', 'utf-8')).toMatchInlineSnapshot(` + expect(tree.read('.eslintrc.json', 'utf-8')).toMatchSnapshot(); + expect(tree.read('.eslintignore', 'utf-8')).toMatchInlineSnapshot(` "node_modules " `); - }); + }); - it('should add the root eslint config to the lint targetDefaults for lint', async () => { - await lintInitGenerator(tree, { - linter: Linter.EsLint, - }); + it('should add the root eslint config to the lint targetDefaults for lint', async () => { + await lintInitGenerator(tree, { + linter: Linter.EsLint, + }); - expect(readJson(tree, 'nx.json').targetDefaults.lint).toEqual({ - cache: true, - inputs: [ - 'default', - '{workspaceRoot}/.eslintrc.json', - '{workspaceRoot}/.eslintignore', - '{workspaceRoot}/eslint.config.js', - ], - }); - }); - - it('should not generate the global eslint config if it already exist', async () => { - tree.write('.eslintrc.js', '{}'); - - await lintInitGenerator(tree, { - linter: Linter.EsLint, - }); - - expect(tree.exists('.eslintrc.json')).toBe(false); - }); + expect(readJson(tree, 'nx.json').targetDefaults.lint).toEqual({ + cache: true, + inputs: [ + 'default', + '{workspaceRoot}/.eslintrc.json', + '{workspaceRoot}/.eslintignore', + '{workspaceRoot}/eslint.config.js', + ], }); }); + + it('should not generate the global eslint config if it already exist', async () => { + tree.write('.eslintrc.js', '{}'); + + await lintInitGenerator(tree, { + linter: Linter.EsLint, + }); + + expect(tree.exists('.eslintrc.json')).toBe(false); + }); + + it('should setup lint target defaults', async () => { + updateJson(tree, 'nx.json', (json) => { + json.namedInputs ??= {}; + json.namedInputs.production = ['default']; + return json; + }); + + await lintInitGenerator(tree, {}); + + expect( + readJson(tree, 'nx.json').targetDefaults.lint + ).toEqual({ + cache: true, + inputs: [ + 'default', + '{workspaceRoot}/.eslintrc.json', + '{workspaceRoot}/.eslintignore', + '{workspaceRoot}/eslint.config.js', + ], + }); + }); + + it('should setup @nx/eslint/plugin', async () => { + process.env.NX_PCV3 = 'true'; + updateJson(tree, 'nx.json', (json) => { + json.namedInputs ??= {}; + json.namedInputs.production = ['default']; + return json; + }); + + await lintInitGenerator(tree, {}); + + expect( + readJson(tree, 'nx.json').targetDefaults.lint + ).toEqual({ + cache: true, + }); + expect(readJson(tree, 'nx.json').plugins) + .toMatchInlineSnapshot(` + [ + { + "options": { + "targetName": "lint", + }, + "plugin": "@nx/eslint/plugin", + }, + ] + `); + }); }); diff --git a/packages/eslint/src/generators/init/init.ts b/packages/eslint/src/generators/init/init.ts index 521b2e3da7..c155b30c4e 100644 --- a/packages/eslint/src/generators/init/init.ts +++ b/packages/eslint/src/generators/init/init.ts @@ -17,6 +17,7 @@ import { import { Linter } from '../utils/linter'; import { findEslintFile } from '../utils/eslint-file'; import { getGlobalEsLintConfiguration } from './global-eslint-config'; +import { EslintPluginOptions } from '../../plugins/plugin'; export interface LinterInitOptions { linter?: Linter; @@ -25,21 +26,25 @@ export interface LinterInitOptions { rootProject?: boolean; } -function addTargetDefaults(tree: Tree) { +function updateProductionFileset(tree: Tree) { const nxJson = readNxJson(tree); const productionFileSet = nxJson.namedInputs?.production; if (productionFileSet) { - // Remove .eslintrc.json productionFileSet.push('!{projectRoot}/.eslintrc.json'); productionFileSet.push('!{projectRoot}/eslint.config.js'); // Dedupe and set nxJson.namedInputs.production = Array.from(new Set(productionFileSet)); } + updateNxJson(tree, nxJson); +} + +function addTargetDefaults(tree: Tree) { + const nxJson = readNxJson(tree); nxJson.targetDefaults ??= {}; - nxJson.targetDefaults.lint ??= {}; + nxJson.targetDefaults.lint.cache ??= true; nxJson.targetDefaults.lint.inputs ??= [ 'default', `{workspaceRoot}/.eslintrc.json`, @@ -49,6 +54,42 @@ function addTargetDefaults(tree: Tree) { updateNxJson(tree, nxJson); } +function addPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + + for (const plugin of nxJson.plugins) { + if ( + typeof plugin === 'string' + ? plugin === '@nx/eslint/plugin' + : plugin.plugin === '@nx/eslint/plugin' + ) { + return; + } + } + + nxJson.plugins.push({ + plugin: '@nx/eslint/plugin', + options: { + targetName: 'lint', + } as EslintPluginOptions, + }); + updateNxJson(tree, nxJson); +} + +function updateVSCodeExtensions(tree: Tree) { + if (tree.exists('.vscode/extensions.json')) { + updateJson(tree, '.vscode/extensions.json', (json) => { + json.recommendations ||= []; + const extension = 'dbaeumer.vscode-eslint'; + if (!json.recommendations.includes(extension)) { + json.recommendations.push(extension); + } + return json; + }); + } +} + /** * Initializes ESLint configuration in a workspace and adds necessary dependencies. */ @@ -67,19 +108,18 @@ function initEsLint(tree: Tree, options: LinterInitOptions): GeneratorCallback { getGlobalEsLintConfiguration(options.unitTestRunner, options.rootProject) ); tree.write('.eslintignore', 'node_modules\n'); - addTargetDefaults(tree); - if (tree.exists('.vscode/extensions.json')) { - updateJson(tree, '.vscode/extensions.json', (json) => { - json.recommendations ||= []; - const extension = 'dbaeumer.vscode-eslint'; - if (!json.recommendations.includes(extension)) { - json.recommendations.push(extension); - } - return json; - }); + updateProductionFileset(tree); + + const addPlugins = process.env.NX_PCV3 === 'true'; + if (addPlugins) { + addPlugin(tree); + } else { + addTargetDefaults(tree); } + updateVSCodeExtensions(tree); + return !options.skipPackageJson ? addDependenciesToPackageJson( tree, diff --git a/packages/eslint/src/generators/lint-project/lint-project.ts b/packages/eslint/src/generators/lint-project/lint-project.ts index 6981ca57dd..5181dc4336 100644 --- a/packages/eslint/src/generators/lint-project/lint-project.ts +++ b/packages/eslint/src/generators/lint-project/lint-project.ts @@ -14,11 +14,7 @@ import { } from '@nx/devkit'; import { Linter as LinterEnum } from '../utils/linter'; -import { - baseEsLintConfigFile, - baseEsLintFlatConfigFile, - findEslintFile, -} from '../utils/eslint-file'; +import { findEslintFile } from '../utils/eslint-file'; import { join } from 'path'; import { lintInitGenerator } from '../init/init'; import type { Linter } from 'eslint'; @@ -34,6 +30,10 @@ import { generateSpreadElement, stringifyNodeList, } from '../utils/flat-config/ast-utils'; +import { + baseEsLintConfigFile, + baseEsLintFlatConfigFile, +} from '../../utils/config-file'; interface LintProjectOptions { project: string; @@ -59,27 +59,46 @@ export async function lintProjectGenerator( }); const projectConfig = readProjectConfiguration(tree, options.project); - projectConfig.targets['lint'] = { - executor: '@nx/eslint:lint', - outputs: ['{options.outputFile}'], - }; - let lintFilePatterns = options.eslintFilePatterns; if (!lintFilePatterns && options.rootProject && projectConfig.root === '.') { lintFilePatterns = ['./src']; } - if (lintFilePatterns && lintFilePatterns.length) { - if ( - isBuildableLibraryProject(projectConfig) && - !lintFilePatterns.includes('{projectRoot}') - ) { - lintFilePatterns.push(`{projectRoot}/package.json`); - } + if ( + lintFilePatterns && + lintFilePatterns.length && + !lintFilePatterns.includes('{projectRoot}') && + isBuildableLibraryProject(projectConfig) + ) { + lintFilePatterns.push(`{projectRoot}/package.json`); + } - // only add lintFilePatterns if they are explicitly defined - projectConfig.targets['lint'].options = { - lintFilePatterns, + const usePlugin = process.env.NX_PCV3 === 'true'; + if (usePlugin) { + if ( + lintFilePatterns && + lintFilePatterns.length && + lintFilePatterns.some( + (p) => !['./src', '{projectRoot}', projectConfig.root].includes(p) + ) + ) { + projectConfig.targets['lint'] = { + command: `eslint ${lintFilePatterns + .join(' ') + .replace('{projectRoot}', projectConfig.root)}`, + }; + } + } else { + projectConfig.targets['lint'] = { + executor: '@nx/eslint:lint', + outputs: ['{options.outputFile}'], }; + + if (lintFilePatterns && lintFilePatterns.length) { + // only add lintFilePatterns if they are explicitly defined + projectConfig.targets['lint'].options = { + lintFilePatterns, + }; + } } // we are adding new project which is not the root project or diff --git a/packages/eslint/src/generators/utils/eslint-file.spec.ts b/packages/eslint/src/generators/utils/eslint-file.spec.ts index 9566da31cd..8d22fb633a 100644 --- a/packages/eslint/src/generators/utils/eslint-file.spec.ts +++ b/packages/eslint/src/generators/utils/eslint-file.spec.ts @@ -1,13 +1,15 @@ import { addExtendsToLintConfig, - baseEsLintConfigFile, - eslintConfigFileWhitelist, findEslintFile, lintConfigHasOverride, } from './eslint-file'; import { Tree, readJson } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { + ESLINT_CONFIG_FILENAMES, + baseEsLintConfigFile, +} from '../../utils/config-file'; describe('@nx/eslint:lint-file', () => { let tree: Tree; @@ -21,7 +23,7 @@ describe('@nx/eslint:lint-file', () => { expect(findEslintFile(tree)).toBe(null); }); - test.each(eslintConfigFileWhitelist)( + test.each(ESLINT_CONFIG_FILENAMES)( 'should return %p when calling findEslintFile', (eslintFileName) => { tree.write(eslintFileName, '{}'); @@ -29,7 +31,7 @@ describe('@nx/eslint:lint-file', () => { } ); - test.each(eslintConfigFileWhitelist)( + test.each(ESLINT_CONFIG_FILENAMES)( 'should return base file instead %p when calling findEslintFile', (eslintFileName) => { tree.write(baseEsLintConfigFile, '{}'); diff --git a/packages/eslint/src/generators/utils/eslint-file.ts b/packages/eslint/src/generators/utils/eslint-file.ts index aef69fa708..85e659d190 100644 --- a/packages/eslint/src/generators/utils/eslint-file.ts +++ b/packages/eslint/src/generators/utils/eslint-file.ts @@ -22,28 +22,24 @@ import { } from './flat-config/ast-utils'; import ts = require('typescript'); import { mapFilePath } from './flat-config/path-utils'; +import { + baseEsLintConfigFile, + baseEsLintFlatConfigFile, + ESLINT_CONFIG_FILENAMES, +} from '../../utils/config-file'; -export const eslintConfigFileWhitelist = [ - '.eslintrc', - '.eslintrc.js', - '.eslintrc.cjs', - '.eslintrc.yaml', - '.eslintrc.yml', - '.eslintrc.json', - 'eslint.config.js', -]; - -export const baseEsLintConfigFile = '.eslintrc.base.json'; -export const baseEsLintFlatConfigFile = 'eslint.base.config.js'; - -export function findEslintFile(tree: Tree, projectRoot = ''): string | null { - if (projectRoot === '' && tree.exists(baseEsLintConfigFile)) { +export function findEslintFile( + tree: Tree, + projectRoot?: string +): string | null { + if (projectRoot === undefined && tree.exists(baseEsLintConfigFile)) { return baseEsLintConfigFile; } - if (projectRoot === '' && tree.exists(baseEsLintFlatConfigFile)) { + if (projectRoot === undefined && tree.exists(baseEsLintFlatConfigFile)) { return baseEsLintFlatConfigFile; } - for (const file of eslintConfigFileWhitelist) { + projectRoot ??= ''; + for (const file of ESLINT_CONFIG_FILENAMES) { if (tree.exists(joinPathFragments(projectRoot, file))) { return file; } @@ -148,7 +144,7 @@ function offsetFilePath( tree: Tree ): string { if ( - eslintConfigFileWhitelist.some((eslintFile) => + ESLINT_CONFIG_FILENAMES.some((eslintFile) => pathToFile.includes(eslintFile) ) ) { diff --git a/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.spec.ts b/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.spec.ts index 84b68ed532..6b13219035 100644 --- a/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.spec.ts +++ b/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.spec.ts @@ -6,7 +6,7 @@ import { updateNxJson, } from '@nx/devkit'; import addEslintInputs from './add-eslint-inputs'; -import { eslintConfigFileWhitelist } from '../../generators/utils/eslint-file'; +import { ESLINT_CONFIG_FILENAMES } from '../../utils/config-file'; describe('15.0.0 migration (add-eslint-inputs)', () => { let tree: Tree; @@ -41,7 +41,7 @@ describe('15.0.0 migration (add-eslint-inputs)', () => { }); }); - test.each(eslintConfigFileWhitelist)( + test.each(ESLINT_CONFIG_FILENAMES)( 'should ignore %p for production', async (eslintConfigFilename) => { tree.write(eslintConfigFilename, '{}'); @@ -57,7 +57,7 @@ describe('15.0.0 migration (add-eslint-inputs)', () => { } ); - test.each(eslintConfigFileWhitelist)( + test.each(ESLINT_CONFIG_FILENAMES)( 'should add %p to all lint targets', async (eslintConfigFilename) => { tree.write(eslintConfigFilename, '{}'); @@ -95,7 +95,7 @@ describe('15.0.0 migration (add-eslint-inputs)', () => { }); }); - test.each(eslintConfigFileWhitelist)( + test.each(ESLINT_CONFIG_FILENAMES)( 'should not add `!{projectRoot}/%s` if `workspaceConfiguration.namedInputs` is undefined', async (eslintConfigFilename) => { tree.write(eslintConfigFilename, '{}'); @@ -108,7 +108,7 @@ describe('15.0.0 migration (add-eslint-inputs)', () => { } ); - test.each(eslintConfigFileWhitelist)( + test.each(ESLINT_CONFIG_FILENAMES)( 'should not add `!{projectRoot}/%s` if `workspaceConfiguration.namedInputs.production` is undefined', async (eslintConfigFilename) => { updateNxJson(tree, { @@ -148,7 +148,7 @@ describe('15.0.0 migration (add-eslint-inputs)', () => { }); }); - test.each(eslintConfigFileWhitelist)( + test.each(ESLINT_CONFIG_FILENAMES)( 'should not override `targetDefaults.lint.inputs` with `%s` as there was a default target set in the workspace config', async (eslintConfigFilename) => { updateNxJson(tree, { diff --git a/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.ts b/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.ts index 62abcbbf84..c9bcd1b79c 100644 --- a/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.ts +++ b/packages/eslint/src/migrations/update-15-0-0/add-eslint-inputs.ts @@ -5,13 +5,13 @@ import { Tree, updateNxJson, } from '@nx/devkit'; -import { eslintConfigFileWhitelist } from '../../generators/utils/eslint-file'; import { getEslintTargets } from '../../generators/utils/eslint-targets'; +import { ESLINT_CONFIG_FILENAMES } from '../../utils/config-file'; export default async function addEslintInputs(tree: Tree) { const nxJson = readNxJson(tree); - const globalEslintFile = eslintConfigFileWhitelist.find((file) => + const globalEslintFile = ESLINT_CONFIG_FILENAMES.find((file) => tree.exists(file) ); diff --git a/packages/eslint/src/migrations/update-15-7-1/add-eslint-ignore.ts b/packages/eslint/src/migrations/update-15-7-1/add-eslint-ignore.ts index fff702591d..6fc6f60a7d 100644 --- a/packages/eslint/src/migrations/update-15-7-1/add-eslint-ignore.ts +++ b/packages/eslint/src/migrations/update-15-7-1/add-eslint-ignore.ts @@ -5,13 +5,13 @@ import { Tree, updateNxJson, } from '@nx/devkit'; -import { eslintConfigFileWhitelist } from '../../generators/utils/eslint-file'; import { getEslintTargets } from '../../generators/utils/eslint-targets'; +import { ESLINT_CONFIG_FILENAMES } from '../../utils/config-file'; export default async function addEslintIgnore(tree: Tree) { const nxJson = readJson(tree, 'nx.json'); - const globalEslintFile = eslintConfigFileWhitelist.find((file) => + const globalEslintFile = ESLINT_CONFIG_FILENAMES.find((file) => tree.exists(file) ); diff --git a/packages/eslint/src/plugins/plugin.spec.ts b/packages/eslint/src/plugins/plugin.spec.ts new file mode 100644 index 0000000000..bb974b8755 --- /dev/null +++ b/packages/eslint/src/plugins/plugin.spec.ts @@ -0,0 +1,147 @@ +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)), + }; +}); + +describe('@nx/eslint/plugin', () => { + let createNodesFunction = createNodes[1]; + let context: CreateNodesContext; + + beforeEach(async () => { + context = { + nxJsonConfiguration: { + namedInputs: { + default: ['{projectRoot}/**/*'], + production: ['!{projectRoot}/**/*.spec.ts'], + }, + }, + workspaceRoot: '', + }; + }); + + afterEach(() => { + vol.reset(); + 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', + { + targetName: 'lint', + }, + context + ); + + expect(nodes).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", + ], + }, + ], + "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 + ); + + expect(nodes).toMatchInlineSnapshot(` + { + "projects": { + ".": { + "targets": { + "lint": { + "cache": true, + "command": "eslint ./src", + "inputs": [ + "default", + "{workspaceRoot}/eslint.config.js", + "{workspaceRoot}/tools/eslint-rules/**/*", + { + "externalDependencies": [ + "eslint", + ], + }, + ], + "options": { + "cwd": ".", + "env": { + "ESLINT_USE_FLAT_CONFIG": "true", + }, + }, + }, + }, + }, + }, + } + `); + }); + + 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 + ); + + expect(nodes).toMatchInlineSnapshot(`{}`); + }); +}); diff --git a/packages/eslint/src/plugins/plugin.ts b/packages/eslint/src/plugins/plugin.ts new file mode 100644 index 0000000000..de683913c6 --- /dev/null +++ b/packages/eslint/src/plugins/plugin.ts @@ -0,0 +1,149 @@ +import { + CreateNodes, + CreateNodesContext, + TargetConfiguration, +} from '@nx/devkit'; +import { dirname, join } from 'path'; +import { readTargetDefaultsForTarget } from 'nx/src/project-graph/utils/project-configuration-utils'; +import { readdirSync } from 'fs'; +import { combineGlobPatterns } from 'nx/src/utils/globs'; +import { + ESLINT_CONFIG_FILENAMES, + findBaseEslintFile, + isFlatConfig, +} from '../utils/config-file'; + +export interface EslintPluginOptions { + targetName?: string; +} + +export const createNodes: CreateNodes = [ + combineGlobPatterns(['**/project.json', '**/package.json']), + (configFilePath, options, context) => { + const projectRoot = dirname(configFilePath); + + options = normalizeOptions(options); + + const eslintConfigs = getEslintConfigsForProject( + projectRoot, + context.workspaceRoot + ); + if (!eslintConfigs.length) { + return {}; + } + + return { + projects: { + [projectRoot]: { + targets: buildEslintTargets( + eslintConfigs, + projectRoot, + 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)); + + 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) => + ESLINT_CONFIG_FILENAMES.includes(f) + ); + if (!config) { + return []; + } + 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); + } + 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); + } + return []; +} + +function buildEslintTargets( + eslintConfigs: string[], + projectRoot: string, + options: EslintPluginOptions, + context: CreateNodesContext +) { + const targetDefaults = readTargetDefaultsForTarget( + options.targetName, + context.nxJsonConfiguration.targetDefaults, + '@nx/eslint:lint' + ); + + const isRootProject = projectRoot === '.'; + + const targets: Record = {}; + + const baseTargetConfig: TargetConfiguration = { + command: `eslint ${isRootProject ? './src' : '.'}`, + options: { + cwd: projectRoot, + }, + }; + if (eslintConfigs.some((config) => isFlatConfig(config))) { + baseTargetConfig.options.env = { + ESLINT_USE_FLAT_CONFIG: 'true', + }; + } + + targets[options.targetName] = { + ...baseTargetConfig, + cache: targetDefaults?.cache ?? true, + inputs: targetDefaults?.inputs ?? [ + 'default', + ...eslintConfigs.map((config) => `{workspaceRoot}/${config}`), + '{workspaceRoot}/tools/eslint-rules/**/*', + { externalDependencies: ['eslint'] }, + ], + options: { + ...baseTargetConfig.options, + }, + }; + + return targets; +} + +function normalizeOptions(options: EslintPluginOptions): EslintPluginOptions { + options ??= {}; + options.targetName ??= 'lint'; + return options; +} diff --git a/packages/eslint/src/utils/config-file.ts b/packages/eslint/src/utils/config-file.ts new file mode 100644 index 0000000000..af6601092c --- /dev/null +++ b/packages/eslint/src/utils/config-file.ts @@ -0,0 +1,35 @@ +import { joinPathFragments } from '@nx/devkit'; +import { existsSync } from 'fs'; + +export const ESLINT_CONFIG_FILENAMES = [ + '.eslintrc', + '.eslintrc.js', + '.eslintrc.cjs', + '.eslintrc.yaml', + '.eslintrc.yml', + '.eslintrc.json', + 'eslint.config.js', +]; + +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'); +}