diff --git a/docs/RULES_REQUIRING_TYPE_INFORMATION.md b/docs/RULES_REQUIRING_TYPE_INFORMATION.md new file mode 100644 index 000000000..4685873aa --- /dev/null +++ b/docs/RULES_REQUIRING_TYPE_INFORMATION.md @@ -0,0 +1,329 @@ +# Rules requiring type information + +ESLint is powerful linter by itself, able to work on the syntax of your source files and assert things about based on the rules you configure. It gets even more powerful, however, when TypeScript type-checker is layered on top of it when analyzing TypeScript files, which is something that `@typescript-eslint` allows us to do. + +By default, angular-eslint sets up your ESLint configs with performance in mind - we want your linting to run as fast as possible. Because creating the necessary so called TypeScript `Program`s required to create the type-checker behind the scenes is relatively expensive compared to pure syntax analysis, you should only configure the `parserOptions.project` option in your project's `.eslintrc.json` when you need to use rules requiring type information. + +## How to configure `parserOptions.project` + +### EXAMPLE 1: Root/Single App Project + +Let's take an example of an ESLint config that angular-eslint might generate for you out of the box (in v15 onwards) for single app workspace/the root project in a multi-project workspace: + +```jsonc {% fileName=".eslintrc.json" %} +{ + "root": true, + "ignorePatterns": ["projects/**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@angular-eslint/template/recommended"], + "rules": {} + } + ] +} +``` + +Here we do _not_ have `parserOptions.project`, which is appropriate because we are not leveraging any rules which require type information. + +If we now come in and add a rule which does require type information, for example `@typescript-eslint/await-thenable`, our config will look as follows: + +```jsonc {% fileName=".eslintrc.json" %} +{ + "root": true, + "ignorePatterns": ["projects/**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ], + // This rule requires the TypeScript type checker to be present when it runs + "@typescript-eslint/await-thenable": "error" + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@angular-eslint/template/recommended"], + "rules": {} + } + ] +} +``` + +Now if we try and run `ng lint` we will get an error + +``` +> ng lint + +Linting... + + Error: You have attempted to use a lint rule which requires the full TypeScript type-checker to be available, but you do not have `parserOptions.project` + configured to point at your project tsconfig.json files in the relevant TypeScript file "overrides" block of your ESLint config `/.eslintrc.json` + + For full guidance on how to resolve this issue, please see https://github.com/angular-eslint/angular-eslint/blob/main/docs/RULES_REQUIRING_TYPE_INFORMATION.md + +``` + +The solution is to update our config once more, this time to set `parserOptions.project` to appropriately point at our various tsconfig.json files which belong to our project: + +```jsonc {% fileName=".eslintrc.json" %} +{ + "root": true, + "ignorePatterns": ["projects/**/*"], + "overrides": [ + { + "files": ["*.ts"], + // We set parserOptions.project for the project to allow TypeScript to create the type-checker behind the scenes when we run linting + "parserOptions": { + "project": ["tsconfig.(app|spec).json"] + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ], + // This rule requires the TypeScript type checker to be present when it runs + "@typescript-eslint/await-thenable": "error" + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@angular-eslint/template/recommended"], + "rules": {} + } + ] +} +``` + +And that's it! Now any rules requiring type information will run correctly when we run `ng lint`. + +### EXAMPLE 2: Library Project (in `projects/` for example) + +Let's take an example of an ESLint config that angular-eslint might generate for you out of the box (in v15 onwards) for a library project called `my-library`: + +```jsonc {% fileName="projects/my-library/.eslintrc.json" %} +{ + "extends": "../../.eslintrc.json", + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "lib", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "lib", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "rules": {} + } + ] +} +``` + +Here we do _not_ have `parserOptions.project`, which is appropriate because we are not leveraging any rules which require type information. + +If we now come in and add a rule which does require type information, for example `@typescript-eslint/await-thenable`, our config will look as follows: + +```jsonc {% fileName="projects/my-library/.eslintrc.json" %} +{ + "extends": "../../.eslintrc.json", + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "lib", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "lib", + "style": "kebab-case" + } + ], + // This rule requires the TypeScript type checker to be present when it runs + "@typescript-eslint/await-thenable": "error" + } + }, + { + "files": ["*.html"], + "rules": {} + } + ] +} +``` + +Now if we try and run `ng lint my-library` we will get an error + +``` +> ng lint my-library + +Linting "my-library"... + + Error: You have attempted to use a lint rule which requires the full TypeScript type-checker to be available, but you do not have `parserOptions.project` + configured to point at your project tsconfig.json files in the relevant TypeScript file "overrides" block of your ESLint config `projects/my-library/.eslintrc.json` + + For full guidance on how to resolve this issue, please see https://github.com/angular-eslint/angular-eslint/blob/main/docs/RULES_REQUIRING_TYPE_INFORMATION.md + +``` + +The solution is to update our config once more, this time to set `parserOptions.project` to appropriately point at our various tsconfig.json files which belong to our project: + +```jsonc {% fileName="projects/my-library/.eslintrc.json" %} +{ + "extends": "../../.eslintrc.json", + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + // We set parserOptions.project for the project to allow TypeScript to create the type-checker behind the scenes when we run linting + "parserOptions": { + "project": ["projects/my-library/tsconfig.(app|lib|spec).json"] + }, + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "lib", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "lib", + "style": "kebab-case" + } + ], + // This rule requires the TypeScript type checker to be present when it runs + "@typescript-eslint/await-thenable": "error" + } + }, + { + "files": ["*.html"], + "rules": {} + } + ] +} +``` + +And that's it! Now any rules requiring type information will run correctly when we run `ng lint my-library`. + +## Generating new projects and automatically configuring `parserOptions.project` + +If your workspace is already leveraging rules requiring type information and you want any newly generated projects to be set up with an appropriate setting for `parserOptions.project` automatically, then you can add the `--set-parser-options-project` flag when generating the new application or library: + +E.g. + +```sh +ng g @angular-eslint/schematics:application {PROJECT_NAME_HERE} --set-parser-options-project + +ng g @angular-eslint/schematics:library {PROJECT_NAME_HERE} --set-parser-options-project +``` + +If you don't want to have to remember to pass `--set-parser-options-project` each time, then you can set it to true by default in your schematic defaults in your `angular.json` file: + +```jsonc +{ + // ... more angular.json config here ... + + "schematics": { + "@angular-eslint/schematics:application": { + "setParserOptionsProject": true + }, + "@angular-eslint/schematics:library": { + "setParserOptionsProject": true + } + } +} +``` diff --git a/packages/builder/src/lint.impl.ts b/packages/builder/src/lint.impl.ts index f79f16f19..e0c37f738 100644 --- a/packages/builder/src/lint.impl.ts +++ b/packages/builder/src/lint.impl.ts @@ -42,11 +42,39 @@ export default createBuilder( ? resolve(workspaceRoot, options.eslintConfig) : undefined; - const lintResults: ESLint.LintResult[] = await lint( - workspaceRoot, - eslintConfigPath, - options, - ); + let lintResults: ESLint.LintResult[] = []; + + try { + lintResults = await lint(workspaceRoot, eslintConfigPath, options); + } catch (err) { + if ( + err instanceof Error && + err.message.includes( + 'You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser', + ) + ) { + let eslintConfigPathForError = `for ${projectName}`; + + const projectMetadata = await context.getProjectMetadata(projectName); + if (projectMetadata) { + eslintConfigPathForError = `\`${projectMetadata.root}/.eslintrc.json\``; + } + + console.error(` + Error: You have attempted to use a lint rule which requires the full TypeScript type-checker to be available, but you do not have \`parserOptions.project\` configured to point at your project tsconfig.json files in the relevant TypeScript file "overrides" block of your ESLint config ${ + eslintConfigPath || eslintConfigPathForError + } + + For full guidance on how to resolve this issue, please see https://github.com/angular-eslint/angular-eslint/blob/main/docs/RULES_REQUIRING_TYPE_INFORMATION.md + `); + + return { + success: false, + }; + } + // If some unexpected error, rethrow + throw err; + } if (lintResults.length === 0) { throw new Error('Invalid lint configuration. Nothing to lint.'); diff --git a/packages/eslint-plugin/src/configs/base.json b/packages/eslint-plugin/src/configs/base.json index efa0169d8..56f9a4d13 100644 --- a/packages/eslint-plugin/src/configs/base.json +++ b/packages/eslint-plugin/src/configs/base.json @@ -1,9 +1,4 @@ { "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2020, - "sourceType": "module", - "project": "./tsconfig.json" - }, "plugins": ["@typescript-eslint", "@angular-eslint"] } diff --git a/packages/integration-tests/tests/__snapshots__/v1123-multi-project-yarn-auto-convert.test.ts.snap b/packages/integration-tests/tests/__snapshots__/v1123-multi-project-yarn-auto-convert.test.ts.snap index 4226fad13..17ee99115 100644 --- a/packages/integration-tests/tests/__snapshots__/v1123-multi-project-yarn-auto-convert.test.ts.snap +++ b/packages/integration-tests/tests/__snapshots__/v1123-multi-project-yarn-auto-convert.test.ts.snap @@ -81,8 +81,7 @@ Object { "parserOptions": Object { "createDefaultProgram": true, "project": Array [ - "projects/another-app/tsconfig.app.json", - "projects/another-app/tsconfig.spec.json", + "projects/another-app/tsconfig.(app|spec).json", "projects/another-app/e2e/tsconfig.json", ], }, @@ -141,8 +140,7 @@ Object { "parserOptions": Object { "createDefaultProgram": true, "project": Array [ - "projects/another-lib/tsconfig.lib.json", - "projects/another-lib/tsconfig.spec.json", + "projects/another-lib/tsconfig.(lib|spec).json", ], }, "rules": Object { diff --git a/packages/integration-tests/tests/__snapshots__/v1123-strict-multi-project-auto-convert.test.ts.snap b/packages/integration-tests/tests/__snapshots__/v1123-strict-multi-project-auto-convert.test.ts.snap index 39eb049c2..fb93ff78f 100644 --- a/packages/integration-tests/tests/__snapshots__/v1123-strict-multi-project-auto-convert.test.ts.snap +++ b/packages/integration-tests/tests/__snapshots__/v1123-strict-multi-project-auto-convert.test.ts.snap @@ -81,8 +81,7 @@ Object { "parserOptions": Object { "createDefaultProgram": true, "project": Array [ - "projects/another-app/tsconfig.app.json", - "projects/another-app/tsconfig.spec.json", + "projects/another-app/tsconfig.(app|spec).json", "projects/another-app/e2e/tsconfig.json", ], }, @@ -141,8 +140,7 @@ Object { "parserOptions": Object { "createDefaultProgram": true, "project": Array [ - "projects/another-lib/tsconfig.lib.json", - "projects/another-lib/tsconfig.spec.json", + "projects/another-lib/tsconfig.(lib|spec).json", ], }, "rules": Object { diff --git a/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-ng-add-then-project.test.ts.snap b/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-ng-add-then-project.test.ts.snap index 07b3e5347..852695189 100644 --- a/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-ng-add-then-project.test.ts.snap +++ b/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-ng-add-then-project.test.ts.snap @@ -32,18 +32,14 @@ Object { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - ], - }, "rules": Object {}, }, Object { @@ -71,13 +67,6 @@ Object { "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "projects/app-project/tsconfig.app.json", - "projects/app-project/tsconfig.spec.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", diff --git a/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-project-then-ng-add.test.ts.snap b/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-project-then-ng-add.test.ts.snap index 0a9a416a6..9e8bb7bb4 100644 --- a/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-project-then-ng-add.test.ts.snap +++ b/packages/integration-tests/tests/__snapshots__/v13-new-workspace-create-application-false-project-then-ng-add.test.ts.snap @@ -32,18 +32,14 @@ Object { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -88,13 +84,6 @@ Object { "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "projects/app-project/tsconfig.app.json", - "projects/app-project/tsconfig.spec.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", diff --git a/packages/integration-tests/tests/__snapshots__/v13-new-workspace.test.ts.snap b/packages/integration-tests/tests/__snapshots__/v13-new-workspace.test.ts.snap index 61c8fef39..a902f5342 100644 --- a/packages/integration-tests/tests/__snapshots__/v13-new-workspace.test.ts.snap +++ b/packages/integration-tests/tests/__snapshots__/v13-new-workspace.test.ts.snap @@ -33,18 +33,14 @@ Object { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -101,13 +97,6 @@ Object { "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "projects/another-app/tsconfig.app.json", - "projects/another-app/tsconfig.spec.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -160,13 +149,6 @@ Object { "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "projects/another-lib/tsconfig.lib.json", - "projects/another-lib/tsconfig.spec.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -222,6 +204,9 @@ All files pass linting. Linting \\"another-lib\\"... -All files pass linting. +__ROOT__/v13-new-workspace/projects/another-lib/src/lib/another-lib.service.ts + 8:17 error Unexpected empty constructor @typescript-eslint/no-empty-function + +✖ 1 problem (1 error, 0 warnings) " `; diff --git a/packages/schematics/project.json b/packages/schematics/project.json index df9b08dbd..71fb7afd3 100644 --- a/packages/schematics/project.json +++ b/packages/schematics/project.json @@ -12,7 +12,7 @@ "commands": [ "npx ncp ../../node_modules/@schematics/angular/application/schema.json src/application/schema.json", "npx ncp ../../node_modules/@schematics/angular/library/schema.json src/library/schema.json", - "npx rimraf ./dist", + "npx ts-node --project ../../tsconfig.tools.json ../../tools/scripts/enhance-angular-schemas.ts", "npx tsc -p tsconfig.build.json", "npx ncp src/collection.json dist/collection.json", "npx ncp src/migrations.json dist/migrations.json", diff --git a/packages/schematics/src/add-eslint-to-project/index.ts b/packages/schematics/src/add-eslint-to-project/index.ts index 97020a7a0..ebb6783e2 100644 --- a/packages/schematics/src/add-eslint-to-project/index.ts +++ b/packages/schematics/src/add-eslint-to-project/index.ts @@ -8,6 +8,7 @@ import { interface Schema { project?: string; + setParserOptionsProject?: boolean; } export default function addESLintToProject(schema: Schema): Rule { @@ -27,7 +28,10 @@ E.g. npx ng g @angular-eslint/schematics:add-eslint-to-project {{YOUR_PROJECT_NA // Set the lint builder and config in angular.json addESLintTargetToProject(projectName, 'lint'), // Create the ESLint config file for the project - createESLintConfigForProject(projectName), + createESLintConfigForProject( + projectName, + schema.setParserOptionsProject ?? false, + ), ]); }; } diff --git a/packages/schematics/src/add-eslint-to-project/schema.json b/packages/schematics/src/add-eslint-to-project/schema.json index 25ecaad43..7ed9be97f 100644 --- a/packages/schematics/src/add-eslint-to-project/schema.json +++ b/packages/schematics/src/add-eslint-to-project/schema.json @@ -11,6 +11,11 @@ "$source": "argv", "index": 0 } + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint `parserOptions.project` option. We do not do this by default for lint performance reasons.", + "default": false } }, "required": [] diff --git a/packages/schematics/src/application/index.ts b/packages/schematics/src/application/index.ts index 88291313c..434b4bf10 100644 --- a/packages/schematics/src/application/index.ts +++ b/packages/schematics/src/application/index.ts @@ -5,19 +5,26 @@ import { chain, externalSchematic } from '@angular-devkit/schematics'; * The applicable json file is copied from node_modules as a prebuiid step to ensure * they stay in sync. */ -import type { Schema } from '@schematics/angular/application/schema'; +import type { Schema as AngularSchema } from '@schematics/angular/application/schema'; import { addESLintTargetToProject, createESLintConfigForProject, removeTSLintJSONForProject, } from '../utils'; +interface Schema extends AngularSchema { + setParserOptionsProject?: boolean; +} + function eslintRelatedChanges(options: Schema) { return chain([ // Update the lint builder and config in angular.json addESLintTargetToProject(options.name, 'lint'), // Create the ESLint config file for the project - createESLintConfigForProject(options.name), + createESLintConfigForProject( + options.name, + options.setParserOptionsProject ?? false, + ), // Delete the TSLint config file for the project removeTSLintJSONForProject(options.name), ]); @@ -25,8 +32,12 @@ function eslintRelatedChanges(options: Schema) { export default function (options: Schema): Rule { return (host: Tree, context: SchematicContext) => { + // Remove angular-eslint specific options before passing to the Angular schematic + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { setParserOptionsProject, ...angularOptions } = options; + return chain([ - externalSchematic('@schematics/angular', 'application', options), + externalSchematic('@schematics/angular', 'application', angularOptions), eslintRelatedChanges(options), ])(host, context); }; diff --git a/packages/schematics/src/application/schema.json b/packages/schematics/src/application/schema.json index 42cf17920..be3c2d6a4 100644 --- a/packages/schematics/src/application/schema.json +++ b/packages/schematics/src/application/schema.json @@ -60,7 +60,10 @@ "message": "Which stylesheet format would you like to use?", "type": "list", "items": [ - { "value": "css", "label": "CSS" }, + { + "value": "css", + "label": "CSS" + }, { "value": "scss", "label": "SCSS [ https://sass-lang.com/documentation/syntax#scss ]" @@ -102,6 +105,11 @@ "description": "Creates an application with stricter bundle budgets settings.", "type": "boolean", "default": true + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint `parserOptions.project` option. We do not do this by default for lint performance reasons.", + "default": false } }, "required": ["name"] diff --git a/packages/schematics/src/convert-tslint-to-eslint/index.ts b/packages/schematics/src/convert-tslint-to-eslint/index.ts index 810c05f81..2d197014b 100644 --- a/packages/schematics/src/convert-tslint-to-eslint/index.ts +++ b/packages/schematics/src/convert-tslint-to-eslint/index.ts @@ -96,7 +96,7 @@ E.g. npx ng g @angular-eslint/schematics:convert-tslint-to-eslint {{YOUR_PROJECT : schema.ignoreExistingTslintConfig ? chain([ // Create the latest recommended ESLint config file for the project - createESLintConfigForProject(projectName), + createESLintConfigForProject(projectName, true), // Delete the TSLint config file for the project removeTSLintJSONForProject(projectName), ]) diff --git a/packages/schematics/src/library/index.ts b/packages/schematics/src/library/index.ts index ed3b21363..e6b4ad5e8 100644 --- a/packages/schematics/src/library/index.ts +++ b/packages/schematics/src/library/index.ts @@ -5,33 +5,39 @@ import { chain, externalSchematic } from '@angular-devkit/schematics'; * The applicable json file is copied from node_modules as a prebuiid step to ensure * they stay in sync. */ -import type { Schema } from '@schematics/angular/library/schema'; +import type { Schema as AngularSchema } from '@schematics/angular/library/schema'; import { addESLintTargetToProject, createESLintConfigForProject, removeTSLintJSONForProject, } from '../utils'; +interface Schema extends AngularSchema { + setParserOptionsProject?: boolean; +} + function eslintRelatedChanges(options: Schema) { - /** - * The types coming from the @schematics/angular schema seem to be wrong, if name isn't - * provided the interactive CLI prompt will throw - */ - const projectName = options.name as string; return chain([ // Update the lint builder and config in angular.json - addESLintTargetToProject(projectName, 'lint'), + addESLintTargetToProject(options.name, 'lint'), // Create the ESLint config file for the project - createESLintConfigForProject(projectName), + createESLintConfigForProject( + options.name, + options.setParserOptionsProject ?? false, + ), // Delete the TSLint config file for the project - removeTSLintJSONForProject(projectName), + removeTSLintJSONForProject(options.name), ]); } export default function (options: Schema): Rule { return (host: Tree, context: SchematicContext) => { + // Remove angular-eslint specific options before passing to the Angular schematic + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { setParserOptionsProject, ...angularOptions } = options; + return chain([ - externalSchematic('@schematics/angular', 'library', options), + externalSchematic('@schematics/angular', 'library', angularOptions), eslintRelatedChanges(options), ])(host, context); }; diff --git a/packages/schematics/src/library/schema.json b/packages/schematics/src/library/schema.json index 95c97f173..bfc1663c1 100644 --- a/packages/schematics/src/library/schema.json +++ b/packages/schematics/src/library/schema.json @@ -47,6 +47,11 @@ "projectRoot": { "type": "string", "description": "The root directory of the new library." + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint `parserOptions.project` option. We do not do this by default for lint performance reasons.", + "default": false } }, "required": ["name"] diff --git a/packages/schematics/src/ng-add/index.ts b/packages/schematics/src/ng-add/index.ts index fcf7cb539..959b695a7 100644 --- a/packages/schematics/src/ng-add/index.ts +++ b/packages/schematics/src/ng-add/index.ts @@ -99,9 +99,7 @@ function applyESLintConfigIfSingleProjectWithNoExistingTSLint() { const projectNames = Object.keys(angularJson.projects); if (projectNames.length === 0) { return chain([ - updateJsonInTree('.eslintrc.json', () => - createRootESLintConfig(null, false), - ), + updateJsonInTree('.eslintrc.json', () => createRootESLintConfig(null)), updateJsonInTree('angular.json', (json) => updateSchematicCollections(json), ), diff --git a/packages/schematics/src/utils.ts b/packages/schematics/src/utils.ts index 7faf93d46..b73d6ed4e 100644 --- a/packages/schematics/src/utils.ts +++ b/packages/schematics/src/utils.ts @@ -231,10 +231,7 @@ export function setESLintProjectBasedOnProjectType( ) { let project; if (projectType === 'application') { - project = [ - `${projectRoot}/tsconfig.app.json`, - `${projectRoot}/tsconfig.spec.json`, - ]; + project = [`${projectRoot}/tsconfig.(app|spec).json`]; if (hasE2e) { project.push(`${projectRoot}/e2e/tsconfig.json`); @@ -242,19 +239,13 @@ export function setESLintProjectBasedOnProjectType( } // Libraries don't have an e2e directory if (projectType === 'library') { - project = [ - `${projectRoot}/tsconfig.lib.json`, - `${projectRoot}/tsconfig.spec.json`, - ]; + project = [`${projectRoot}/tsconfig.(lib|spec).json`]; } return project; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function createRootESLintConfig( - prefix: string | null, - hasE2e?: boolean, -) { +export function createRootESLintConfig(prefix: string | null) { let codeRules; if (prefix) { codeRules = { @@ -277,13 +268,9 @@ export function createRootESLintConfig( overrides: [ { files: ['*.ts'], - parserOptions: { - project: hasE2e - ? ['tsconfig.json', 'e2e/tsconfig.json'] - : ['tsconfig.json'], - createDefaultProgram: true, - }, extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', 'plugin:@angular-eslint/recommended', 'plugin:@angular-eslint/template/process-inline-templates', ], @@ -304,6 +291,7 @@ function createProjectESLintConfig( projectRoot: string, projectType: ProjectType, prefix: string, + setParserOptionsProject: boolean, hasE2e: boolean, ) { return { @@ -312,14 +300,17 @@ function createProjectESLintConfig( overrides: [ { files: ['*.ts'], - parserOptions: { - project: setESLintProjectBasedOnProjectType( - projectRoot, - projectType, - hasE2e, - ), - createDefaultProgram: true, - }, + ...(setParserOptionsProject + ? { + parserOptions: { + project: setESLintProjectBasedOnProjectType( + projectRoot, + projectType, + hasE2e, + ), + }, + } + : null), rules: { '@angular-eslint/directive-selector': [ 'error', @@ -340,7 +331,10 @@ function createProjectESLintConfig( }; } -export function createESLintConfigForProject(projectName: string): Rule { +export function createESLintConfigForProject( + projectName: string, + setParserOptionsProject: boolean, +): Rule { return (tree: Tree) => { const angularJSON = readJsonInTree(tree, 'angular.json'); const { @@ -370,6 +364,7 @@ export function createESLintConfigForProject(projectName: string): Rule { projectRoot, projectType, prefix, + setParserOptionsProject, hasE2e, ), ), @@ -392,7 +387,6 @@ function createRootESLintConfigFile(projectName: string): Rule { return (tree) => { const angularJSON = readJsonInTree(tree, getWorkspacePath(tree)); let lintPrefix: string | null = null; - const hasE2e = determineTargetProjectHasE2E(angularJSON, projectName); if (angularJSON.projects?.[projectName]) { const { prefix } = angularJSON.projects[projectName]; @@ -400,7 +394,7 @@ function createRootESLintConfigFile(projectName: string): Rule { } return updateJsonInTree('.eslintrc.json', () => - createRootESLintConfig(lintPrefix, hasE2e), + createRootESLintConfig(lintPrefix), ); }; } diff --git a/packages/schematics/tests/add-eslint-to-project/index.test.ts b/packages/schematics/tests/add-eslint-to-project/index.test.ts index 1bb7fdf93..7ab580fe3 100644 --- a/packages/schematics/tests/add-eslint-to-project/index.test.ts +++ b/packages/schematics/tests/add-eslint-to-project/index.test.ts @@ -108,18 +108,14 @@ describe('add-eslint-to-project', () => { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -154,7 +150,7 @@ describe('add-eslint-to-project', () => { `); }); - it('should add ESLint to the legacy Angular CLI projects which are generated with e2e after the workspace is', async () => { + it('should add ESLint to the legacy Angular CLI projects which are generated with e2e after the workspace already exists', async () => { const options = { project: legacyProjectName, }; @@ -191,14 +187,6 @@ describe('add-eslint-to-project', () => { "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "projects/legacy-project/tsconfig.app.json", - "projects/legacy-project/tsconfig.spec.json", - "projects/legacy-project/e2e/tsconfig.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -229,7 +217,7 @@ describe('add-eslint-to-project', () => { `); }); - it('should add ESLint to the any other Angular CLI projects which are generated after the workspace is', async () => { + it('should add ESLint to the any other Angular CLI projects which are generated after the workspace already exists', async () => { const options = { project: otherProjectName, }; @@ -266,13 +254,6 @@ describe('add-eslint-to-project', () => { "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "projects/other-project/tsconfig.app.json", - "projects/other-project/tsconfig.spec.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -303,6 +284,155 @@ describe('add-eslint-to-project', () => { `); }); + describe('--setParserOptionsProject=true', () => { + it('should add ESLint to the legacy Angular CLI projects which are generated with e2e after the workspace already exists', async () => { + const options = { + project: legacyProjectName, + setParserOptionsProject: true, + }; + + await schematicRunner + .runSchematicAsync('add-eslint-to-project', options, appTree) + .toPromise(); + + const projectConfig = readJsonInTree(appTree, 'angular.json').projects[ + legacyProjectName + ]; + + expect(projectConfig.architect.lint).toMatchInlineSnapshot(` + Object { + "builder": "@angular-eslint/builder:lint", + "options": Object { + "lintFilePatterns": Array [ + "projects/legacy-project/**/*.ts", + "projects/legacy-project/**/*.html", + ], + }, + } + `); + + expect(readJsonInTree(appTree, `${projectConfig.root}/.eslintrc.json`)) + .toMatchInlineSnapshot(` + Object { + "extends": "../../.eslintrc.json", + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + ], + "parserOptions": Object { + "project": Array [ + "projects/legacy-project/tsconfig.(app|spec).json", + "projects/legacy-project/e2e/tsconfig.json", + ], + }, + "rules": Object { + "@angular-eslint/component-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "kebab-case", + "type": "element", + }, + ], + "@angular-eslint/directive-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "camelCase", + "type": "attribute", + }, + ], + }, + }, + Object { + "files": Array [ + "*.html", + ], + "rules": Object {}, + }, + ], + } + `); + }); + + it('should add ESLint to the any other Angular CLI projects which are generated after the workspace already exists', async () => { + const options = { + project: otherProjectName, + setParserOptionsProject: true, + }; + + await schematicRunner + .runSchematicAsync('add-eslint-to-project', options, appTree) + .toPromise(); + + const projectConfig = readJsonInTree(appTree, 'angular.json').projects[ + otherProjectName + ]; + + expect(projectConfig.architect.lint).toMatchInlineSnapshot(` + Object { + "builder": "@angular-eslint/builder:lint", + "options": Object { + "lintFilePatterns": Array [ + "projects/other-project/**/*.ts", + "projects/other-project/**/*.html", + ], + }, + } + `); + + expect(readJsonInTree(appTree, `${projectConfig.root}/.eslintrc.json`)) + .toMatchInlineSnapshot(` + Object { + "extends": "../../.eslintrc.json", + "ignorePatterns": Array [ + "!**/*", + ], + "overrides": Array [ + Object { + "files": Array [ + "*.ts", + ], + "parserOptions": Object { + "project": Array [ + "projects/other-project/tsconfig.(app|spec).json", + ], + }, + "rules": Object { + "@angular-eslint/component-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "kebab-case", + "type": "element", + }, + ], + "@angular-eslint/directive-selector": Array [ + "error", + Object { + "prefix": "app", + "style": "camelCase", + "type": "attribute", + }, + ], + }, + }, + Object { + "files": Array [ + "*.html", + ], + "rules": Object {}, + }, + ], + } + `); + }); + }); + describe('custom root project sourceRoot', () => { let tree2: UnitTestTree; @@ -396,18 +526,14 @@ describe('add-eslint-to-project', () => { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", diff --git a/packages/schematics/tests/application/index.test.ts b/packages/schematics/tests/application/index.test.ts index a1a6f20ac..86c76839b 100644 --- a/packages/schematics/tests/application/index.test.ts +++ b/packages/schematics/tests/application/index.test.ts @@ -84,7 +84,67 @@ describe('application', () => { const tree = await schematicRunner .runSchematicAsync( 'application', - { name: 'foo', prefix: 'something-custom' }, + { + name: 'foo', + prefix: 'something-custom', + }, + appTree, + ) + .toPromise(); + + expect(tree.exists('projects/foo/tslint.json')).toBe(false); + expect(tree.read('projects/foo/.eslintrc.json')?.toString()) + .toMatchInlineSnapshot(` + "{ + \\"extends\\": \\"../../.eslintrc.json\\", + \\"ignorePatterns\\": [ + \\"!**/*\\" + ], + \\"overrides\\": [ + { + \\"files\\": [ + \\"*.ts\\" + ], + \\"rules\\": { + \\"@angular-eslint/directive-selector\\": [ + \\"error\\", + { + \\"type\\": \\"attribute\\", + \\"prefix\\": \\"something-custom\\", + \\"style\\": \\"camelCase\\" + } + ], + \\"@angular-eslint/component-selector\\": [ + \\"error\\", + { + \\"type\\": \\"element\\", + \\"prefix\\": \\"something-custom\\", + \\"style\\": \\"kebab-case\\" + } + ] + } + }, + { + \\"files\\": [ + \\"*.html\\" + ], + \\"rules\\": {} + } + ] + } + " + `); + }); + + it('should add the ESLint config for the project and delete the TSLint config (--setParserOptionsProject=true)', async () => { + const tree = await schematicRunner + .runSchematicAsync( + 'application', + { + name: 'foo', + prefix: 'something-custom', + setParserOptionsProject: true, + }, appTree, ) .toPromise(); @@ -104,10 +164,8 @@ describe('application', () => { ], \\"parserOptions\\": { \\"project\\": [ - \\"projects/foo/tsconfig.app.json\\", - \\"projects/foo/tsconfig.spec.json\\" - ], - \\"createDefaultProgram\\": true + \\"projects/foo/tsconfig.(app|spec).json\\" + ] }, \\"rules\\": { \\"@angular-eslint/directive-selector\\": [ diff --git a/packages/schematics/tests/library/index.test.ts b/packages/schematics/tests/library/index.test.ts index 315791686..b84725d72 100644 --- a/packages/schematics/tests/library/index.test.ts +++ b/packages/schematics/tests/library/index.test.ts @@ -63,7 +63,7 @@ describe('library', () => { it('should change the lint target to use the @angular-eslint builder', async () => { const tree = await schematicRunner - .runSchematicAsync('application', { name: 'bar' }, appTree) + .runSchematicAsync('library', { name: 'bar' }, appTree) .toPromise(); expect(readJsonInTree(tree, 'angular.json').projects.bar.architect.lint) @@ -83,8 +83,68 @@ describe('library', () => { it('should add the ESLint config for the project and delete the TSLint config', async () => { const tree = await schematicRunner .runSchematicAsync( - 'application', - { name: 'bar', prefix: 'something-else-custom' }, + 'library', + { + name: 'bar', + prefix: 'something-else-custom', + }, + appTree, + ) + .toPromise(); + + expect(tree.exists('projects/bar/tslint.json')).toBe(false); + expect(tree.read('projects/bar/.eslintrc.json')?.toString()) + .toMatchInlineSnapshot(` + "{ + \\"extends\\": \\"../../.eslintrc.json\\", + \\"ignorePatterns\\": [ + \\"!**/*\\" + ], + \\"overrides\\": [ + { + \\"files\\": [ + \\"*.ts\\" + ], + \\"rules\\": { + \\"@angular-eslint/directive-selector\\": [ + \\"error\\", + { + \\"type\\": \\"attribute\\", + \\"prefix\\": \\"something-else-custom\\", + \\"style\\": \\"camelCase\\" + } + ], + \\"@angular-eslint/component-selector\\": [ + \\"error\\", + { + \\"type\\": \\"element\\", + \\"prefix\\": \\"something-else-custom\\", + \\"style\\": \\"kebab-case\\" + } + ] + } + }, + { + \\"files\\": [ + \\"*.html\\" + ], + \\"rules\\": {} + } + ] + } + " + `); + }); + + it('should add the ESLint config for the project and delete the TSLint config (--setParserOptionsProject=true)', async () => { + const tree = await schematicRunner + .runSchematicAsync( + 'library', + { + name: 'bar', + prefix: 'something-else-custom', + setParserOptionsProject: true, + }, appTree, ) .toPromise(); @@ -104,10 +164,8 @@ describe('library', () => { ], \\"parserOptions\\": { \\"project\\": [ - \\"projects/bar/tsconfig.app.json\\", - \\"projects/bar/tsconfig.spec.json\\" - ], - \\"createDefaultProgram\\": true + \\"projects/bar/tsconfig.(lib|spec).json\\" + ] }, \\"rules\\": { \\"@angular-eslint/directive-selector\\": [ diff --git a/packages/schematics/tests/ng-add/index.test.ts b/packages/schematics/tests/ng-add/index.test.ts index 7a50f089a..29acf9892 100644 --- a/packages/schematics/tests/ng-add/index.test.ts +++ b/packages/schematics/tests/ng-add/index.test.ts @@ -138,19 +138,14 @@ describe('ng-add', () => { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - "e2e/tsconfig.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", @@ -273,18 +268,14 @@ describe('ng-add', () => { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - ], - }, "rules": Object {}, }, Object { @@ -408,18 +399,14 @@ describe('ng-add', () => { "overrides": Array [ Object { "extends": Array [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", "plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates", ], "files": Array [ "*.ts", ], - "parserOptions": Object { - "createDefaultProgram": true, - "project": Array [ - "tsconfig.json", - ], - }, "rules": Object { "@angular-eslint/component-selector": Array [ "error", diff --git a/tools/scripts/enhance-angular-schemas.ts b/tools/scripts/enhance-angular-schemas.ts new file mode 100644 index 000000000..99aa56080 --- /dev/null +++ b/tools/scripts/enhance-angular-schemas.ts @@ -0,0 +1,61 @@ +import { writeFileSync } from 'fs'; +import { join } from 'path'; +import { format, resolveConfig } from 'prettier'; + +(async function main() { + const setParserOptionsProjectConfig = { + type: 'boolean', + description: + 'Whether or not to configure the ESLint `parserOptions.project` option. We do not do this by default for lint performance reasons.', + default: false, + }; + + const applicationSchemaJsonPath = join( + __dirname, + '../../packages/schematics/src/application/schema.json', + ); + + await enhanceSchemaWithProperties(applicationSchemaJsonPath, { + setParserOptionsProject: setParserOptionsProjectConfig, + }); + + const librarySchemaJsonPath = join( + __dirname, + '../../packages/schematics/src/library/schema.json', + ); + + await enhanceSchemaWithProperties(librarySchemaJsonPath, { + setParserOptionsProject: setParserOptionsProjectConfig, + }); +})(); + +async function enhanceSchemaWithProperties( + schemaJsonPath: string, + properties: Record, +) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const schemaJson = require(schemaJsonPath); + const updatedSchemaJson = JSON.stringify( + { + ...schemaJson, + properties: { + ...schemaJson.properties, + ...properties, + }, + }, + null, + 2, + ); + + writeFileSync( + schemaJsonPath, + format(updatedSchemaJson, { + ...(await resolveConfig(schemaJsonPath)), + parser: 'json', + }), + ); + + console.log( + `\n✨ Enhanced ${schemaJsonPath} with angular-eslint specific options`, + ); +} diff --git a/tools/scripts/generate-configs.ts b/tools/scripts/generate-configs.ts index 484dc361f..87e3a4360 100644 --- a/tools/scripts/generate-configs.ts +++ b/tools/scripts/generate-configs.ts @@ -118,11 +118,6 @@ console.log( const baseConfig: LinterConfig = { parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module', - project: './tsconfig.json', - }, plugins: ['@typescript-eslint', '@angular-eslint'], }; writeConfig(