From 50b9353636f740f9b51ca02d839ce5e55daf1c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 3 Dec 2024 12:03:26 +0100 Subject: [PATCH] feat(angular): add migration to disable `@angular-eslint/prefer-standalone` when not set --- packages/angular/migrations.json | 13 +- packages/angular/package.json | 8 +- ...e-angular-eslint-prefer-standalone.spec.ts | 428 ++++++++++++++++++ ...isable-angular-eslint-prefer-standalone.ts | 78 ++++ 4 files changed, 521 insertions(+), 6 deletions(-) create mode 100644 packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.spec.ts create mode 100644 packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.ts diff --git a/packages/angular/migrations.json b/packages/angular/migrations.json index 97a800742ad04..bef49fd08f526 100644 --- a/packages/angular/migrations.json +++ b/packages/angular/migrations.json @@ -292,7 +292,7 @@ "cli": "nx", "version": "20.2.0-beta.5", "requires": { - "@angular/core": ">=19.0.0-rc.1" + "@angular/core": ">=19.0.0" }, "description": "Add the '@angular/localize/init' polyfill to the 'polyfills' option of targets using esbuild-based executors.", "factory": "./src/migrations/update-20-2-0/add-localize-polyfill-to-targets" @@ -301,10 +301,19 @@ "cli": "nx", "version": "20.2.0-beta.5", "requires": { - "@angular/core": ">=19.0.0-rc.1" + "@angular/core": ">=19.0.0" }, "description": "Update '@angular/ssr' import paths to use the new '/node' entry point when 'CommonEngine' is detected.", "factory": "./src/migrations/update-20-2-0/update-angular-ssr-imports-to-use-node-entry-point" + }, + "disable-angular-eslint-prefer-standalone": { + "cli": "nx", + "version": "20.2.0-beta.6", + "requires": { + "@angular/core": ">=19.0.0" + }, + "description": "Disable the Angular ESLint prefer-standalone rule if not set.", + "factory": "./src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone" } }, "packageJsonUpdates": { diff --git a/packages/angular/package.json b/packages/angular/package.json index 54b76661b4752..1ebeda5888af8 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -66,10 +66,10 @@ "piscina": "^4.4.0" }, "peerDependencies": { - "@angular-devkit/build-angular": ">= 16.0.0 < 19.0.0", - "@angular-devkit/core": ">= 16.0.0 < 19.0.0", - "@angular-devkit/schematics": ">= 16.0.0 < 19.0.0", - "@schematics/angular": ">= 16.0.0 < 19.0.0", + "@angular-devkit/build-angular": ">= 17.0.0 < 20.0.0", + "@angular-devkit/core": ">= 17.0.0 < 20.0.0", + "@angular-devkit/schematics": ">= 17.0.0 < 20.0.0", + "@schematics/angular": ">= 17.0.0 < 20.0.0", "rxjs": "^6.5.3 || ^7.5.0" }, "publishConfig": { diff --git a/packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.spec.ts b/packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.spec.ts new file mode 100644 index 0000000000000..0fb1b4bedc586 --- /dev/null +++ b/packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.spec.ts @@ -0,0 +1,428 @@ +import { + addProjectConfiguration, + writeJson, + type ProjectConfiguration, + type ProjectGraph, + type Tree, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import migration from './disable-angular-eslint-prefer-standalone'; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: () => Promise.resolve(projectGraph), +})); + +describe('disable-angular-eslint-prefer-standalone', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + + const projectConfig: ProjectConfiguration = { + name: 'app1', + root: 'apps/app1', + }; + projectGraph = { + dependencies: { + app1: [ + { + source: 'app1', + target: 'npm:@angular/core', + type: 'static', + }, + ], + }, + nodes: { + app1: { + data: projectConfig, + name: 'app1', + type: 'app', + }, + }, + }; + addProjectConfiguration(tree, projectConfig.name, projectConfig); + }); + + describe('.eslintrc.json', () => { + it('should not disable @angular-eslint/prefer-standalone when it is set', async () => { + writeJson(tree, 'apps/app1/.eslintrc.json', { + overrides: [ + { + files: ['*.ts'], + rules: { '@angular-eslint/prefer-standalone': ['error'] }, + }, + ], + }); + + await migration(tree); + + expect(tree.read('apps/app1/.eslintrc.json', 'utf8')) + .toMatchInlineSnapshot(` + "{ + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/prefer-standalone": ["error"] + } + } + ] + } + " + `); + }); + + it('should not disable @angular-eslint/prefer-standalone when there are multiple overrides for angular eslint and the rule is set in one of them', async () => { + writeJson(tree, 'apps/app1/.eslintrc.json', { + overrides: [ + { + files: ['*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + }, + }, + { + files: ['*.ts'], + rules: { '@angular-eslint/prefer-standalone': ['error'] }, + }, + ], + }); + + await migration(tree); + + expect(tree.read('apps/app1/.eslintrc.json', 'utf8')) + .toMatchInlineSnapshot(` + "{ + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ] + } + }, + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/prefer-standalone": ["error"] + } + } + ] + } + " + `); + }); + + it('should disable @angular-eslint/prefer-standalone in an existing override for angular eslint', async () => { + writeJson(tree, 'apps/app1/.eslintrc.json', { + overrides: [ + { + files: ['*.ts'], + rules: { 'no-unused-vars': 'error' }, + }, + { + files: ['*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + }, + }, + ], + }); + + await migration(tree); + + expect(tree.read('apps/app1/.eslintrc.json', 'utf8')) + .toMatchInlineSnapshot(` + "{ + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "no-unused-vars": "error" + } + }, + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/prefer-standalone": "off" + } + } + ] + } + " + `); + }); + + it('should disable @angular-eslint/prefer-standalone in an existing override for ts files', async () => { + writeJson(tree, 'apps/app1/.eslintrc.json', { + overrides: [ + { + files: ['*.ts'], + rules: { 'no-unused-vars': 'error' }, + }, + ], + }); + + await migration(tree); + + expect(tree.read('apps/app1/.eslintrc.json', 'utf8')) + .toMatchInlineSnapshot(` + "{ + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "no-unused-vars": "error", + "@angular-eslint/prefer-standalone": "off" + } + } + ] + } + " + `); + }); + + it('should disable @angular-eslint/prefer-standalone in a new override', async () => { + writeJson(tree, 'apps/app1/.eslintrc.json', { + overrides: [ + { + files: ['*.html'], + rules: { 'some-rule-for-html': 'error' }, + }, + ], + }); + + await migration(tree); + + expect(tree.read('apps/app1/.eslintrc.json', 'utf8')) + .toMatchInlineSnapshot(` + "{ + "overrides": [ + { + "files": ["*.html"], + "rules": { + "some-rule-for-html": "error" + } + }, + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/prefer-standalone": "off" + } + } + ] + } + " + `); + }); + }); + + describe('flat config', () => { + it('should not disable @angular-eslint/prefer-standalone when it is set', async () => { + tree.write('eslint.config.js', 'module.exports = [];'); + tree.write( + 'apps/app1/eslint.config.js', + `module.exports = [ + { + files: ['*.ts'], + rules: { '@angular-eslint/prefer-standalone': ['error'] }, + }, + ]; + ` + ); + + await migration(tree); + + expect(tree.read('apps/app1/eslint.config.js', 'utf8')) + .toMatchInlineSnapshot(` + "module.exports = [ + { + files: ['*.ts'], + rules: { '@angular-eslint/prefer-standalone': ['error'] }, + }, + ]; + " + `); + }); + + it('should not disable @angular-eslint/prefer-standalone when there are multiple overrides for angular eslint and the rule is set in one of them', async () => { + tree.write('eslint.config.js', 'module.exports = [];'); + tree.write( + 'apps/app1/eslint.config.js', + `module.exports = [ + { + files: ['*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + }, + }, + { + files: ['*.ts'], + rules: { '@angular-eslint/prefer-standalone': ['error'] }, + }, + ]; + ` + ); + + await migration(tree); + + expect(tree.read('apps/app1/eslint.config.js', 'utf8')) + .toMatchInlineSnapshot(` + "module.exports = [ + { + files: ['*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + }, + }, + { + files: ['*.ts'], + rules: { '@angular-eslint/prefer-standalone': ['error'] }, + }, + ]; + " + `); + }); + + it('should disable @angular-eslint/prefer-standalone in an existing override for angular eslint', async () => { + tree.write('eslint.config.js', 'module.exports = [];'); + tree.write( + 'apps/app1/eslint.config.js', + `module.exports = [ + { + files: ['*.ts'], + rules: { 'no-unused-vars': 'error' }, + }, + { + files: ['*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + }, + }, + ]; + ` + ); + + await migration(tree); + + expect(tree.read('apps/app1/eslint.config.js', 'utf8')) + .toMatchInlineSnapshot(` + "module.exports = [ + { + files: ['*.ts'], + rules: { 'no-unused-vars': 'error' }, + }, + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@angular-eslint/prefer-standalone': 'off', + }, + }, + ]; + " + `); + }); + + it('should disable @angular-eslint/prefer-standalone in an existing override for ts files', async () => { + tree.write('eslint.config.js', 'module.exports = [];'); + tree.write( + 'apps/app1/eslint.config.js', + `module.exports = [ + { + files: ['*.ts'], + rules: { 'no-unused-vars': 'error' }, + }, + ]; + ` + ); + + await migration(tree); + + expect(tree.read('apps/app1/eslint.config.js', 'utf8')) + .toMatchInlineSnapshot(` + "module.exports = [ + { + files: ['**/*.ts'], + rules: { + 'no-unused-vars': 'error', + '@angular-eslint/prefer-standalone': 'off', + }, + }, + ]; + " + `); + }); + + it('should disable @angular-eslint/prefer-standalone in a new override', async () => { + tree.write('eslint.config.js', 'module.exports = [];'); + tree.write( + 'apps/app1/eslint.config.js', + `module.exports = [ + { + files: ['*.html'], + rules: { 'some-rule-for-html': 'error' }, + }, + ]; + ` + ); + + await migration(tree); + + expect(tree.read('apps/app1/eslint.config.js', 'utf8')) + .toMatchInlineSnapshot(` + "module.exports = [ + { + files: ['*.html'], + rules: { 'some-rule-for-html': 'error' }, + }, + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/prefer-standalone': 'off', + }, + }, + ]; + " + `); + }); + }); +}); diff --git a/packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.ts b/packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.ts new file mode 100644 index 0000000000000..deab7f750f48a --- /dev/null +++ b/packages/angular/src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone.ts @@ -0,0 +1,78 @@ +import { formatFiles, type Tree } from '@nx/devkit'; +import { + addOverrideToLintConfig, + isEslintConfigSupported, + lintConfigHasOverride, + updateOverrideInLintConfig, +} from '@nx/eslint/src/generators/utils/eslint-file'; +import { getProjectsFilteredByDependencies } from '../utils/projects'; + +const preferStandaloneRule = '@angular-eslint/prefer-standalone'; + +export default async function (tree: Tree) { + const projects = await getProjectsFilteredByDependencies(tree, [ + 'npm:@angular/core', + ]); + + for (const { + project: { root }, + } of projects) { + if (!isEslintConfigSupported(tree, root)) { + // ESLint config is not supported, skip + continue; + } + + if ( + lintConfigHasOverride( + tree, + root, + (o) => !!o.rules?.[preferStandaloneRule], + true + ) + ) { + // the @angular-eslint/prefer-standalone rule is set in an override, skip + continue; + } + + const ngEslintOverrideLookup: Parameters< + typeof lintConfigHasOverride + >[2] = (o) => + o.files?.includes('*.ts') && + Object.keys(o.rules ?? {}).some((r) => r.startsWith('@angular-eslint/')); + const tsFilesOverrideLookup: Parameters[2] = ( + o + ) => o.files?.length === 1 && o.files[0] === '*.ts'; + + if (lintConfigHasOverride(tree, root, ngEslintOverrideLookup, false)) { + // there is an override containing an Angular ESLint rule + updateOverrideInLintConfig(tree, root, ngEslintOverrideLookup, (o) => { + o.rules = { + ...o.rules, + [preferStandaloneRule]: 'off', + }; + return o; + }); + } else if ( + lintConfigHasOverride(tree, root, tsFilesOverrideLookup, false) + ) { + // there is an override for just *.ts files + updateOverrideInLintConfig(tree, root, tsFilesOverrideLookup, (o) => { + o.rules = { + ...o.rules, + [preferStandaloneRule]: 'off', + }; + return o; + }); + } else { + // there are no overrides for any Angular ESLint rule or just *.ts files, add a new override + addOverrideToLintConfig(tree, root, { + files: ['*.ts'], + rules: { + [preferStandaloneRule]: 'off', + }, + }); + } + } + + await formatFiles(tree); +}