From acd0993f1ac4890be94859b1b2c58e2b39819fbf Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 3 May 2024 14:23:01 +0100 Subject: [PATCH] feat(linter): add convert-to-inferred migration generator (#23142) --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/nx-api.json | 9 + docs/generated/packages-metadata.json | 9 + .../generators/convert-to-inferred.json | 30 + docs/shared/reference/sitemap.md | 1 + .../convert-to-inferred.ts | 7 +- .../executor-to-plugin-migrator.ts | 13 +- packages/eslint/generators.json | 5 + .../convert-to-inferred.spec.ts | 671 ++++++++++++++++++ .../convert-to-inferred.ts | 137 ++++ .../lib/target-options-map.ts | 16 + .../convert-to-inferred/schema.json | 19 + .../convert-to-inferred.ts | 2 +- 13 files changed, 919 insertions(+), 8 deletions(-) create mode 100644 docs/generated/packages/eslint/generators/convert-to-inferred.json create mode 100644 packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.spec.ts create mode 100644 packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.ts create mode 100644 packages/eslint/src/generators/convert-to-inferred/lib/target-options-map.ts create mode 100644 packages/eslint/src/generators/convert-to-inferred/schema.json diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 7d7ebbeb53..23961d2cb4 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -7310,6 +7310,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/eslint/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 0b95e0d931..c9a90de386 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -766,6 +766,15 @@ "originalFilePath": "/packages/eslint/src/generators/convert-to-flat-config/schema.json", "path": "/nx-api/eslint/generators/convert-to-flat-config", "type": "generator" + }, + "/nx-api/eslint/generators/convert-to-inferred": { + "description": "Convert existing ESLint project(s) using `@nx/eslint:lint` executor to use `@nx/eslint/plugin`.", + "file": "generated/packages/eslint/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/eslint/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/eslint/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/eslint" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 6960472363..527f9f06e5 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -754,6 +754,15 @@ "originalFilePath": "/packages/eslint/src/generators/convert-to-flat-config/schema.json", "path": "eslint/generators/convert-to-flat-config", "type": "generator" + }, + { + "description": "Convert existing ESLint project(s) using `@nx/eslint:lint` executor to use `@nx/eslint/plugin`.", + "file": "generated/packages/eslint/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/eslint/src/generators/convert-to-inferred/schema.json", + "path": "eslint/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/eslint/generators/convert-to-inferred.json b/docs/generated/packages/eslint/generators/convert-to-inferred.json new file mode 100644 index 0000000000..93ec7741c4 --- /dev/null +++ b/docs/generated/packages/eslint/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxEslintConvertToInferred", + "description": "Convert existing Eslint project(s) using `@nx/eslint:lint` executor to use `@nx/eslint/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Eslint project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/eslint:lint` executor to use `@nx/eslint/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing ESLint project(s) using `@nx/eslint:lint` executor to use `@nx/eslint/plugin`.", + "implementation": "/packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/eslint/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 755c859d24..79329ff20a 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -403,6 +403,7 @@ - [workspace-rules-project](/nx-api/eslint/generators/workspace-rules-project) - [workspace-rule](/nx-api/eslint/generators/workspace-rule) - [convert-to-flat-config](/nx-api/eslint/generators/convert-to-flat-config) + - [convert-to-inferred](/nx-api/eslint/generators/convert-to-inferred) - [eslint-plugin](/nx-api/eslint-plugin) - [documents](/nx-api/eslint-plugin/documents) - [Overview](/nx-api/eslint-plugin/documents/overview) diff --git a/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts index 31d52629b3..dabdbe82c2 100644 --- a/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts +++ b/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -21,7 +21,7 @@ interface Schema { export async function convertToInferred(tree: Tree, options: Schema) { const projectGraph = await createProjectGraphAsync(); - let migratedProjects = await migrateExecutorToPlugin( + const migratedProjectsModern = await migrateExecutorToPlugin( tree, projectGraph, '@nx/cypress:cypress', @@ -35,7 +35,7 @@ export async function convertToInferred(tree: Tree, options: Schema) { options.project ); - migratedProjects += await migrateExecutorToPlugin( + const migratedProjectsLegacy = await migrateExecutorToPlugin( tree, projectGraph, '@nrwl/cypress:cypress', @@ -49,6 +49,9 @@ export async function convertToInferred(tree: Tree, options: Schema) { options.project ); + const migratedProjects = + migratedProjectsModern.size + migratedProjectsLegacy.size; + if (migratedProjects === 0) { throw new Error('Could not find any targets to migrate.'); } diff --git a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts index b09c89001a..4e2c9faddc 100644 --- a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts +++ b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts @@ -29,7 +29,8 @@ import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-con type PluginOptionsBuilder = (targetName: string) => T; type PostTargetTransformer = ( targetConfiguration: TargetConfiguration, - tree?: Tree + tree?: Tree, + projectDetails?: { projectName: string; root: string } ) => TargetConfiguration; type SkipTargetFilter = ( targetConfiguration: TargetConfiguration @@ -129,7 +130,10 @@ class ExecutorToPluginMigrator { delete projectTarget.executor; deleteMatchingProperties(projectTarget, createdTarget); - projectTarget = this.#postTargetTransformer(projectTarget, this.tree); + projectTarget = this.#postTargetTransformer(projectTarget, this.tree, { + projectName, + root: projectFromGraph.data.root, + }); if ( projectTarget.options && @@ -308,7 +312,7 @@ export async function migrateExecutorToPlugin( createNodes: CreateNodes, specificProjectToMigrate?: string, skipTargetFilter?: SkipTargetFilter -): Promise { +): Promise>> { const migrator = new ExecutorToPluginMigrator( tree, projectGraph, @@ -320,6 +324,5 @@ export async function migrateExecutorToPlugin( specificProjectToMigrate, skipTargetFilter ); - const migratedProjectsAndTargets = await migrator.run(); - return migratedProjectsAndTargets.size; + return await migrator.run(); } diff --git a/packages/eslint/generators.json b/packages/eslint/generators.json index 9bf1062b53..44e35ce19b 100644 --- a/packages/eslint/generators.json +++ b/packages/eslint/generators.json @@ -23,6 +23,11 @@ "factory": "./src/generators/convert-to-flat-config/generator", "schema": "./src/generators/convert-to-flat-config/schema.json", "description": "Convert an Nx workspace's ESLint configs to use Flat Config." + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing ESLint project(s) using `@nx/eslint:lint` executor to use `@nx/eslint/plugin`." } } } diff --git a/packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000..b9e3461b23 --- /dev/null +++ b/packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,671 @@ +import { + getRelativeProjectJsonSchemaPath, + updateProjectConfiguration, +} from 'nx/src/generators/utils/project-configuration'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { convertToInferred } from './convert-to-inferred'; +import { + addProjectConfiguration as _addProjectConfiguration, + type ExpandedPluginConfiguration, + joinPathFragments, + type ProjectConfiguration, + type ProjectGraph, + readNxJson, + readProjectConfiguration, + type Tree, + updateNxJson, + writeJson, +} from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { join } from 'node:path'; + +let fs: TempFs; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest.fn().mockImplementation(async () => { + return projectGraph; + }), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); + +function addProjectConfiguration( + tree: Tree, + name: string, + project: ProjectConfiguration +) { + _addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface CreateEslintLintProjectOptions { + appName: string; + appRoot: string; + targetName: string; + legacyExecutor?: boolean; +} + +const defaultCreateEslintLintProjectOptions: CreateEslintLintProjectOptions = { + appName: 'myapp', + appRoot: 'myapp', + targetName: 'lint', + legacyExecutor: false, +}; + +function createTestProject( + tree: Tree, + opts: Partial = defaultCreateEslintLintProjectOptions +) { + let projectOpts = { ...defaultCreateEslintLintProjectOptions, ...opts }; + const project: ProjectConfiguration = { + name: projectOpts.appName, + root: projectOpts.appRoot, + projectType: 'application', + targets: { + [projectOpts.targetName]: { + executor: projectOpts.legacyExecutor + ? '@nrwl/linter:eslint' + : '@nx/eslint:lint', + options: { + eslintConfig: `${projectOpts.appRoot}/.eslintrc.json`, + }, + }, + }, + }; + + const eslintConfigContents = { + rules: {}, + overrides: [ + { + files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + rules: {}, + }, + { + files: ['./project.json'], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/nx-plugin-checks': 'error', + }, + }, + { + files: ['./package.json'], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/dependency-checks': [ + 'error', + { + buildTargets: ['build-base'], + ignoredDependencies: [ + 'nx', + '@nx/jest', + 'typescript', + 'eslint', + '@angular-devkit/core', + '@typescript-eslint/eslint-plugin', + ], + }, + ], + }, + }, + ], + ignorePatterns: ['!**/*'], + }; + const eslintConfigContentsAsString = JSON.stringify(eslintConfigContents); + + tree.write( + `${projectOpts.appRoot}/.eslintrc.json`, + eslintConfigContentsAsString + ); + fs.createFileSync( + `${projectOpts.appRoot}/.eslintrc.json`, + eslintConfigContentsAsString + ); + + tree.write(`${projectOpts.appRoot}/src/foo.ts`, `export const myValue = 2;`); + fs.createFileSync( + `${projectOpts.appRoot}/src/foo.ts`, + `export const myValue = 2;` + ); + jest.doMock( + join(fs.tempDir, `${projectOpts.appRoot}/.eslintrc.json`), + () => ({ + default: { + extends: '../../.eslintrc', + rules: {}, + overrides: [ + { + files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + rules: {}, + }, + { + files: ['**/*.ts'], + excludedFiles: ['./src/migrations/**'], + rules: { + 'no-restricted-imports': ['error', '@nx/workspace'], + }, + }, + { + files: [ + './package.json', + './generators.json', + './executors.json', + './migrations.json', + ], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/nx-plugin-checks': 'error', + }, + }, + { + files: ['./package.json'], + parser: 'jsonc-eslint-parser', + rules: { + '@nx/dependency-checks': [ + 'error', + { + buildTargets: ['build-base'], + ignoredDependencies: [ + 'nx', + '@nx/jest', + 'typescript', + 'eslint', + '@angular-devkit/core', + '@typescript-eslint/eslint-plugin', + ], + }, + ], + }, + }, + ], + ignorePatterns: ['!**/*'], + }, + }), + { + virtual: true, + } + ); + + addProjectConfiguration(tree, project.name, project); + fs.createFileSync( + `${projectOpts.appRoot}/project.json`, + JSON.stringify(project) + ); + return project; +} + +describe('Eslint - Convert Executors To Plugin', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('eslint'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.reset(); + }); + + describe('--project', () => { + it('should setup a new Eslint plugin and only migrate one specific project', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + targetName: 'lint', + }); + const project = createTestProject(tree, { + targetName: 'eslint', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + targetName: 'eslint', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + targetName: 'linter', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/eslint/plugin', + options: { + targetName: 'lint', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { project: 'myapp', skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + ['lint'].forEach((key) => expect(targetKeys).not.toContain(key)); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const addedTestEslintPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/eslint/plugin' && + plugin.include?.length === 1 + ) { + return true; + } + }); + expect(addedTestEslintPlugin).toBeTruthy(); + expect( + (addedTestEslintPlugin as ExpandedPluginConfiguration).include + ).toEqual(['myapp/**/*']); + }); + + it('should add project to existing plugins includes', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + targetName: 'lint', + }); + const project = createTestProject(tree, { + targetName: 'lint', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + targetName: 'lint', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + targetName: 'lint', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/eslint/plugin', + include: ['existing/**/*'], + options: { + targetName: 'lint', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { project: 'myapp', skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + expect(targetKeys).not.toContain('lint'); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const addedTestEslintPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/eslint/plugin' && + plugin.include?.length === 2 + ) { + return true; + } + }); + expect(addedTestEslintPlugin).toBeTruthy(); + expect( + (addedTestEslintPlugin as ExpandedPluginConfiguration).include + ).toEqual(['existing/**/*', 'myapp/**/*']); + }); + + it('should remove include when all projects are included', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + targetName: 'lint', + }); + const project = createTestProject(tree, { + targetName: 'lint', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + targetName: 'lint', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + targetName: 'lint', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/eslint/plugin', + include: ['existing/**/*', 'second/**/*', 'third/**/*'], + options: { + targetName: 'lint', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { project: 'myapp', skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + ['lint'].forEach((key) => expect(targetKeys).not.toContain(key)); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const addedTestEslintPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/eslint/plugin' && + !plugin.include + ) { + return true; + } + }); + expect(addedTestEslintPlugin).toBeTruthy(); + expect( + (addedTestEslintPlugin as ExpandedPluginConfiguration).include + ).not.toBeDefined(); + }); + }); + + describe('--all', () => { + it('should successfully migrate a project using Eslint executors to plugin', async () => { + const project = createTestProject(tree); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + expect(targetKeys).not.toContain('lint'); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasEslintPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/eslint/plugin' + : plugin.plugin === '@nx/eslint/plugin' + ); + expect(hasEslintPlugin).toBeTruthy(); + if (typeof hasEslintPlugin !== 'string') { + [['targetName', 'lint']].forEach(([targetOptionName, targetName]) => { + expect(hasEslintPlugin.options[targetOptionName]).toEqual(targetName); + }); + } + }); + + it('should setup Eslint plugin to match projects', async () => { + // ARRANGE + const project = createTestProject(tree, { + targetName: 'eslint', + }); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + ['eslint'].forEach((key) => expect(targetKeys).not.toContain(key)); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasEslintPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/eslint/plugin' + : plugin.plugin === '@nx/eslint/plugin' + ); + expect(hasEslintPlugin).toBeTruthy(); + if (typeof hasEslintPlugin !== 'string') { + [['targetName', 'eslint']].forEach(([targetOptionName, targetName]) => { + expect(hasEslintPlugin.options[targetOptionName]).toEqual(targetName); + }); + } + }); + + it('should handle targets using legacy executor', async () => { + // ARRANGE + const project = createTestProject(tree, { + targetName: 'eslint', + legacyExecutor: true, + }); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + expect(targetKeys).not.toContain('eslint'); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasEslintPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/eslint/plugin' + : plugin.plugin === '@nx/eslint/plugin' + ); + expect(hasEslintPlugin).toBeTruthy(); + if (typeof hasEslintPlugin !== 'string') { + [['targetName', 'eslint']].forEach(([targetOptionName, targetName]) => { + expect(hasEslintPlugin.options[targetOptionName]).toEqual(targetName); + }); + } + }); + + it('should setup a new Eslint plugin to match only projects migrated', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + targetName: 'lint', + }); + const project = createTestProject(tree, { + targetName: 'eslint', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + targetName: 'eslint', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + targetName: 'linter', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/eslint/plugin', + options: { + targetName: 'lint', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + expect(targetKeys).not.toContain('eslint'); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const addedLintEslintPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/eslint/plugin' && + plugin.include?.length === 2 + ) { + return true; + } + }); + expect(addedLintEslintPlugin).toBeTruthy(); + expect( + (addedLintEslintPlugin as ExpandedPluginConfiguration).include + ).toEqual(['myapp/**/*', 'second/**/*']); + + const addedLinterEslintPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/eslint/plugin' && + plugin.include?.length === 1 + ) { + return true; + } + }); + expect(addedLinterEslintPlugin).toBeTruthy(); + expect( + (addedLinterEslintPlugin as ExpandedPluginConfiguration).include + ).toEqual(['third/**/*']); + }); + + it('should keep Eslint options in project.json', async () => { + // ARRANGE + const project = createTestProject(tree); + project.targets.lint.options.cacheLocation = 'cache-dir'; + updateProjectConfiguration(tree, project.name, project); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.lint).toMatchInlineSnapshot(` + { + "options": { + "cache-location": "cache-dir", + }, + } + `); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasEslintPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/eslint/plugin' + : plugin.plugin === '@nx/eslint/plugin' + ); + expect(hasEslintPlugin).toBeTruthy(); + if (typeof hasEslintPlugin !== 'string') { + [['targetName', 'lint']].forEach(([targetOptionName, targetName]) => { + expect(hasEslintPlugin.options[targetOptionName]).toEqual(targetName); + }); + } + }); + + it('should add Eslint options found in targetDefaults for the executor to the project.json', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['@nx/eslint:lint'] = { + options: { + maxWarnings: 10, + }, + }; + updateNxJson(tree, nxJson); + const project = createTestProject(tree); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.lint).toMatchInlineSnapshot(` + { + "options": { + "max-warnings": 10, + }, + } + `); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasEslintPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/eslint/plugin' + : plugin.plugin === '@nx/eslint/plugin' + ); + expect(hasEslintPlugin).toBeTruthy(); + if (typeof hasEslintPlugin !== 'string') { + [['targetName', 'lint']].forEach(([targetOptionName, targetName]) => { + expect(hasEslintPlugin.options[targetOptionName]).toEqual(targetName); + }); + } + }); + }); +}); diff --git a/packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000..6dbfcfe3be --- /dev/null +++ b/packages/eslint/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,137 @@ +import { + createProjectGraphAsync, + formatFiles, + names, + type TargetConfiguration, + type Tree, +} from '@nx/devkit'; +import { createNodes, EslintPluginOptions } from '../../plugins/plugin'; +import { migrateExecutorToPlugin } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { targetOptionsToCliMap } from './lib/target-options-map'; +import { interpolate } from 'nx/src/tasks-runner/utils'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + + const migratedProjectsModern = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nx/eslint:lint', + '@nx/eslint/plugin', + (targetName) => ({ targetName }), + postTargetTransformer, + createNodes, + options.project + ); + + const migratedProjectsLegacy = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nrwl/linter:eslint', + '@nx/eslint/plugin', + (targetName) => ({ targetName }), + postTargetTransformer, + createNodes, + options.project + ); + + const migratedProjects = + migratedProjectsModern.size + migratedProjectsLegacy.size; + if (migratedProjects === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +function postTargetTransformer( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string } +): TargetConfiguration { + if (target.inputs) { + target.inputs = target.inputs.filter( + (input) => + typeof input === 'string' && + ![ + 'default', + '{workspaceRoot}/.eslintrc.json', + '{workspaceRoot}/.eslintignore', + '{workspaceRoot}/eslint.config.js', + ].includes(input) + ); + if (target.inputs.length === 0) { + delete target.inputs; + } + } + + if (target.options) { + if ('eslintConfig' in target.options) { + delete target.options.eslintConfig; + } + + if ('force' in target.options) { + delete target.options.force; + } + + if ('silent' in target.options) { + delete target.options.silent; + } + + if ('hasTypeAwareRules' in target.options) { + delete target.options.hasTypeAwareRules; + } + + if ('errorOnUnmatchedPattern' in target.options) { + if (!target.options.errorOnUnmatchedPattern) { + target.options['no-error-on-unmatched-pattern'] = true; + } + delete target.options.errorOnUnmatchedPattern; + } + + if ('outputFile' in target.options) { + target.outputs ??= []; + target.outputs.push(target.options.outputFile); + } + + for (const key in targetOptionsToCliMap) { + if (target.options[key]) { + target.options[targetOptionsToCliMap[key]] = target.options[key]; + delete target.options[key]; + } + } + + if ('lintFilePatterns' in target.options) { + const normalizedLintFilePatterns = target.options.lintFilePatterns.map( + (pattern) => { + return interpolate(pattern, { + workspaceRoot: '', + projectRoot: projectDetails.root, + projectName: projectDetails.projectName, + }); + } + ); + + target.options.args = normalizedLintFilePatterns.map((pattern) => + pattern.startsWith(projectDetails.root) + ? pattern.replace(new RegExp(`^${projectDetails.root}/`), './') + : pattern + ); + + delete target.options.lintFilePatterns; + } + } + + return target; +} + +export default convertToInferred; diff --git a/packages/eslint/src/generators/convert-to-inferred/lib/target-options-map.ts b/packages/eslint/src/generators/convert-to-inferred/lib/target-options-map.ts new file mode 100644 index 0000000000..54ce17ff7c --- /dev/null +++ b/packages/eslint/src/generators/convert-to-inferred/lib/target-options-map.ts @@ -0,0 +1,16 @@ +export const targetOptionsToCliMap = { + fix: 'fix', + format: 'format', + cache: 'cache', + cacheLocation: 'cache-location', + cacheStrategy: 'cache-strategy', + noEslintrc: 'no-eslintrc', + outputFile: 'output-file', + maxWarnings: 'max-warnings', + quiet: 'quiet', + ignorePath: 'ignore-path', + rulesdir: 'rulesdir', + resolvePluginsRelativeTo: 'resolve-plugins-relative-to', + reportUnusedDisableDirectives: 'report-unused-disable-directives', + printConfig: 'print-config', +}; diff --git a/packages/eslint/src/generators/convert-to-inferred/schema.json b/packages/eslint/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000..a6d0789647 --- /dev/null +++ b/packages/eslint/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxEslintConvertToInferred", + "description": "Convert existing Eslint project(s) using `@nx/eslint:lint` executor to use `@nx/eslint/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Eslint project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/eslint:lint` executor to use `@nx/eslint/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + } +} diff --git a/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts index 8e2c0533b7..856e3ca1f1 100644 --- a/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts +++ b/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -28,7 +28,7 @@ export async function convertToInferred(tree: Tree, options: Schema) { options.project ); - if (migratedProjects === 0) { + if (migratedProjects.size === 0) { throw new Error('Could not find any targets to migrate.'); }