From f5b3ab0f4151918796ecd41ae4f966ce3a5a393b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Mon, 11 Apr 2022 12:16:04 +0100 Subject: [PATCH] feat(angular): allow migrating angular cli workspaces using the @angular-eslint/builder:lint builder --- e2e/angular-core/src/ng-add.test.ts | 42 +++ e2e/utils/index.ts | 2 +- .../migrate-from-angular-cli.spec.ts.snap | 194 +++++++++++++- .../ng-add/migrate-from-angular-cli.spec.ts | 180 +++++++++++-- .../ng-add/migrate-from-angular-cli.ts | 45 +++- .../ng-add/utilities/app.migrator.ts | 137 ++++++++-- .../ng-add/utilities/e2e-project.migrator.ts | 247 +++++++++++++----- .../generators/ng-add/utilities/e2e-utils.ts | 52 ---- .../src/generators/ng-add/utilities/logger.ts | 20 ++ .../ng-add/utilities/project.migrator.ts | 4 + .../src/generators/ng-add/utilities/types.ts | 5 + .../generators/ng-add/utilities/workspace.ts | 185 +++++++------ packages/linter/index.ts | 5 + 13 files changed, 878 insertions(+), 240 deletions(-) delete mode 100644 packages/angular/src/generators/ng-add/utilities/e2e-utils.ts create mode 100644 packages/angular/src/generators/ng-add/utilities/logger.ts diff --git a/e2e/angular-core/src/ng-add.test.ts b/e2e/angular-core/src/ng-add.test.ts index 0c1420fb7ae20a..783faeaa8e7958 100644 --- a/e2e/angular-core/src/ng-add.test.ts +++ b/e2e/angular-core/src/ng-add.test.ts @@ -55,6 +55,10 @@ describe('convert Angular CLI workspace to an Nx workspace', () => { runNgAdd('@cypress/schematic', '--e2e-update', 'latest'); } + function addEsLint() { + runNgAdd('@angular-eslint/schematics', undefined, 'latest'); + } + beforeEach(() => { project = uniq('proj'); packageManager = getSelectedPackageManager(); @@ -387,6 +391,44 @@ describe('convert Angular CLI workspace to an Nx workspace', () => { }); }); + // TODO(leo): The current Verdaccio setup fails to resolve older versions + // of @nrwl/* packages, the @angular-eslint/builder package depends on an + // older version of @nrwl/devkit so we skip this test for now. + it.skip('should handle a workspace with ESLint', () => { + addEsLint(); + + runNgAdd('@nrwl/angular', '--npm-scope projscope'); + + checkFilesExist(`apps/${project}/.eslintrc.json`, `.eslintrc.json`); + + const projectConfig = readJson(`apps/${project}/project.json`); + expect(projectConfig.targets.lint).toStrictEqual({ + executor: '@nrwl/linter:eslint', + options: { + lintFilePatterns: [ + `apps/${project}/src/**/*.ts`, + `apps/${project}/src/**/*.html`, + ], + }, + }); + + let output = runCLI(`lint ${project}`); + expect(output).toContain(`> nx run ${project}:lint`); + expect(output).toContain('All files pass linting.'); + expect(output).toContain( + `Successfully ran target lint for project ${project}` + ); + + output = runCLI(`lint ${project}`); + expect(output).toContain( + `> nx run ${project}:lint [existing outputs match the cache, left as is]` + ); + expect(output).toContain('All files pass linting.'); + expect(output).toContain( + `Successfully ran target lint for project ${project}` + ); + }); + it('should support --preserve-angular-cli-layout', () => { // add another app and a library runCommand(`ng g @schematics/angular:application app2`); diff --git a/e2e/utils/index.ts b/e2e/utils/index.ts index 67e4f6883e91e0..02f2c2158ea415 100644 --- a/e2e/utils/index.ts +++ b/e2e/utils/index.ts @@ -436,7 +436,7 @@ export function runNgAdd( try { const pmc = getPackageManagerCommand(); packageInstall(packageName, null, version); - return execSync(pmc.run(`ng g ${packageName}:ng-add`, command), { + return execSync(pmc.run(`ng g ${packageName}:ng-add`, command ?? ''), { cwd: tmpProjPath(), env: { ...(opts.env || process.env) }, encoding: 'utf-8', diff --git a/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap b/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap index 7b5f554eafb572..364cdbd9c9cf55 100644 --- a/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap +++ b/packages/angular/src/generators/ng-add/__snapshots__/migrate-from-angular-cli.spec.ts.snap @@ -105,6 +105,17 @@ Object { "watch": true, }, }, + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "apps/myApp-e2e/**/*.{js,ts}", + ], + }, + "outputs": Array [ + "{options.outputFile}", + ], + }, }, } `; @@ -132,6 +143,29 @@ Object { `; exports[`workspace move to nx layout cypress should migrate e2e tests correctly 3`] = ` +Object { + "extends": Array [ + "plugin:cypress/recommended", + "../../.eslintrc.json", + ], + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + ], +} +`; + +exports[`workspace move to nx layout cypress should migrate e2e tests correctly 4`] = ` Object { "implicitDependencies": Array [ "myApp", @@ -173,6 +207,17 @@ Object { "watch": true, }, }, + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "apps/myApp-e2e/**/*.{js,ts}", + ], + }, + "outputs": Array [ + "{options.outputFile}", + ], + }, }, } `; @@ -234,10 +279,99 @@ Object { "watch": true, }, }, + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "apps/myApp-e2e/**/*.{js,ts}", + ], + }, + "outputs": Array [ + "{options.outputFile}", + ], + }, }, } `; +exports[`workspace move to nx layout should create a root eslint config 1`] = ` +Object { + "ignorePatterns": Array [ + "**/*", + ], + "overrides": Array [ + Object { + "extends": Array [ + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates", + ], + "files": Array [ + "*.ts", + ], + "parserOptions": Object { + "createDefaultProgram": true, + }, + "rules": Object { + "@angular-eslint/component-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "kebab-case", + "type": "element", + }, + ], + "@angular-eslint/directive-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "camelCase", + "type": "attribute", + }, + ], + }, + }, + Object { + "extends": Array [ + "plugin:@angular-eslint/template/recommended", + ], + "files": Array [ + "*.html", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": Object { + "@nrwl/nx/enforce-module-boundaries": Array [ + "error", + Object { + "allow": Array [], + "depConstraints": Array [ + Object { + "onlyDependOnLibsWithTags": Array [ + "*", + ], + "sourceTag": "*", + }, + ], + "enforceBuildableLibDependency": true, + }, + ], + }, + }, + ], + "plugins": Array [ + "@nrwl/nx", + ], + "root": true, +} +`; + exports[`workspace move to nx layout should create nx.json 1`] = ` Object { "affected": Object { @@ -279,6 +413,59 @@ Object { } `; +exports[`workspace move to nx layout should move the project eslint config 1`] = ` +Object { + "extends": "../../.eslintrc.json", + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "extends": Array [ + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates", + ], + "files": Array [ + "*.ts", + ], + "parserOptions": Object { + "createDefaultProgram": true, + "project": Array [ + "apps/myApp/tsconfig.*?.json", + ], + }, + "rules": Object { + "@angular-eslint/component-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "kebab-case", + "type": "element", + }, + ], + "@angular-eslint/directive-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "camelCase", + "type": "attribute", + }, + ], + }, + }, + Object { + "extends": Array [ + "plugin:@angular-eslint/template/recommended", + ], + "files": Array [ + "*.html", + ], + "rules": Object {}, + }, + ], +} +`; + exports[`workspace move to nx layout should update project configuration 1`] = ` Object { "root": "apps/myApp", @@ -291,10 +478,11 @@ Object { }, }, "lint": Object { + "executor": "@nrwl/linter:eslint", "options": Object { - "tsConfig": Array [ - "apps/myApp/tsconfig.app.json", - "apps/myApp/tsconfig.spec.json", + "lintFilePatterns": Array [ + "apps/myApp/src/**/*.ts", + "apps/myApp/src/**/*.html", ], }, }, diff --git a/packages/angular/src/generators/ng-add/migrate-from-angular-cli.spec.ts b/packages/angular/src/generators/ng-add/migrate-from-angular-cli.spec.ts index 26d14de0684b69..e139acedb14f91 100644 --- a/packages/angular/src/generators/ng-add/migrate-from-angular-cli.spec.ts +++ b/packages/angular/src/generators/ng-add/migrate-from-angular-cli.spec.ts @@ -2,6 +2,7 @@ import { readJson, readProjectConfiguration, Tree, + updateJson, updateProjectConfiguration, } from '@nrwl/devkit'; import { createTree } from '@nrwl/devkit/testing'; @@ -39,8 +40,9 @@ describe('workspace', () => { }, }, lint: { + builder: '@angular-eslint/builder:lint', options: { - tsConfig: 'tsconfig.app.json', + lintFilePatterns: ['src/**/*.ts', 'src/**/*.html'], }, }, e2e: { @@ -67,7 +69,41 @@ describe('workspace', () => { '{"extends": "../tsconfig.json", "compilerOptions": {}}' ); tree.write('/tsconfig.json', '{"compilerOptions": {}}'); - tree.write('/tslint.json', '{"rules": {}}'); + tree.write( + '.eslintrc.json', + JSON.stringify({ + root: true, + ignorePatterns: ['projects/**/*'], + overrides: [ + { + files: ['*.ts'], + parserOptions: { + project: ['tsconfig.json', 'e2e/tsconfig.json'], + createDefaultProgram: true, + }, + extends: [ + 'plugin:@angular-eslint/recommended', + 'plugin:@angular-eslint/template/process-inline-templates', + ], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + '@angular-eslint/component-selector': [ + 'error', + { type: 'element', prefix: 'app', style: 'kebab-case' }, + ], + }, + }, + { + files: ['*.html'], + extends: ['plugin:@angular-eslint/template/recommended'], + rules: {}, + }, + ], + }) + ); tree.write('/e2e/protractor.conf.js', '// content'); tree.write('/src/app/app.module.ts', '// content'); }); @@ -145,6 +181,18 @@ describe('workspace', () => { ); }); + it('should error when having a lint target using an unknown executor', async () => { + const project = readProjectConfiguration(tree, 'myApp'); + project.targets.lint.executor = '@my-org/my-package:my-executor'; + updateProjectConfiguration(tree, 'myApp', project); + + await expect( + migrateFromAngularCli(tree, { name: 'myApp' }) + ).rejects.toThrow( + `The "myApp" project is using an unsupported executor "@my-org/my-package:my-executor".` + ); + }); + it('should error if no angular.json is present', async () => { tree.delete('angular.json'); @@ -264,10 +312,11 @@ describe('workspace', () => { }, }, lint: { + builder: '@angular-eslint/builder:lint', options: { - tsConfig: [ - 'projects/myApp/tslint.json', - 'projects/myApp/tsconfig.app.json', + lintFilePatterns: [ + 'projects/myApp/src/**/*.ts', + 'projects/myApp/src/**/*.html', ], }, }, @@ -283,7 +332,7 @@ describe('workspace', () => { }) ); - tree.write('/projects/myApp/tslint.json', '{"rules": {}}'); + tree.write('/projects/myApp/.eslintrc.json', '{}'); tree.write('/projects/myApp/tsconfig.app.json', '{}'); tree.write('/projects/myApp/tsconfig.spec.json', '{}'); tree.write('/projects/myApp/e2e/tsconfig.json', '{}'); @@ -347,6 +396,7 @@ describe('workspace', () => { }, }, lint: { + builder: '@angular-eslint/builder:lint', options: { tsConfig: 'src/tsconfig.app.json', }, @@ -400,10 +450,11 @@ describe('workspace', () => { }, }, lint: { + builder: '@angular-eslint/builder:lint', options: { - tsConfig: [ - 'projects/myApp/tslint.json', - 'projects/myApp/tsconfig.app.json', + lintFilePatterns: [ + 'projects/myApp/src/**/*.ts', + 'projects/myApp/src/**/*.html', ], }, }, @@ -418,7 +469,7 @@ describe('workspace', () => { }, }) ); - tree.write('/projects/myApp/tslint.json', '{"rules": {}}'); + tree.write('/projects/myApp/.eslintrc.json', '{}'); tree.write('/projects/myApp/tsconfig.app.json', '{}'); tree.write('/projects/myApp/tsconfig.spec.json', '{}'); tree.write('/projects/myApp/e2e/tsconfig.json', '{}'); @@ -427,7 +478,7 @@ describe('workspace', () => { await migrateFromAngularCli(tree, { name: 'myApp' }); - expect(tree.exists('/tslint.json')).toBe(true); + expect(tree.exists('/.eslintrc.json')).toBe(true); expect(tree.exists('/apps/myApp/tsconfig.app.json')).toBe(true); expect(tree.exists('/apps/myApp-e2e/protractor.conf.js')).toBe(true); expect(tree.exists('/apps/myApp/src/app/app.module.ts')).toBe(true); @@ -435,7 +486,7 @@ describe('workspace', () => { it('should work with missing e2e, lint, or test targets', async () => { tree.write( - '/angular.json', + 'angular.json', JSON.stringify({ version: 1, defaultProject: 'myApp', @@ -455,14 +506,16 @@ describe('workspace', () => { }, }) ); - tree.write('/karma.conf.js', '// content'); + tree.write('karma.conf.js', '// content'); await migrateFromAngularCli(tree, { name: 'myApp' }); - expect(tree.exists('/apps/myApp/tsconfig.app.json')).toBe(true); - expect(tree.exists('/apps/myApp/karma.conf.js')).toBe(true); - - expect(tree.exists('/karma.conf.js')).toBe(true); + expect(tree.exists('apps/myApp/tsconfig.app.json')).toBe(true); + expect(tree.exists('apps/myApp/karma.conf.js')).toBe(true); + expect(tree.exists('apps/myApp/.eslintrc.json')).toBe(true); + expect(tree.exists('tsconfig.base.json')).toBe(true); + expect(tree.exists('karma.conf.js')).toBe(true); + expect(tree.exists('.eslintrc.json')).toBe(true); }); it('should work with existing .prettierignore file', async () => { @@ -481,11 +534,39 @@ describe('workspace', () => { expect(prettierIgnore).toBe('# existing ignore rules'); }); - it('should work with no root tslint.json', async () => { - tree.delete('/tslint.json'); + it('should move the project eslint config', async () => { await migrateFromAngularCli(tree, { name: 'myApp' }); - expect(tree.exists('/tslint.json')).toBe(false); + expect(readJson(tree, 'apps/myApp/.eslintrc.json')).toMatchSnapshot(); + }); + + it('should create a root eslint config', async () => { + await migrateFromAngularCli(tree, { name: 'myApp' }); + + expect(readJson(tree, '.eslintrc.json')).toMatchSnapshot(); + }); + + it('should work when eslint is not being used', async () => { + tree.delete('.eslintrc.json'); + updateJson(tree, 'angular.json', (json) => { + delete json.projects.myApp.architect.lint; + return json; + }); + + await migrateFromAngularCli(tree, { name: 'myApp' }); + + expect(tree.exists('.eslintrc.json')).toBe(false); + }); + + describe('protractor', () => { + it('should migrate e2e tests correctly', async () => { + await migrateFromAngularCli(tree, { name: 'myApp' }); + + expect(tree.exists('e2e')).toBe(false); + expect(tree.exists('apps/myApp-e2e/protractor.conf.js')).toBe(true); + expect(tree.exists('apps/myApp-e2e/tsconfig.json')).toBe(true); + expect(tree.exists('apps/myApp-e2e/.eslintrc.json')).toBe(true); + }); }); describe('cypress', () => { @@ -581,6 +662,10 @@ describe('workspace', () => { expect( readJson(tree, '/apps/myApp-e2e/cypress.json') ).toMatchSnapshot(); + expect(tree.exists('/apps/myApp-e2e/.eslintrc.json')).toBe(true); + expect( + readJson(tree, '/apps/myApp-e2e/.eslintrc.json') + ).toMatchSnapshot(); expect(tree.exists('/apps/myApp-e2e/src/fixtures/example.json')).toBe( true ); @@ -607,10 +692,21 @@ describe('workspace', () => { expect(tree.exists('cypress')).toBe(false); expect(tree.exists('/apps/myApp-e2e/tsconfig.json')).toBe(true); expect(tree.exists('/apps/myApp-e2e/cypress.json')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/.eslintrc.json')).toBe(true); expect( readJson(tree, '/apps/myApp-e2e/cypress.json') ).toMatchSnapshot(); - expect(tree.exists('/apps/myApp-e2e/src')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src/fixtures/example.json')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/integration/spec.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/plugins/index.ts')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src/support/commands.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/support/index.ts')).toBe(true); expect(readProjectConfiguration(tree, 'myApp-e2e')).toMatchSnapshot(); }); @@ -626,9 +722,49 @@ describe('workspace', () => { expect(tree.exists('cypress')).toBe(false); expect(tree.exists('/apps/myApp-e2e/tsconfig.json')).toBe(true); expect(tree.exists('/apps/myApp-e2e/cypress.json')).toBe(true); - expect(tree.exists('/apps/myApp-e2e/src')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/.eslintrc.json')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src/fixtures/example.json')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/integration/spec.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/plugins/index.ts')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src/support/commands.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/support/index.ts')).toBe(true); expect(readProjectConfiguration(tree, 'myApp-e2e')).toMatchSnapshot(); }); + + it('should work when eslint is not being used', async () => { + tree.delete('.eslintrc.json'); + updateJson(tree, 'angular.json', (json) => { + delete json.projects.myApp.architect.lint; + return json; + }); + + await migrateFromAngularCli(tree, { name: 'myApp' }); + + expect(tree.exists('/apps/myApp-e2e/.eslintrc.json')).toBe(false); + expect(tree.exists('cypress.json')).toBe(false); + expect(tree.exists('cypress')).toBe(false); + expect(tree.exists('/apps/myApp-e2e/tsconfig.json')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/cypress.json')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src/fixtures/example.json')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/integration/spec.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/plugins/index.ts')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src/support/commands.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/support/index.ts')).toBe(true); + const project = readProjectConfiguration(tree, 'myApp-e2e'); + expect(project.targets.lint).toBeUndefined(); + }); }); }); diff --git a/packages/angular/src/generators/ng-add/migrate-from-angular-cli.ts b/packages/angular/src/generators/ng-add/migrate-from-angular-cli.ts index f70bbadd107ab5..31977792e8290c 100755 --- a/packages/angular/src/generators/ng-add/migrate-from-angular-cli.ts +++ b/packages/angular/src/generators/ng-add/migrate-from-angular-cli.ts @@ -2,6 +2,7 @@ import { addDependenciesToPackageJson, formatFiles, installPackagesTask, + readJson, Tree, updateJson, } from '@nrwl/devkit'; @@ -12,13 +13,15 @@ import { getAllProjects } from './utilities/get-all-projects'; import { normalizeOptions } from './utilities/normalize-options'; import { ProjectMigrator } from './utilities/project.migrator'; import { - createWorkspaceFiles, + cleanupEsLintPackages, createNxJson, createRootKarmaConfig, + createWorkspaceFiles, decorateAngularCli, + getWorkspaceCapabilities, updatePackageJson, + updateRootEsLintConfig, updateRootTsConfig, - updateTsLint, updateWorkspaceConfigDefaults, validateWorkspace, } from './utilities/workspace'; @@ -49,8 +52,24 @@ export async function migrateFromAngularCli( // TODO: add libraries migrator when support for libs is added ]; - // TODO: validate all projects and collect errors before migrating when - // multiple projects are supported + // validate all projects + for (const migrator of migrators) { + // TODO: validator will throw on their own until multiple project are supported + migrator.validate(); + } + + const workspaceCapabilities = getWorkspaceCapabilities(tree, projects); + + let eslintConfig; + if (workspaceCapabilities.eslint && tree.exists('.eslintrc.json')) { + /** + * Keep a copy of the root eslint config to restore it later. We need to + * do this because the root config can also be the config for the app at + * the root of the Angular CLI workspace and it will be moved as part of + * the app migration. + */ + eslintConfig = readJson(tree, '.eslintrc.json'); + } // create and update root files and configurations updateJson(tree, 'angular.json', (json) => ({ @@ -63,8 +82,6 @@ export async function migrateFromAngularCli( updateRootTsConfig(tree); updatePackageJson(tree); decorateAngularCli(tree); - // TODO: check later if it's still needed - updateTsLint(tree); await createWorkspaceFiles(tree); // migrate all projects @@ -72,10 +89,18 @@ export async function migrateFromAngularCli( await migrator.migrate(); } - // needs to be done last because the Angular CLI workspace can have one - // in the root for the root application, so we wait until that root Karma - // config is moved when the projects are migrated before creating this one - createRootKarmaConfig(tree); + /** + * This needs to be done last because the Angular CLI workspace can have + * these files in the root for the root application, so we wait until + * those root config files are moved when the projects are migrated. + */ + if (workspaceCapabilities.karma) { + createRootKarmaConfig(tree); + } + if (workspaceCapabilities.eslint) { + updateRootEsLintConfig(tree, eslintConfig); + cleanupEsLintPackages(tree); + } await formatFiles(tree); } diff --git a/packages/angular/src/generators/ng-add/utilities/app.migrator.ts b/packages/angular/src/generators/ng-add/utilities/app.migrator.ts index 789c96da541f11..27f68c8be475db 100644 --- a/packages/angular/src/generators/ng-add/utilities/app.migrator.ts +++ b/packages/angular/src/generators/ng-add/utilities/app.migrator.ts @@ -1,12 +1,15 @@ import { joinPathFragments, offsetFromRoot, + readJson, Tree, updateJson, updateProjectConfiguration, } from '@nrwl/devkit'; +import { hasRulesRequiringTypeChecking } from '@nrwl/linter'; import { convertToNxProjectGenerator } from '@nrwl/workspace/generators'; import { getRootTsConfigPathInTree } from '@nrwl/workspace/src/utilities/typescript'; +import { basename } from 'path'; import { GeneratorOptions } from '../schema'; import { E2eProjectMigrator } from './e2e-project.migrator'; import { ProjectMigrator } from './project.migrator'; @@ -31,12 +34,26 @@ export class AppMigrator extends ProjectMigrator { this.moveProjectFiles(); await this.updateProjectConfiguration(); this.updateTsConfigs(); - // TODO: check later if it's still needed - this.updateProjectTsLint(); + this.updateEsLintConfig(); } validate(): ValidationResult { - // TODO: implement validation when multiple apps are supported + // TODO: properly return the validation results once we support multiple projects + if ( + this.projectConfig.targets.lint && + this.projectConfig.targets.lint.executor !== + '@angular-eslint/builder:lint' + ) { + throw new Error( + `The "${this.project.name}" project is using an unsupported executor "${this.projectConfig.targets.lint.executor}".` + ); + } + + const result = this.e2eMigrator.validate(); + if (result) { + throw new Error(result); + } + return null; } @@ -63,9 +80,15 @@ export class AppMigrator extends ProjectMigrator { } else { // there could still be a karma.conf.js file in the root // so move to new location - if (this.tree.exists('karma.conf.js')) { - console.info('No test configuration, but root Karma config file found'); - this.moveProjectRootFile('karma.conf.js'); + const karmaConfig = joinPathFragments( + this.project.oldRoot, + 'karma.conf.js' + ); + if (this.tree.exists(karmaConfig)) { + this.logger.info( + 'No "test" target was found, but a root Karma config file was found' + ); + this.moveProjectRootFile(karmaConfig); } } @@ -75,6 +98,26 @@ export class AppMigrator extends ProjectMigrator { ); } + if (this.projectConfig.targets.lint) { + this.moveProjectRootFile( + this.projectConfig.targets.lint.options.eslintConfig ?? + joinPathFragments(this.project.oldRoot, '.eslintrc.json') + ); + } else { + // there could still be a .eslintrc.json file in the root + // so move to new location + const eslintConfig = joinPathFragments( + this.project.oldRoot, + '.eslintrc.json' + ); + if (this.tree.exists(eslintConfig)) { + this.logger.info( + 'No "lint" target was found, but a root ESLint config file was found' + ); + this.moveProjectRootFile(eslintConfig); + } + } + this.moveDir(this.project.oldSourceRoot, this.project.newSourceRoot); } @@ -113,10 +156,27 @@ export class AppMigrator extends ProjectMigrator { } if (this.projectConfig.targets.lint) { - this.projectConfig.targets.lint.options.tsConfig = [ - joinPathFragments(this.project.newRoot, 'tsconfig.app.json'), - joinPathFragments(this.project.newRoot, 'tsconfig.spec.json'), - ]; + this.projectConfig.targets.lint.executor = '@nrwl/linter:eslint'; + const lintOptions = this.projectConfig.targets.lint.options; + lintOptions.eslintConfig = + lintOptions.eslintConfig && + joinPathFragments( + this.project.newRoot, + basename(lintOptions.eslintConfig) + ); + lintOptions.lintFilePatterns = + lintOptions.lintFilePatterns && + lintOptions.lintFilePatterns.map((pattern) => + this.convertAsset(pattern) + ); + + const eslintConfigPath = + lintOptions.eslintConfig ?? + joinPathFragments(this.project.newRoot, '.eslintrc.json'); + const eslintConfig = readJson(this.tree, eslintConfigPath); + if (hasRulesRequiringTypeChecking(eslintConfig)) { + lintOptions.hasTypeAwareRules = true; + } } if (this.projectConfig.targets.server) { @@ -179,13 +239,60 @@ export class AppMigrator extends ProjectMigrator { } } - private updateProjectTsLint(): void { - if (this.tree.exists(`${this.project.newRoot}/tslint.json`)) { - updateJson(this.tree, `${this.project.newRoot}/tslint.json`, (json) => { - json.extends = '../../tslint.json'; - return json; + private updateEsLintConfig(): void { + const eslintConfigPath = + this.projectConfig.targets.lint?.options?.eslintConfig ?? + joinPathFragments(this.project.newRoot, '.eslintrc.json'); + + if (!this.tree.exists(eslintConfigPath)) { + return; + } + + updateJson(this.tree, eslintConfigPath, (json) => { + delete json.root; + json.ignorePatterns = ['!**/*']; + + const rootEsLintConfigRelativePath = joinPathFragments( + offsetFromRoot(this.projectConfig.root), + '.eslintrc.json' + ); + if (Array.isArray(json.extends)) { + json.extends = json.extends.map((extend: string) => + this.convertEsLintConfigExtendToNewPath(extend) + ); + + // it might have not been extending from the root config, make sure it does + if (!json.extends.includes(rootEsLintConfigRelativePath)) { + json.extends.push(rootEsLintConfigRelativePath); + } + } else { + json.extends = rootEsLintConfigRelativePath; + } + + json.overrides?.forEach((override) => { + if (!override.parserOptions?.project) { + return; + } + + override.parserOptions.project = [ + `${this.projectConfig.root}/tsconfig.*?.json`, + ]; }); + + return json; + }); + } + + private convertEsLintConfigExtendToNewPath(pathToFile: string): string { + if (!pathToFile.startsWith('..')) { + // not a relative path to adjust + return pathToFile; } + + return joinPathFragments( + offsetFromRoot(this.projectConfig.root), + basename(pathToFile) + ); } private convertBuildOptions(buildOptions: any): void { diff --git a/packages/angular/src/generators/ng-add/utilities/e2e-project.migrator.ts b/packages/angular/src/generators/ng-add/utilities/e2e-project.migrator.ts index fb61e7af3e223e..ce3b806e3b6516 100644 --- a/packages/angular/src/generators/ng-add/utilities/e2e-project.migrator.ts +++ b/packages/angular/src/generators/ng-add/utilities/e2e-project.migrator.ts @@ -1,29 +1,31 @@ +import { cypressProjectGenerator } from '@nrwl/cypress'; import { addProjectConfiguration, joinPathFragments, + names, offsetFromRoot, ProjectConfiguration, readJson, + readProjectConfiguration, + removeProjectConfiguration, TargetConfiguration, Tree, updateJson, updateProjectConfiguration, + visitNotIgnoredFiles, writeJson, } from '@nrwl/devkit'; +import { Linter, lintProjectGenerator } from '@nrwl/linter'; import { getRootTsConfigPathInTree } from '@nrwl/workspace/src/utilities/typescript'; import { basename, relative } from 'path'; import { GeneratorOptions } from '../schema'; -import { - getCypressConfigFile, - isCypressE2eProject, - isProtractorE2eProject, -} from './e2e-utils'; import { ProjectMigrator } from './project.migrator'; import { MigrationProjectConfiguration, ValidationResult } from './types'; export class E2eProjectMigrator extends ProjectMigrator { private appConfig: ProjectConfiguration; private appName: string; + private isProjectUsingEsLint: boolean; constructor( tree: Tree, @@ -35,21 +37,21 @@ export class E2eProjectMigrator extends ProjectMigrator { this.appConfig = project.config; this.appName = this.project.name; - this.initializeProject(); + this.initialize(); } async migrate(): Promise { if (!this.project) { - console.warn( + this.logger.warn( 'No e2e project was migrated because there was none declared in angular.json.' ); return; } - if (isProtractorE2eProject(this.projectConfig)) { - this.migrateProtractorE2eProject(); - } else if (isCypressE2eProject(this.projectConfig)) { - this.migrateCypressE2eProject(); + if (this.isProtractorE2eProject()) { + await this.migrateProtractorE2eProject(); + } else if (this.isCypressE2eProject()) { + await this.migrateCypressE2eProject(); } const tsConfig = joinPathFragments( @@ -68,39 +70,77 @@ export class E2eProjectMigrator extends ProjectMigrator { } validate(): ValidationResult { - // TODO: implement validation when multiple apps are supported + if (!this.project) { + return null; + } + + if (this.isProtractorE2eProject()) { + if ( + this.tree.exists( + this.projectConfig.targets.e2e.options.protractorConfig + ) + ) { + return; + } + + return ( + `An e2e project with Protractor was found but "${this.projectConfig.targets.e2e.options.protractorConfig}" could not be found.\n` + + `Make sure the "${this.appName}.architect.e2e.options.protractorConfig" is valid or the "${this.appName}" project is removed from "angular.json".` + ); + } else if (this.isCypressE2eProject()) { + const configFile = this.getCypressConfigFile(); + if (configFile && !this.tree.exists(configFile)) { + return `An e2e project with Cypress was found but "${configFile}" could not be found.`; + } + + if (!this.tree.exists('cypress')) { + return `An e2e project with Cypress was found but the "cypress" directory could not be found.`; + } + } else { + return `An e2e project was found but it's using an unsupported executor "${this.projectConfig.targets.e2e.executor}".`; + } + return null; } - private initializeProject(): void { + private initialize(): void { if (!this.projectConfig.targets?.e2e) { this.project = null; return; } + this.isProjectUsingEsLint = + Boolean(this.appConfig.targets.lint) || + this.tree.exists( + joinPathFragments(this.appConfig.root, '.eslintrc.json') + ); + const name = this.project.name.endsWith('-e2e') ? this.project.name : `${this.project.name}-e2e`; const newRoot = joinPathFragments('apps', name); + const newSourceRoot = joinPathFragments('apps', name, 'src'); - if (isProtractorE2eProject(this.projectConfig)) { + if (this.isProtractorE2eProject()) { this.project = { ...this.project, name, oldRoot: joinPathFragments(this.project.oldRoot, 'e2e'), newRoot, + newSourceRoot, }; - } else if (isCypressE2eProject(this.projectConfig)) { + } else if (this.isCypressE2eProject()) { this.project = { ...this.project, name, oldRoot: 'cypress', newRoot, + newSourceRoot, }; } } - private migrateProtractorE2eProject(): void { + private async migrateProtractorE2eProject(): Promise { this.moveDir(this.project.oldRoot, this.project.newRoot); this.projectConfig = { @@ -117,13 +157,6 @@ export class E2eProjectMigrator extends ProjectMigrator { ), }, }, - lint: { - executor: '@angular-devkit/build-angular:tslint', - options: { - ...this.projectConfig.targets.lint, - tsConfig: joinPathFragments(this.project.newRoot, 'tsconfig.json'), - }, - }, }, implicitDependencies: [this.appName], tags: [], @@ -137,6 +170,18 @@ export class E2eProjectMigrator extends ProjectMigrator { true ); + if (this.isProjectUsingEsLint) { + await lintProjectGenerator(this.tree, { + project: this.project.name, + linter: Linter.EsLint, + eslintFilePatterns: [`${this.project.newRoot}/**/*.{js,ts}`], + tsConfigPaths: [ + joinPathFragments(this.project.newRoot, 'tsconfig.json'), + ], + skipFormat: true, + }); + } + // remove e2e target from the app config delete this.appConfig.targets.e2e; updateProjectConfiguration(this.tree, this.appName, { @@ -144,23 +189,41 @@ export class E2eProjectMigrator extends ProjectMigrator { }); } - private migrateCypressE2eProject(): void { - const oldCypressConfigFilePath = getCypressConfigFile(this.projectConfig); + private async migrateCypressE2eProject(): Promise { + const oldCypressConfigFilePath = this.getCypressConfigFile(); + + await cypressProjectGenerator(this.tree, { + name: this.project.name, + project: this.appName, + linter: this.isProjectUsingEsLint ? Linter.EsLint : Linter.None, + standaloneConfig: true, + skipFormat: true, + }); const cypressConfigFilePath = this.updateOrCreateCypressConfigFile( oldCypressConfigFilePath ); + this.updateCypressProjectConfiguration(cypressConfigFilePath); + // replace the generated tsconfig.json with the project one + const newTsConfigPath = joinPathFragments( + this.project.newRoot, + 'tsconfig.json' + ); + this.tree.delete(newTsConfigPath); this.moveFile( joinPathFragments(this.project.oldRoot, 'tsconfig.json'), - joinPathFragments(this.project.newRoot, 'tsconfig.json') + newTsConfigPath ); + + // replace the generated source with the project source + visitNotIgnoredFiles(this.tree, this.project.newSourceRoot, (filePath) => { + this.tree.delete(filePath); + }); this.moveDir( this.project.oldRoot, - joinPathFragments(this.project.newRoot, 'src') + joinPathFragments(this.project.newSourceRoot) ); - - this.updateCypressProjectConfiguration(cypressConfigFilePath); } private updateOrCreateCypressConfigFile(configFile: string): string { @@ -172,6 +235,7 @@ export class E2eProjectMigrator extends ProjectMigrator { basename(configFile) ); this.updateCypressConfigFilePaths(configFile); + this.tree.delete(cypressConfigFilePath); this.moveFile(configFile, cypressConfigFilePath); } else { cypressConfigFilePath = joinPathFragments( @@ -199,41 +263,60 @@ export class E2eProjectMigrator extends ProjectMigrator { } private updateCypressProjectConfiguration(cypressConfigPath: string): void { - this.projectConfig = { - root: this.project.newRoot, - sourceRoot: joinPathFragments(this.project.newRoot, 'src'), - projectType: 'application', - targets: { - e2e: this.updateE2eCypressTarget( - this.projectConfig.targets.e2e, - cypressConfigPath - ), - }, - implicitDependencies: [this.appName], - tags: [], - }; + /** + * The `cypressProjectGenerator` function normalizes the project name. The + * migration keeps the names for existing projects as-is to avoid any + * confusion. The e2e project is technically new, but it's associated + * to an existing application. + */ + const generatedProjectName = names(this.project.name).fileName; + if (this.project.name !== generatedProjectName) { + // If the names are different, we "rename" the newly added project. + this.projectConfig = readProjectConfiguration( + this.tree, + generatedProjectName + ); - if (this.appConfig.targets['cypress-run']) { - this.projectConfig.targets['cypress-run'] = this.updateE2eCypressTarget( - this.appConfig.targets['cypress-run'], - cypressConfigPath + this.projectConfig.root = this.project.newRoot; + this.projectConfig.sourceRoot = this.project.newSourceRoot; + removeProjectConfiguration(this.tree, generatedProjectName); + addProjectConfiguration( + this.tree, + this.project.name, + { ...this.projectConfig }, + true ); - } - if (this.appConfig.targets['cypress-open']) { - this.projectConfig.targets['cypress-open'] = this.updateE2eCypressTarget( - this.appConfig.targets['cypress-open'], - cypressConfigPath + } else { + this.projectConfig = readProjectConfiguration( + this.tree, + this.project.name ); } - addProjectConfiguration( - this.tree, - this.project.name, - { - ...this.projectConfig, - }, - true - ); + if (this.isProjectUsingEsLint) { + this.projectConfig.targets.lint.options.lintFilePatterns = + this.projectConfig.targets.lint.options.lintFilePatterns.map( + (pattern) => + pattern.replace( + `apps/${generatedProjectName}`, + this.project.newRoot + ) + ); + } + + ['e2e', 'cypress-run', 'cypress-open'].forEach((target) => { + if (this.appConfig.targets[target]) { + this.projectConfig.targets[target] = this.updateE2eCypressTarget( + this.appConfig.targets[target], + this.projectConfig.targets[target], + cypressConfigPath + ); + } + }); + + updateProjectConfiguration(this.tree, this.project.name, { + ...this.projectConfig, + }); delete this.appConfig.targets['cypress-run']; delete this.appConfig.targets['cypress-open']; @@ -244,19 +327,32 @@ export class E2eProjectMigrator extends ProjectMigrator { } private updateE2eCypressTarget( - target: TargetConfiguration, + existingTarget: TargetConfiguration, + generatedTarget: TargetConfiguration, cypressConfig: string ): TargetConfiguration { const updatedTarget = { - ...target, + ...existingTarget, executor: '@nrwl/cypress:cypress', options: { - ...target.options, + ...existingTarget.options, cypressConfig, }, }; delete updatedTarget.options.configFile; - delete updatedTarget.options.tsConfig; + if ( + generatedTarget && + !generatedTarget.options.tsConfig && + updatedTarget.options.tsConfig + ) { + // if what we generate doesn't have a tsConfig, we don't need it + delete updatedTarget.options.tsConfig; + } else if (updatedTarget.options.tsConfig) { + updatedTarget.options.tsConfig = joinPathFragments( + this.project.newRoot, + 'tsconfig.json' + ); + } if (updatedTarget.options.headless && updatedTarget.options.watch) { updatedTarget.options.headed = false; @@ -326,4 +422,29 @@ export class E2eProjectMigrator extends ProjectMigrator { './src/' ); } + + private getCypressConfigFile(): string | undefined { + let cypressConfig = 'cypress.json'; + const configFileOption = this.projectConfig.targets.e2e.options.configFile; + if (configFileOption === false) { + cypressConfig = undefined; + } else if (typeof configFileOption === 'string') { + cypressConfig = basename(configFileOption); + } + + return cypressConfig; + } + + private isCypressE2eProject(): boolean { + return ( + this.projectConfig.targets.e2e.executor === '@cypress/schematic:cypress' + ); + } + + private isProtractorE2eProject(): boolean { + return ( + this.projectConfig.targets.e2e.executor === + '@angular-devkit/build-angular:protractor' + ); + } } diff --git a/packages/angular/src/generators/ng-add/utilities/e2e-utils.ts b/packages/angular/src/generators/ng-add/utilities/e2e-utils.ts deleted file mode 100644 index ec06327d543b77..00000000000000 --- a/packages/angular/src/generators/ng-add/utilities/e2e-utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ProjectConfiguration } from '@nrwl/devkit'; -import { basename } from 'path'; -import { MigrationProjectConfiguration, WorkspaceProjects } from './types'; - -export function getE2eKey(projects: WorkspaceProjects): string | null { - for (const project of projects.apps) { - if (project.config.targets?.e2e) { - return project.name; - } - } - - return null; -} - -export function getE2eProject( - projects: WorkspaceProjects -): MigrationProjectConfiguration | null { - for (const project of projects.apps) { - if (project.config.targets?.e2e) { - return project; - } - } - - return null; -} - -export function getCypressConfigFile( - e2eProject: ProjectConfiguration -): string | undefined { - let cypressConfig = 'cypress.json'; - const configFileOption = e2eProject.targets.e2e.options.configFile; - if (configFileOption === false) { - cypressConfig = undefined; - } else if (typeof configFileOption === 'string') { - cypressConfig = basename(configFileOption); - } - - return cypressConfig; -} - -export function isCypressE2eProject(e2eProject: ProjectConfiguration): boolean { - return e2eProject.targets.e2e.executor === '@cypress/schematic:cypress'; -} - -export function isProtractorE2eProject( - e2eProject: ProjectConfiguration -): boolean { - return ( - e2eProject.targets.e2e.executor === - '@angular-devkit/build-angular:protractor' - ); -} diff --git a/packages/angular/src/generators/ng-add/utilities/logger.ts b/packages/angular/src/generators/ng-add/utilities/logger.ts new file mode 100644 index 00000000000000..a296d72bb00c28 --- /dev/null +++ b/packages/angular/src/generators/ng-add/utilities/logger.ts @@ -0,0 +1,20 @@ +import { logger } from '@nrwl/devkit'; + +export class Logger { + private message = (_: TemplateStringsArray, message: string) => + `[${this.project}] ${message}`; + + constructor(private project: string) {} + + public info(message: string): void { + logger.info(this.message`${message}`); + } + + public warn(message: string): void { + logger.warn(this.message`${message}`); + } + + public error(message: string): void { + logger.error(this.message`${message}`); + } +} diff --git a/packages/angular/src/generators/ng-add/utilities/project.migrator.ts b/packages/angular/src/generators/ng-add/utilities/project.migrator.ts index 8c06df7e9d93e9..a1c56f8d5d2758 100644 --- a/packages/angular/src/generators/ng-add/utilities/project.migrator.ts +++ b/packages/angular/src/generators/ng-add/utilities/project.migrator.ts @@ -6,6 +6,7 @@ import { } from '@nrwl/devkit'; import { basename } from 'path'; import { GeneratorOptions } from '../schema'; +import { Logger } from './logger'; import { MigrationProjectConfiguration, ValidationResult } from './types'; export abstract class ProjectMigrator { @@ -17,6 +18,7 @@ export abstract class ProjectMigrator { newRoot: string; newSourceRoot: string; }; + protected logger: Logger; constructor( protected readonly tree: Tree, @@ -33,6 +35,8 @@ export abstract class ProjectMigrator { newRoot: `${rootDir}/${project.name}`, newSourceRoot: `${rootDir}/${project.name}/src`, }; + + this.logger = new Logger(this.project.name); } abstract migrate(): Promise; diff --git a/packages/angular/src/generators/ng-add/utilities/types.ts b/packages/angular/src/generators/ng-add/utilities/types.ts index bdc6c858deb2d2..1e6db174f6cb2a 100644 --- a/packages/angular/src/generators/ng-add/utilities/types.ts +++ b/packages/angular/src/generators/ng-add/utilities/types.ts @@ -10,4 +10,9 @@ export type WorkspaceProjects = { libs: MigrationProjectConfiguration[]; }; +export type WorkspaceCapabilities = { + karma: boolean; + eslint: boolean; +}; + export type ValidationResult = string | null; diff --git a/packages/angular/src/generators/ng-add/utilities/workspace.ts b/packages/angular/src/generators/ng-add/utilities/workspace.ts index 9b8e0ec9d0e7aa..48b54c88d9eb90 100644 --- a/packages/angular/src/generators/ng-add/utilities/workspace.ts +++ b/packages/angular/src/generators/ng-add/utilities/workspace.ts @@ -9,6 +9,7 @@ import { updateWorkspaceConfiguration, writeJson, } from '@nrwl/devkit'; +import { Linter, lintInitGenerator } from '@nrwl/linter'; import { DEFAULT_NRWL_PRETTIER_CONFIG } from '@nrwl/workspace/src/generators/workspace/workspace'; import { deduceDefaultBase } from '@nrwl/workspace/src/utilities/default-base'; import { resolveUserExistingPrettierConfig } from '@nrwl/workspace/src/utilities/prettier'; @@ -18,18 +19,8 @@ import { readFileSync } from 'fs'; import { dirname, join } from 'path'; import { angularDevkitVersion, nxVersion } from '../../../utils/versions'; import { GeneratorOptions } from '../schema'; -import { - getCypressConfigFile, - getE2eKey, - getE2eProject, - isCypressE2eProject, - isProtractorE2eProject, -} from './e2e-utils'; -import { WorkspaceProjects } from './types'; - -// TODO: most of the validation here will be moved to the app migrator when -// support for multiple apps is added. This will only contain workspace-wide -// validation. +import { WorkspaceCapabilities, WorkspaceProjects } from './types'; + export function validateWorkspace( tree: Tree, projects: WorkspaceProjects @@ -48,47 +39,6 @@ export function validateWorkspace( if (projects.apps.length > 2 || projects.libs.length > 0) { throw new Error('Can only convert projects with one app'); } - - const e2eKey = getE2eKey(projects); - const e2eApp = getE2eProject(projects); - - if (!e2eApp) { - return; - } - - if (isProtractorE2eProject(e2eApp.config)) { - if (tree.exists(e2eApp.config.targets.e2e.options.protractorConfig)) { - return; - } - - console.info( - `Make sure the "${e2eKey}.architect.e2e.options.protractorConfig" is valid or the "${e2eKey}" project is removed from "angular.json".` - ); - throw new Error( - `An e2e project with Protractor was found but "${e2eApp.config.targets.e2e.options.protractorConfig}" could not be found.` - ); - } - - if (isCypressE2eProject(e2eApp.config)) { - const configFile = getCypressConfigFile(e2eApp.config); - if (configFile && !tree.exists(configFile)) { - throw new Error( - `An e2e project with Cypress was found but "${configFile}" could not be found.` - ); - } - - if (!tree.exists('cypress')) { - throw new Error( - `An e2e project with Cypress was found but the "cypress" directory could not be found.` - ); - } - - return; - } - - throw new Error( - `An e2e project was found but it's using an unsupported executor "${e2eApp.config.targets.e2e.executor}".` - ); } catch (e) { console.error(e.message); console.error( @@ -223,31 +173,77 @@ export function updatePackageJson(tree: Tree): void { }); } -export function updateTsLint(tree: Tree): void { - if (!tree.exists(`tslint.json`)) { +export function updateRootEsLintConfig( + tree: Tree, + existingEsLintConfig: any +): void { + if (tree.exists('.eslintrc.json')) { + /** + * If it still exists it means that there was no project at the root of the + * workspace, so it was not moved. In that case, we remove the file so the + * init generator do its work. We still receive the content of the file, + * so we update it after the init generator has run. + */ + tree.delete('.eslintrc.json'); + } + + lintInitGenerator(tree, { linter: Linter.EsLint }); + + if (!existingEsLintConfig) { + // There was no eslint config in the root, so we keep the generated one as-is. return; } - updateJson(tree, 'tslint.json', (tslintJson) => { - [ - 'no-trailing-whitespace', - 'one-line', - 'quotemark', - 'typedef-whitespace', - 'whitespace', - ].forEach((key) => { - tslintJson[key] = undefined; - }); - tslintJson.rulesDirectory = tslintJson.rulesDirectory ?? []; - tslintJson.rulesDirectory.push('node_modules/@nrwl/workspace/src/tslint'); - tslintJson.rules['nx-enforce-module-boundaries'] = [ - true, - { - allow: [], - depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }], + existingEsLintConfig.ignorePatterns = ['**/*']; + existingEsLintConfig.plugins = Array.from( + new Set([...(existingEsLintConfig.plugins ?? []), '@nrwl/nx']) + ); + existingEsLintConfig.overrides?.forEach((override) => { + if (!override.parserOptions?.project) { + return; + } + + delete override.parserOptions.project; + }); + // add the @nrwl/nx/enforce-module-boundaries rule + existingEsLintConfig.overrides = [ + ...(existingEsLintConfig.overrides ?? []), + { + files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + rules: { + '@nrwl/nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }, + ], + }, + ], }, - ]; - return tslintJson; + }, + ]; + + writeJson(tree, '.eslintrc.json', existingEsLintConfig); +} + +export function cleanupEsLintPackages(tree: Tree): void { + updateJson(tree, 'package.json', (json) => { + if (json.devDependencies?.['@angular-eslint/builder']) { + delete json.devDependencies['@angular-eslint/builder']; + } + if (json.dependencies?.['@angular-eslint/builder']) { + delete json.dependencies['@angular-eslint/builder']; + } + if (json.devDependencies?.['@angular-eslint/schematics']) { + delete json.devDependencies['@angular-eslint/schematics']; + } + if (json.dependencies?.['@angular-eslint/schematics']) { + delete json.dependencies['@angular-eslint/schematics']; + } + + return json; }); } @@ -273,6 +269,47 @@ export function createRootKarmaConfig(tree: Tree): void { ); } +export function getWorkspaceCapabilities( + tree: Tree, + projects: WorkspaceProjects +): WorkspaceCapabilities { + const capabilities: WorkspaceCapabilities = { eslint: false, karma: false }; + + if (tree.exists('.eslintrc.json')) { + capabilities.eslint = true; + } + if (tree.exists('karma.conf.js')) { + capabilities.karma = true; + } + + if (capabilities.eslint && capabilities.karma) { + return capabilities; + } + + for (const project of [...projects.apps, ...projects.libs]) { + if ( + !capabilities.eslint && + (project.config.targets?.lint || + tree.exists(`${project.config.root}/.eslintrc.json`)) + ) { + capabilities.eslint = true; + } + if ( + !capabilities.karma && + (project.config.targets?.test || + tree.exists(`${project.config.root}/karma.conf.js`)) + ) { + capabilities.karma = true; + } + + if (capabilities.eslint && capabilities.karma) { + return capabilities; + } + } + + return capabilities; +} + function updateVsCodeRecommendedExtensions(tree: Tree): void { const recommendations = [ 'nrwl.angular-console', diff --git a/packages/linter/index.ts b/packages/linter/index.ts index 4f5263328f257a..8cb80da42c0fb1 100644 --- a/packages/linter/index.ts +++ b/packages/linter/index.ts @@ -2,3 +2,8 @@ export { lintProjectGenerator } from './src/generators/lint-project/lint-project export { lintInitGenerator } from './src/generators/init/init'; export { Linter } from './src/generators/utils/linter'; export * from './src/utils/convert-tslint-to-eslint'; + +// @nrwl/angular needs it for the Angular CLI workspace migration to Nx to +// infer whether a config is using type aware rules and set the +// `hasTypeAwareRules` option of the `@nrwl/linter:eslint` executor. +export { hasRulesRequiringTypeChecking } from './src/utils/rules-requiring-type-checking';