diff --git a/docs/angular/api-angular/generators/convert-tslint-to-eslint.md b/docs/angular/api-angular/generators/convert-tslint-to-eslint.md new file mode 100644 index 0000000000..7d91f58a0f --- /dev/null +++ b/docs/angular/api-angular/generators/convert-tslint-to-eslint.md @@ -0,0 +1,47 @@ +# convert-tslint-to-eslint + +Convert a project from TSLint to ESLint + +## Usage + +```bash +nx generate convert-tslint-to-eslint ... +``` + +By default, Nx will search for `convert-tslint-to-eslint` in the default collection provisioned in `angular.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/angular:convert-tslint-to-eslint ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g convert-tslint-to-eslint ... --dry-run +``` + +### Examples + +Convert the Angular project `myapp` from TSLint to ESLint: + +```bash +nx g convert-tslint-to-eslint myapp +``` + +## Options + +### project + +Type: `string` + +The name of the Angular project to convert. + +### removeTSLintIfNoMoreTSLintTargets + +Default: `true` + +Type: `boolean` + +If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration diff --git a/docs/angular/api-nest/generators/convert-tslint-to-eslint.md b/docs/angular/api-nest/generators/convert-tslint-to-eslint.md new file mode 100644 index 0000000000..5abd6a8a7b --- /dev/null +++ b/docs/angular/api-nest/generators/convert-tslint-to-eslint.md @@ -0,0 +1,47 @@ +# convert-tslint-to-eslint + +Convert a project from TSLint to ESLint + +## Usage + +```bash +nx generate convert-tslint-to-eslint ... +``` + +By default, Nx will search for `convert-tslint-to-eslint` in the default collection provisioned in `angular.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/nest:convert-tslint-to-eslint ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g convert-tslint-to-eslint ... --dry-run +``` + +### Examples + +Convert the NestJS project `myapp` from TSLint to ESLint: + +```bash +nx g convert-tslint-to-eslint myapp +``` + +## Options + +### project + +Type: `string` + +The name of the NestJS project to convert. + +### removeTSLintIfNoMoreTSLintTargets + +Default: `true` + +Type: `boolean` + +If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration diff --git a/docs/map.json b/docs/map.json index a961fd05d6..6a095fa53c 100644 --- a/docs/map.json +++ b/docs/map.json @@ -443,6 +443,11 @@ "id": "upgrade-module", "file": "angular/api-angular/generators/upgrade-module" }, + { + "name": "convert-tslint-to-eslint", + "id": "convert-tslint-to-eslint", + "file": "angular/api-angular/generators/convert-tslint-to-eslint" + }, { "name": "package executor", "id": "package", @@ -747,6 +752,11 @@ "name": "service generator", "id": "service", "file": "angular/api-nest/generators/service" + }, + { + "name": "convert-tslint-to-eslint", + "id": "convert-tslint-to-eslint", + "file": "angular/api-nest/generators/convert-tslint-to-eslint" } ] }, @@ -1466,6 +1476,11 @@ "id": "ngrx", "file": "react/api-angular/generators/ngrx" }, + { + "name": "convert-tslint-to-eslint", + "id": "convert-tslint-to-eslint", + "file": "react/api-angular/generators/convert-tslint-to-eslint" + }, { "name": "stories generator", "id": "stories", @@ -1796,6 +1811,11 @@ "name": "service generator", "id": "service", "file": "react/api-nest/generators/service" + }, + { + "name": "convert-tslint-to-eslint", + "id": "convert-tslint-to-eslint", + "file": "react/api-nest/generators/convert-tslint-to-eslint" } ] }, @@ -2488,6 +2508,11 @@ "id": "upgrade-module", "file": "node/api-angular/generators/upgrade-module" }, + { + "name": "convert-tslint-to-eslint", + "id": "convert-tslint-to-eslint", + "file": "node/api-angular/generators/convert-tslint-to-eslint" + }, { "name": "package executor", "id": "package", @@ -2791,6 +2816,11 @@ "name": "service generator", "id": "service", "file": "node/api-nest/generators/service" + }, + { + "name": "convert-tslint-to-eslint", + "id": "convert-tslint-to-eslint", + "file": "node/api-nest/generators/convert-tslint-to-eslint" } ] }, diff --git a/docs/node/api-angular/generators/convert-tslint-to-eslint.md b/docs/node/api-angular/generators/convert-tslint-to-eslint.md new file mode 100644 index 0000000000..aeb395c023 --- /dev/null +++ b/docs/node/api-angular/generators/convert-tslint-to-eslint.md @@ -0,0 +1,47 @@ +# convert-tslint-to-eslint + +Convert a project from TSLint to ESLint + +## Usage + +```bash +nx generate convert-tslint-to-eslint ... +``` + +By default, Nx will search for `convert-tslint-to-eslint` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/angular:convert-tslint-to-eslint ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g convert-tslint-to-eslint ... --dry-run +``` + +### Examples + +Convert the Angular project `myapp` from TSLint to ESLint: + +```bash +nx g convert-tslint-to-eslint myapp +``` + +## Options + +### project + +Type: `string` + +The name of the Angular project to convert. + +### removeTSLintIfNoMoreTSLintTargets + +Default: `true` + +Type: `boolean` + +If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration diff --git a/docs/node/api-nest/generators/convert-tslint-to-eslint.md b/docs/node/api-nest/generators/convert-tslint-to-eslint.md new file mode 100644 index 0000000000..f86652cf12 --- /dev/null +++ b/docs/node/api-nest/generators/convert-tslint-to-eslint.md @@ -0,0 +1,47 @@ +# convert-tslint-to-eslint + +Convert a project from TSLint to ESLint + +## Usage + +```bash +nx generate convert-tslint-to-eslint ... +``` + +By default, Nx will search for `convert-tslint-to-eslint` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/nest:convert-tslint-to-eslint ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g convert-tslint-to-eslint ... --dry-run +``` + +### Examples + +Convert the NestJS project `myapp` from TSLint to ESLint: + +```bash +nx g convert-tslint-to-eslint myapp +``` + +## Options + +### project + +Type: `string` + +The name of the NestJS project to convert. + +### removeTSLintIfNoMoreTSLintTargets + +Default: `true` + +Type: `boolean` + +If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration diff --git a/docs/react/api-angular/generators/convert-tslint-to-eslint.md b/docs/react/api-angular/generators/convert-tslint-to-eslint.md new file mode 100644 index 0000000000..aeb395c023 --- /dev/null +++ b/docs/react/api-angular/generators/convert-tslint-to-eslint.md @@ -0,0 +1,47 @@ +# convert-tslint-to-eslint + +Convert a project from TSLint to ESLint + +## Usage + +```bash +nx generate convert-tslint-to-eslint ... +``` + +By default, Nx will search for `convert-tslint-to-eslint` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/angular:convert-tslint-to-eslint ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g convert-tslint-to-eslint ... --dry-run +``` + +### Examples + +Convert the Angular project `myapp` from TSLint to ESLint: + +```bash +nx g convert-tslint-to-eslint myapp +``` + +## Options + +### project + +Type: `string` + +The name of the Angular project to convert. + +### removeTSLintIfNoMoreTSLintTargets + +Default: `true` + +Type: `boolean` + +If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration diff --git a/docs/react/api-nest/generators/convert-tslint-to-eslint.md b/docs/react/api-nest/generators/convert-tslint-to-eslint.md new file mode 100644 index 0000000000..f86652cf12 --- /dev/null +++ b/docs/react/api-nest/generators/convert-tslint-to-eslint.md @@ -0,0 +1,47 @@ +# convert-tslint-to-eslint + +Convert a project from TSLint to ESLint + +## Usage + +```bash +nx generate convert-tslint-to-eslint ... +``` + +By default, Nx will search for `convert-tslint-to-eslint` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/nest:convert-tslint-to-eslint ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g convert-tslint-to-eslint ... --dry-run +``` + +### Examples + +Convert the NestJS project `myapp` from TSLint to ESLint: + +```bash +nx g convert-tslint-to-eslint myapp +``` + +## Options + +### project + +Type: `string` + +The name of the NestJS project to convert. + +### removeTSLintIfNoMoreTSLintTargets + +Default: `true` + +Type: `boolean` + +If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration diff --git a/nx.json b/nx.json index da08e6762e..5f4c9d887b 100644 --- a/nx.json +++ b/nx.json @@ -79,7 +79,7 @@ }, "nest": { "tags": [], - "implicitDependencies": ["node"] + "implicitDependencies": ["node", "linter"] }, "linter": { "tags": [], diff --git a/package.json b/package.json index 8df995d03b..9946f41bbd 100644 --- a/package.json +++ b/package.json @@ -249,6 +249,7 @@ "tsickle": "^0.38.1", "tslib": "^2.0.0", "tslint": "6.1.3", + "tslint-to-eslint-config": "2.2.0", "typescript": "4.0.5", "url-loader": "^3.0.0", "verdaccio": "^4.11.1", diff --git a/packages/angular/collection.json b/packages/angular/collection.json index bf4f184d67..22390413aa 100644 --- a/packages/angular/collection.json +++ b/packages/angular/collection.json @@ -95,6 +95,26 @@ "schema": "./src/schematics/move/schema.json", "aliases": ["mv"], "description": "Move an Angular application or library to another folder" + }, + + "add-linting": { + "factory": "./src/schematics/add-linting/add-linting", + "schema": "./src/schematics/add-linting/schema.json", + "description": "Add linting configuration to an Angular project", + "hidden": true + }, + + "convert-tslint-to-eslint": { + "factory": "./src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint#conversionSchematic", + "schema": "./src/generators/convert-tslint-to-eslint/schema.json", + "description": "Convert a project from TSLint to ESLint" + } + }, + "generators": { + "convert-tslint-to-eslint": { + "factory": "./src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint#conversionGenerator", + "schema": "./src/generators/convert-tslint-to-eslint/schema.json", + "description": "Convert a project from TSLint to ESLint" } } } diff --git a/packages/angular/generators.ts b/packages/angular/generators.ts index a87fec19eb..e7886b3591 100644 --- a/packages/angular/generators.ts +++ b/packages/angular/generators.ts @@ -1 +1,2 @@ export * from './src/schematics/generators'; +export * from './src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint'; diff --git a/packages/angular/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap b/packages/angular/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap new file mode 100644 index 0000000000..4e88cb7d94 --- /dev/null +++ b/packages/angular/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap @@ -0,0 +1,679 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convert-tslint-to-eslint should work for Angular applications 1`] = ` +Object { + "dependencies": Object {}, + "devDependencies": Object { + "@angular-eslint/eslint-plugin": "~1.0.0", + "@angular-eslint/eslint-plugin-template": "~1.0.0", + "@angular-eslint/template-parser": "~1.0.0", + "@nrwl/eslint-plugin-nx": "*", + "@nrwl/linter": "*", + "@typescript-eslint/eslint-plugin": "4.3.0", + "@typescript-eslint/parser": "4.3.0", + "eslint": "7.10.0", + "eslint-config-prettier": "8.1.0", + "eslint-plugin-import": "latest", + }, + "name": "test-name", +} +`; + +exports[`convert-tslint-to-eslint should work for Angular applications 2`] = ` +Object { + "prefix": "angular-app", + "projectType": "application", + "root": "apps/angular-app-1", + "targets": Object { + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "apps/angular-app-1/src/**/*.ts", + "apps/angular-app-1/src/**/*.html", + ], + }, + }, + }, +} +`; + +exports[`convert-tslint-to-eslint should work for Angular applications 3`] = ` +Object { + "ignorePatterns": Array [ + "**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": Object { + "@nrwl/nx/enforce-module-boundaries": Array [ + "error", + Object { + "allow": Array [ + "@nx-example/shared/product/data/testing", + ], + "depConstraints": Array [ + Object { + "onlyDependOnLibsWithTags": Array [ + "type:feature", + "type:ui", + ], + "sourceTag": "type:app", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:ui", + "type:data", + "type:types", + "type:state", + ], + "sourceTag": "type:feature", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:types", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:state", + "type:types", + "type:data", + ], + "sourceTag": "type:state", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:data", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:e2e-utils", + ], + "sourceTag": "type:e2e", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + "type:ui", + ], + "sourceTag": "type:ui", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:products", + "scope:shared", + ], + "sourceTag": "scope:products", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:cart", + "scope:shared", + ], + "sourceTag": "scope:cart", + }, + ], + "enforceBuildableLibDependency": true, + }, + ], + }, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/typescript", + ], + "files": Array [ + "*.ts", + "*.tsx", + ], + "rules": Object {}, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/javascript", + ], + "files": Array [ + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + ], + "plugins": Array [ + "eslint-plugin-import", + ], + "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", + }, + ], + "@angular-eslint/no-conflicting-lifecycle": "error", + "@angular-eslint/no-host-metadata-property": "error", + "@angular-eslint/no-input-rename": "error", + "@angular-eslint/no-inputs-metadata-property": "error", + "@angular-eslint/no-output-native": "error", + "@angular-eslint/no-output-on-prefix": "error", + "@angular-eslint/no-output-rename": "error", + "@angular-eslint/no-outputs-metadata-property": "error", + "@angular-eslint/use-lifecycle-interface": "error", + "@angular-eslint/use-pipe-transform-interface": "error", + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/dot-notation": "off", + "@typescript-eslint/explicit-member-accessibility": Array [ + "off", + Object { + "accessibility": "explicit", + }, + ], + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/naming-convention": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "error", + "@typescript-eslint/no-inferrable-types": Array [ + "error", + Object { + "ignoreParameters": true, + }, + ], + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-shadow": Array [ + "error", + Object { + "hoist": "all", + }, + ], + "@typescript-eslint/no-unused-expressions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/unified-signatures": "error", + "arrow-body-style": "error", + "constructor-super": "error", + "eqeqeq": Array [ + "error", + "smart", + ], + "guard-for-in": "error", + "id-blacklist": "off", + "id-match": "off", + "import/no-deprecated": "warn", + "no-bitwise": "error", + "no-caller": "error", + "no-console": Array [ + "error", + Object { + "allow": Array [ + "log", + "warn", + "dir", + "timeLog", + "assert", + "clear", + "count", + "countReset", + "group", + "groupEnd", + "table", + "dirxml", + "error", + "groupCollapsed", + "_buffer", + "_counters", + "_timers", + "_groupDepth", + ], + }, + ], + "no-debugger": "error", + "no-empty": "off", + "no-eval": "error", + "no-fallthrough": "error", + "no-new-wrappers": "error", + "no-restricted-imports": Array [ + "error", + "rxjs/Rx", + ], + "no-throw-literal": "error", + "no-undef-init": "error", + "no-underscore-dangle": "off", + "no-var": "error", + "prefer-const": "error", + "radix": "error", + }, + }, + Object { + "files": Array [ + "*.html", + ], + "rules": Object { + "@angular-eslint/template/banana-in-box": "error", + "@angular-eslint/template/no-negated-async": "error", + }, + }, + ], + "plugins": Array [ + "@nrwl/nx", + ], + "root": true, +} +`; + +exports[`convert-tslint-to-eslint should work for Angular applications 4`] = ` +Object { + "extends": Array [ + "../../.eslintrc.json", + ], + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "extends": Array [ + "plugin:@nrwl/nx/angular", + "plugin:@angular-eslint/template/process-inline-templates", + ], + "files": Array [ + "*.ts", + ], + "parserOptions": Object { + "project": Array [ + "apps/angular-app-1/tsconfig.*?.json", + ], + }, + "rules": Object { + "@angular-eslint/component-selector": Array [ + "error", + Object { + "prefix": "angular-app", + "style": "kebab-case", + "type": "element", + }, + ], + "@angular-eslint/directive-selector": Array [ + "error", + Object { + "prefix": "angular-app", + "style": "camelCase", + "type": "attribute", + }, + ], + "@typescript-eslint/no-empty-interface": "error", + }, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/angular-template", + ], + "files": Array [ + "*.html", + ], + "rules": Object { + "@angular-eslint/template/banana-in-box": "error", + }, + }, + ], +} +`; + +exports[`convert-tslint-to-eslint should work for Angular libraries 1`] = ` +Object { + "dependencies": Object {}, + "devDependencies": Object { + "@angular-eslint/eslint-plugin": "~1.0.0", + "@angular-eslint/eslint-plugin-template": "~1.0.0", + "@angular-eslint/template-parser": "~1.0.0", + "@nrwl/eslint-plugin-nx": "*", + "@nrwl/linter": "*", + "@typescript-eslint/eslint-plugin": "4.3.0", + "@typescript-eslint/parser": "4.3.0", + "eslint": "7.10.0", + "eslint-config-prettier": "8.1.0", + "eslint-plugin-import": "latest", + }, + "name": "test-name", +} +`; + +exports[`convert-tslint-to-eslint should work for Angular libraries 2`] = ` +Object { + "prefix": "angular-app", + "projectType": "library", + "root": "libs/angular-lib-1", + "targets": Object { + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "libs/angular-lib-1/src/**/*.ts", + "libs/angular-lib-1/src/**/*.html", + ], + }, + }, + }, +} +`; + +exports[`convert-tslint-to-eslint should work for Angular libraries 3`] = ` +Object { + "ignorePatterns": Array [ + "**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": Object { + "@nrwl/nx/enforce-module-boundaries": Array [ + "error", + Object { + "allow": Array [ + "@nx-example/shared/product/data/testing", + ], + "depConstraints": Array [ + Object { + "onlyDependOnLibsWithTags": Array [ + "type:feature", + "type:ui", + ], + "sourceTag": "type:app", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:ui", + "type:data", + "type:types", + "type:state", + ], + "sourceTag": "type:feature", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:types", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:state", + "type:types", + "type:data", + ], + "sourceTag": "type:state", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:data", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:e2e-utils", + ], + "sourceTag": "type:e2e", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + "type:ui", + ], + "sourceTag": "type:ui", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:products", + "scope:shared", + ], + "sourceTag": "scope:products", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:cart", + "scope:shared", + ], + "sourceTag": "scope:cart", + }, + ], + "enforceBuildableLibDependency": true, + }, + ], + }, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/typescript", + ], + "files": Array [ + "*.ts", + "*.tsx", + ], + "rules": Object {}, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/javascript", + ], + "files": Array [ + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + ], + "plugins": Array [ + "eslint-plugin-import", + ], + "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", + }, + ], + "@angular-eslint/no-conflicting-lifecycle": "error", + "@angular-eslint/no-host-metadata-property": "error", + "@angular-eslint/no-input-rename": "error", + "@angular-eslint/no-inputs-metadata-property": "error", + "@angular-eslint/no-output-native": "error", + "@angular-eslint/no-output-on-prefix": "error", + "@angular-eslint/no-output-rename": "error", + "@angular-eslint/no-outputs-metadata-property": "error", + "@angular-eslint/use-lifecycle-interface": "error", + "@angular-eslint/use-pipe-transform-interface": "error", + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/dot-notation": "off", + "@typescript-eslint/explicit-member-accessibility": Array [ + "off", + Object { + "accessibility": "explicit", + }, + ], + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/naming-convention": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "error", + "@typescript-eslint/no-inferrable-types": Array [ + "error", + Object { + "ignoreParameters": true, + }, + ], + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-shadow": Array [ + "error", + Object { + "hoist": "all", + }, + ], + "@typescript-eslint/no-unused-expressions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/unified-signatures": "error", + "arrow-body-style": "error", + "constructor-super": "error", + "eqeqeq": Array [ + "error", + "smart", + ], + "guard-for-in": "error", + "id-blacklist": "off", + "id-match": "off", + "import/no-deprecated": "warn", + "no-bitwise": "error", + "no-caller": "error", + "no-console": Array [ + "error", + Object { + "allow": Array [ + "log", + "warn", + "dir", + "timeLog", + "assert", + "clear", + "count", + "countReset", + "group", + "groupEnd", + "table", + "dirxml", + "error", + "groupCollapsed", + "_buffer", + "_counters", + "_timers", + "_groupDepth", + ], + }, + ], + "no-debugger": "error", + "no-empty": "off", + "no-eval": "error", + "no-fallthrough": "error", + "no-new-wrappers": "error", + "no-restricted-imports": Array [ + "error", + "rxjs/Rx", + ], + "no-throw-literal": "error", + "no-undef-init": "error", + "no-underscore-dangle": "off", + "no-var": "error", + "prefer-const": "error", + "radix": "error", + }, + }, + Object { + "files": Array [ + "*.html", + ], + "rules": Object { + "@angular-eslint/template/banana-in-box": "error", + "@angular-eslint/template/no-negated-async": "error", + }, + }, + ], + "plugins": Array [ + "@nrwl/nx", + ], + "root": true, +} +`; + +exports[`convert-tslint-to-eslint should work for Angular libraries 4`] = ` +Object { + "extends": Array [ + "../../.eslintrc.json", + ], + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "extends": Array [ + "plugin:@nrwl/nx/angular", + "plugin:@angular-eslint/template/process-inline-templates", + ], + "files": Array [ + "*.ts", + ], + "parserOptions": Object { + "project": Array [ + "libs/angular-lib-1/tsconfig.*?.json", + ], + }, + "rules": Object { + "@angular-eslint/component-selector": Array [ + "error", + Object { + "prefix": "angular-app", + "style": "kebab-case", + "type": "element", + }, + ], + "@angular-eslint/directive-selector": Array [ + "error", + Object { + "prefix": "angular-app", + "style": "camelCase", + "type": "attribute", + }, + ], + "@typescript-eslint/no-empty-interface": "error", + }, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/angular-template", + ], + "files": Array [ + "*.html", + ], + "rules": Object { + "@angular-eslint/template/banana-in-box": "error", + }, + }, + ], +} +`; diff --git a/packages/angular/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts b/packages/angular/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts new file mode 100644 index 0000000000..2120b55199 --- /dev/null +++ b/packages/angular/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts @@ -0,0 +1,264 @@ +import { + addProjectConfiguration, + joinPathFragments, + ProjectConfiguration, + readJson, + readProjectConfiguration, + Tree, + writeJson, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { exampleRootTslintJson } from '@nrwl/linter'; +import { conversionGenerator } from './convert-tslint-to-eslint'; + +/** + * Don't run actual child_process implementation of installPackagesTask() + */ +jest.mock('child_process'); + +const appProjectName = 'angular-app-1'; +const appProjectRoot = `apps/${appProjectName}`; +const appProjectTSLintJsonPath = joinPathFragments( + appProjectRoot, + 'tslint.json' +); +const projectPrefix = 'angular-app'; +const libProjectName = 'angular-lib-1'; +const libProjectRoot = `libs/${libProjectName}`; +const libProjectTSLintJsonPath = joinPathFragments( + libProjectRoot, + 'tslint.json' +); +// Used to configure the test Tree and stub the response from tslint-to-eslint-config util findReportedConfiguration() +const projectTslintJsonData = { + raw: { + extends: '../../tslint.json', + rules: { + // Standard Nx/Angular CLI generated rules + 'directive-selector': [true, 'attribute', projectPrefix, 'camelCase'], + 'component-selector': [true, 'element', projectPrefix, 'kebab-case'], + // User custom TS rule + 'no-empty-interface': true, + // User custom template/HTML rule + 'template-banana-in-box': true, + // User custom rule with no known automated converter + 'some-super-custom-rule-with-no-converter': true, + }, + linterOptions: { + exclude: ['!**/*'], + }, + }, + tslintPrintConfigResult: { + rules: { + 'directive-selector': { + ruleArguments: ['attribute', projectPrefix, 'camelCase'], + ruleSeverity: 'error', + }, + 'component-selector': { + ruleArguments: ['element', projectPrefix, 'kebab-case'], + ruleSeverity: 'error', + }, + 'no-empty-interface': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'template-banana-in-box': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'some-super-custom-rule-with-no-converter': { + ruleArguments: [], + ruleSeverity: 'error', + }, + }, + }, +}; + +function mockFindReportedConfiguration(_, pathToTslintJson) { + switch (pathToTslintJson) { + case 'tslint.json': + return exampleRootTslintJson.tslintPrintConfigResult; + case appProjectTSLintJsonPath: + return projectTslintJsonData.tslintPrintConfigResult; + case libProjectTSLintJsonPath: + return projectTslintJsonData.tslintPrintConfigResult; + default: + throw new Error( + `mockFindReportedConfiguration - Did not recognize path ${pathToTslintJson}` + ); + } +} + +/** + * See ./mock-tslint-to-eslint-config.ts for why this is needed + */ +jest.mock('tslint-to-eslint-config', () => { + return { + // Since upgrading to (ts-)jest 26 this usage of this mock has caused issues... + // @ts-ignore + ...jest.requireActual('tslint-to-eslint-config'), + findReportedConfiguration: jest.fn(mockFindReportedConfiguration), + }; +}); + +/** + * Mock the the mutating fs utilities used within the conversion logic, they are not + * needed because of our stubbed response for findReportedConfiguration() above, and + * they would cause noise in the git data of the actual Nx repo when the tests run. + */ +jest.mock('fs', () => { + return { + // Since upgrading to (ts-)jest 26 this usage of this mock has caused issues... + // @ts-ignore + ...jest.requireActual('fs'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + }; +}); + +describe('convert-tslint-to-eslint', () => { + let host: Tree; + + beforeEach(async () => { + host = createTreeWithEmptyWorkspace(); + + writeJson(host, 'tslint.json', exampleRootTslintJson.raw); + + addProjectConfiguration(host, appProjectName, { + root: appProjectRoot, + prefix: projectPrefix, + projectType: 'application', + targets: { + /** + * LINT TARGET CONFIG - BEFORE CONVERSION + * + * TSLint executor configured for the project + */ + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + exclude: ['**/node_modules/**', '!apps/angular-app-1/**/*'], + tsConfig: ['apps/angular-app-1/tsconfig.app.json'], + }, + }, + }, + } as ProjectConfiguration); + + addProjectConfiguration(host, libProjectName, { + root: libProjectRoot, + prefix: projectPrefix, + projectType: 'library', + targets: { + /** + * LINT TARGET CONFIG - BEFORE CONVERSION + * + * TSLint executor configured for the project + */ + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + exclude: ['**/node_modules/**', '!libs/angular-lib-1/**/*'], + tsConfig: ['libs/angular-lib-1/tsconfig.app.json'], + }, + }, + }, + } as ProjectConfiguration); + + /** + * Existing tslint.json file for the app project + */ + writeJson( + host, + 'apps/angular-app-1/tslint.json', + projectTslintJsonData.raw + ); + /** + * Existing tslint.json file for the lib project + */ + writeJson( + host, + 'libs/angular-lib-1/tslint.json', + projectTslintJsonData.raw + ); + }); + + it('should work for Angular applications', async () => { + await conversionGenerator(host, { + project: appProjectName, + removeTSLintIfNoMoreTSLintTargets: false, + }); + + /** + * It should ensure the required Nx packages are installed and available + * + * NOTE: tslint-to-eslint-config should NOT be present + */ + expect(readJson(host, 'package.json')).toMatchSnapshot(); + + /** + * LINT TARGET CONFIG - AFTER CONVERSION + * + * It should replace the TSLint executor with the ESLint one + */ + expect(readProjectConfiguration(host, appProjectName)).toMatchSnapshot(); + + /** + * The root level .eslintrc.json should now have been generated + */ + expect(readJson(host, '.eslintrc.json')).toMatchSnapshot(); + + /** + * The project level .eslintrc.json should now have been generated + * and extend from the root, as well as applying any customizations + * which are specific to this projectType. + */ + expect( + readJson(host, joinPathFragments(appProjectRoot, '.eslintrc.json')) + ).toMatchSnapshot(); + + /** + * The project's TSLint file should have been deleted + */ + expect(host.exists(appProjectTSLintJsonPath)).toEqual(false); + }); + + it('should work for Angular libraries', async () => { + await conversionGenerator(host, { + project: libProjectName, + removeTSLintIfNoMoreTSLintTargets: false, + }); + + /** + * It should ensure the required Nx packages are installed and available + * + * NOTE: tslint-to-eslint-config should NOT be present + */ + expect(readJson(host, 'package.json')).toMatchSnapshot(); + + /** + * LINT TARGET CONFIG - AFTER CONVERSION + * + * It should replace the TSLint executor with the ESLint one + */ + expect(readProjectConfiguration(host, libProjectName)).toMatchSnapshot(); + + /** + * The root level .eslintrc.json should now have been generated + */ + expect(readJson(host, '.eslintrc.json')).toMatchSnapshot(); + + /** + * The project level .eslintrc.json should now have been generated + * and extend from the root, as well as applying any customizations + * which are specific to this projectType. + */ + expect( + readJson(host, joinPathFragments(libProjectRoot, '.eslintrc.json')) + ).toMatchSnapshot(); + + /** + * The project's TSLint file should have been deleted + */ + expect(host.exists(libProjectTSLintJsonPath)).toEqual(false); + }); +}); diff --git a/packages/angular/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts b/packages/angular/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts new file mode 100755 index 0000000000..89d5748f6e --- /dev/null +++ b/packages/angular/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts @@ -0,0 +1,185 @@ +import { conversionGenerator as cypressConversionGenerator } from '@nrwl/cypress'; +import { + convertNxGenerator, + formatFiles, + GeneratorCallback, + logger, + Tree, +} from '@nrwl/devkit'; +import { ConvertTSLintToESLintSchema, ProjectConverter } from '@nrwl/linter'; +import type { Linter } from 'eslint'; +import { addLintingGenerator } from '../../schematics/add-linting/add-linting'; + +export async function conversionGenerator( + host: Tree, + options: ConvertTSLintToESLintSchema +) { + /** + * The ProjectConverter instance encapsulates all the standard operations we need + * to perform in order to convert a project from TSLint to ESLint, as well as some + * extensibility points for adjusting the behavior on a per package basis. + * + * E.g. @nrwl/angular projects might need to make different changes to the final + * ESLint config when compared with @nrwl/next projects. + * + * See the ProjectConverter implementation for a full breakdown of what it does. + */ + const projectConverter = new ProjectConverter({ + host, + projectName: options.project, + eslintInitializer: async ({ projectName, projectConfig }) => { + await addLintingGenerator(host, { + linter: 'eslint', + projectType: projectConfig.projectType, + projectName, + projectRoot: projectConfig.root, + prefix: (projectConfig as any).prefix || 'app', + }); + }, + }); + + /** + * Dynamically install tslint-to-eslint-config to assist with the conversion. + */ + projectConverter.installTSLintToESLintConfigPackage(); + + /** + * Create the standard (which is applicable to the current package) ESLint setup + * for converting the project. + */ + await projectConverter.initESLint(); + /** + * Convert the root tslint.json and apply the converted rules to the root .eslintrc.json + */ + const rootConfigInstallTask = await projectConverter.convertRootTSLintConfig( + (json) => { + json.overrides = [ + { files: ['*.ts'], rules: {} }, + { files: ['*.html'], rules: {} }, + ]; + return applyAngularRulesToCorrectOverrides(json); + } + ); + /** + * Convert the project's tslint.json to an equivalent ESLint config. + */ + const projectConfigInstallTask = await projectConverter.convertProjectConfig( + (json) => applyAngularRulesToCorrectOverrides(json) + ); + /** + * Clean up the original TSLint configuration for the project. + */ + projectConverter.removeProjectTSLintFile(); + + /** + * Store user preference regarding removeTSLintIfNoMoreTSLintTargets for the collection + */ + projectConverter.setDefaults( + '@nrwl/angular', + options.removeTSLintIfNoMoreTSLintTargets + ); + + /** + * If the Angular project is an app which has an e2e project, try and convert that as well. + */ + let cypressInstallTask: GeneratorCallback = () => Promise.resolve(undefined); + const e2eProjectName = projectConverter.getE2EProjectName(); + if (e2eProjectName) { + try { + cypressInstallTask = await cypressConversionGenerator(host, { + project: e2eProjectName, + /** + * We can always set this to false, because it will already be handled by the next + * step of this parent generator, if applicable + */ + removeTSLintIfNoMoreTSLintTargets: false, + }); + } catch { + logger.warn( + 'This Angular app has an e2e project, but it was not possible to convert it from TSLint to ESLint. This could be because the e2e project did not have a tslint.json file to begin with.' + ); + } + } + + /** + * Based on user preference and remaining usage, remove TSLint from the workspace entirely. + */ + let uninstallTSLintTask: GeneratorCallback = () => Promise.resolve(undefined); + if ( + options.removeTSLintIfNoMoreTSLintTargets && + !projectConverter.isTSLintUsedInWorkspace() + ) { + uninstallTSLintTask = projectConverter.removeTSLintFromWorkspace(); + } + + await formatFiles(host); + + return async () => { + projectConverter.uninstallTSLintToESLintConfigPackage(); + await rootConfigInstallTask(); + await projectConfigInstallTask(); + await cypressInstallTask(); + await uninstallTSLintTask(); + }; +} + +export const conversionSchematic = convertNxGenerator(conversionGenerator); + +/** + * In the case of Angular lint rules, we need to apply them to correct override depending upon whether + * or not they require @typescript-eslint/parser or @angular-eslint/template-parser in order to function. + * + * By this point, the applicable overrides have already been scaffolded for us by the Nx generators + * that ran earlier within this generator. + */ +function applyAngularRulesToCorrectOverrides( + json: Linter.Config +): Linter.Config { + const rules = json.rules; + + if (rules && Object.keys(rules).length) { + for (const [ruleName, ruleConfig] of Object.entries(rules)) { + for (const override of json.overrides) { + if ( + override.files.includes('*.html') && + ruleName.startsWith('@angular-eslint/template') + ) { + // Prioritize the converted rules over any base implementations from the original Nx generator + override.rules[ruleName] = ruleConfig; + } + + /** + * By default, tslint-to-eslint-config will try and apply any rules without known converters + * by using eslint-plugin-tslint. We instead explicitly warn the user about this missing converter, + * and therefore at this point we strip out any rules which start with @typescript-eslint/tslint/config + */ + if ( + override.files.includes('*.ts') && + !ruleName.startsWith('@angular-eslint/template') && + !ruleName.startsWith('@typescript-eslint/tslint/config') + ) { + // Prioritize the converted rules over any base implementations from the original Nx generator + override.rules[ruleName] = ruleConfig; + } + } + } + } + + // It's possible that there are plugins to apply to the TS override + if (json.plugins) { + for (const override of json.overrides) { + if (override.files.includes('*.ts')) { + override.plugins = override.plugins || []; + override.plugins = [...override.plugins, ...json.plugins]; + } + } + delete json.plugins; + } + + /** + * We now no longer need the flat list of rules at the root of the config + * because they have all been applied to an appropriate override. + */ + delete json.rules; + return json; +} diff --git a/packages/angular/src/generators/convert-tslint-to-eslint/schema.json b/packages/angular/src/generators/convert-tslint-to-eslint/schema.json new file mode 100644 index 0000000000..5bbb375130 --- /dev/null +++ b/packages/angular/src/generators/convert-tslint-to-eslint/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "angular-convert-tslint-to-eslint", + "cli": "nx", + "title": "Convert an Angular project from TSLint to ESLint", + "description": "NOTE: Does not work in --dry-run mode", + "examples": [ + { + "command": "g convert-tslint-to-eslint myapp", + "description": "Convert the Angular project `myapp` from TSLint to ESLint" + } + ], + "type": "object", + "properties": { + "project": { + "description": "The name of the Angular project to convert.", + "type": "string", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "Which Angular project would you like to convert from TSLint to ESLint?" + }, + "removeTSLintIfNoMoreTSLintTargets": { + "type": "boolean", + "description": "If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration", + "default": true, + "x-prompt": "Would you like to remove TSLint and its related config if there are no TSLint projects remaining after this conversion?" + } + }, + "required": ["project"] +} diff --git a/packages/angular/src/schematics/add-linting/add-linting.ts b/packages/angular/src/schematics/add-linting/add-linting.ts new file mode 100755 index 0000000000..edc3f7bebd --- /dev/null +++ b/packages/angular/src/schematics/add-linting/add-linting.ts @@ -0,0 +1,131 @@ +import { join, normalize } from '@angular-devkit/core'; +import { chain, noop, Rule, Tree } from '@angular-devkit/schematics'; +import { wrapAngularDevkitSchematic } from '@nrwl/devkit/ngcli-adapter'; +import { + addLintFiles, + getWorkspacePath, + Linter, + offsetFromRoot, + updateJsonInTree, +} from '@nrwl/workspace'; +import { + createAngularEslintJson, + createAngularProjectESLintLintTarget, + extraEslintDependencies, +} from '../../utils/lint'; +import { Schema } from './schema'; + +export default function addLinting(options: Schema): Rule { + return chain([ + addLintFiles(options.projectRoot, options.linter, { + onlyGlobal: options.linter === Linter.TsLint, // local lint files are added differently when tslint + localConfig: + options.linter === Linter.TsLint + ? undefined + : createAngularEslintJson(options.projectRoot, options.prefix), + extraPackageDeps: + options.linter === Linter.TsLint ? undefined : extraEslintDependencies, + }), + options.projectType === 'application' && options.linter === Linter.TsLint + ? updateTsLintConfig(options) + : noop(), + options.projectType === 'library' && options.linter === Linter.TsLint + ? updateJsonInTree(`${options.projectRoot}/tslint.json`, (json) => { + return { + ...json, + extends: `${offsetFromRoot(options.projectRoot)}tslint.json`, + linterOptions: { + exclude: ['!**/*'], + }, + }; + }) + : noop(), + updateProject(options), + ]); +} + +function updateTsLintConfig(options: Schema): Rule { + return chain([ + updateJsonInTree('tslint.json', (json) => { + if ( + json.rulesDirectory && + json.rulesDirectory.indexOf('node_modules/codelyzer') === -1 + ) { + json.rulesDirectory.push('node_modules/codelyzer'); + json.rules = { + ...json.rules, + + 'directive-selector': [true, 'attribute', 'app', 'camelCase'], + 'component-selector': [true, 'element', 'app', 'kebab-case'], + 'no-conflicting-lifecycle': true, + 'no-host-metadata-property': true, + 'no-input-rename': true, + 'no-inputs-metadata-property': true, + 'no-output-native': true, + 'no-output-on-prefix': true, + 'no-output-rename': true, + 'no-outputs-metadata-property': true, + 'template-banana-in-box': true, + 'template-no-negated-async': true, + 'use-lifecycle-interface': true, + 'use-pipe-transform-interface': true, + }; + } + return json; + }), + updateJsonInTree(`${options.projectRoot}/tslint.json`, (json) => { + json.extends = `${offsetFromRoot(options.projectRoot)}tslint.json`; + json.linterOptions = { + exclude: ['!**/*'], + }; + return json; + }), + ]); +} + +function updateProject(options: Schema): Rule { + return (host: Tree) => { + return chain([ + updateJsonInTree(getWorkspacePath(host), (json) => { + const project = json.projects[options.projectName]; + + if (options.linter === Linter.TsLint) { + project.architect.lint.options.exclude.push( + '!' + join(normalize(options.projectRoot), '**/*') + ); + + if (options.projectType === 'application') { + project.architect.lint.options.tsConfig = project.architect.lint.options.tsConfig.filter( + (path) => + path !== + join(normalize(options.projectRoot), 'tsconfig.spec.json') && + path !== + join(normalize(options.projectRoot), 'e2e/tsconfig.json') + ); + } + + if (options.projectType === 'library') { + project.architect.lint.options.tsConfig = Array.from( + new Set(project.architect.lint.options.tsConfig) + ); + } + } + + if (options.linter === Linter.EsLint) { + project.architect.lint = createAngularProjectESLintLintTarget( + options.projectRoot + ); + host.delete(`${options.projectRoot}/tslint.json`); + } + + json.projects[options.projectName] = project; + return json; + }), + ]); + }; +} + +export const addLintingGenerator = wrapAngularDevkitSchematic( + '@nrwl/angular', + 'add-linting' +); diff --git a/packages/angular/src/schematics/add-linting/schema.d.ts b/packages/angular/src/schematics/add-linting/schema.d.ts new file mode 100644 index 0000000000..b28297bb99 --- /dev/null +++ b/packages/angular/src/schematics/add-linting/schema.d.ts @@ -0,0 +1,9 @@ +import { Linter } from '@nrwl/workspace'; + +export interface Schema { + projectName: string; + projectType: 'application' | 'library'; + projectRoot: string; + prefix: string; + linter: Linter; +} diff --git a/packages/angular/src/schematics/add-linting/schema.json b/packages/angular/src/schematics/add-linting/schema.json new file mode 100644 index 0000000000..b22b14fb2a --- /dev/null +++ b/packages/angular/src/schematics/add-linting/schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsAngularAddLinting", + "title": "Add linting to an Angular project", + "type": "object", + "properties": { + "prefix": { + "type": "string", + "format": "html-selector", + "description": "The prefix to apply to generated selectors." + }, + "projectName": { + "type": "string", + "description": "The name of the selected project." + }, + "projectType": { + "type": "string", + "enum": ["application", "library"] + }, + "projectRoot": { + "type": "string", + "description": "The path to the root of the selected project." + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["tslint", "eslint"], + "default": "eslint" + } + } +} diff --git a/packages/angular/src/schematics/application/application.ts b/packages/angular/src/schematics/application/application.ts index 7ed78c04a8..bda8444965 100644 --- a/packages/angular/src/schematics/application/application.ts +++ b/packages/angular/src/schematics/application/application.ts @@ -24,9 +24,8 @@ import { replaceNodeValue, updateJsonInTree, updateWorkspace, - addLintFiles, - Linter, generateProjectLint, + Linter, } from '@nrwl/workspace'; import { join, normalize } from '@angular-devkit/core'; import init from '../init/init'; @@ -41,10 +40,6 @@ import { updateWorkspaceInTree, appsDir, } from '@nrwl/workspace/src/utils/ast-utils'; -import { - createAngularEslintJson, - extraEslintDependencies, -} from '../../utils/lint'; import { names, offsetFromRoot } from '@nrwl/devkit'; import { wrapAngularDevkitSchematic } from '@nrwl/devkit/ngcli-adapter'; @@ -417,45 +412,6 @@ function updateComponentSpec(options: NormalizedSchema) { }; } -function updateTsLintConfig(options: NormalizedSchema): Rule { - return chain([ - updateJsonInTree('tslint.json', (json) => { - if ( - json.rulesDirectory && - json.rulesDirectory.indexOf('node_modules/codelyzer') === -1 - ) { - json.rulesDirectory.push('node_modules/codelyzer'); - json.rules = { - ...json.rules, - - 'directive-selector': [true, 'attribute', 'app', 'camelCase'], - 'component-selector': [true, 'element', 'app', 'kebab-case'], - 'no-conflicting-lifecycle': true, - 'no-host-metadata-property': true, - 'no-input-rename': true, - 'no-inputs-metadata-property': true, - 'no-output-native': true, - 'no-output-on-prefix': true, - 'no-output-rename': true, - 'no-outputs-metadata-property': true, - 'template-banana-in-box': true, - 'template-no-negated-async': true, - 'use-lifecycle-interface': true, - 'use-pipe-transform-interface': true, - }; - } - return json; - }), - updateJsonInTree(`${options.appProjectRoot}/tslint.json`, (json) => { - json.extends = `${offsetFromRoot(options.appProjectRoot)}tslint.json`; - json.linterOptions = { - exclude: ['!**/*'], - }; - return json; - }), - ]); -} - function addSchematicFiles( appProjectRoot: string, options: NormalizedSchema @@ -488,30 +444,6 @@ function updateProject(options: NormalizedSchema): Rule { delete fixedProject.architect.test; - if (options.linter === Linter.TsLint) { - fixedProject.architect.lint.options.tsConfig = fixedProject.architect.lint.options.tsConfig.filter( - (path) => - path !== - join(normalize(options.appProjectRoot), 'tsconfig.spec.json') && - path !== - join(normalize(options.appProjectRoot), 'e2e/tsconfig.json') - ); - fixedProject.architect.lint.options.exclude.push( - '!' + join(normalize(options.appProjectRoot), '**/*') - ); - } - - if (options.linter === Linter.EsLint) { - fixedProject.architect.lint.builder = '@nrwl/linter:eslint'; - fixedProject.architect.lint.options.lintFilePatterns = [ - `${options.appProjectRoot}/src/**/*.ts`, - `${options.appProjectRoot}/src/**/*.html`, - ]; - delete fixedProject.architect.lint.options.tsConfig; - delete fixedProject.architect.lint.options.exclude; - host.delete(`${options.appProjectRoot}/tslint.json`); - } - if (options.unitTestRunner === 'none') { host.delete( `${options.appProjectRoot}/src/app/app.component.spec.ts` @@ -817,18 +749,7 @@ export default function (schema: Schema): Rule { updateComponentStyles(options), options.unitTestRunner !== 'none' ? updateComponentSpec(options) : noop(), options.routing ? addRouterRootConfiguration(options) : noop(), - addLintFiles(options.appProjectRoot, options.linter, { - onlyGlobal: options.linter === Linter.TsLint, // local lint files are added differently when tslint - localConfig: - options.linter === Linter.TsLint - ? undefined - : createAngularEslintJson(options.appProjectRoot, options.prefix), - extraPackageDeps: - options.linter === Linter.TsLint - ? undefined - : extraEslintDependencies, - }), - options.linter === 'tslint' ? updateTsLintConfig(options) : noop(), + addLinting(options), options.unitTestRunner === 'jest' ? externalSchematic('@nrwl/jest', 'jest-project', { project: options.name, @@ -860,6 +781,31 @@ export default function (schema: Schema): Rule { }; } +const addLinting = (options: NormalizedSchema) => () => { + return chain([ + schematic('add-linting', { + linter: options.linter, + projectType: 'application', + projectName: options.name, + projectRoot: options.appProjectRoot, + prefix: options.prefix, + }), + /** + * I cannot explain why this extra rule is needed, the add-linting + * schematic applies the exact same host.delete() call but the main + * chain of this schematic still preserves it... + */ + (host) => { + if ( + options.linter === Linter.EsLint && + host.exists(`${options.appProjectRoot}/tslint.json`) + ) { + host.delete(`${options.appProjectRoot}/tslint.json`); + } + }, + ]); +}; + function normalizeOptions(host: Tree, options: Schema): NormalizedSchema { const appDirectory = options.directory ? `${names(options.directory).fileName}/${names(options.name).fileName}` diff --git a/packages/angular/src/schematics/library/lib/update-project.ts b/packages/angular/src/schematics/library/lib/update-project.ts index a63c0613ee..68ec61dcde 100644 --- a/packages/angular/src/schematics/library/lib/update-project.ts +++ b/packages/angular/src/schematics/library/lib/update-project.ts @@ -14,7 +14,6 @@ import { } from '@angular-devkit/schematics'; import { getWorkspacePath, - Linter, replaceAppNameWithPath, updateJsonInTree, } from '@nrwl/workspace'; @@ -147,28 +146,6 @@ export function updateProject(options: NormalizedSchema): Rule { delete fixedProject.architect.test; - if (options.linter === Linter.TsLint) { - fixedProject.architect.lint.options.tsConfig = fixedProject.architect.lint.options.tsConfig.filter( - (path) => - path !== - join(normalize(options.projectRoot), 'tsconfig.spec.json') - ); - fixedProject.architect.lint.options.exclude.push( - '!' + join(normalize(options.projectRoot), '**/*') - ); - } - - if (options.linter === Linter.EsLint) { - fixedProject.architect.lint.builder = '@nrwl/linter:eslint'; - fixedProject.architect.lint.options.lintFilePatterns = [ - `${options.projectRoot}/src/**/*.ts`, - `${options.projectRoot}/src/**/*.html`, - ]; - delete fixedProject.architect.lint.options.tsConfig; - delete fixedProject.architect.lint.options.exclude; - host.delete(`${options.projectRoot}/tslint.json`); - } - json.projects[options.name] = fixedProject; return json; }); @@ -191,17 +168,6 @@ export function updateProject(options: NormalizedSchema): Rule { }, }; }), - options.linter === Linter.TsLint - ? updateJsonInTree(`${options.projectRoot}/tslint.json`, (json) => { - return { - ...json, - extends: `${offsetFromRoot(options.projectRoot)}tslint.json`, - linterOptions: { - exclude: ['!**/*'], - }, - }; - }) - : noop(), updateJsonInTree(`/nx.json`, (json) => { return { ...json, diff --git a/packages/angular/src/schematics/library/library.spec.ts b/packages/angular/src/schematics/library/library.spec.ts index 2c48de4b79..b149a76189 100644 --- a/packages/angular/src/schematics/library/library.spec.ts +++ b/packages/angular/src/schematics/library/library.spec.ts @@ -1164,6 +1164,7 @@ describe('lib', () => { { name: 'myLib', linter: 'eslint' }, appTree ); + expect(tree.exists('libs/my-lib/tslint.json')).toBe(false); const workspaceJson = readJsonInTree(tree, 'workspace.json'); expect(workspaceJson.projects['my-lib'].architect.lint) .toMatchInlineSnapshot(` diff --git a/packages/angular/src/schematics/library/library.ts b/packages/angular/src/schematics/library/library.ts index 1ce35d323c..9eec35e789 100644 --- a/packages/angular/src/schematics/library/library.ts +++ b/packages/angular/src/schematics/library/library.ts @@ -8,12 +8,7 @@ import { SchematicsException, Tree, } from '@angular-devkit/schematics'; -import { - addLintFiles, - formatFiles, - Linter, - updateJsonInTree, -} from '@nrwl/workspace'; +import { formatFiles, Linter, updateJsonInTree } from '@nrwl/workspace'; import init, { addUnitTestRunner } from '../init/init'; import { addModule } from './lib/add-module'; import { normalizeOptions } from './lib/normalize-options'; @@ -22,11 +17,8 @@ import { updateProject } from './lib/update-project'; import { updateTsConfig } from './lib/update-tsconfig'; import { Schema } from './schema'; import { enableStrictTypeChecking } from './lib/enable-strict-type-checking'; -import { - createAngularEslintJson, - extraEslintDependencies, -} from '../../utils/lint'; import { wrapAngularDevkitSchematic } from '@nrwl/devkit/ngcli-adapter'; +import { NormalizedSchema } from './lib/normalized-schema'; export default function (schema: Schema): Rule { return (host: Tree): Rule => { @@ -50,17 +42,6 @@ export default function (schema: Schema): Rule { ...options, skipFormat: true, }), - addLintFiles(options.projectRoot, options.linter, { - onlyGlobal: options.linter === Linter.TsLint, - localConfig: - options.linter === Linter.TsLint - ? undefined - : createAngularEslintJson(options.projectRoot, options.prefix), - extraPackageDeps: - options.linter === Linter.TsLint - ? undefined - : extraEslintDependencies, - }), addUnitTestRunner(options), // TODO: Remove this after Angular 10.1.0 updateJsonInTree('tsconfig.json', () => ({ @@ -101,10 +82,37 @@ export default function (schema: Schema): Rule { : noop(), addModule(options), options.strict ? enableStrictTypeChecking(options) : noop(), + addLinting(options), formatFiles(options), ]); }; } + +const addLinting = (options: NormalizedSchema) => () => { + return chain([ + schematic('add-linting', { + linter: options.linter, + projectType: 'library', + projectName: options.name, + projectRoot: options.projectRoot, + prefix: options.prefix, + }), + /** + * I cannot explain why this extra rule is needed, the add-linting + * schematic applies the exact same host.delete() call but the main + * chain of this library schematic still preserves it... + */ + (host) => { + if ( + options.linter === Linter.EsLint && + host.exists(`${options.projectRoot}/tslint.json`) + ) { + host.delete(`${options.projectRoot}/tslint.json`); + } + }, + ]); +}; + export const libraryGenerator = wrapAngularDevkitSchematic( '@nrwl/angular', 'library' diff --git a/packages/angular/src/schematics/storybook-configuration/__snapshots__/configuration.spec.ts.snap b/packages/angular/src/schematics/storybook-configuration/__snapshots__/configuration.spec.ts.snap index 6b6c83da6e..69030af243 100644 --- a/packages/angular/src/schematics/storybook-configuration/__snapshots__/configuration.spec.ts.snap +++ b/packages/angular/src/schematics/storybook-configuration/__snapshots__/configuration.spec.ts.snap @@ -10,12 +10,12 @@ Array [ "/jest.config.js", "/jest.preset.js", "/.eslintrc.json", - "/libs/test-ui-lib/.eslintrc.json", "/libs/test-ui-lib/README.md", "/libs/test-ui-lib/tsconfig.lib.json", "/libs/test-ui-lib/tsconfig.json", "/libs/test-ui-lib/jest.config.js", "/libs/test-ui-lib/tsconfig.spec.json", + "/libs/test-ui-lib/.eslintrc.json", "/libs/test-ui-lib/src/index.ts", "/libs/test-ui-lib/src/test-setup.ts", "/libs/test-ui-lib/src/lib/test-ui-lib.module.ts", diff --git a/packages/angular/src/utils/lint.ts b/packages/angular/src/utils/lint.ts index 519c136c50..1a23bb3009 100644 --- a/packages/angular/src/utils/lint.ts +++ b/packages/angular/src/utils/lint.ts @@ -1,5 +1,20 @@ +import type { TargetDefinition } from '@angular-devkit/core/src/workspace'; import { angularEslintVersion } from './versions'; +export function createAngularProjectESLintLintTarget( + projectRoot: string +): TargetDefinition { + return { + builder: '@nrwl/linter:eslint', + options: { + lintFilePatterns: [ + `${projectRoot}/src/**/*.ts`, + `${projectRoot}/src/**/*.html`, + ], + }, + }; +} + export const extraEslintDependencies = { dependencies: {}, devDependencies: { diff --git a/packages/cypress/index.ts b/packages/cypress/index.ts index 1a6175679a..999469755c 100644 --- a/packages/cypress/index.ts +++ b/packages/cypress/index.ts @@ -1,2 +1,3 @@ export { cypressProjectGenerator } from './src/generators/cypress-project/cypress-project'; export { cypressInitGenerator } from './src/generators/init/init'; +export { conversionGenerator } from './src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint'; diff --git a/packages/cypress/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap b/packages/cypress/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap new file mode 100644 index 0000000000..7a0920f8b8 --- /dev/null +++ b/packages/cypress/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap @@ -0,0 +1,282 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convert-tslint-to-eslint should work for Cypress applications 1`] = ` +Object { + "dependencies": Object {}, + "devDependencies": Object { + "@nrwl/eslint-plugin-nx": "*", + "@nrwl/linter": "*", + "@typescript-eslint/eslint-plugin": "4.3.0", + "@typescript-eslint/parser": "4.3.0", + "eslint": "7.10.0", + "eslint-config-prettier": "8.1.0", + "eslint-plugin-cypress": "^2.10.3", + "eslint-plugin-import": "latest", + }, + "name": "test-name", +} +`; + +exports[`convert-tslint-to-eslint should work for Cypress applications 2`] = ` +Object { + "projectType": "application", + "root": "apps/e2e-app-1", + "targets": Object { + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "apps/e2e-app-1/**/*.{js,ts}", + ], + }, + }, + }, +} +`; + +exports[`convert-tslint-to-eslint should work for Cypress applications 3`] = ` +Object { + "ignorePatterns": Array [ + "**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": Object { + "@nrwl/nx/enforce-module-boundaries": Array [ + "error", + Object { + "allow": Array [ + "@nx-example/shared/product/data/testing", + ], + "depConstraints": Array [ + Object { + "onlyDependOnLibsWithTags": Array [ + "type:feature", + "type:ui", + ], + "sourceTag": "type:app", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:ui", + "type:data", + "type:types", + "type:state", + ], + "sourceTag": "type:feature", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:types", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:state", + "type:types", + "type:data", + ], + "sourceTag": "type:state", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:data", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:e2e-utils", + ], + "sourceTag": "type:e2e", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + "type:ui", + ], + "sourceTag": "type:ui", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:products", + "scope:shared", + ], + "sourceTag": "scope:products", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:cart", + "scope:shared", + ], + "sourceTag": "scope:cart", + }, + ], + "enforceBuildableLibDependency": true, + }, + ], + }, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/typescript", + ], + "files": Array [ + "*.ts", + "*.tsx", + ], + "rules": Object {}, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/javascript", + ], + "files": Array [ + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + ], + "plugins": Array [ + "eslint-plugin-import", + ], + "rules": Object { + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/dot-notation": "off", + "@typescript-eslint/explicit-member-accessibility": Array [ + "off", + Object { + "accessibility": "explicit", + }, + ], + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/naming-convention": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "error", + "@typescript-eslint/no-inferrable-types": Array [ + "error", + Object { + "ignoreParameters": true, + }, + ], + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-shadow": Array [ + "error", + Object { + "hoist": "all", + }, + ], + "@typescript-eslint/no-unused-expressions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/unified-signatures": "error", + "arrow-body-style": "error", + "constructor-super": "error", + "eqeqeq": Array [ + "error", + "smart", + ], + "guard-for-in": "error", + "id-blacklist": "off", + "id-match": "off", + "import/no-deprecated": "warn", + "no-bitwise": "error", + "no-caller": "error", + "no-console": Array [ + "error", + Object { + "allow": Array [ + "log", + "warn", + "dir", + "timeLog", + "assert", + "clear", + "count", + "countReset", + "group", + "groupEnd", + "table", + "dirxml", + "error", + "groupCollapsed", + "_buffer", + "_counters", + "_timers", + "_groupDepth", + ], + }, + ], + "no-debugger": "error", + "no-empty": "off", + "no-eval": "error", + "no-fallthrough": "error", + "no-new-wrappers": "error", + "no-restricted-imports": Array [ + "error", + "rxjs/Rx", + ], + "no-throw-literal": "error", + "no-undef-init": "error", + "no-underscore-dangle": "off", + "no-var": "error", + "prefer-const": "error", + "radix": "error", + }, + }, + ], + "plugins": Array [ + "@nrwl/nx", + ], + "root": true, +} +`; + +exports[`convert-tslint-to-eslint should work for Cypress applications 4`] = ` +Object { + "extends": Array [ + "plugin:cypress/recommended", + "../../.eslintrc.json", + ], + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "parserOptions": Object { + "project": "apps/e2e-app-1/tsconfig.*?.json", + }, + "rules": Object {}, + }, + Object { + "files": Array [ + "src/plugins/index.js", + ], + "rules": Object { + "@typescript-eslint/no-var-requires": "off", + "no-undef": "off", + }, + }, + ], + "rules": Object { + "@typescript-eslint/no-empty-interface": "error", + }, +} +`; diff --git a/packages/cypress/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts b/packages/cypress/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts new file mode 100644 index 0000000000..0ab1d31df8 --- /dev/null +++ b/packages/cypress/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts @@ -0,0 +1,161 @@ +import { + addProjectConfiguration, + joinPathFragments, + readJson, + readProjectConfiguration, + Tree, + writeJson, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { exampleRootTslintJson } from '@nrwl/linter'; +import { conversionGenerator } from './convert-tslint-to-eslint'; + +/** + * Don't run actual child_process implementation of installPackagesTask() + */ +jest.mock('child_process'); + +const projectName = 'e2e-app-1'; +const projectRoot = `apps/${projectName}`; +const projectTSLintJsonPath = joinPathFragments(projectRoot, 'tslint.json'); +// Used to configure the test Tree and stub the response from tslint-to-eslint-config util findReportedConfiguration() +const projectTslintJsonData = { + raw: { + extends: '../../tslint.json', + rules: { + // User custom TS rule + 'no-empty-interface': true, + // User custom rule with no known automated converter + 'some-super-custom-rule-with-no-converter': true, + }, + linterOptions: { + exclude: ['!**/*'], + }, + }, + tslintPrintConfigResult: { + rules: { + 'no-empty-interface': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'some-super-custom-rule-with-no-converter': { + ruleArguments: [], + ruleSeverity: 'error', + }, + }, + }, +}; + +function mockFindReportedConfiguration(_, pathToTslintJson) { + switch (pathToTslintJson) { + case 'tslint.json': + return exampleRootTslintJson.tslintPrintConfigResult; + case projectTSLintJsonPath: + return projectTslintJsonData.tslintPrintConfigResult; + default: + throw new Error( + `mockFindReportedConfiguration - Did not recognize path ${pathToTslintJson}` + ); + } +} + +/** + * See ./mock-tslint-to-eslint-config.ts for why this is needed + */ +jest.mock('tslint-to-eslint-config', () => { + return { + // Since upgrading to (ts-)jest 26 this usage of this mock has caused issues... + // @ts-ignore + ...jest.requireActual('tslint-to-eslint-config'), + findReportedConfiguration: jest.fn(mockFindReportedConfiguration), + }; +}); + +/** + * Mock the the mutating fs utilities used within the conversion logic, they are not + * needed because of our stubbed response for findReportedConfiguration() above, and + * they would cause noise in the git data of the actual Nx repo when the tests run. + */ +jest.mock('fs', () => { + return { + // Since upgrading to (ts-)jest 26 this usage of this mock has caused issues... + // @ts-ignore + ...jest.requireActual('fs'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + }; +}); + +describe('convert-tslint-to-eslint', () => { + let host: Tree; + + beforeEach(async () => { + host = createTreeWithEmptyWorkspace(); + + writeJson(host, 'tslint.json', exampleRootTslintJson.raw); + + addProjectConfiguration(host, projectName, { + root: projectRoot, + projectType: 'application', + targets: { + /** + * LINT TARGET CONFIG - BEFORE CONVERSION + * + * TSLint executor configured for the project + */ + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + exclude: ['**/node_modules/**', '!apps/e2e-app-1/**/*'], + tsConfig: ['apps/e2e-app-1/tsconfig.app.json'], + }, + }, + }, + }); + + /** + * Existing tslint.json file for the project + */ + writeJson(host, 'apps/e2e-app-1/tslint.json', projectTslintJsonData.raw); + }); + + it('should work for Cypress applications', async () => { + await conversionGenerator(host, { + project: projectName, + removeTSLintIfNoMoreTSLintTargets: false, + }); + + /** + * It should ensure the required Nx packages are installed and available + * + * NOTE: tslint-to-eslint-config should NOT be present + */ + expect(readJson(host, 'package.json')).toMatchSnapshot(); + + /** + * LINT TARGET CONFIG - AFTER CONVERSION + * + * It should replace the TSLint executor with the ESLint one + */ + expect(readProjectConfiguration(host, projectName)).toMatchSnapshot(); + + /** + * The root level .eslintrc.json should now have been generated + */ + expect(readJson(host, '.eslintrc.json')).toMatchSnapshot(); + + /** + * The project level .eslintrc.json should now have been generated + * and extend from the root, as well as applying any customizations + * which are specific to this projectType. + */ + expect( + readJson(host, joinPathFragments(projectRoot, '.eslintrc.json')) + ).toMatchSnapshot(); + + /** + * The project's TSLint file should have been deleted + */ + expect(host.exists(projectTSLintJsonPath)).toEqual(false); + }); +}); diff --git a/packages/cypress/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts b/packages/cypress/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts new file mode 100755 index 0000000000..47ce3a016a --- /dev/null +++ b/packages/cypress/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts @@ -0,0 +1,112 @@ +import { + convertNxGenerator, + formatFiles, + GeneratorCallback, + Tree, +} from '@nrwl/devkit'; +import { ConvertTSLintToESLintSchema, ProjectConverter } from '@nrwl/linter'; +import type { Linter } from 'eslint'; +import { + addLinter, + CypressProjectSchema, +} from '../cypress-project/cypress-project'; + +export async function conversionGenerator( + host: Tree, + options: ConvertTSLintToESLintSchema +) { + /** + * The ProjectConverter instance encapsulates all the standard operations we need + * to perform in order to convert a project from TSLint to ESLint, as well as some + * extensibility points for adjusting the behavior on a per package basis. + * + * E.g. @nrwl/angular projects might need to make different changes to the final + * ESLint config when compared with @nrwl/next projects. + * + * See the ProjectConverter implementation for a full breakdown of what it does. + */ + const projectConverter = new ProjectConverter({ + host, + projectName: options.project, + eslintInitializer: async ({ projectName, projectConfig }) => { + await addLinter(host, { + linter: 'eslint', + projectName, + projectRoot: projectConfig.root, + } as CypressProjectSchema); + }, + }); + + /** + * Dynamically install tslint-to-eslint-config to assist with the conversion. + */ + projectConverter.installTSLintToESLintConfigPackage(); + + /** + * Create the standard (which is applicable to the current package) ESLint setup + * for converting the project. + */ + await projectConverter.initESLint(); + + /** + * Convert the root tslint.json and apply the converted rules to the root .eslintrc.json. + */ + const rootConfigInstallTask = await projectConverter.convertRootTSLintConfig( + (json) => removeCodelyzerRelatedRules(json) + ); + + /** + * Convert the project's tslint.json to an equivalent ESLint config. + */ + const projectConfigInstallTask = await projectConverter.convertProjectConfig( + (json) => json + ); + + /** + * Clean up the original TSLint configuration for the project. + */ + projectConverter.removeProjectTSLintFile(); + + /** + * Store user preference regarding removeTSLintIfNoMoreTSLintTargets for the collection + */ + projectConverter.setDefaults( + '@nrwl/cypress', + options.removeTSLintIfNoMoreTSLintTargets + ); + + /** + * Based on user preference and remaining usage, remove TSLint from the workspace entirely. + */ + let uninstallTSLintTask: GeneratorCallback = () => Promise.resolve(undefined); + if ( + options.removeTSLintIfNoMoreTSLintTargets && + !projectConverter.isTSLintUsedInWorkspace() + ) { + uninstallTSLintTask = projectConverter.removeTSLintFromWorkspace(); + } + + await formatFiles(host); + + return async () => { + projectConverter.uninstallTSLintToESLintConfigPackage(); + await rootConfigInstallTask(); + await projectConfigInstallTask(); + await uninstallTSLintTask(); + }; +} + +export const conversionSchematic = convertNxGenerator(conversionGenerator); + +/** + * Remove any @angular-eslint rules that were applied as a result of converting prior codelyzer + * rules, because they are only relevant for Angular projects. + */ +function removeCodelyzerRelatedRules(json: Linter.Config): Linter.Config { + for (const ruleName of Object.keys(json.rules)) { + if (ruleName.startsWith('@angular-eslint')) { + delete json.rules[ruleName]; + } + } + return json; +} diff --git a/packages/cypress/src/generators/convert-tslint-to-eslint/schema.json b/packages/cypress/src/generators/convert-tslint-to-eslint/schema.json new file mode 100644 index 0000000000..76b06b400b --- /dev/null +++ b/packages/cypress/src/generators/convert-tslint-to-eslint/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "cypress-convert-tslint-to-eslint", + "cli": "nx", + "title": "Convert a Cypress project from TSLint to ESLint", + "description": "NOTE: Does not work in --dry-run mode", + "examples": [ + { + "command": "g convert-tslint-to-eslint myapp", + "description": "Convert the Cypress project `myapp` from TSLint to ESLint" + } + ], + "type": "object", + "properties": { + "project": { + "description": "The name of the Cypress project to convert.", + "type": "string", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "Which Cypress project would you like to convert from TSLint to ESLint?" + }, + "removeTSLintIfNoMoreTSLintTargets": { + "type": "boolean", + "description": "If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration", + "default": true, + "x-prompt": "Would you like to remove TSLint and its related config if there are no TSLint projects remaining after this conversion?" + } + }, + "required": ["project"] +} diff --git a/packages/cypress/src/generators/cypress-project/cypress-project.ts b/packages/cypress/src/generators/cypress-project/cypress-project.ts index 564d9af0d4..6d24cc2faa 100644 --- a/packages/cypress/src/generators/cypress-project/cypress-project.ts +++ b/packages/cypress/src/generators/cypress-project/cypress-project.ts @@ -64,7 +64,7 @@ function addProject(host: Tree, options: CypressProjectSchema) { }); } -async function addLinter(host: Tree, options: CypressProjectSchema) { +export async function addLinter(host: Tree, options: CypressProjectSchema) { const installTask = await lintProjectGenerator(host, { project: options.projectName, linter: options.linter, diff --git a/packages/devkit/index.ts b/packages/devkit/index.ts index 3ffe5d044d..b1d1aa3b70 100644 --- a/packages/devkit/index.ts +++ b/packages/devkit/index.ts @@ -37,7 +37,10 @@ export { parseTargetString } from './src/executors/parse-target-string'; export { readTargetOptions } from './src/executors/read-target-options'; export { readJson, writeJson, updateJson } from './src/utils/json'; -export { addDependenciesToPackageJson } from './src/utils/package-json'; +export { + addDependenciesToPackageJson, + removeDependenciesFromPackageJson, +} from './src/utils/package-json'; export { installPackagesTask } from './src/tasks/install-packages-task'; export { names } from './src/utils/names'; export { diff --git a/packages/devkit/src/utils/package-json.ts b/packages/devkit/src/utils/package-json.ts index 1e41bb282f..1d8d6c322f 100644 --- a/packages/devkit/src/utils/package-json.ts +++ b/packages/devkit/src/utils/package-json.ts @@ -47,6 +47,49 @@ export function addDependenciesToPackageJson( }; } +/** + * Remove Dependencies and Dev Dependencies from package.json + * + * For example, `removeDependenciesFromPackageJson(host, ['react'], ['jest'])` + * will remove `react` and `jest` from the dependencies and devDependencies sections of package.json respectively + * + * @param dependencies Dependencies to be removed from the dependencies section of package.json + * @param devDependencies Dependencies to be removed from the devDependencies section of package.json + * @returns Callback to uninstall dependencies only if necessary. undefined is returned if changes are not necessary. + */ +export function removeDependenciesFromPackageJson( + host: Tree, + dependencies: string[], + devDependencies: string[], + packageJsonPath: string = 'package.json' +): GeneratorCallback { + const currentPackageJson = readJson(host, packageJsonPath); + + if ( + requiresRemovingOfPackages( + currentPackageJson, + dependencies, + devDependencies + ) + ) { + updateJson(host, packageJsonPath, (json) => { + for (const dep of dependencies) { + delete json.dependencies[dep]; + } + for (const devDep of devDependencies) { + delete json.devDependencies[devDep]; + } + json.dependencies = sortObjectByKeys(json.dependencies); + json.devDependencies = sortObjectByKeys(json.devDependencies); + + return json; + }); + } + return () => { + installPackagesTask(host); + }; +} + function sortObjectByKeys(obj: unknown) { return Object.keys(obj) .sort() @@ -83,3 +126,31 @@ function requiresAddingOfPackages(packageJsonFile, deps, devDeps): boolean { return needsDepsUpdate || needsDevDepsUpdate; } + +/** + * Verifies whether the given packageJson dependencies require an update + * given the deps & devDeps passed in + */ +function requiresRemovingOfPackages( + packageJsonFile, + deps: string[], + devDeps: string[] +): boolean { + let needsDepsUpdate = false; + let needsDevDepsUpdate = false; + + packageJsonFile.dependencies = packageJsonFile.dependencies || {}; + packageJsonFile.devDependencies = packageJsonFile.devDependencies || {}; + + if (deps.length > 0) { + needsDepsUpdate = deps.some((entry) => packageJsonFile.dependencies[entry]); + } + + if (devDeps.length > 0) { + needsDevDepsUpdate = devDeps.some( + (entry) => packageJsonFile.devDependencies[entry] + ); + } + + return needsDepsUpdate || needsDevDepsUpdate; +} diff --git a/packages/eslint-plugin-nx/src/configs/angular.ts b/packages/eslint-plugin-nx/src/configs/angular.ts index 51a3823cf0..2bad9512dc 100644 --- a/packages/eslint-plugin-nx/src/configs/angular.ts +++ b/packages/eslint-plugin-nx/src/configs/angular.ts @@ -10,6 +10,11 @@ * package. */ export default { + env: { + browser: true, + es6: true, + node: true, + }, plugins: ['@angular-eslint'], extends: ['plugin:@angular-eslint/recommended'], rules: {}, diff --git a/packages/linter/index.ts b/packages/linter/index.ts index e52c4ab1cc..4f5263328f 100644 --- a/packages/linter/index.ts +++ b/packages/linter/index.ts @@ -1,3 +1,4 @@ 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'; diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/__snapshots__/convert-to-eslint-config.spec.ts.snap b/packages/linter/src/utils/convert-tslint-to-eslint/__snapshots__/convert-to-eslint-config.spec.ts.snap new file mode 100644 index 0000000000..a174c3fa8c --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/__snapshots__/convert-to-eslint-config.spec.ts.snap @@ -0,0 +1,224 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convertToESLintConfig() should work for a non-Angular project tslint.json file 1`] = ` +Object { + "convertedESLintConfig": Object { + "env": Object { + "browser": true, + "es6": true, + "node": true, + }, + "parser": "@typescript-eslint/parser", + "parserOptions": Object { + "project": "tsconfig.json", + "sourceType": "module", + }, + "plugins": Array [ + "@typescript-eslint", + ], + }, + "ensureESLintPlugins": Array [], + "unconvertedTSLintRules": Array [], +} +`; + +exports[`convertToESLintConfig() should work for a project tslint.json file 1`] = ` +Object { + "convertedESLintConfig": Object { + "env": Object { + "browser": true, + "es6": true, + "node": true, + }, + "parser": "@typescript-eslint/parser", + "parserOptions": Object { + "project": "tsconfig.json", + "sourceType": "module", + }, + "plugins": Array [ + "@angular-eslint/eslint-plugin", + "@typescript-eslint", + ], + "rules": Object { + "@angular-eslint/component-selector": Array [ + "error", + Object { + "prefix": "angular-app", + "style": "kebab-case", + "type": "element", + }, + ], + "@angular-eslint/directive-selector": Array [ + "error", + Object { + "prefix": "angular-app", + "style": "camelCase", + "type": "attribute", + }, + ], + }, + }, + "ensureESLintPlugins": Array [], + "unconvertedTSLintRules": Array [], +} +`; + +exports[`convertToESLintConfig() should work for a root tslint.json file 1`] = ` +Object { + "convertedESLintConfig": Object { + "env": Object { + "browser": true, + "es6": true, + "node": true, + }, + "parser": "@typescript-eslint/parser", + "parserOptions": Object { + "project": "tsconfig.json", + "sourceType": "module", + }, + "plugins": Array [ + "eslint-plugin-import", + "@angular-eslint/eslint-plugin", + "@angular-eslint/eslint-plugin-template", + "@typescript-eslint", + ], + "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", + }, + ], + "@angular-eslint/no-conflicting-lifecycle": "error", + "@angular-eslint/no-host-metadata-property": "error", + "@angular-eslint/no-input-rename": "error", + "@angular-eslint/no-inputs-metadata-property": "error", + "@angular-eslint/no-output-native": "error", + "@angular-eslint/no-output-on-prefix": "error", + "@angular-eslint/no-output-rename": "error", + "@angular-eslint/no-outputs-metadata-property": "error", + "@angular-eslint/template/banana-in-box": "error", + "@angular-eslint/template/no-negated-async": "error", + "@angular-eslint/use-lifecycle-interface": "error", + "@angular-eslint/use-pipe-transform-interface": "error", + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/dot-notation": "off", + "@typescript-eslint/explicit-member-accessibility": Array [ + "off", + Object { + "accessibility": "explicit", + }, + ], + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/naming-convention": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "error", + "@typescript-eslint/no-inferrable-types": Array [ + "error", + Object { + "ignoreParameters": true, + }, + ], + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-shadow": Array [ + "error", + Object { + "hoist": "all", + }, + ], + "@typescript-eslint/no-unused-expressions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/unified-signatures": "error", + "arrow-body-style": "error", + "constructor-super": "error", + "eqeqeq": Array [ + "error", + "smart", + ], + "guard-for-in": "error", + "id-blacklist": "off", + "id-match": "off", + "import/no-deprecated": "warn", + "no-bitwise": "error", + "no-caller": "error", + "no-console": Array [ + "error", + Object { + "allow": Array [ + "_buffer", + "_counters", + "_groupDepth", + "_timers", + "assert", + "clear", + "count", + "countReset", + "dir", + "dirxml", + "error", + "group", + "groupCollapsed", + "groupEnd", + "log", + "table", + "timeLog", + "warn", + ], + }, + ], + "no-debugger": "error", + "no-empty": "off", + "no-eval": "error", + "no-fallthrough": "error", + "no-new-wrappers": "error", + "no-restricted-imports": Array [ + "error", + "rxjs/Rx", + ], + "no-throw-literal": "error", + "no-undef-init": "error", + "no-underscore-dangle": "off", + "no-var": "error", + "prefer-const": "error", + "radix": "error", + }, + }, + "ensureESLintPlugins": Array [ + "eslint-plugin-import", + ], + "unconvertedTSLintRules": Array [], +} +`; + +exports[`convertToESLintConfig() should work for an e2e project tslint.json file 1`] = ` +Object { + "convertedESLintConfig": Object { + "env": Object { + "browser": true, + "es6": true, + "node": true, + }, + "parser": "@typescript-eslint/parser", + "parserOptions": Object { + "project": "tsconfig.json", + "sourceType": "module", + }, + "plugins": Array [ + "@typescript-eslint", + ], + }, + "ensureESLintPlugins": Array [], + "unconvertedTSLintRules": Array [], +} +`; diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/__snapshots__/project-converter.spec.ts.snap b/packages/linter/src/utils/convert-tslint-to-eslint/__snapshots__/project-converter.spec.ts.snap new file mode 100644 index 0000000000..9327d53da0 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/__snapshots__/project-converter.spec.ts.snap @@ -0,0 +1,336 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProjectConverter removeTSLintFromWorkspace() should remove all relevant traces of TSLint from the workspace 1`] = ` +Object { + "dependencies": Object {}, + "devDependencies": Object { + "codelyzer": "latest", + "tslint": "latest", + }, + "name": "test-name", +} +`; + +exports[`ProjectConverter removeTSLintFromWorkspace() should remove all relevant traces of TSLint from the workspace 2`] = ` +Object { + "generators": Object { + "@nrwl/angular": Object { + "application": Object { + "linter": "tslint", + }, + "library": Object { + "linter": "tslint", + }, + }, + }, + "projects": Object { + "foo": Object { + "generators": Object { + "@nrwl/angular:application": Object { + "e2eTestRunner": "cypress", + "linter": "eslint", + "unitTestRunner": "jest", + }, + "@nrwl/angular:library": Object { + "linter": "tslint", + }, + }, + "projectType": "application", + "root": "apps/foo", + "targets": Object { + "lint": Object { + "executor": "@angular-devkit/build-angular:tslint", + "options": Object { + "exclude": Array [ + "**/node_modules/**", + "!apps/foo/**/*", + ], + "tsConfig": Array [ + "apps/foo/tsconfig.app.json", + ], + }, + }, + }, + }, + }, + "version": 1, +} +`; + +exports[`ProjectConverter removeTSLintFromWorkspace() should remove all relevant traces of TSLint from the workspace 3`] = ` +Object { + "dependencies": Object {}, + "devDependencies": Object {}, + "name": "test-name", +} +`; + +exports[`ProjectConverter removeTSLintFromWorkspace() should remove all relevant traces of TSLint from the workspace 4`] = ` +Object { + "projects": Object { + "foo": Object { + "generators": Object { + "@nrwl/angular:application": Object { + "e2eTestRunner": "cypress", + "unitTestRunner": "jest", + }, + }, + "projectType": "application", + "root": "apps/foo", + "targets": Object { + "lint": Object { + "executor": "@angular-devkit/build-angular:tslint", + "options": Object { + "exclude": Array [ + "**/node_modules/**", + "!apps/foo/**/*", + ], + "tsConfig": Array [ + "apps/foo/tsconfig.app.json", + ], + }, + }, + }, + }, + }, + "version": 1, +} +`; + +exports[`ProjectConverter removeTSLintFromWorkspace() should remove the entry in generators for convert-tslint-to-eslint because it is no longer needed 1`] = ` +Object { + "generators": Object { + "@nrwl/angular": Object { + "convert-tslint-to-eslint": Object { + "removeTSLintIfNoMoreTSLintTargets": true, + }, + }, + }, + "projects": Object { + "foo": Object { + "generators": Object { + "@nrwl/angular:application": Object { + "e2eTestRunner": "cypress", + "linter": "eslint", + "unitTestRunner": "jest", + }, + "@nrwl/angular:library": Object { + "linter": "tslint", + }, + }, + "projectType": "application", + "root": "apps/foo", + "targets": Object { + "lint": Object { + "executor": "@angular-devkit/build-angular:tslint", + "options": Object { + "exclude": Array [ + "**/node_modules/**", + "!apps/foo/**/*", + ], + "tsConfig": Array [ + "apps/foo/tsconfig.app.json", + ], + }, + }, + }, + }, + }, + "version": 1, +} +`; + +exports[`ProjectConverter removeTSLintFromWorkspace() should remove the entry in generators for convert-tslint-to-eslint because it is no longer needed 2`] = ` +Object { + "projects": Object { + "foo": Object { + "generators": Object { + "@nrwl/angular:application": Object { + "e2eTestRunner": "cypress", + "unitTestRunner": "jest", + }, + }, + "projectType": "application", + "root": "apps/foo", + "targets": Object { + "lint": Object { + "executor": "@angular-devkit/build-angular:tslint", + "options": Object { + "exclude": Array [ + "**/node_modules/**", + "!apps/foo/**/*", + ], + "tsConfig": Array [ + "apps/foo/tsconfig.app.json", + ], + }, + }, + }, + }, + }, + "version": 1, +} +`; + +exports[`ProjectConverter setDefaults() should set the default configuration for removeTSLintIfNoMoreTSLintTargets in workspace.json 1`] = ` +Object { + "generators": Object { + "@nrwl/angular": Object { + "application": Object { + "linter": "tslint", + }, + "library": Object { + "linter": "tslint", + }, + }, + }, + "projects": Object { + "foo": Object { + "generators": Object { + "@nrwl/angular:application": Object { + "e2eTestRunner": "cypress", + "linter": "eslint", + "unitTestRunner": "jest", + }, + "@nrwl/angular:library": Object { + "linter": "tslint", + }, + }, + "projectType": "application", + "root": "apps/foo", + "targets": Object { + "lint": Object { + "executor": "@angular-devkit/build-angular:tslint", + "options": Object { + "exclude": Array [ + "**/node_modules/**", + "!apps/foo/**/*", + ], + "tsConfig": Array [ + "apps/foo/tsconfig.app.json", + ], + }, + }, + }, + }, + }, + "version": 1, +} +`; + +exports[`ProjectConverter setDefaults() should set the default configuration for removeTSLintIfNoMoreTSLintTargets in workspace.json 2`] = ` +Object { + "generators": Object { + "@nrwl/angular": Object { + "application": Object { + "linter": "tslint", + }, + "convert-tslint-to-eslint": Object { + "removeTSLintIfNoMoreTSLintTargets": true, + }, + "library": Object { + "linter": "tslint", + }, + }, + }, + "projects": Object { + "foo": Object { + "generators": Object { + "@nrwl/angular:application": Object { + "e2eTestRunner": "cypress", + "linter": "eslint", + "unitTestRunner": "jest", + }, + "@nrwl/angular:library": Object { + "linter": "tslint", + }, + }, + "projectType": "application", + "root": "apps/foo", + "targets": Object { + "lint": Object { + "executor": "@angular-devkit/build-angular:tslint", + "options": Object { + "exclude": Array [ + "**/node_modules/**", + "!apps/foo/**/*", + ], + "tsConfig": Array [ + "apps/foo/tsconfig.app.json", + ], + }, + }, + }, + }, + }, + "version": 1, +} +`; + +exports[`ProjectConverter setDefaults() should set the default configuration for removeTSLintIfNoMoreTSLintTargets in workspace.json 3`] = ` +Object { + "generators": Object { + "@nrwl/angular": Object { + "application": Object { + "linter": "tslint", + }, + "convert-tslint-to-eslint": Object { + "removeTSLintIfNoMoreTSLintTargets": false, + }, + "library": Object { + "linter": "tslint", + }, + }, + }, + "projects": Object { + "foo": Object { + "generators": Object { + "@nrwl/angular:application": Object { + "e2eTestRunner": "cypress", + "linter": "eslint", + "unitTestRunner": "jest", + }, + "@nrwl/angular:library": Object { + "linter": "tslint", + }, + }, + "projectType": "application", + "root": "apps/foo", + "targets": Object { + "lint": Object { + "executor": "@angular-devkit/build-angular:tslint", + "options": Object { + "exclude": Array [ + "**/node_modules/**", + "!apps/foo/**/*", + ], + "tsConfig": Array [ + "apps/foo/tsconfig.app.json", + ], + }, + }, + }, + }, + }, + "version": 1, +} +`; + +exports[`ProjectConverter should throw if --dry-run is set 1`] = ` +"NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the \\"Run\\" button. +" +`; + +exports[`ProjectConverter should throw if --dryRun is set 1`] = ` +"NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the \\"Run\\" button. +" +`; + +exports[`ProjectConverter should throw if -d is set 1`] = ` +"NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the \\"Run\\" button. +" +`; + +exports[`ProjectConverter should throw if no project tslint.json is found 1`] = `"We could not find a tslint.json for the selected project \\"apps/foo/tslint.json\\", maybe you have already migrated to ESLint?"`; + +exports[`ProjectConverter should throw if no root tslint.json is found 1`] = `"We could not find a tslint.json at the root of your workspace, maybe you have already migrated to ESLint?"`; diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/convert-nx-enforce-module-boundaries-rule.spec.ts b/packages/linter/src/utils/convert-tslint-to-eslint/convert-nx-enforce-module-boundaries-rule.spec.ts new file mode 100644 index 0000000000..044ca66897 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/convert-nx-enforce-module-boundaries-rule.spec.ts @@ -0,0 +1,166 @@ +import { convertTslintNxRuleToEslintNxRule } from './convert-nx-enforce-module-boundaries-rule'; + +describe('convertTslintNxRuleToEslintNxRule()', () => { + const configFromNxExamplesRepo = { + allow: ['@nx-example/shared/product/data/testing'], + depConstraints: [ + { + sourceTag: 'type:app', + onlyDependOnLibsWithTags: ['type:feature', 'type:ui'], + }, + { + sourceTag: 'type:feature', + onlyDependOnLibsWithTags: [ + 'type:ui', + 'type:data', + 'type:types', + 'type:state', + ], + }, + { + sourceTag: 'type:types', + onlyDependOnLibsWithTags: ['type:types'], + }, + { + sourceTag: 'type:state', + onlyDependOnLibsWithTags: ['type:state', 'type:types', 'type:data'], + }, + { + sourceTag: 'type:data', + onlyDependOnLibsWithTags: ['type:types'], + }, + { + sourceTag: 'type:e2e', + onlyDependOnLibsWithTags: ['type:e2e-utils'], + }, + { + sourceTag: 'type:ui', + onlyDependOnLibsWithTags: ['type:types', 'type:ui'], + }, + { + sourceTag: 'scope:products', + onlyDependOnLibsWithTags: ['scope:products', 'scope:shared'], + }, + { + sourceTag: 'scope:cart', + onlyDependOnLibsWithTags: ['scope:cart', 'scope:shared'], + }, + ], + enforceBuildableLibDependency: true, + }; + + const testCases = [ + { + tslintJson: {}, + // Should return null if no existing config found + expected: null, + }, + { + // Real usage in nx-examples repo + tslintJson: { + rules: { + 'nx-enforce-module-boundaries': [true, configFromNxExamplesRepo], + }, + }, + expected: { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: ['error', configFromNxExamplesRepo], + }, + }, + { + // Should respect boolean + tslintJson: { + defaultSeverity: 'warning', + rules: { + 'nx-enforce-module-boundaries': [false, configFromNxExamplesRepo], + }, + }, + expected: { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: ['off', configFromNxExamplesRepo], + }, + }, + { + // Should respect boolean + defaultSeverity format + tslintJson: { + defaultSeverity: 'warning', + rules: { + 'nx-enforce-module-boundaries': [true, configFromNxExamplesRepo], + }, + }, + expected: { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: ['warn', configFromNxExamplesRepo], + }, + }, + { + // Should respect object format + tslintJson: { + rules: { + 'nx-enforce-module-boundaries': { + severity: 'error', + options: [configFromNxExamplesRepo], + }, + }, + }, + expected: { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: ['error', configFromNxExamplesRepo], + }, + }, + { + // Should respect object format + tslintJson: { + rules: { + 'nx-enforce-module-boundaries': { + severity: 'warning', + options: [configFromNxExamplesRepo], + }, + }, + }, + expected: { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: ['warn', configFromNxExamplesRepo], + }, + }, + { + // Should respect object format + tslintJson: { + rules: { + 'nx-enforce-module-boundaries': { + severity: 'off', + options: [configFromNxExamplesRepo], + }, + }, + }, + expected: { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: ['off', configFromNxExamplesRepo], + }, + }, + { + // Should respect object format + defaultSeverity option + tslintJson: { + defaultSeverity: 'warning', + rules: { + 'nx-enforce-module-boundaries': { + severity: 'default', + options: [configFromNxExamplesRepo], + }, + }, + }, + expected: { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: ['warn', configFromNxExamplesRepo], + }, + }, + ]; + + testCases.forEach((tc, i) => { + it(`should appropriately convert the nx-enforce-module-boundaries rule usage from TSLint, CASE ${i}`, () => { + expect(convertTslintNxRuleToEslintNxRule(tc.tslintJson)).toEqual( + tc.expected + ); + }); + }); +}); diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/convert-nx-enforce-module-boundaries-rule.ts b/packages/linter/src/utils/convert-tslint-to-eslint/convert-nx-enforce-module-boundaries-rule.ts new file mode 100644 index 0000000000..36c00eaa49 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/convert-nx-enforce-module-boundaries-rule.ts @@ -0,0 +1,78 @@ +import type { ESLintRuleSeverity } from 'tslint-to-eslint-config'; + +type TSLintRuleSeverity = 'default' | 'warning' | 'error' | 'off' | boolean; +type TSLintRuleSeverityNonDefaultString = Exclude< + TSLintRuleSeverity, + boolean | 'default' +>; + +function convertTSLintRuleSeverity( + tslintConfig: any, + tslintSeverity: TSLintRuleSeverity +): ESLintRuleSeverity { + if (tslintSeverity === true) { + tslintSeverity = 'default'; + } + if (tslintSeverity === false) { + tslintSeverity = 'off'; + } + if (tslintSeverity === 'default') { + tslintSeverity = tslintConfig.defaultSeverity || 'error'; + } + const narrowedTslintSeverity = tslintSeverity as TSLintRuleSeverityNonDefaultString; + return narrowedTslintSeverity === 'warning' ? 'warn' : narrowedTslintSeverity; +} + +const NX_TSLINT_RULE_NAME = 'nx-enforce-module-boundaries'; + +export function convertTslintNxRuleToEslintNxRule( + tslintJson: Record +): { + ruleName: string; + ruleConfig: [ESLintRuleSeverity, Record]; +} | null { + /** + * TSLint supports a number of different formats for rule configuration + */ + const existingRuleDefinition = tslintJson?.rules?.[NX_TSLINT_RULE_NAME]; + if (!existingRuleDefinition) { + return null; + } + let existingRuleSeverity: TSLintRuleSeverity = 'error'; + let existingRuleConfig = { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }; + + if (Array.isArray(existingRuleDefinition)) { + existingRuleSeverity = existingRuleDefinition[0]; + existingRuleConfig = existingRuleDefinition[1]; + } else if ( + typeof existingRuleDefinition === 'object' && + existingRuleDefinition.severity + ) { + existingRuleSeverity = existingRuleDefinition.severity; + if ( + Array.isArray(existingRuleDefinition.options) && + existingRuleDefinition.options[0] + ) { + existingRuleConfig = existingRuleDefinition.options[0]; + } + } + + const ruleSeverity: ESLintRuleSeverity = convertTSLintRuleSeverity( + tslintJson, + existingRuleSeverity + ); + + return { + ruleName: '@nrwl/nx/enforce-module-boundaries', + ruleConfig: [ruleSeverity, existingRuleConfig], + }; +} diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/convert-to-eslint-config.spec.ts b/packages/linter/src/utils/convert-tslint-to-eslint/convert-to-eslint-config.spec.ts new file mode 100644 index 0000000000..6c87057855 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/convert-to-eslint-config.spec.ts @@ -0,0 +1,102 @@ +import { + exampleE2eProjectTslintJson, + exampleAngularProjectTslintJson, + exampleRootTslintJson, + exampleNonAngularProjectTslintJson, +} from './example-tslint-configs'; +import { convertToESLintConfig } from './convert-to-eslint-config'; + +/** + * The actual `findReportedConfiguration()` function is used to execute + * `tslint --print-config` in a child process and read from the real + * file system. This won't work for us in tests where we are dealing + * with a Tree, so we mock out the responses from `findReportedConfiguration()` + * with previously captured result data from that same command. + */ + +export function mockFindReportedConfiguration(_, pathToTSLintJson) { + if ( + pathToTSLintJson === 'tslint.json' || + pathToTSLintJson === '/tslint.json' + ) { + return exampleRootTslintJson.tslintPrintConfigResult; + } + + if ( + pathToTSLintJson === 'apps/app1/tslint.json' || + pathToTSLintJson === 'libs/lib1/tslint.json' + ) { + return exampleAngularProjectTslintJson.tslintPrintConfigResult; + } + + if (pathToTSLintJson === 'apps/app1-e2e/tslint.json') { + return exampleE2eProjectTslintJson.tslintPrintConfigResult; + } + + if (pathToTSLintJson === 'apps/app2/tslint.json') { + return exampleNonAngularProjectTslintJson.tslintPrintConfigResult; + } + + throw new Error( + `${pathToTSLintJson} is not a part of the supported mock data for these tests` + ); +} + +/** + * See ./mock-tslint-to-eslint-config.ts for why this is needed + */ +jest.mock('tslint-to-eslint-config', () => { + return { + // Since upgrading to (ts-)jest 26 this usage of this mock has caused issues... + // @ts-ignore + ...jest.requireActual('tslint-to-eslint-config'), + findReportedConfiguration: jest.fn(mockFindReportedConfiguration), + }; +}); + +describe('convertToESLintConfig()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should work for a root tslint.json file', async () => { + const converted = await convertToESLintConfig( + 'tslint.json', + exampleRootTslintJson.raw, + [] + ); + // Ensure no-console snapshot is deterministic + converted.convertedESLintConfig.rules['no-console'][1].allow.sort(); + expect(converted).toMatchSnapshot(); + }); + + it('should work for a project tslint.json file', async () => { + await expect( + convertToESLintConfig( + 'apps/app1/tslint.json', + exampleAngularProjectTslintJson.raw, + [] + ) + ).resolves.toMatchSnapshot(); + }); + + it('should work for an e2e project tslint.json file', async () => { + await expect( + convertToESLintConfig( + 'apps/app1-e2e/tslint.json', + exampleE2eProjectTslintJson.raw, + [] + ) + ).resolves.toMatchSnapshot(); + }); + + it('should work for a non-Angular project tslint.json file', async () => { + await expect( + convertToESLintConfig( + 'apps/app2/tslint.json', + exampleNonAngularProjectTslintJson.raw, + [] + ) + ).resolves.toMatchSnapshot(); + }); +}); diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/convert-to-eslint-config.ts b/packages/linter/src/utils/convert-tslint-to-eslint/convert-to-eslint-config.ts new file mode 100644 index 0000000000..0495ed8026 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/convert-to-eslint-config.ts @@ -0,0 +1,151 @@ +import type { Linter as ESLintLinter } from 'eslint'; +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; +import type { + TSLintRuleOptions, + createESLintConfiguration as CreateESLintConfiguration, +} from 'tslint-to-eslint-config'; + +export async function convertToESLintConfig( + pathToTslintJson: string, + tslintJson: Record, + ignoreExtendsVals: string[] +): Promise<{ + convertedESLintConfig: ESLintLinter.Config; + unconvertedTSLintRules: TSLintRuleOptions[]; + ensureESLintPlugins: string[]; +}> { + /** + * We need to avoid a direct dependency on tslint-to-eslint-config + * and ensure we are only resolving the dependency from the user's + * node_modules on demand (it will be installed as part of the + * conversion generator). + */ + const { + createESLintConfiguration, + findReportedConfiguration, + joinConfigConversionResults, + } = require('tslint-to-eslint-config'); + + const updatedTSLintJson = tslintJson; + /** + * If ignoreExtendsVals are provided, strip them from the config + * and commit the result to disk per the notes below. + */ + if (ignoreExtendsVals.length && updatedTSLintJson.extends) { + if ( + typeof updatedTSLintJson.extends === 'string' && + ignoreExtendsVals.includes(updatedTSLintJson.extends) + ) { + delete updatedTSLintJson.extends; + } + if (Array.isArray(updatedTSLintJson.extends)) { + updatedTSLintJson.extends = updatedTSLintJson.extends.filter( + (ext) => !ignoreExtendsVals.includes(ext) + ); + } + /** + * The reasons we need to interact with the filesystem here: + * + * 1) The result of the tslint CLI flag `--print-config` is needed for the + * conversion process, and unfortunately no equivalent Node API was ever + * added to tslint, so the tslint CLI needs to always read from disk. + * + * 2) When converting project configs, we need to strip the extends path + * which corresponds to the workspace's root config, otherwise all of the + * root config's rules will be included in the resultant eslint config for + * the project. The interaction with the filesystem is needed because of + * point (1) above - we need to strip the relevant extends and commit that + * change to disk before the tslint CLI reads the config file. + */ + mkdirSync(dirname(pathToTslintJson), { recursive: true }); + writeFileSync(pathToTslintJson, JSON.stringify(updatedTSLintJson)); + } + const reportedConfiguration = await findReportedConfiguration( + 'npx tslint --print-config', + pathToTslintJson + ); + + if (reportedConfiguration instanceof Error) { + if ( + reportedConfiguration.message.includes('unknown option `--print-config') + ) { + throw new Error( + '\nError: TSLint v5.18 required in order to run this schematic. Please update your version and try again.\n' + ); + } + /** + * Make a print-config issue easier to understand for the end user. + * This error could occur if, for example, the user does not have a TSLint plugin installed correctly that they + * reference in their config. + */ + const printConfigFailureMessageStart = + 'Command failed: npx tslint --print-config "tslint.json"'; + if ( + reportedConfiguration.message.startsWith(printConfigFailureMessageStart) + ) { + throw new Error( + `\nThere was a critical error when trying to inspect your tslint.json: \n${reportedConfiguration.message.replace( + printConfigFailureMessageStart, + '' + )}` + ); + } + + throw new Error(`Unexpected error: ${reportedConfiguration.message}`); + } + + const originalConfigurations = { + tslint: { + full: reportedConfiguration, + raw: updatedTSLintJson, + }, + }; + + const summarizedConfiguration = await (createESLintConfiguration as typeof CreateESLintConfiguration)( + originalConfigurations + ); + + /** + * We are expecting it to not find a converter for nx-enforce-module-boundaries + * and we will explicitly replace it with the ESLint equivalent ourselves. + */ + if (summarizedConfiguration.missing) { + summarizedConfiguration.missing = summarizedConfiguration.missing.filter( + (missingRuleData) => + missingRuleData.ruleName !== 'nx-enforce-module-boundaries' + ); + } + + // These are already covered by our extraEslintDependencies which get installed by the schematic + const expectedESLintPlugins = [ + '@angular-eslint/eslint-plugin', + '@angular-eslint/eslint-plugin-template', + ]; + + const convertedESLintConfig = joinConfigConversionResults( + summarizedConfiguration, + originalConfigurations + ) as ESLintLinter.Config; + + if ( + Array.isArray(convertedESLintConfig.extends) && + convertedESLintConfig.extends.length + ) { + // Ignore any tslint-to-eslint-config default extends that do not apply to Nx + convertedESLintConfig.extends = convertedESLintConfig.extends.filter( + (ext) => !ext.startsWith('prettier') + ); + if (convertedESLintConfig.extends.length === 0) { + delete convertedESLintConfig.extends; + } + } + + return { + convertedESLintConfig, + unconvertedTSLintRules: summarizedConfiguration.missing, + ensureESLintPlugins: Array.from(summarizedConfiguration.plugins).filter( + (pluginName) => !expectedESLintPlugins.includes(pluginName) + ), + }; +} diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/example-tslint-configs.ts b/packages/linter/src/utils/convert-tslint-to-eslint/example-tslint-configs.ts new file mode 100644 index 0000000000..2c4123c381 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/example-tslint-configs.ts @@ -0,0 +1,324 @@ +// Based on latest Angular project root tslint.json + enforce-module-boundaries config from nx-examples +export const exampleRootTslintJson = { + raw: { + rulesDirectory: [ + 'node_modules/@nrwl/workspace/src/tslint', + 'node_modules/codelyzer', + ], + linterOptions: { + exclude: ['**/*'], + }, + rules: { + 'arrow-return-shorthand': true, + 'callable-types': true, + 'class-name': true, + deprecation: { + severity: 'warn', + }, + forin: true, + 'import-blacklist': [true, 'rxjs/Rx'], + 'interface-over-type-literal': true, + 'member-access': false, + 'member-ordering': [ + true, + { + order: [ + 'static-field', + 'instance-field', + 'static-method', + 'instance-method', + ], + }, + ], + 'no-arg': true, + 'no-bitwise': true, + 'no-console': [true, 'debug', 'info', 'time', 'timeEnd', 'trace'], + 'no-construct': true, + 'no-debugger': true, + 'no-duplicate-super': true, + 'no-empty': false, + 'no-empty-interface': true, + 'no-eval': true, + 'no-inferrable-types': [true, 'ignore-params'], + 'no-misused-new': true, + 'no-non-null-assertion': true, + 'no-shadowed-variable': true, + 'no-string-literal': false, + 'no-string-throw': true, + 'no-switch-case-fall-through': true, + 'no-unnecessary-initializer': true, + 'no-unused-expression': true, + 'no-var-keyword': true, + 'object-literal-sort-keys': false, + 'prefer-const': true, + radix: true, + 'triple-equals': [true, 'allow-null-check'], + 'unified-signatures': true, + 'variable-name': false, + 'nx-enforce-module-boundaries': [ + true, + { + allow: ['@nx-example/shared/product/data/testing'], + depConstraints: [ + { + sourceTag: 'type:app', + onlyDependOnLibsWithTags: ['type:feature', 'type:ui'], + }, + { + sourceTag: 'type:feature', + onlyDependOnLibsWithTags: [ + 'type:ui', + 'type:data', + 'type:types', + 'type:state', + ], + }, + { + sourceTag: 'type:types', + onlyDependOnLibsWithTags: ['type:types'], + }, + { + sourceTag: 'type:state', + onlyDependOnLibsWithTags: [ + 'type:state', + 'type:types', + 'type:data', + ], + }, + { + sourceTag: 'type:data', + onlyDependOnLibsWithTags: ['type:types'], + }, + { + sourceTag: 'type:e2e', + onlyDependOnLibsWithTags: ['type:e2e-utils'], + }, + { + sourceTag: 'type:ui', + onlyDependOnLibsWithTags: ['type:types', 'type:ui'], + }, + { + sourceTag: 'scope:products', + onlyDependOnLibsWithTags: ['scope:products', 'scope:shared'], + }, + { + sourceTag: 'scope:cart', + onlyDependOnLibsWithTags: ['scope:cart', 'scope:shared'], + }, + ], + enforceBuildableLibDependency: true, + }, + ], + 'directive-selector': [true, 'attribute', 'app', 'camelCase'], + 'component-selector': [true, 'element', 'app', 'kebab-case'], + 'no-conflicting-lifecycle': true, + 'no-host-metadata-property': true, + 'no-input-rename': true, + 'no-inputs-metadata-property': true, + 'no-output-native': true, + 'no-output-on-prefix': true, + 'no-output-rename': true, + 'no-outputs-metadata-property': true, + 'template-banana-in-box': true, + 'template-no-negated-async': true, + 'use-lifecycle-interface': true, + 'use-pipe-transform-interface': true, + }, + }, + tslintPrintConfigResult: { + rules: { + 'arrow-return-shorthand': { ruleArguments: [], ruleSeverity: 'error' }, + 'callable-types': { ruleArguments: [], ruleSeverity: 'error' }, + 'class-name': { ruleArguments: [], ruleSeverity: 'error' }, + deprecation: { ruleSeverity: 'warning' }, + forin: { ruleArguments: [], ruleSeverity: 'error' }, + 'import-blacklist': { ruleArguments: ['rxjs/Rx'], ruleSeverity: 'error' }, + 'interface-over-type-literal': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'member-access': { ruleArguments: [], ruleSeverity: 'off' }, + 'member-ordering': { + ruleArguments: [ + { + order: [ + 'static-field', + 'instance-field', + 'static-method', + 'instance-method', + ], + }, + ], + ruleSeverity: 'error', + }, + 'no-arg': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-bitwise': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-console': { + ruleArguments: ['debug', 'info', 'time', 'timeEnd', 'trace'], + ruleSeverity: 'error', + }, + 'no-construct': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-debugger': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-duplicate-super': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-empty': { ruleArguments: [], ruleSeverity: 'off' }, + 'no-empty-interface': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-eval': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-inferrable-types': { + ruleArguments: ['ignore-params'], + ruleSeverity: 'error', + }, + 'no-misused-new': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-non-null-assertion': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-shadowed-variable': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-string-literal': { ruleArguments: [], ruleSeverity: 'off' }, + 'no-string-throw': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-switch-case-fall-through': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'no-unnecessary-initializer': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'no-unused-expression': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-var-keyword': { ruleArguments: [], ruleSeverity: 'error' }, + 'object-literal-sort-keys': { ruleArguments: [], ruleSeverity: 'off' }, + 'prefer-const': { ruleArguments: [], ruleSeverity: 'error' }, + radix: { ruleArguments: [], ruleSeverity: 'error' }, + 'triple-equals': { + ruleArguments: ['allow-null-check'], + ruleSeverity: 'error', + }, + 'unified-signatures': { ruleArguments: [], ruleSeverity: 'error' }, + 'variable-name': { ruleArguments: [], ruleSeverity: 'off' }, + 'nx-enforce-module-boundaries': { + ruleArguments: [ + { + allow: ['@nx-example/shared/product/data/testing'], + depConstraints: [ + { + sourceTag: 'type:app', + onlyDependOnLibsWithTags: ['type:feature', 'type:ui'], + }, + { + sourceTag: 'type:feature', + onlyDependOnLibsWithTags: [ + 'type:ui', + 'type:data', + 'type:types', + 'type:state', + ], + }, + { + sourceTag: 'type:types', + onlyDependOnLibsWithTags: ['type:types'], + }, + { + sourceTag: 'type:state', + onlyDependOnLibsWithTags: [ + 'type:state', + 'type:types', + 'type:data', + ], + }, + { + sourceTag: 'type:data', + onlyDependOnLibsWithTags: ['type:types'], + }, + { + sourceTag: 'type:e2e', + onlyDependOnLibsWithTags: ['type:e2e-utils'], + }, + { + sourceTag: 'type:ui', + onlyDependOnLibsWithTags: ['type:types', 'type:ui'], + }, + { + sourceTag: 'scope:products', + onlyDependOnLibsWithTags: ['scope:products', 'scope:shared'], + }, + { + sourceTag: 'scope:cart', + onlyDependOnLibsWithTags: ['scope:cart', 'scope:shared'], + }, + ], + enforceBuildableLibDependency: true, + }, + ], + ruleSeverity: 'error', + }, + 'directive-selector': { + ruleArguments: ['attribute', 'app', 'camelCase'], + ruleSeverity: 'error', + }, + 'component-selector': { + ruleArguments: ['element', 'app', 'kebab-case'], + ruleSeverity: 'error', + }, + 'no-conflicting-lifecycle': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-host-metadata-property': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-input-rename': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-inputs-metadata-property': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'no-output-native': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-output-on-prefix': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-output-rename': { ruleArguments: [], ruleSeverity: 'error' }, + 'no-outputs-metadata-property': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'template-banana-in-box': { ruleArguments: [], ruleSeverity: 'error' }, + 'template-no-negated-async': { ruleArguments: [], ruleSeverity: 'error' }, + 'use-lifecycle-interface': { ruleArguments: [], ruleSeverity: 'error' }, + 'use-pipe-transform-interface': { + ruleArguments: [], + ruleSeverity: 'error', + }, + }, + }, +}; + +export const exampleAngularProjectTslintJson = { + raw: { + extends: '../../tslint.json', + rules: { + 'directive-selector': [true, 'attribute', 'angular-app', 'camelCase'], + 'component-selector': [true, 'element', 'angular-app', 'kebab-case'], + }, + linterOptions: { + exclude: ['!**/*'], + }, + }, + tslintPrintConfigResult: { + rules: { + 'directive-selector': { + ruleArguments: ['attribute', 'angular-app', 'camelCase'], + ruleSeverity: 'error', + }, + 'component-selector': { + ruleArguments: ['element', 'angular-app', 'kebab-case'], + ruleSeverity: 'error', + }, + }, + }, +}; + +export const exampleNonAngularProjectTslintJson = { + raw: { + extends: '../../tslint.json', + linterOptions: { exclude: ['!**/*'] }, + rules: {}, + }, + tslintPrintConfigResult: { rules: {} }, +}; + +export const exampleE2eProjectTslintJson = { + raw: { + extends: '../../tslint.json', + linterOptions: { exclude: ['!**/*'] }, + rules: {}, + }, + tslintPrintConfigResult: { rules: {} }, +}; diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/index.ts b/packages/linter/src/utils/convert-tslint-to-eslint/index.ts new file mode 100644 index 0000000000..cc95094649 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/index.ts @@ -0,0 +1,7 @@ +export { + ConvertTSLintToESLintSchema, + ProjectConverter, +} from './project-converter'; + +// For testing +export { exampleRootTslintJson } from './example-tslint-configs'; diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/project-converter.spec.ts b/packages/linter/src/utils/convert-tslint-to-eslint/project-converter.spec.ts new file mode 100644 index 0000000000..dad4a72271 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/project-converter.spec.ts @@ -0,0 +1,267 @@ +import { + addDependenciesToPackageJson, + addProjectConfiguration, + readJson, + readWorkspaceConfiguration, + Tree, + updateWorkspaceConfiguration, + writeJson, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { ProjectConverter } from './project-converter'; + +/** + * Don't run actual child_process implementation of installPackagesTask() + */ +jest.mock('child_process'); + +describe('ProjectConverter', () => { + let host: Tree; + const projectName = 'foo'; + const projectRoot = `apps/${projectName}`; + + beforeEach(async () => { + // Clean up any previous dry run simulations + process.argv = process.argv.filter( + (a) => !['--dry-run', '--dryRun', '-d'].includes(a) + ); + host = createTreeWithEmptyWorkspace(); + addProjectConfiguration(host, projectName, { + root: projectRoot, + projectType: 'application', + targets: { + /** + * LINT TARGET CONFIG - BEFORE CONVERSION + * + * TSLint executor configured for the project + */ + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + exclude: ['**/node_modules/**', `!${projectRoot}/**/*`], + tsConfig: [`${projectRoot}/tsconfig.app.json`], + }, + }, + }, + /** + * PROJECT-LEVEL GENERATOR CONFIG - BEFORE CONVERSION + * + * Default set to tslint, using shorthand syntax + */ + generators: { + '@nrwl/angular:library': { + linter: 'tslint', + }, + '@nrwl/angular:application': { + e2eTestRunner: 'cypress', + linter: 'eslint', + unitTestRunner: 'jest', + }, + }, + }); + }); + + it('should throw if --dry-run is set', () => { + writeJson(host, 'tslint.json', {}); + writeJson(host, `${projectRoot}/tslint.json`, {}); + + process.argv.push('--dry-run'); + + expect( + () => + new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if --dryRun is set', () => { + writeJson(host, 'tslint.json', {}); + writeJson(host, `${projectRoot}/tslint.json`, {}); + + process.argv.push('--dryRun'); + + expect( + () => + new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if -d is set', () => { + writeJson(host, 'tslint.json', {}); + writeJson(host, `${projectRoot}/tslint.json`, {}); + + process.argv.push('-d'); + + expect( + () => + new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if no root tslint.json is found', () => { + expect( + () => + new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if no project tslint.json is found', () => { + writeJson(host, 'tslint.json', {}); + + expect( + () => + new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should not throw when not in dry-run and config files successfully found', () => { + writeJson(host, 'tslint.json', {}); + writeJson(host, `${projectRoot}/tslint.json`, {}); + + expect( + () => + new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }) + ).not.toThrow(); + }); + + describe('setDefaults()', () => { + it('should set the default configuration for removeTSLintIfNoMoreTSLintTargets in workspace.json', async () => { + writeJson(host, 'tslint.json', {}); + writeJson(host, `${projectRoot}/tslint.json`, {}); + + const projectConverter = new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }); + + const workspace = readWorkspaceConfiguration(host); + workspace.generators = { + '@nrwl/angular': { + application: { + linter: 'tslint', + }, + library: { + linter: 'tslint', + }, + }, + }; + updateWorkspaceConfiguration(host, workspace); + + // BEFORE - no entry for convert-tslint-to-eslint wthin @nrwl/angular generators + expect(readJson(host, 'workspace.json')).toMatchSnapshot(); + + projectConverter.setDefaults('@nrwl/angular', true); + + // AFTER (1) - convert-tslint-to-eslint wthin @nrwl/angular generators has removeTSLintIfNoMoreTSLintTargets set to true + expect(readJson(host, 'workspace.json')).toMatchSnapshot(); + + projectConverter.setDefaults('@nrwl/angular', false); + + // AFTER (2) - convert-tslint-to-eslint wthin @nrwl/angular generators has removeTSLintIfNoMoreTSLintTargets set to false + expect(readJson(host, 'workspace.json')).toMatchSnapshot(); + }); + }); + + describe('removeTSLintFromWorkspace()', () => { + it('should remove all relevant traces of TSLint from the workspace', async () => { + writeJson(host, 'tslint.json', {}); + writeJson(host, `${projectRoot}/tslint.json`, {}); + + const projectConverter = new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }); + + await addDependenciesToPackageJson( + host, + {}, + { + codelyzer: 'latest', + tslint: 'latest', + } + )(); + + const workspace = readWorkspaceConfiguration(host); + // Not using shorthand syntax this time + workspace.generators = { + '@nrwl/angular': { + application: { + linter: 'tslint', + }, + library: { + linter: 'tslint', + }, + }, + }; + updateWorkspaceConfiguration(host, workspace); + + // BEFORE - tslint and codelyzer are present + expect(readJson(host, 'package.json')).toMatchSnapshot(); + + // BEFORE - tslint set as both global and project-level default linter for @nrwl/angular generators + expect(readJson(host, 'workspace.json')).toMatchSnapshot(); + + await projectConverter.removeTSLintFromWorkspace()(); + + // AFTER - it should remove tslint and codelyzer + expect(readJson(host, 'package.json')).toMatchSnapshot(); + + // AFTER - generators config from global and project-level settings removed (because eslint is always default) + expect(readJson(host, 'workspace.json')).toMatchSnapshot(); + }); + + it('should remove the entry in generators for convert-tslint-to-eslint because it is no longer needed', async () => { + writeJson(host, 'tslint.json', {}); + writeJson(host, `${projectRoot}/tslint.json`, {}); + + const projectConverter = new ProjectConverter({ + host, + projectName, + eslintInitializer: () => undefined, + }); + + const workspace = readWorkspaceConfiguration(host); + workspace.generators = { + '@nrwl/angular': { + 'convert-tslint-to-eslint': { + removeTSLintIfNoMoreTSLintTargets: true, + }, + }, + }; + updateWorkspaceConfiguration(host, workspace); + + // BEFORE - convert-tslint-to-eslint wthin @nrwl/angular generators has a value for removeTSLintIfNoMoreTSLintTargets + expect(readJson(host, 'workspace.json')).toMatchSnapshot(); + + await projectConverter.removeTSLintFromWorkspace()(); + + // AFTER - generators config no longer has a reference to convert-tslint-to-eslint + expect(readJson(host, 'workspace.json')).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/project-converter.ts b/packages/linter/src/utils/convert-tslint-to-eslint/project-converter.ts new file mode 100644 index 0000000000..ad6ea8d702 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/project-converter.ts @@ -0,0 +1,535 @@ +import { + GeneratorCallback, + getPackageManagerCommand, + getProjects, + joinPathFragments, + logger, + NxJsonProjectConfiguration, + offsetFromRoot, + ProjectConfiguration, + readJson, + readProjectConfiguration, + readWorkspaceConfiguration, + removeDependenciesFromPackageJson, + Tree, + updateJson, + updateProjectConfiguration, + updateWorkspaceConfiguration, +} from '@nrwl/devkit'; +import { detectPackageManager } from '@nrwl/tao/src/shared/package-manager'; +import { execSync } from 'child_process'; +import type { Linter } from 'eslint'; +import { tslintToEslintConfigVersion } from '../versions'; +import { + convertTSLintConfig, + ensureESLintPluginsAreInstalled, + deduplicateOverrides, +} from './utils'; + +/** + * Common schema used by all implementations of convert-tslint-to-eslint generators + */ +export interface ConvertTSLintToESLintSchema { + project: string; + removeTSLintIfNoMoreTSLintTargets: boolean; +} + +/** + * When we convert a TSLint setup to an ESLint setup for a particular project, there are a number of + * shared/common concerns (implemented as library utilities within @nrwl/linter), and a few things + * which are specific to this package and the types of projects it produces. + * + * The key structure of the converted ESLint support is as follows: + * + * - We will first generate a workspace root .eslintrc.json which is the same as the one generated + * for new workspaces (i.e. it is NOT just a converted version of their root tslint.json). This allows us + * to have a consistent base for all users, as well as standardized patterns around "overrides". + * + * - The user's original root tslint.json will be converted and any applicable settings will be stored + * within ADDITIONAL override blocks within the root .eslintrc.json. + * + * - The user's project-level tslint.json file will be converted into a corresponding .eslintrc.json file + * and it will extend from the root workspace .eslintrc.json file as normal. + */ +export class ProjectConverter { + private readonly projectConfig: ProjectConfiguration & + NxJsonProjectConfiguration; + private readonly rootTSLintJsonPath = 'tslint.json'; + private readonly rootTSLintJson: Record; + private readonly projectTSLintJsonPath: string; + private readonly projectTSLintJson: Record; + private readonly host: Tree; + private readonly projectName: string; + private readonly eslintInitializer: (projectInfo: { + projectName: string; + projectConfig: ProjectConfiguration & NxJsonProjectConfiguration; + }) => Promise; + private readonly pmc: ReturnType; + + /** + * Using an object as the argument to the constructor means we sacrifice some + * authoring sugar around initializing these properties but it makes the usage + * of the class much easier to read and maintain. + */ + constructor({ + host, + projectName, + eslintInitializer, + }: { + host: Tree; + projectName: string; + eslintInitializer: (projectInfo: { + projectName: string; + projectConfig: ProjectConfiguration & NxJsonProjectConfiguration; + }) => Promise; + }) { + this.host = host; + this.projectName = projectName; + this.eslintInitializer = eslintInitializer; + this.projectConfig = readProjectConfiguration(this.host, this.projectName); + this.projectTSLintJsonPath = joinPathFragments( + this.projectConfig.root, + 'tslint.json' + ); + + /** + * Given the user is converting a project from TSLint to ESLint, we expect them + * to have both a root and a project-specific tslint.json + */ + if (!host.exists(this.rootTSLintJsonPath)) { + throw new Error( + 'We could not find a tslint.json at the root of your workspace, maybe you have already migrated to ESLint?' + ); + } + if (!host.exists(this.projectTSLintJsonPath)) { + throw new Error( + `We could not find a tslint.json for the selected project "${this.projectTSLintJsonPath}", maybe you have already migrated to ESLint?` + ); + } + this.rootTSLintJson = readJson(host, this.rootTSLintJsonPath); + this.projectTSLintJson = readJson(host, this.projectTSLintJsonPath); + + const pm = detectPackageManager(); + this.pmc = getPackageManagerCommand(pm); + + /** + * We are not able to support --dry-run in this generator, because we need to dynamically install + * and use the tslint-to-eslint-config package within the same execution. + * + * This is a worthwhile trade-off and the dry-run output doesn't offer a ton of value for this use-case anyway. + */ + if ( + process.argv.includes('--dry-run') || + process.argv.includes('--dryRun') || + process.argv.includes('-d') + ) { + throw new Error( + 'NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the "Run" button.\n' + ); + } + } + + /** + * In order to avoid all users of Nx needing to have tslint-to-eslint-config (and therefore tslint) + * in their node_modules, we dynamically install and uninstall the library as part of the conversion + * process. + * + * NOTE: By taking this approach we have to sacrifice dry-run capabilities for this generator. + */ + installTSLintToESLintConfigPackage() { + execSync( + `${this.pmc.addDev} tslint-to-eslint-config@${tslintToEslintConfigVersion}`, + { + cwd: this.host.root, + stdio: [0, 1, 2], + } + ); + } + + uninstallTSLintToESLintConfigPackage() { + execSync(`${this.pmc.rm} tslint-to-eslint-config`, { + cwd: this.host.root, + stdio: [0, 1, 2], + }); + } + + async initESLint() { + return this.eslintInitializer({ + projectName: this.projectName, + projectConfig: this.projectConfig, + }); + } + + /** + * If the package-specific shareable config already exists then the workspace must already + * be part way through migrating from TSLint to ESLint. In this case we do not want to convert + * the root tslint.json again (and this utility will return a noop task), and we instead just + * focus on the project-level config conversion. + */ + async convertRootTSLintConfig( + applyPackageSpecificModifications: (json: Linter.Config) => Linter.Config + ): Promise> { + const convertedRoot = await convertTSLintConfig( + this.rootTSLintJson, + this.rootTSLintJsonPath, + [] + ); + const convertedRootESLintConfig = convertedRoot.convertedESLintConfig; + + /** + * Already set by Nx's shareable configs + */ + delete convertedRootESLintConfig.env; + delete convertedRootESLintConfig.parser; + delete convertedRootESLintConfig.parserOptions; + convertedRootESLintConfig.plugins = convertedRootESLintConfig.plugins.filter( + (p) => + !p.startsWith('@angular-eslint') && !p.startsWith('@typescript-eslint') + ); + + /** + * The only piece of the converted root tslint.json that we need to pull out to + * apply to the existing overrides within the root .eslintrc.json is the + * @nrwl/nx/enforce-module-boundaries rule. + */ + const nxRuleName = '@nrwl/nx/enforce-module-boundaries'; + const nxEnforceModuleBoundariesRule = + convertedRootESLintConfig.rules[nxRuleName]; + if (nxEnforceModuleBoundariesRule) { + updateJson(this.host, '.eslintrc.json', (json) => { + if (!json.overrides) { + return json; + } + for (const override of json.overrides) { + if (!override.rules) { + continue; + } + if (!override.rules[nxRuleName]) { + continue; + } + override.rules[nxRuleName] = nxEnforceModuleBoundariesRule; + } + return json; + }); + /** + * Remove it once we've used it on the root, so that is isn't applied + * to the package-specific shareable config + */ + delete convertedRootESLintConfig.rules[nxRuleName]; + } + + /** + * Update the root workspace .eslintrc.json with additional overrides + */ + const finalConvertedRootESLintConfig = applyPackageSpecificModifications( + convertedRootESLintConfig + ); + updateJson(this.host, '.eslintrc.json', (json) => { + json.overrides = json.overrides || []; + if ( + finalConvertedRootESLintConfig.overrides && + finalConvertedRootESLintConfig.overrides.length + ) { + json.overrides = [ + ...json.overrides, + ...finalConvertedRootESLintConfig.overrides, + ]; + } else { + json.overrides.push({ + files: ['*.ts'], + ...finalConvertedRootESLintConfig, + }); + } + json.overrides = deduplicateOverrides(json.overrides); + return json; + }); + + /** + * Through converting the config we may encounter TSLint rules whose closest + * equivalent in the ESLint ecosystem comes from a separate package/plugin. + * + * We therefore automatically install those extra packages for the user and + * explain that that's what we are doing. + */ + return ensureESLintPluginsAreInstalled( + this.host, + convertedRoot.ensureESLintPlugins + ); + } + + async convertProjectConfig( + applyPackageSpecificModifications: (json: Linter.Config) => Linter.Config + ): Promise { + const convertedProjectConfig = await convertTSLintConfig( + this.projectTSLintJson, + this.projectTSLintJsonPath, + // Strip the extends on workspace tslint.json (see this util's docs for more info) + [`${offsetFromRoot(this.projectConfig.root)}tslint.json`] + ); + + const convertedProjectESLintConfig = + convertedProjectConfig.convertedESLintConfig; + + /** + * Already set by Nx's shareable configs + */ + delete convertedProjectESLintConfig.env; + delete convertedProjectESLintConfig.parser; + delete convertedProjectESLintConfig.parserOptions; + convertedProjectESLintConfig.plugins = convertedProjectESLintConfig.plugins.filter( + (p) => + !p.startsWith('@angular-eslint') && !p.startsWith('@typescript-eslint') + ); + + const projectESLintConfigPath = joinPathFragments( + this.projectConfig.root, + '.eslintrc.json' + ); + + /** + * Apply updates to the new .eslintrc.json file for the project + */ + updateJson(this.host, projectESLintConfigPath, (json) => { + if (typeof json.extends === 'string') { + json.extends = [json.extends]; + } + // Custom extends from conversion + if ( + Array.isArray(convertedProjectESLintConfig.extends) && + convertedProjectESLintConfig.extends.length + ) { + // Ignore any tslint-to-eslint-config default extends that do not apply to Nx + const applicableExtends = convertedProjectESLintConfig.extends.filter( + (ext) => !ext.startsWith('prettier') + ); + if (applicableExtends.length) { + json.extends = [...json.extends, ...applicableExtends]; + } + } + // Custom plugins from conversion + if ( + Array.isArray(convertedProjectESLintConfig.plugins) && + convertedProjectESLintConfig.plugins.length + ) { + json.plugins = [ + ...json.plugins, + ...convertedProjectESLintConfig.plugins, + ]; + } + /** + * Custom rules + * + * By default, tslint-to-eslint-config will try and apply any rules without known converters + * by using eslint-plugin-tslint. We instead explicitly warn the user about this missing converter, + * and therefore at this point we strip out any rules which start with @typescript-eslint/tslint/config + */ + json.rules = json.rules || {}; + if ( + convertedProjectESLintConfig.rules && + Object.keys(convertedProjectESLintConfig.rules).length + ) { + for (const [ruleName, ruleConfig] of Object.entries( + convertedProjectESLintConfig.rules + )) { + if (!ruleName.startsWith('@typescript-eslint/tslint/config')) { + // Prioritize the converted rules over any base implementations from the original Nx generator + json.rules[ruleName] = ruleConfig; + } + } + } + /** + * Apply any package-specific modifications to the converted config before + * updating the config file. + */ + const finalJson = applyPackageSpecificModifications(json); + return finalJson; + }); + + /** + * Through converting the config we may encounter TSLint rules whose closest + * equivalent in the ESLint ecosystem comes from a separate package/plugin. + * + * We therefore automatically install those extra packages for the user and + * explain that that's what we are doing. + */ + return ensureESLintPluginsAreInstalled( + this.host, + convertedProjectConfig.ensureESLintPlugins + ); + } + + removeProjectTSLintFile() { + this.host.delete(joinPathFragments(this.projectConfig.root, 'tslint.json')); + } + + isTSLintUsedInWorkspace(): boolean { + const projects = getProjects(this.host); + for (const [, projectConfig] of projects.entries()) { + for (const [, targetConfig] of Object.entries(projectConfig.targets)) { + if (targetConfig.executor === '@angular-devkit/build-angular:tslint') { + // Workspace is still using TSLint, exit early + return true; + } + } + } + // If we got this far the user has no remaining TSLint usage + return false; + } + + removeTSLintFromWorkspace(): GeneratorCallback { + logger.info( + `No TSLint usage will remain in the workspace, removing TSLint...` + ); + /** + * Delete the root tslint.json + */ + this.host.delete(this.rootTSLintJsonPath); + + /** + * Prepare the package.json and the uninstall task + */ + const uninstallTask = removeDependenciesFromPackageJson( + this.host, + [], + ['tslint', 'codelyzer'] + ); + + /** + * Update global linter configuration defaults in workspace.json + */ + const workspace = readWorkspaceConfiguration(this.host); + this.cleanUpGeneratorsConfig(workspace); + updateWorkspaceConfiguration(this.host, workspace); + + /** + * Update project-level linter configuration defaults in workspace.json + */ + const projects = getProjects(this.host); + for (const [projectName, { generators }] of projects.entries()) { + if (!generators || Object.keys(generators).length === 0) { + continue; + } + const project = readProjectConfiguration(this.host, projectName); + this.cleanUpGeneratorsConfig(project); + updateProjectConfiguration(this.host, projectName, project); + } + + return uninstallTask; + } + + private cleanUpGeneratorsConfig(parentConfig: { generators?: any }) { + if ( + !parentConfig.generators || + Object.keys(parentConfig.generators).length === 0 + ) { + return; + } + for (const [collectionName, maybeGeneratorConfig] of Object.entries( + parentConfig.generators + )) { + // Shorthand syntax is possible + if (collectionName.includes(':')) { + const generatorConfig = maybeGeneratorConfig; + for (const optionName of Object.keys(generatorConfig)) { + if (optionName === 'linter') { + // Default is eslint, so in all cases we can just remove the config altogether + delete generatorConfig[optionName]; + } + } + // If removing linter leaves no other options in the config, remove the config as well + if (Object.keys(generatorConfig).length === 0) { + delete parentConfig.generators[collectionName]; + } + } else { + // Not shorthand syntax, so next level down is generator name -> config mapping + const collectionConfig = maybeGeneratorConfig; + + for (const [generatorName, generatorConfig] of Object.entries( + collectionConfig + )) { + if (generatorName === 'convert-tslint-to-eslint') { + // No longer relevant because of TSLint is being removed the conversion process must be complete + delete collectionConfig[generatorName]; + continue; + } + + for (const optionName of Object.keys(generatorConfig)) { + if (optionName === 'linter') { + // Default is eslint, so in all cases we can just remove the config altogether + delete generatorConfig[optionName]; + } + } + // If removing linter leaves no other options in the config, remove the generator config as well + if (Object.keys(generatorConfig).length === 0) { + delete collectionConfig[generatorName]; + } + } + + // If removing the generator leaves no other generators in the config, remove the config as well + if ( + parentConfig.generators[collectionName] && + Object.keys(parentConfig.generators[collectionName]).length === 0 + ) { + delete parentConfig.generators[collectionName]; + } + } + } + + // If removing the linter defaults leaves absolutely no generators configuration remaining, remove it + if (Object.keys(parentConfig.generators).length === 0) { + delete parentConfig.generators; + } + } + + /** + * If the project which is the subject of the ProjectConverter instance is an application, + * figure out its associated e2e project's name. + */ + getE2EProjectName(): string | null { + if (this.projectConfig.projectType !== 'application') { + return null; + } + let e2eProjectName = null; + + const projects = getProjects(this.host); + for (const [projectName, projectConfig] of projects.entries()) { + for (const [, targetConfig] of Object.entries(projectConfig.targets)) { + if (targetConfig.executor === '@nrwl/cypress:cypress') { + if ( + targetConfig.options.devServerTarget === `${this.projectName}:serve` + ) { + e2eProjectName = projectName; + logger.info( + `Found e2e project for "${this.projectName}" called "${e2eProjectName}", converting that project as well...` + ); + } + } + } + } + return e2eProjectName; + } + + setDefaults( + collectionName: string, + removeTSLintIfNoMoreTSLintTargets: ConvertTSLintToESLintSchema['removeTSLintIfNoMoreTSLintTargets'] + ) { + const workspace = readWorkspaceConfiguration(this.host); + + workspace.generators = workspace.generators || {}; + workspace.generators[collectionName] = + workspace.generators[collectionName] || {}; + const prev = workspace.generators[collectionName]; + + workspace.generators = { + ...workspace.generators, + [collectionName]: { + ...prev, + 'convert-tslint-to-eslint': { + removeTSLintIfNoMoreTSLintTargets, + }, + }, + }; + + updateWorkspaceConfiguration(this.host, workspace); + } +} diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/utils.spec.ts b/packages/linter/src/utils/convert-tslint-to-eslint/utils.spec.ts new file mode 100644 index 0000000000..6b1b4492c3 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/utils.spec.ts @@ -0,0 +1,58 @@ +import type { Linter } from 'eslint'; +import { deduplicateOverrides } from './utils'; + +describe('deduplicateOverrides()', () => { + it('should deduplicate overrides with identical values for "files"', () => { + const initialOverrides: Linter.Config['overrides'] = [ + { + files: ['*.ts'], + env: { + foo: true, + }, + rules: { + bar: 'error', + }, + }, + { + files: ['*.html'], + rules: {}, + }, + { + files: '*.ts', + plugins: ['wat'], + parserOptions: { + qux: false, + }, + rules: { + bar: 'warn', + baz: 'error', + }, + }, + { + files: ['*.ts'], + extends: ['something'], + }, + ]; + expect(deduplicateOverrides(initialOverrides)).toEqual([ + { + files: ['*.ts'], + env: { + foo: true, + }, + plugins: ['wat'], + extends: ['something'], + parserOptions: { + qux: false, + }, + rules: { + bar: 'warn', + baz: 'error', + }, + }, + { + files: ['*.html'], + rules: {}, + }, + ]); + }); +}); diff --git a/packages/linter/src/utils/convert-tslint-to-eslint/utils.ts b/packages/linter/src/utils/convert-tslint-to-eslint/utils.ts new file mode 100644 index 0000000000..234c659f55 --- /dev/null +++ b/packages/linter/src/utils/convert-tslint-to-eslint/utils.ts @@ -0,0 +1,136 @@ +import { + addDependenciesToPackageJson, + GeneratorCallback, + logger, + Tree, +} from '@nrwl/devkit'; +import type { Linter } from 'eslint'; +import type { TSLintRuleOptions } from 'tslint-to-eslint-config'; +import { convertTslintNxRuleToEslintNxRule } from './convert-nx-enforce-module-boundaries-rule'; +import { convertToESLintConfig } from './convert-to-eslint-config'; + +export function ensureESLintPluginsAreInstalled( + host: Tree, + eslintPluginsToBeInstalled: string[] +): GeneratorCallback { + if (!eslintPluginsToBeInstalled?.length) { + return () => undefined; + } + + const additionalDevDependencies = {}; + + for (const pluginName of eslintPluginsToBeInstalled) { + additionalDevDependencies[pluginName] = 'latest'; + } + + logger.info( + '\nINFO: To most closely match your tslint.json, we will ensure the `latest` version of the following eslint plugin(s) are installed:' + ); + logger.info('\n - ' + eslintPluginsToBeInstalled.join('\n - ')); + logger.info( + '\nPlease note, you may later wish to pin these to a specific version number in your package.json, rather than leaving it open to `latest`.\n' + ); + + return addDependenciesToPackageJson(host, {}, additionalDevDependencies); +} + +/** + * We don't want the user to depend on the TSLint fallback plugin, we will instead + * explicitly inform them of the rules that could not be converted automatically and + * advise them on what to do next. + */ +function warnInCaseOfUnconvertedRules( + tslintConfigPath: string, + unconvertedTSLintRules: TSLintRuleOptions[] +): void { + const unconvertedTSLintRuleNames = unconvertedTSLintRules + .filter( + // Ignore formatting related rules, they are handled by Nx format/prettier + (unconverted) => + !['import-spacing', 'whitespace', 'typedef'].includes( + unconverted.ruleName + ) + ) + .map((unconverted) => unconverted.ruleName); + + if (unconvertedTSLintRuleNames.length > 0) { + logger.warn( + `\nWARNING: Within "${tslintConfigPath}", the following ${unconvertedTSLintRuleNames.length} rule(s) did not have known converters in https://github.com/typescript-eslint/tslint-to-eslint-config` + ); + logger.warn('\n - ' + unconvertedTSLintRuleNames.join('\n - ')); + logger.warn( + '\nYou will need to decide on how to handle the above manually, but everything else has been handled for you automatically.\n' + ); + } +} + +export async function convertTSLintConfig( + rawTSLintJson: any, + tslintJsonPath: string, + ignoreExtendsVals: string[] +) { + const convertedProject = await convertToESLintConfig( + tslintJsonPath, + rawTSLintJson, + ignoreExtendsVals + ); + + /** + * Apply the custom converter for the nx-module-boundaries rule if applicable + */ + const convertedNxRule = convertTslintNxRuleToEslintNxRule(rawTSLintJson); + if (convertedNxRule) { + convertedProject.convertedESLintConfig.rules = + convertedProject.convertedESLintConfig.rules || {}; + convertedProject.convertedESLintConfig.rules[convertedNxRule.ruleName] = + convertedNxRule.ruleConfig; + } + + warnInCaseOfUnconvertedRules( + tslintJsonPath, + convertedProject.unconvertedTSLintRules + ); + + return convertedProject; +} + +export function deduplicateOverrides( + overrides: Linter.Config['overrides'] = [] +) { + const map = new Map(); + for (const override of overrides) { + const mapKey: string = + typeof override.files === 'string' + ? override.files + : override.files.join(','); + const existing: Set = map.get(mapKey); + if (existing) { + existing.add(override); + map.set(mapKey, existing); + continue; + } + const set = new Set(); + set.add(override); + map.set(mapKey, set); + } + + let dedupedOverrides = []; + + for (const [, overrides] of map.entries()) { + const overridesArr = Array.from(overrides); + if (overridesArr.length === 1) { + dedupedOverrides = [...dedupedOverrides, ...overridesArr]; + continue; + } + let mergedOverride = {}; + for (const override of overridesArr) { + mergedOverride = { + ...mergedOverride, + ...(override as any), + }; + } + dedupedOverrides.push(mergedOverride); + } + + return dedupedOverrides; +} diff --git a/packages/linter/src/utils/versions.ts b/packages/linter/src/utils/versions.ts index 3d0252dcfd..30b55920e8 100644 --- a/packages/linter/src/utils/versions.ts +++ b/packages/linter/src/utils/versions.ts @@ -1,6 +1,7 @@ export const nxVersion = '*'; export const tslintVersion = '~6.1.0'; +export const tslintToEslintConfigVersion = '2.2.0'; export const buildAngularVersion = '~0.1102.0'; export const typescriptESLintVersion = '4.3.0'; diff --git a/packages/nest/collection.json b/packages/nest/collection.json index 6673bc61ca..5d0887c1ba 100644 --- a/packages/nest/collection.json +++ b/packages/nest/collection.json @@ -91,6 +91,18 @@ "factory": "./src/schematics/nestjs-schematics/nestjs-schematics#service", "schema": "./src/schematics/nestjs-schematics/schema.json", "description": "Run the 'service' NestJs generator with Nx project support" + }, + "convert-tslint-to-eslint": { + "factory": "./src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint#conversionSchematic", + "schema": "./src/generators/convert-tslint-to-eslint/schema.json", + "description": "Convert a project from TSLint to ESLint" + } + }, + "generators": { + "convert-tslint-to-eslint": { + "factory": "./src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint#conversionGenerator", + "schema": "./src/generators/convert-tslint-to-eslint/schema.json", + "description": "Convert a project from TSLint to ESLint" } } } diff --git a/packages/nest/package.json b/packages/nest/package.json index 012f9eea7b..5b20759c31 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@nrwl/devkit": "*", + "@nrwl/linter": "*", "@nrwl/node": "*", "@nrwl/jest": "*", "@angular-devkit/core": "~11.2.0", diff --git a/packages/nest/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap b/packages/nest/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap new file mode 100644 index 0000000000..68234e4811 --- /dev/null +++ b/packages/nest/src/generators/convert-tslint-to-eslint/__snapshots__/convert-tslint-to-eslint.spec.ts.snap @@ -0,0 +1,573 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convert-tslint-to-eslint should work for NestJS applications 1`] = ` +Object { + "dependencies": Object {}, + "devDependencies": Object { + "@nrwl/eslint-plugin-nx": "*", + "@nrwl/linter": "*", + "@typescript-eslint/eslint-plugin": "4.3.0", + "@typescript-eslint/parser": "4.3.0", + "eslint": "7.10.0", + "eslint-config-prettier": "8.1.0", + "eslint-plugin-import": "latest", + }, + "name": "test-name", +} +`; + +exports[`convert-tslint-to-eslint should work for NestJS applications 2`] = ` +Object { + "projectType": "application", + "root": "apps/nest-app-1", + "targets": Object { + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "apps/nest-app-1/**/*.ts", + ], + }, + }, + }, +} +`; + +exports[`convert-tslint-to-eslint should work for NestJS applications 3`] = ` +Object { + "ignorePatterns": Array [ + "**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": Object { + "@nrwl/nx/enforce-module-boundaries": Array [ + "error", + Object { + "allow": Array [ + "@nx-example/shared/product/data/testing", + ], + "depConstraints": Array [ + Object { + "onlyDependOnLibsWithTags": Array [ + "type:feature", + "type:ui", + ], + "sourceTag": "type:app", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:ui", + "type:data", + "type:types", + "type:state", + ], + "sourceTag": "type:feature", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:types", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:state", + "type:types", + "type:data", + ], + "sourceTag": "type:state", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:data", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:e2e-utils", + ], + "sourceTag": "type:e2e", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + "type:ui", + ], + "sourceTag": "type:ui", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:products", + "scope:shared", + ], + "sourceTag": "scope:products", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:cart", + "scope:shared", + ], + "sourceTag": "scope:cart", + }, + ], + "enforceBuildableLibDependency": true, + }, + ], + }, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/typescript", + ], + "files": Array [ + "*.ts", + "*.tsx", + ], + "rules": Object {}, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/javascript", + ], + "files": Array [ + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + ], + "plugins": Array [ + "eslint-plugin-import", + ], + "rules": Object { + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/dot-notation": "off", + "@typescript-eslint/explicit-member-accessibility": Array [ + "off", + Object { + "accessibility": "explicit", + }, + ], + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/naming-convention": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "error", + "@typescript-eslint/no-inferrable-types": Array [ + "error", + Object { + "ignoreParameters": true, + }, + ], + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-shadow": Array [ + "error", + Object { + "hoist": "all", + }, + ], + "@typescript-eslint/no-unused-expressions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/unified-signatures": "error", + "arrow-body-style": "error", + "constructor-super": "error", + "eqeqeq": Array [ + "error", + "smart", + ], + "guard-for-in": "error", + "id-blacklist": "off", + "id-match": "off", + "import/no-deprecated": "warn", + "no-bitwise": "error", + "no-caller": "error", + "no-console": Array [ + "error", + Object { + "allow": Array [ + "log", + "warn", + "dir", + "timeLog", + "assert", + "clear", + "count", + "countReset", + "group", + "groupEnd", + "table", + "dirxml", + "error", + "groupCollapsed", + "_buffer", + "_counters", + "_timers", + "_groupDepth", + ], + }, + ], + "no-debugger": "error", + "no-empty": "off", + "no-eval": "error", + "no-fallthrough": "error", + "no-new-wrappers": "error", + "no-restricted-imports": Array [ + "error", + "rxjs/Rx", + ], + "no-throw-literal": "error", + "no-undef-init": "error", + "no-underscore-dangle": "off", + "no-var": "error", + "prefer-const": "error", + "radix": "error", + }, + }, + ], + "plugins": Array [ + "@nrwl/nx", + ], + "root": true, +} +`; + +exports[`convert-tslint-to-eslint should work for NestJS applications 4`] = ` +Object { + "extends": Array [ + "../../.eslintrc.json", + ], + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "parserOptions": Object { + "project": Array [ + "apps/nest-app-1/tsconfig.*?.json", + ], + }, + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + "*.tsx", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + ], + "rules": Object { + "@typescript-eslint/no-empty-interface": "error", + }, +} +`; + +exports[`convert-tslint-to-eslint should work for NestJS libraries 1`] = ` +Object { + "dependencies": Object {}, + "devDependencies": Object { + "@nrwl/eslint-plugin-nx": "*", + "@nrwl/linter": "*", + "@typescript-eslint/eslint-plugin": "4.3.0", + "@typescript-eslint/parser": "4.3.0", + "eslint": "7.10.0", + "eslint-config-prettier": "8.1.0", + "eslint-plugin-import": "latest", + }, + "name": "test-name", +} +`; + +exports[`convert-tslint-to-eslint should work for NestJS libraries 2`] = ` +Object { + "projectType": "library", + "root": "libs/nest-lib-1", + "targets": Object { + "lint": Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "libs/nest-lib-1/**/*.ts", + ], + }, + }, + }, +} +`; + +exports[`convert-tslint-to-eslint should work for NestJS libraries 3`] = ` +Object { + "ignorePatterns": Array [ + "**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "rules": Object { + "@nrwl/nx/enforce-module-boundaries": Array [ + "error", + Object { + "allow": Array [ + "@nx-example/shared/product/data/testing", + ], + "depConstraints": Array [ + Object { + "onlyDependOnLibsWithTags": Array [ + "type:feature", + "type:ui", + ], + "sourceTag": "type:app", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:ui", + "type:data", + "type:types", + "type:state", + ], + "sourceTag": "type:feature", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:types", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:state", + "type:types", + "type:data", + ], + "sourceTag": "type:state", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + ], + "sourceTag": "type:data", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:e2e-utils", + ], + "sourceTag": "type:e2e", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "type:types", + "type:ui", + ], + "sourceTag": "type:ui", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:products", + "scope:shared", + ], + "sourceTag": "scope:products", + }, + Object { + "onlyDependOnLibsWithTags": Array [ + "scope:cart", + "scope:shared", + ], + "sourceTag": "scope:cart", + }, + ], + "enforceBuildableLibDependency": true, + }, + ], + }, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/typescript", + ], + "files": Array [ + "*.ts", + "*.tsx", + ], + "rules": Object {}, + }, + Object { + "extends": Array [ + "plugin:@nrwl/nx/javascript", + ], + "files": Array [ + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + ], + "plugins": Array [ + "eslint-plugin-import", + ], + "rules": Object { + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/dot-notation": "off", + "@typescript-eslint/explicit-member-accessibility": Array [ + "off", + Object { + "accessibility": "explicit", + }, + ], + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/naming-convention": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "error", + "@typescript-eslint/no-inferrable-types": Array [ + "error", + Object { + "ignoreParameters": true, + }, + ], + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-shadow": Array [ + "error", + Object { + "hoist": "all", + }, + ], + "@typescript-eslint/no-unused-expressions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/unified-signatures": "error", + "arrow-body-style": "error", + "constructor-super": "error", + "eqeqeq": Array [ + "error", + "smart", + ], + "guard-for-in": "error", + "id-blacklist": "off", + "id-match": "off", + "import/no-deprecated": "warn", + "no-bitwise": "error", + "no-caller": "error", + "no-console": Array [ + "error", + Object { + "allow": Array [ + "log", + "warn", + "dir", + "timeLog", + "assert", + "clear", + "count", + "countReset", + "group", + "groupEnd", + "table", + "dirxml", + "error", + "groupCollapsed", + "_buffer", + "_counters", + "_timers", + "_groupDepth", + ], + }, + ], + "no-debugger": "error", + "no-empty": "off", + "no-eval": "error", + "no-fallthrough": "error", + "no-new-wrappers": "error", + "no-restricted-imports": Array [ + "error", + "rxjs/Rx", + ], + "no-throw-literal": "error", + "no-undef-init": "error", + "no-underscore-dangle": "off", + "no-var": "error", + "prefer-const": "error", + "radix": "error", + }, + }, + ], + "plugins": Array [ + "@nrwl/nx", + ], + "root": true, +} +`; + +exports[`convert-tslint-to-eslint should work for NestJS libraries 4`] = ` +Object { + "extends": Array [ + "../../.eslintrc.json", + ], + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx", + ], + "parserOptions": Object { + "project": Array [ + "libs/nest-lib-1/tsconfig.*?.json", + ], + }, + "rules": Object {}, + }, + Object { + "files": Array [ + "*.ts", + "*.tsx", + ], + "rules": Object {}, + }, + Object { + "files": Array [ + "*.js", + "*.jsx", + ], + "rules": Object {}, + }, + ], + "rules": Object { + "@typescript-eslint/no-empty-interface": "error", + }, +} +`; diff --git a/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts b/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts new file mode 100644 index 0000000000..f9ef6f3cdc --- /dev/null +++ b/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.spec.ts @@ -0,0 +1,235 @@ +import { + addProjectConfiguration, + joinPathFragments, + readJson, + readProjectConfiguration, + Tree, + writeJson, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { exampleRootTslintJson } from '@nrwl/linter'; +import { conversionGenerator } from './convert-tslint-to-eslint'; + +/** + * Don't run actual child_process implementation of installPackagesTask() + */ +jest.mock('child_process'); + +const appProjectName = 'nest-app-1'; +const appProjectRoot = `apps/${appProjectName}`; +const appProjectTSLintJsonPath = joinPathFragments( + appProjectRoot, + 'tslint.json' +); +const libProjectName = 'nest-lib-1'; +const libProjectRoot = `libs/${libProjectName}`; +const libProjectTSLintJsonPath = joinPathFragments( + libProjectRoot, + 'tslint.json' +); +// Used to configure the test Tree and stub the response from tslint-to-eslint-config util findReportedConfiguration() +const projectTslintJsonData = { + raw: { + extends: '../../tslint.json', + rules: { + // User custom TS rule + 'no-empty-interface': true, + // User custom rule with no known automated converter + 'some-super-custom-rule-with-no-converter': true, + }, + linterOptions: { + exclude: ['!**/*'], + }, + }, + tslintPrintConfigResult: { + rules: { + 'no-empty-interface': { + ruleArguments: [], + ruleSeverity: 'error', + }, + 'some-super-custom-rule-with-no-converter': { + ruleArguments: [], + ruleSeverity: 'error', + }, + }, + }, +}; + +function mockFindReportedConfiguration(_, pathToTslintJson) { + switch (pathToTslintJson) { + case 'tslint.json': + return exampleRootTslintJson.tslintPrintConfigResult; + case appProjectTSLintJsonPath: + return projectTslintJsonData.tslintPrintConfigResult; + case libProjectTSLintJsonPath: + return projectTslintJsonData.tslintPrintConfigResult; + default: + throw new Error( + `mockFindReportedConfiguration - Did not recognize path ${pathToTslintJson}` + ); + } +} + +/** + * See ./mock-tslint-to-eslint-config.ts for why this is needed + */ +jest.mock('tslint-to-eslint-config', () => { + return { + // Since upgrading to (ts-)jest 26 this usage of this mock has caused issues... + // @ts-ignore + ...jest.requireActual('tslint-to-eslint-config'), + findReportedConfiguration: jest.fn(mockFindReportedConfiguration), + }; +}); + +/** + * Mock the the mutating fs utilities used within the conversion logic, they are not + * needed because of our stubbed response for findReportedConfiguration() above, and + * they would cause noise in the git data of the actual Nx repo when the tests run. + */ +jest.mock('fs', () => { + return { + // Since upgrading to (ts-)jest 26 this usage of this mock has caused issues... + // @ts-ignore + ...jest.requireActual('fs'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + }; +}); + +describe('convert-tslint-to-eslint', () => { + let host: Tree; + + beforeEach(async () => { + host = createTreeWithEmptyWorkspace(); + + writeJson(host, 'tslint.json', exampleRootTslintJson.raw); + + addProjectConfiguration(host, appProjectName, { + root: appProjectRoot, + projectType: 'application', + targets: { + /** + * LINT TARGET CONFIG - BEFORE CONVERSION + * + * TSLint executor configured for the project + */ + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + exclude: ['**/node_modules/**', '!apps/nest-app-1/**/*'], + tsConfig: ['apps/nest-app-1/tsconfig.app.json'], + }, + }, + }, + }); + + addProjectConfiguration(host, libProjectName, { + root: libProjectRoot, + projectType: 'library', + targets: { + /** + * LINT TARGET CONFIG - BEFORE CONVERSION + * + * TSLint executor configured for the project + */ + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + exclude: ['**/node_modules/**', '!libs/nest-lib-1/**/*'], + tsConfig: ['libs/nest-lib-1/tsconfig.app.json'], + }, + }, + }, + }); + + /** + * Existing tslint.json file for the app project + */ + writeJson(host, 'apps/nest-app-1/tslint.json', projectTslintJsonData.raw); + /** + * Existing tslint.json file for the lib project + */ + writeJson(host, 'libs/nest-lib-1/tslint.json', projectTslintJsonData.raw); + }); + + it('should work for NestJS applications', async () => { + await conversionGenerator(host, { + project: appProjectName, + removeTSLintIfNoMoreTSLintTargets: false, + }); + + /** + * It should ensure the required Nx packages are installed and available + * + * NOTE: tslint-to-eslint-config should NOT be present + */ + expect(readJson(host, 'package.json')).toMatchSnapshot(); + + /** + * LINT TARGET CONFIG - AFTER CONVERSION + * + * It should replace the TSLint executor with the ESLint one + */ + expect(readProjectConfiguration(host, appProjectName)).toMatchSnapshot(); + + /** + * The root level .eslintrc.json should now have been generated + */ + expect(readJson(host, '.eslintrc.json')).toMatchSnapshot(); + + /** + * The project level .eslintrc.json should now have been generated + * and extend from the root, as well as applying any customizations + * which are specific to this projectType. + */ + expect( + readJson(host, joinPathFragments(appProjectRoot, '.eslintrc.json')) + ).toMatchSnapshot(); + + /** + * The project's TSLint file should have been deleted + */ + expect(host.exists(appProjectTSLintJsonPath)).toEqual(false); + }); + + it('should work for NestJS libraries', async () => { + await conversionGenerator(host, { + project: libProjectName, + removeTSLintIfNoMoreTSLintTargets: false, + }); + + /** + * It should ensure the required Nx packages are installed and available + * + * NOTE: tslint-to-eslint-config should NOT be present + */ + expect(readJson(host, 'package.json')).toMatchSnapshot(); + + /** + * LINT TARGET CONFIG - AFTER CONVERSION + * + * It should replace the TSLint executor with the ESLint one + */ + expect(readProjectConfiguration(host, libProjectName)).toMatchSnapshot(); + + /** + * The root level .eslintrc.json should now have been generated + */ + expect(readJson(host, '.eslintrc.json')).toMatchSnapshot(); + + /** + * The project level .eslintrc.json should now have been generated + * and extend from the root, as well as applying any customizations + * which are specific to this projectType. + */ + expect( + readJson(host, joinPathFragments(libProjectRoot, '.eslintrc.json')) + ).toMatchSnapshot(); + + /** + * The project's TSLint file should have been deleted + */ + expect(host.exists(libProjectTSLintJsonPath)).toEqual(false); + }); +}); diff --git a/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts b/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts new file mode 100755 index 0000000000..b55c575ac0 --- /dev/null +++ b/packages/nest/src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint.ts @@ -0,0 +1,134 @@ +import { + convertNxGenerator, + formatFiles, + GeneratorCallback, + Tree, +} from '@nrwl/devkit'; +import { ConvertTSLintToESLintSchema, ProjectConverter } from '@nrwl/linter'; +import { + addLintingToApplication, + NormalizedSchema as AddLintForApplicationSchema, +} from '@nrwl/node/src/generators/application/application'; +import { + addLint as addLintingToLibraryGenerator, + NormalizedSchema as AddLintForLibrarySchema, +} from '@nrwl/workspace/src/generators/library/library'; +import type { Linter } from 'eslint'; + +export async function conversionGenerator( + host: Tree, + options: ConvertTSLintToESLintSchema +) { + /** + * The ProjectConverter instance encapsulates all the standard operations we need + * to perform in order to convert a project from TSLint to ESLint, as well as some + * extensibility points for adjusting the behavior on a per package basis. + * + * E.g. @nrwl/angular projects might need to make different changes to the final + * ESLint config when compared with @nrwl/nest projects. + * + * See the ProjectConverter implementation for a full breakdown of what it does. + */ + const projectConverter = new ProjectConverter({ + host, + projectName: options.project, + eslintInitializer: async ({ projectName, projectConfig }) => { + /** + * Using .js is not an option with NestJS, so we always set it to false when + * delegating to the external (more generic) generators below. + */ + const js = false; + + if (projectConfig.projectType === 'application') { + await addLintingToApplication(host, { + linter: 'eslint' as any, + name: projectName, + appProjectRoot: projectConfig.root, + js, + } as AddLintForApplicationSchema); + } + + if (projectConfig.projectType === 'library') { + await addLintingToLibraryGenerator(host, { + linter: 'eslint', + name: projectName, + projectRoot: projectConfig.root, + js, + } as AddLintForLibrarySchema); + } + }, + }); + + /** + * Dynamically install tslint-to-eslint-config to assist with the conversion. + */ + projectConverter.installTSLintToESLintConfigPackage(); + + /** + * Create the standard (which is applicable to the current package) ESLint setup + * for converting the project. + */ + await projectConverter.initESLint(); + + /** + * Convert the root tslint.json and apply the converted rules to the root .eslintrc.json. + */ + const rootConfigInstallTask = await projectConverter.convertRootTSLintConfig( + (json) => removeCodelyzerRelatedRules(json) + ); + + /** + * Convert the project's tslint.json to an equivalent ESLint config. + */ + const projectConfigInstallTask = await projectConverter.convertProjectConfig( + (json) => json + ); + + /** + * Clean up the original TSLint configuration for the project. + */ + projectConverter.removeProjectTSLintFile(); + + /** + * Store user preference regarding removeTSLintIfNoMoreTSLintTargets for the collection + */ + projectConverter.setDefaults( + '@nrwl/nest', + options.removeTSLintIfNoMoreTSLintTargets + ); + + /** + * Based on user preference and remaining usage, remove TSLint from the workspace entirely. + */ + let uninstallTSLintTask: GeneratorCallback = () => Promise.resolve(undefined); + if ( + options.removeTSLintIfNoMoreTSLintTargets && + !projectConverter.isTSLintUsedInWorkspace() + ) { + uninstallTSLintTask = projectConverter.removeTSLintFromWorkspace(); + } + + await formatFiles(host); + + return async () => { + projectConverter.uninstallTSLintToESLintConfigPackage(); + await rootConfigInstallTask(); + await projectConfigInstallTask(); + await uninstallTSLintTask(); + }; +} + +export const conversionSchematic = convertNxGenerator(conversionGenerator); + +/** + * Remove any @angular-eslint rules that were applied as a result of converting prior codelyzer + * rules, because they are only relevant for Angular projects. + */ +function removeCodelyzerRelatedRules(json: Linter.Config): Linter.Config { + for (const ruleName of Object.keys(json.rules)) { + if (ruleName.startsWith('@angular-eslint')) { + delete json.rules[ruleName]; + } + } + return json; +} diff --git a/packages/nest/src/generators/convert-tslint-to-eslint/schema.json b/packages/nest/src/generators/convert-tslint-to-eslint/schema.json new file mode 100644 index 0000000000..9c6984af92 --- /dev/null +++ b/packages/nest/src/generators/convert-tslint-to-eslint/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "nest-convert-tslint-to-eslint", + "cli": "nx", + "title": "Convert a NestJS project from TSLint to ESLint", + "description": "NOTE: Does not work in --dry-run mode", + "examples": [ + { + "command": "g convert-tslint-to-eslint myapp", + "description": "Convert the NestJS project `myapp` from TSLint to ESLint" + } + ], + "type": "object", + "properties": { + "project": { + "description": "The name of the NestJS project to convert.", + "type": "string", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "Which NestJS project would you like to convert from TSLint to ESLint?" + }, + "removeTSLintIfNoMoreTSLintTargets": { + "type": "boolean", + "description": "If this conversion leaves no more TSLint usage in the workspace, it will remove TSLint and related dependencies and configuration", + "default": true, + "x-prompt": "Would you like to remove TSLint and its related config if there are no TSLint projects remaining after this conversion?" + } + }, + "required": ["project"] +} diff --git a/packages/node/src/generators/application/application.ts b/packages/node/src/generators/application/application.ts index 14e354266d..e18e9b86a5 100644 --- a/packages/node/src/generators/application/application.ts +++ b/packages/node/src/generators/application/application.ts @@ -30,7 +30,7 @@ import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-ser import { Schema } from './schema'; import { initGenerator } from '../init/init'; -interface NormalizedSchema extends Schema { +export interface NormalizedSchema extends Schema { appProjectRoot: string; parsedTags: string[]; } @@ -157,6 +157,25 @@ function addProxy(tree: Tree, options: NormalizedSchema) { } } +export async function addLintingToApplication( + tree: Tree, + options: NormalizedSchema +): Promise { + const lintTask = await lintProjectGenerator(tree, { + linter: options.linter, + project: options.name, + tsConfigPaths: [ + joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'), + ], + eslintFilePatterns: [ + `${options.appProjectRoot}/**/*.${options.js ? 'js' : 'ts'}`, + ], + skipFormat: true, + }); + + return lintTask; +} + export async function applicationGenerator(tree: Tree, schema: Schema) { const options = normalizeOptions(tree, schema); @@ -171,15 +190,7 @@ export async function applicationGenerator(tree: Tree, schema: Schema) { addProject(tree, options); if (options.linter !== Linter.None) { - const lintTask = await lintProjectGenerator(tree, { - linter: options.linter, - project: options.name, - tsConfigPaths: [ - joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'), - ], - eslintFilePatterns: [`${options.appProjectRoot}/**/*.ts`], - skipFormat: true, - }); + const lintTask = await addLintingToApplication(tree, options); tasks.push(lintTask); } diff --git a/packages/workspace/src/generators/library/library.ts b/packages/workspace/src/generators/library/library.ts index 7fd6a7d211..0967a57047 100644 --- a/packages/workspace/src/generators/library/library.ts +++ b/packages/workspace/src/generators/library/library.ts @@ -38,7 +38,7 @@ function addProject(tree: Tree, options: NormalizedSchema) { }); } -function addLint( +export function addLint( tree: Tree, options: NormalizedSchema ): Promise { diff --git a/scripts/depcheck/missing.ts b/scripts/depcheck/missing.ts index ed9cc730a7..1a9333dec0 100644 --- a/scripts/depcheck/missing.ts +++ b/scripts/depcheck/missing.ts @@ -27,7 +27,13 @@ const IGNORE_MATCHES = { 'identity-obj-proxy', '@angular-devkit/schematics', ], - linter: ['eslint', '@angular-devkit/schematics', '@angular-devkit/architect'], + linter: [ + 'eslint', + '@angular-devkit/schematics', + '@angular-devkit/architect', + // Installed and uninstalled dynamically when the conversion generator runs + 'tslint-to-eslint-config', + ], next: [ '@angular-devkit/architect', '@nrwl/devkit', diff --git a/tsconfig.base.json b/tsconfig.base.json index 5013a4f6b8..0a549586ef 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,8 @@ "@nrwl/express": ["./packages/express"], "@nrwl/nest": ["./packages/nest"], "@nrwl/next": ["./packages/next"], + "@nrwl/node": ["./packages/node"], + "@nrwl/node/*": ["./packages/node/*"], "@nrwl/linter": ["./packages/linter"], "@nrwl/jest": ["./packages/jest"], "@nrwl/workspace/testing": ["./packages/workspace/testing"], diff --git a/yarn.lock b/yarn.lock index d53c19f293..d8cd63abe8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8124,6 +8124,11 @@ codelyzer@~5.0.1: source-map "^0.5.7" sprintf-js "^1.1.2" +coffeescript@1.12.7: + version "1.12.7" + resolved "https://registry.yarnpkg.com/coffeescript/-/coffeescript-1.12.7.tgz#e57ee4c4867cf7f606bfc4a0f2d550c0981ddd27" + integrity sha512-pLXHFxQMPklVoEekowk8b3erNynC+DVJzChxS/LCBBgR6/8AJkHivkm//zbowcfc7BTCAjryuhx6gPqPRfsFoA== + collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" @@ -8219,6 +8224,11 @@ commander@3.0.2: resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== +commander@7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff" + integrity sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg== + commander@^2.11.0, commander@^2.12.1, commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -8999,6 +9009,13 @@ crypto-random-string@^1.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= +cson-parser@4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/cson-parser/-/cson-parser-4.0.8.tgz#373f3bde1be018267fccbc575d82120c2a7a645e" + integrity sha512-Hdv3N2E5JU4vAp88cxcs/Y+0L0y0HJnpoc067E//qbXNF4/cG713rFLryD0QvKZYK6w3QBA67t7UOfo2ymh8Sg== + dependencies: + coffeescript "1.12.7" + css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -10563,7 +10580,7 @@ escodegen@^1.11.1, escodegen@^1.14.1: optionalDependencies: source-map "~0.6.1" -eslint-config-prettier@^8.1.0: +eslint-config-prettier@8.1.0, eslint-config-prettier@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6" integrity sha512-oKMhGv3ihGbCIimCAjqkdzx2Q+jthoqnXSP+d86M9tptwugycmTFdVR4IpLgq2c4SHifbwO90z2fQ8/Aio73yw== @@ -14452,6 +14469,13 @@ json3@^3.3.2, json3@^3.3.3: resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== +json5@2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" + integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== + dependencies: + minimist "^1.2.5" + json5@2.x, json5@^2.1.1, json5@^2.1.2, json5@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" @@ -15209,6 +15233,11 @@ lodash@4.17.20, lodash@^4.17.20: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.5.0: version "4.17.19" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" @@ -21932,6 +21961,22 @@ tslib@^1.13.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslint-to-eslint-config@2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/tslint-to-eslint-config/-/tslint-to-eslint-config-2.2.0.tgz#711a1596b765175139193a3878a8a12ebdf91318" + integrity sha512-ta+V1G8y431CPXuJHbzlYYxuAyvKZM8llLZnFN7jy0C98dMsz0jIQCZW7dH5I6wt10gTyMOw7h5W+JEeQumbfQ== + dependencies: + chalk "4.1.0" + commander "7.1.0" + cson-parser "4.0.8" + eslint-config-prettier "8.1.0" + glob "7.1.6" + json5 "2.2.0" + lodash "4.17.21" + minimatch "3.0.4" + tslint "6.1.3" + typescript "4.2.2" + tslint@6.1.3: version "6.1.3" resolved "https://registry.yarnpkg.com/tslint/-/tslint-6.1.3.tgz#5c23b2eccc32487d5523bd3a470e9aa31789d904" @@ -22076,6 +22121,11 @@ typescript@4.1.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== +typescript@4.2.2: + version "4.2.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.2.2.tgz#1450f020618f872db0ea17317d16d8da8ddb8c4c" + integrity sha512-tbb+NVrLfnsJy3M59lsDgrzWIflR4d4TIUjz+heUnHZwdF7YsrMTKoRERiIvI2lvBG95dfpLxB21WZhys1bgaQ== + uglify-js@3.4.x: version "3.4.10" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"