diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b01051e90..f6444d6bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,8 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 # we need the tags to be available + fetch-depth: 0 # we need the tags to be available but not the full tree + filter: "tree:0" - uses: pnpm/action-setup@v4 name: Install pnpm diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index bcd8ef593..de39ca403 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,6 +10,9 @@ on: pull_request_review: types: [submitted] +env: + SKIP_POSTINSTALL: true + jobs: claude: if: | @@ -27,11 +30,26 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 1 + fetch-depth: 0 # we need the tags to be available but not the full tree + filter: "tree:0" + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js per package.json + uses: actions/setup-node@v4 + with: + # Use the volta.node property as the source of truth + node-version-file: 'package.json' + cache: 'pnpm' + registry-url: https://registry.npmjs.org/ - name: Run Claude Code id: claude uses: anthropics/claude-code-action@beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - + timeout_minutes: 20 + allowed_tools: "Edit,Read,Write,Glob,Grep,LS,MultiEdit,Bash(pnpm install),Bash(pnpm --version),Bash(pnpm build),Bash(pnpm test),Bash(pnpm e2e),Bash(pnpm update-e2e-snapshots),Bash(pnpm lint),Bash(pnpm typecheck),Bash(pnpm format),Bash(pnpm format-check),Bash(pnpm update-rule-docs),Bash(pnpm check-rule-docs),Bash(pnpm update-rule-lists),Bash(pnpm check-rule-lists),Bash(pnpm update-rule-configs),Bash(pnpm check-rule-configs),Bash(pnpm nx:*),Bash(git status),Bash(git add .),Bash(git commit -m),Bash(git diff),Bash(git log --oneline -10),Bash(node --version)" diff --git a/.gitignore b/.gitignore index 4aa9fa6cc..78fcc94ea 100644 --- a/.gitignore +++ b/.gitignore @@ -119,5 +119,8 @@ tmp .nx/workspace-data *.tsbuildinfo + +# AI .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index cb6541380..9697c8160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,12 @@ # Guidelines for Codex -This repository uses Nx as the task runner. Nx Cloud requires internet access, which is not available in the Codex environment, so **all nx commands must be executed with `NX_NO_CLOUD=true`**. +This repository uses Nx as the task runner and monorepo manager for building and testing Angular ESLint packages. + +NOTE: This repo uses Nx Cloud for the distributed task execution, AI integration, and remote caching benefits. It requires internet access, which is not available in the Codex environment, so **all nx commands must be executed with `NX_NO_CLOUD=true`**. + +## Package Manager + +This project uses **pnpm** (version 10) as the package manager. Always use `pnpm` commands instead of `npm` or `yarn`. ## Required checks @@ -14,14 +20,21 @@ NX_NO_CLOUD=true pnpm nx run-many -t check-rule-lists # run NX_NO_CLOUD=true pnp NX_NO_CLOUD=true pnpm nx run-many -t check-rule-configs # run NX_NO_CLOUD=true pnpm nx run-many -t update-rule-configs and commit the result if this check fails ``` -Additionally, run tests and lints for any affected project. For example, changes to `eslint-plugin-template` require: +When working on an individual rule, the preferred way to run tests is to target the specific spec file. For example, to run tests for the `prefer-standalone` rule within the `eslint-plugin` project, run: ```bash -NX_NO_CLOUD=true pnpm nx test eslint-plugin-template -NX_NO_CLOUD=true pnpm nx lint eslint-plugin-template +NX_NO_CLOUD=true pnpm nx test eslint-plugin packages/eslint-plugin/tests/rules/prefer-standalone/spec.ts --runInBand ``` -If there are memory issues with jest tests, try passing `--runInBand` to the test command. +Once rule specific tests have passed, run commands for all projects: + +Use `pnpm nx run-many -t test --parallel 2` to run all tests across all packages. +Use `pnpm nx run-many -t lint --parallel 2` to run all linting across all packages. +Use `pnpm nx run-many -t typecheck --parallel 2` to run TypeScript type checking across all packages. + +If there are memory issues with jest tests, try passing `--runInBand` to the test command andor changing the number of parallel tests to 1. + +If you are updating e2e tests, you may need to update the snapshots. Run `pnpm update-e2e-snapshots` to update the snapshots and commit the resulting snapshot changes. NOTHING ELSE. There will be a diff on package.json files etc when doing this, but ONLY commit the snapshot changes. ## Commit conventions @@ -31,3 +44,5 @@ Use [Conventional Commits](https://www.conventionalcommits.org/) for commit mess - When multiple projects are affected, omit the scope: `fix: correct lint configuration`. By convention, if only updating a single rule within a single project, for example the `alt-text` rule within the `eslint-plugin-template` project, the commit message should be `fix(eslint-plugin-template): [alt-text] description of the change`. + +For any changes that only update tests, use `test` or `chore` as the commit/PR type, do not use `fix` or `feat`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f4d6797..0231864f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 19.6.0 (2025-05-27) + +### 🚀 Features + +- **eslint-plugin:** [prefer-inject] add new rule ([#2461](https://github.com/angular-eslint/angular-eslint/pull/2461)) + +### 🩹 Fixes + +- respect existing eslint.config.ts, eslint.config.cts, eslint.config.mts files ([#2458](https://github.com/angular-eslint/angular-eslint/pull/2458)) +- **eslint-plugin:** [sort-keys-in-type-decorator] preserve unconfigured properties during autofix ([#2456](https://github.com/angular-eslint/angular-eslint/pull/2456)) +- **eslint-plugin:** [use-lifecycle-interface] do not report if the method uses override ([#2463](https://github.com/angular-eslint/angular-eslint/pull/2463)) + +### ❤️ Thank You + +- James Henry @JamesHenry + ## 19.5.0 (2025-05-25) ### 🚀 Features diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2a34a36f3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,156 @@ +# Guidelines for Claude Code + +This repository uses Nx as the task runner and monorepo manager for building and testing Angular ESLint packages. + +## Package Manager + +This project uses **pnpm** (version 10) as the package manager. Always use `pnpm` commands instead of `npm` or `yarn`. + +## Required checks + +When modifying rule implementations or documentation, run the following commands and ensure they pass (noting the comments that explain what to do if any of these checks fail): + +```bash +pnpm format-check # run pnpm format and commit the result if this check fails +pnpm nx sync:check # run pnpm nx sync and commit the result if this check fails +pnpm nx run-many -t check-rule-docs # run pnpm nx run-many -t update-rule-docs and commit the result if this check fails +pnpm nx run-many -t check-rule-lists # run pnpm nx run-many -t update-rule-lists and commit the result if this check fails +pnpm nx run-many -t check-rule-configs # run pnpm nx run-many -t update-rule-configs and commit the result if this check fails +``` + +When working on an individual rule, the preferred way to run tests is to target the specific spec file. For example, to run tests for the `prefer-standalone` rule within the `eslint-plugin` project, run: + +```bash +NX_NO_CLOUD=true pnpm nx test eslint-plugin packages/eslint-plugin/tests/rules/prefer-standalone/spec.ts --runInBand +``` + +Once rule specific tests have passed, run commands for all projects: + +Use `pnpm nx run-many -t test --parallel 2` to run all tests across all packages. +Use `pnpm nx run-many -t lint --parallel 2` to run all linting across all packages. +Use `pnpm nx run-many -t typecheck --parallel 2` to run TypeScript type checking across all packages. + +If there are memory issues with jest tests, try passing `--runInBand` to the test command andor changing the number of parallel tests to 1. + +If you are updating e2e tests, you may need to update the snapshots. Run `pnpm update-e2e-snapshots` to update the snapshots and commit the resulting snapshot changes. NOTHING ELSE. There will be a diff on package.json files etc when doing this, but ONLY commit the snapshot changes. + +## Project Structure + +This is a monorepo with the following main packages in `/packages/`: + +- **angular-eslint**: Main package that provides configurations +- **eslint-plugin**: Core ESLint rules for Angular TypeScript code +- **eslint-plugin-template**: ESLint rules for Angular HTML templates +- **builder**: Angular CLI builder for running ESLint +- **schematics**: Angular schematics for adding ESLint to projects +- **template-parser**: Parser for Angular templates +- **test-utils**: Testing utilities +- **utils**: Shared utility functions +- **bundled-angular-compiler**: Bundled Angular compiler +- **nx-plugin**: Nx plugin for the local Angular ESLint monorepo, not published to npm + +## Code Conventions + +### TypeScript Configuration + +- **Strict TypeScript**: Uses strict mode with additional strict flags like `noImplicitReturns`, `noUnusedLocals`, `noFallthroughCasesInSwitch` +- **Target**: ES2022 with Node.js module resolution +- **Module system**: NodeNext for modern Node.js compatibility + +### ESLint Configuration + +- Uses **flat config** format (eslint.config.js) +- Nx ESLint plugin for monorepo management +- JSONC parser for JSON files +- Enforces module boundaries between packages + +### File Naming and Structure + +- **Test files**: Use `.spec.ts` suffix and live in `tests/` directories +- **Rule files**: Individual TypeScript files in `src/rules/` +- **Config files**: JSON files in `src/configs/` +- **Documentation**: Markdown files in `docs/rules/` matching rule names. NOTE: THESE ARE AUTO-GENERATED, DO NOT EDIT THEM MANUALLY. +- **Test cases**: Organized in `tests/rules/{rule-name}/cases.ts` and `tests/rules/{rule-name}/spec.ts` + +### Rule Development Patterns + +When creating or modifying ESLint rules: + +1. Rules are implemented in TypeScript using the `@typescript-eslint/utils` package +2. Each rule has its own file in `src/rules/` +3. Rule documentation is auto-generated and lives in `docs/rules/` +4. Test cases follow a pattern with `cases.ts` and `spec.ts` files +5. Rules use a utility function `createESLintRule()` for consistent structure + +### Testing Conventions + +- **Jest**: Primary testing framework +- **Rule testing**: Uses `@typescript-eslint/rule-tester` for ESLint rule testing +- **Snapshot testing**: Used for E2E tests and some rule outputs +- **Test organization**: Tests mirror the source structure in separate `tests/` directories + +### Build and Release + +- **Build**: Uses Nx with TypeScript compilation +- **Targets**: Multiple build targets (`build`, `compile`) with dependency management +- **Release**: Conventional Commits with automated changelogs +- **Versioning**: Uses Nx release management with GitHub integration + +Claude should NEVER run versioning or publishing commands. + +## Commit conventions + +Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages and PR titles. + +- When a change affects a single project, include its name as the scope: `feat(eslint-plugin-template): add new rule`. +- When multiple projects are affected, omit the scope: `fix: correct lint configuration`. + +By convention, if only updating a single rule within a single project, for example the `alt-text` rule within the `eslint-plugin-template` project, the commit message should be `fix(eslint-plugin-template): [alt-text] description of the change`. + +For any changes that only update tests, use `test` or `chore` as the commit/PR type, do not use `fix` or `feat`. + +## Development Tools + +- **Node.js**: Managed by Volta, always check the `package.json` file for the correct version. +- **Husky**: Git hooks for pre-commit and pre-push checks +- **Lint-staged**: Run linters on staged files + +## Common Commands + +```bash +# Install dependencies +pnpm install + +# Build all packages +pnpm build + +# Run all tests +pnpm test + +# Run E2E tests +pnpm e2e + +# Run E2E tests and update snapshots +pnpm update-e2e-snapshots + +# Format code +pnpm format + +# Check formatting +pnpm format-check + +# Lint all packages +pnpm lint + +# Type check all packages +pnpm typecheck + +# Update rule documentation +pnpm update-rule-docs + +# Update rule lists in READMEs +pnpm update-rule-lists + +# Update rule configurations +pnpm update-rule-configs +``` diff --git a/docs/CONFIGURING_FLAT_CONFIG.md b/docs/CONFIGURING_FLAT_CONFIG.md index c6cc1db3b..7a41de133 100644 --- a/docs/CONFIGURING_FLAT_CONFIG.md +++ b/docs/CONFIGURING_FLAT_CONFIG.md @@ -29,6 +29,27 @@ Fortunately, however, ESLint has clearly defined points of extensibility that we Therefore, our flat config will contain two entries, one for TS, one for HTML. We could provide these two entries directly in an exported array, but `typescript-eslint` provides an awesome typed utility function which makes writing our flat configs a lot nicer, so we will instead require the function and pass in multiple objects for our configuration. +## Configuring ESLint for Inline Templates + +One of the features of angular-eslint is its ability to lint **inline templates** within your Angular components. This is made possible through ESLint's processor API, which allows us to extract inline template content from your TypeScript component files and apply HTML template rules to them. + +### How it works + +When you use inline templates in your Angular components (using the `template` property instead of `templateUrl`), angular-eslint can automatically extract these templates and treat them as if they were separate HTML files. This means all your Angular template rules will work seamlessly on both external template files AND inline templates. + +The magic happens through the `angular.processInlineTemplates` processor, which: + +1. Scans your TypeScript component files for inline templates +2. Extracts the template content +3. Applies your HTML configuration rules to the extracted templates +4. Reports any linting issues with proper line and column mapping back to your original TypeScript file + +For more details on how ESLint processors work behind the scenes, see the [ESLint Custom Processors documentation](https://eslint.org/docs/latest/extend/custom-processors). + +### Configuration example + +The key is to add the `processor: angular.processInlineTemplates` to your TypeScript configuration block: + **Workspace root level eslint.config.js** ```js @@ -58,8 +79,9 @@ module.exports = tseslint.config( // Apply the recommended Angular rules ...angular.configs.tsRecommended, ], - // Set the custom processor which will allow us to have our inline Component templates extracted - // and treated as if they are HTML files (and therefore have the .html config below applied to them) + // IMPORTANT: Set the custom processor to enable inline template linting + // This allows your inline Component templates to be extracted and linted with the same + // rules as your external .html template files processor: angular.processInlineTemplates, // Override specific rules for TypeScript files (these will take priority over the extended configs above) rules: { @@ -82,8 +104,8 @@ module.exports = tseslint.config( }, }, { - // Everything in this config object targets our HTML files (external templates, - // and inline templates as long as we have the `processor` set on our TypeScript config above) + // Everything in this config object targets our HTML files (both external template files, + // AND inline templates thanks to the processor set in the TypeScript config above) files: ['**/*.html'], extends: [ // Apply the recommended Angular template rules @@ -138,6 +160,7 @@ module.exports = tseslint.config( }, { // Any project level overrides or additional rules for HTML files can go here + // (applies to both external template files AND inline templates) // (we don't need to extend from any angular-eslint configs because // we already applied the rootConfig above which has them) files: ['**/*.html'], @@ -196,6 +219,7 @@ module.exports = tseslint.config([ }, { // Any project level overrides or additional rules for HTML files can go here + // (applies to both external template files AND inline templates) // (we don't need to extend from any angular-eslint configs because // we already applied the rootConfig above which has them) files: ['**/*.html'], diff --git a/e2e/src/__snapshots__/new-workspace-type-module.test.ts.snap b/e2e/src/__snapshots__/new-workspace-type-module.test.ts.snap index 717107473..7704bfc1b 100644 --- a/e2e/src/__snapshots__/new-workspace-type-module.test.ts.snap +++ b/e2e/src/__snapshots__/new-workspace-type-module.test.ts.snap @@ -195,5 +195,7 @@ __ROOT__//new-workspace-type-module/projects/another-lib/src/lib/another-lib.ser 8:17 error Unexpected empty constructor @typescript-eslint/no-empty-function ✖ 1 problem (1 error, 0 warnings) + +Lint errors found in the listed files. " `; diff --git a/e2e/src/__snapshots__/new-workspace-typescript-config.test.ts.snap b/e2e/src/__snapshots__/new-workspace-typescript-config.test.ts.snap new file mode 100644 index 000000000..85982703b --- /dev/null +++ b/e2e/src/__snapshots__/new-workspace-typescript-config.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`new-workspace with TypeScript config should pass linting with TypeScript config after the user installs jiti 1`] = ` +" +Linting \\"new-workspace-typescript-config\\"... + +All files pass linting. +" +`; + +exports[`new-workspace with TypeScript config should show an error when linting using a TypeScript config before the user installs jiti 1`] = ` +" +Linting \\"new-workspace-typescript-config\\"... +Error when running ESLint: You are using an outdated version of the 'jiti' library. Please update to the latest version of 'jiti' to ensure compatibility and access to the latest features." +`; diff --git a/e2e/src/__snapshots__/new-workspace.test.ts.snap b/e2e/src/__snapshots__/new-workspace.test.ts.snap index 9296c6367..e9958a915 100644 --- a/e2e/src/__snapshots__/new-workspace.test.ts.snap +++ b/e2e/src/__snapshots__/new-workspace.test.ts.snap @@ -195,5 +195,7 @@ __ROOT__//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) + +Lint errors found in the listed files. " `; diff --git a/e2e/src/new-workspace-typescript-config.test.ts b/e2e/src/new-workspace-typescript-config.test.ts new file mode 100644 index 000000000..0f8435fd1 --- /dev/null +++ b/e2e/src/new-workspace-typescript-config.test.ts @@ -0,0 +1,96 @@ +import path from 'node:path'; +import { setWorkspaceRoot } from 'nx/src/utils/workspace-root'; +import { FIXTURES_DIR, Fixture } from '../utils/fixtures'; +import { + LONG_TIMEOUT_MS, + runNgAdd, + runNgNew, +} from '../utils/local-registry-process'; +import { runLint } from '../utils/run-lint'; +import { normalizeVersionsOfPackagesWeDoNotControl } from '../utils/snapshot-serializers'; + +expect.addSnapshotSerializer(normalizeVersionsOfPackagesWeDoNotControl); + +const fixtureDirectory = 'new-workspace-typescript-config'; +let fixture: Fixture; + +describe('new-workspace with TypeScript config', () => { + jest.setTimeout(LONG_TIMEOUT_MS); + + beforeAll(async () => { + process.chdir(FIXTURES_DIR); + await runNgNew(fixtureDirectory); + + process.env.NX_DAEMON = 'false'; + process.env.NX_CACHE_PROJECT_GRAPH = 'false'; + + const workspaceRoot = path.join(FIXTURES_DIR, fixtureDirectory); + process.chdir(workspaceRoot); + process.env.NX_WORKSPACE_ROOT_PATH = workspaceRoot; + setWorkspaceRoot(workspaceRoot); + + fixture = new Fixture(workspaceRoot); + + await runNgAdd(); + + // Replace the default eslint.config.js with a TypeScript version + fixture.deleteFileOrDirectory('eslint.config.js'); + + // Create a TypeScript config with the same content + const tsConfig = `import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import angular from 'angular-eslint'; + +export default tseslint.config( + { + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + 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: [ + ...angular.configs.templateRecommended, + ...angular.configs.templateAccessibility, + ], + rules: {}, + } +);`; + + fixture.writeFile('eslint.config.ts', tsConfig); + }); + + it('should show an error when linting using a TypeScript config before the user installs jiti', async () => { + const result = await runLint(fixtureDirectory); + expect(result).toMatchSnapshot(); + }); + + it('should pass linting with TypeScript config after the user installs jiti', async () => { + fixture.runCommand('npm install -D jiti'); + const result = await runLint(fixtureDirectory); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/e2e/suites/10/project.json b/e2e/suites/10/project.json new file mode 100644 index 000000000..64526152f --- /dev/null +++ b/e2e/suites/10/project.json @@ -0,0 +1,15 @@ +{ + "name": "e2e-suite-10", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "targets": { + "e2e-suite": { + "executor": "./packages/nx-plugin:e2e-test-suite", + "options": { + "cwd": "e2e", + "testFilePath": "src/new-workspace-typescript-config.test.ts" + } + } + }, + "implicitDependencies": ["packages/*"] +} diff --git a/e2e/utils/fixtures.ts b/e2e/utils/fixtures.ts index 1e18c56df..8a16dd3c5 100644 --- a/e2e/utils/fixtures.ts +++ b/e2e/utils/fixtures.ts @@ -4,10 +4,12 @@ import { workspaceRoot, writeJsonFile, } from '@nx/devkit'; +import { execSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, + rmSync, statSync, writeFileSync, } from 'node:fs'; @@ -66,4 +68,12 @@ export class Fixture { writeJson(f: string, content: object): void { writeJsonFile(joinPathFragments(this.root, f), content); } + + deleteFileOrDirectory(f: string): void { + rmSync(joinPathFragments(this.root, f), { recursive: true, force: true }); + } + + runCommand(command: string): void { + execSync(command, { cwd: this.root, maxBuffer: 1024 * 1024 * 10 }); + } } diff --git a/e2e/utils/run-lint.ts b/e2e/utils/run-lint.ts index c3dda8190..93b2f5f7a 100644 --- a/e2e/utils/run-lint.ts +++ b/e2e/utils/run-lint.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ import execa from 'execa'; import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; @@ -27,7 +26,8 @@ export async function runLint(directory: string): Promise { return normalizeOutput(stdout); } catch (error: any) { - return normalizeOutput(error.stdout || error); + const output = error.stdout ? error.stdout + '\n' + error.stderr : error; + return normalizeOutput(output); } } diff --git a/packages/angular-eslint/CHANGELOG.md b/packages/angular-eslint/CHANGELOG.md index a5dca3ec5..28ff53042 100644 --- a/packages/angular-eslint/CHANGELOG.md +++ b/packages/angular-eslint/CHANGELOG.md @@ -1,3 +1,13 @@ +## 19.6.0 (2025-05-27) + +### 🚀 Features + +- **eslint-plugin:** [prefer-inject] add new rule ([#2461](https://github.com/angular-eslint/angular-eslint/pull/2461)) + +### ❤️ Thank You + +- James Henry @JamesHenry + ## 19.5.0 (2025-05-25) ### 🚀 Features diff --git a/packages/angular-eslint/package.json b/packages/angular-eslint/package.json index ba76b28e3..61ef0f90a 100644 --- a/packages/angular-eslint/package.json +++ b/packages/angular-eslint/package.json @@ -1,6 +1,6 @@ { "name": "angular-eslint", - "version": "19.5.0", + "version": "19.6.0", "description": "The tooling which enables ESLint to work with Angular projects", "license": "MIT", "main": "dist/index.js", diff --git a/packages/angular-eslint/src/configs/ts-all.ts b/packages/angular-eslint/src/configs/ts-all.ts index fa71b3684..fd9d62367 100644 --- a/packages/angular-eslint/src/configs/ts-all.ts +++ b/packages/angular-eslint/src/configs/ts-all.ts @@ -41,6 +41,7 @@ export default ( '@angular-eslint/no-pipe-impure': 'error', '@angular-eslint/no-queries-metadata-property': 'error', '@angular-eslint/pipe-prefix': 'error', + '@angular-eslint/prefer-inject': 'error', '@angular-eslint/prefer-on-push-component-change-detection': 'error', '@angular-eslint/prefer-output-emitter-ref': 'error', '@angular-eslint/prefer-output-readonly': 'error', diff --git a/packages/builder/CHANGELOG.md b/packages/builder/CHANGELOG.md index 540698bae..76d9d5763 100644 --- a/packages/builder/CHANGELOG.md +++ b/packages/builder/CHANGELOG.md @@ -1,3 +1,13 @@ +## 19.6.0 (2025-05-27) + +### 🩹 Fixes + +- respect existing eslint.config.ts, eslint.config.cts, eslint.config.mts files ([#2458](https://github.com/angular-eslint/angular-eslint/pull/2458)) + +### ❤️ Thank You + +- James Henry @JamesHenry + ## 19.5.0 (2025-05-25) ### 🚀 Features diff --git a/packages/builder/package.json b/packages/builder/package.json index 37cb62da2..ac9f9f7a6 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/builder", - "version": "19.5.0", + "version": "19.6.0", "description": "Angular CLI builder for ESLint", "license": "MIT", "main": "dist/index.js", diff --git a/packages/builder/src/lint.impl.spec.ts b/packages/builder/src/lint.impl.spec.ts index cf344917a..a073defcc 100644 --- a/packages/builder/src/lint.impl.spec.ts +++ b/packages/builder/src/lint.impl.spec.ts @@ -16,7 +16,6 @@ writeFileSync(join(testWorkspaceRoot, 'package.json'), '{}', { }); // If we use esm here we get `TypeError: Cannot redefine property: writeFileSync` -// eslint-disable-next-line @typescript-eslint/no-var-requires const fs = require('fs'); jest.spyOn(fs, 'writeFileSync').mockImplementation(); jest.spyOn(fs, 'mkdirSync').mockImplementation(); @@ -108,7 +107,6 @@ const builderName = '@angular-eslint/builder:lint'; * to run a build before tests run and it is dynamic enough * to come after jest does its mocking */ -// eslint-disable-next-line @typescript-eslint/no-var-requires const { default: builderImplementation } = require('./lint.impl'); testArchitectHost.addBuilder(builderName, builderImplementation); @@ -145,17 +143,32 @@ describe('Linter Builder', () => { setWorkspaceRoot(previousWorkspaceRoot); }); - it('should throw if the eslint version is not supported', async () => { + it('should fail if the eslint version is not supported', async () => { MockESLint.version = '1.6'; const result = runBuilder(createValidRunBuilderOptions()); - await expect(result).rejects.toThrow( - /ESLint must be version 7.6 or higher/, - ); + await expect(result).resolves.toMatchInlineSnapshot(` + Object { + "error": "Error when running ESLint: ESLint must be version 7.6 or higher.", + "info": Object { + "builderName": "@angular-eslint/builder:lint", + "description": "Testing only builder.", + "optionSchema": Object { + "type": "object", + }, + }, + "success": false, + "target": Object { + "configuration": undefined, + "project": undefined, + "target": undefined, + }, + } + `); }); - it('should not throw if the eslint version is supported', async () => { - const result = runBuilder(createValidRunBuilderOptions()); - await expect(result).resolves.not.toThrow(); + it('should not fail if the eslint version is supported', async () => { + const result = await runBuilder(createValidRunBuilderOptions()); + expect(result.error).toBeUndefined(); }); it('should resolve and instantiate ESLint with the options that were passed to the builder', async () => { @@ -329,15 +342,16 @@ describe('Linter Builder', () => { ); }); - it('should throw if no reports generated', async () => { + it('should fail if no reports generated', async () => { mockReports = []; - await expect( - runBuilder( - createValidRunBuilderOptions({ - lintFilePatterns: ['includedFile1'], - }), - ), - ).rejects.toThrow(/Invalid lint configuration. Nothing to lint./); + const result = await runBuilder( + createValidRunBuilderOptions({ + lintFilePatterns: ['includedFile1'], + }), + ); + expect(result.error).toMatchInlineSnapshot( + `"Error when running ESLint: Invalid lint configuration. Nothing to lint. Please check your lint target pattern(s)."`, + ); }); it('should create a new instance of the formatter with the selected user option', async () => { diff --git a/packages/builder/src/lint.impl.ts b/packages/builder/src/lint.impl.ts index 86452a3d4..767a4f88c 100644 --- a/packages/builder/src/lint.impl.ts +++ b/packages/builder/src/lint.impl.ts @@ -10,193 +10,205 @@ import { export default createBuilder( async (options: Schema, context): Promise => { - const systemRoot = context.workspaceRoot; - - // eslint resolves files relative to the current working directory. - // We want these paths to always be resolved relative to the workspace - // root to be able to run the lint executor from any subfolder. - process.chdir(systemRoot); + try { + const systemRoot = context.workspaceRoot; - const projectName = context.target?.project ?? ''; - const printInfo = options.format && !options.silent; + // eslint resolves files relative to the current working directory. + // We want these paths to always be resolved relative to the workspace + // root to be able to run the lint executor from any subfolder. + process.chdir(systemRoot); - if (printInfo) { - console.info(`\nLinting ${JSON.stringify(projectName)}...`); - } + const projectName = context.target?.project ?? ''; + const printInfo = options.format && !options.silent; - const eslintConfigPath = options.eslintConfig - ? resolve(systemRoot, options.eslintConfig) - : undefined; - - options.cacheLocation = options.cacheLocation - ? join(options.cacheLocation, projectName) - : null; - - /** - * Until ESLint v9 is released and the new so called flat config is the default - * we only want to support it if the user has explicitly opted into it by converting - * their root ESLint config to use a supported flat config file name. - */ - const useFlatConfig = supportedFlatConfigNames.some((name) => - existsSync(join(systemRoot, name)), - ); - const { eslint, ESLint } = await resolveAndInstantiateESLint( - eslintConfigPath, - options, - useFlatConfig, - ); - - const version = ESLint?.version?.split('.'); - if ( - !version || - version.length < 2 || - Number(version[0]) < 7 || - (Number(version[0]) === 7 && Number(version[1]) < 6) - ) { - throw new Error('ESLint must be version 7.6 or higher.'); - } + if (printInfo) { + console.info(`\nLinting ${JSON.stringify(projectName)}...`); + } - let lintResults: ESLint.LintResult[] = []; + const eslintConfigPath = options.eslintConfig + ? resolve(systemRoot, options.eslintConfig) + : undefined; + + options.cacheLocation = options.cacheLocation + ? join(options.cacheLocation, projectName) + : null; + + /** + * Until ESLint v9 is released and the new so called flat config is the default + * we only want to support it if the user has explicitly opted into it by converting + * their root ESLint config to use a supported flat config file name. + */ + const useFlatConfig = supportedFlatConfigNames.some((name) => + existsSync(join(systemRoot, name)), + ); + const { eslint, ESLint } = await resolveAndInstantiateESLint( + eslintConfigPath, + options, + useFlatConfig, + ); - try { - lintResults = await eslint.lintFiles(options.lintFilePatterns); - } catch (err) { + const version = ESLint?.version?.split('.'); if ( - err instanceof Error && - err.message.includes( - 'You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser', - ) + !version || + version.length < 2 || + Number(version[0]) < 7 || + (Number(version[0]) === 7 && Number(version[1]) < 6) ) { - let eslintConfigPathForError = `for ${projectName}`; - const projectMetadata = await context.getProjectMetadata(projectName); - if (projectMetadata?.root) { - const { root } = projectMetadata; - eslintConfigPathForError = - resolveESLintConfigPath(root as string) ?? ''; - } + throw new Error('ESLint must be version 7.6 or higher.'); + } - console.error(` + let lintResults: ESLint.LintResult[] = []; + + try { + lintResults = await eslint.lintFiles(options.lintFilePatterns); + } 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?.root) { + const { root } = projectMetadata; + eslintConfigPathForError = + resolveESLintConfigPath(root as string) ?? ''; + } + + 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 project ESLint config ${ - eslintConfigPath || eslintConfigPathForError - } + 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, - }; + return { + success: false, + }; + } + // If some unexpected error, rethrow + throw err; } - // If some unexpected error, rethrow - throw err; - } - if (lintResults.length === 0) { - const ignoredPatterns = ( - await Promise.all( - options.lintFilePatterns.map(async (pattern) => - (await eslint.isPathIgnored(pattern)) ? pattern : null, - ), + if (lintResults.length === 0) { + const ignoredPatterns = ( + await Promise.all( + options.lintFilePatterns.map(async (pattern) => + (await eslint.isPathIgnored(pattern)) ? pattern : null, + ), + ) ) - ) - .filter((pattern) => !!pattern) - .map((pattern) => `- '${pattern}'`); - if (ignoredPatterns.length) { + .filter((pattern) => !!pattern) + .map((pattern) => `- '${pattern}'`); + if (ignoredPatterns.length) { + throw new Error( + `All files matching the following patterns are ignored:\n${ignoredPatterns.join( + '\n', + )}\n\nPlease check your '.eslintignore' file.`, + ); + } throw new Error( - `All files matching the following patterns are ignored:\n${ignoredPatterns.join( - '\n', - )}\n\nPlease check your '.eslintignore' file.`, + 'Invalid lint configuration. Nothing to lint. Please check your lint target pattern(s).', ); } - throw new Error( - 'Invalid lint configuration. Nothing to lint. Please check your lint target pattern(s).', - ); - } - // output fixes to disk, if applicable based on the options - await ESLint.outputFixes(lintResults); + // output fixes to disk, if applicable based on the options + await ESLint.outputFixes(lintResults); + + const formatter = await eslint.loadFormatter(options.format); - const formatter = await eslint.loadFormatter(options.format); + let totalErrors = 0; + let totalWarnings = 0; - let totalErrors = 0; - let totalWarnings = 0; + const reportOnlyErrors = options.quiet; + const maxWarnings = options.maxWarnings; - const reportOnlyErrors = options.quiet; - const maxWarnings = options.maxWarnings; + /** + * Depending on user configuration we may not want to report on all the + * results, so we need to adjust them before formatting. + */ + const finalLintResults: ESLint.LintResult[] = lintResults + .map((result): ESLint.LintResult | null => { + totalErrors += result.errorCount; + totalWarnings += result.warningCount; - /** - * Depending on user configuration we may not want to report on all the - * results, so we need to adjust them before formatting. - */ - const finalLintResults: ESLint.LintResult[] = lintResults - .map((result): ESLint.LintResult | null => { - totalErrors += result.errorCount; - totalWarnings += result.warningCount; + if (result.errorCount || (result.warningCount && !reportOnlyErrors)) { + if (reportOnlyErrors) { + // Collect only errors (Linter.Severity === 2) + result.messages = result.messages.filter( + ({ severity }) => severity === 2, + ); + } - if (result.errorCount || (result.warningCount && !reportOnlyErrors)) { - if (reportOnlyErrors) { - // Collect only errors (Linter.Severity === 2) - result.messages = result.messages.filter( - ({ severity }) => severity === 2, - ); + return result; } - return result; - } + return null; + }) + // Filter out the null values + .filter(Boolean) as ESLint.LintResult[]; + + const hasWarningsToPrint: boolean = + totalWarnings > 0 && !reportOnlyErrors; + const hasErrorsToPrint: boolean = totalErrors > 0; + + /** + * It's important that we format all results together so that custom + * formatters, such as checkstyle, can provide a valid output for the + * whole project being linted. + * + * Additionally, apart from when outputting to a file, we want to always + * log (even when no results) because different formatters handled the + * "no results" case differently. + */ + const formattedResults = await formatter.format(finalLintResults); + + if (options.outputFile) { + const pathToOutputFile = join(systemRoot, options.outputFile); + mkdirSync(dirname(pathToOutputFile), { recursive: true }); + writeFileSync(pathToOutputFile, formattedResults); + } else { + console.info(formattedResults); + } - return null; - }) - // Filter out the null values - .filter(Boolean) as ESLint.LintResult[]; - - const hasWarningsToPrint: boolean = totalWarnings > 0 && !reportOnlyErrors; - const hasErrorsToPrint: boolean = totalErrors > 0; - - /** - * It's important that we format all results together so that custom - * formatters, such as checkstyle, can provide a valid output for the - * whole project being linted. - * - * Additionally, apart from when outputting to a file, we want to always - * log (even when no results) because different formatters handled the - * "no results" case differently. - */ - const formattedResults = await formatter.format(finalLintResults); - - if (options.outputFile) { - const pathToOutputFile = join(systemRoot, options.outputFile); - mkdirSync(dirname(pathToOutputFile), { recursive: true }); - writeFileSync(pathToOutputFile, formattedResults); - } else { - console.info(formattedResults); - } + if (hasWarningsToPrint && printInfo) { + console.warn('Lint warnings found in the listed files.\n'); + } - if (hasWarningsToPrint && printInfo) { - console.warn('Lint warnings found in the listed files.\n'); - } + if (hasErrorsToPrint && printInfo) { + console.error('Lint errors found in the listed files.\n'); + } - if (hasErrorsToPrint && printInfo) { - console.error('Lint errors found in the listed files.\n'); - } + if ( + (totalWarnings === 0 || reportOnlyErrors) && + totalErrors === 0 && + printInfo + ) { + console.info('All files pass linting.\n'); + } - if ( - (totalWarnings === 0 || reportOnlyErrors) && - totalErrors === 0 && - printInfo - ) { - console.info('All files pass linting.\n'); - } + const tooManyWarnings = maxWarnings >= 0 && totalWarnings > maxWarnings; + if (tooManyWarnings && printInfo) { + console.error( + `Found ${totalWarnings} warnings, which exceeds your configured limit (${options.maxWarnings}). Either increase your maxWarnings limit or fix some of the lint warnings.`, + ); + } - const tooManyWarnings = maxWarnings >= 0 && totalWarnings > maxWarnings; - if (tooManyWarnings && printInfo) { - console.error( - `Found ${totalWarnings} warnings, which exceeds your configured limit (${options.maxWarnings}). Either increase your maxWarnings limit or fix some of the lint warnings.`, - ); + return { + success: options.force || (totalErrors === 0 && !tooManyWarnings), + }; + } catch (err) { + let errorMessage = 'Unknown error'; + if (err instanceof Error) { + errorMessage = `Error when running ESLint: ${err.message}`; + } + return { + success: false, + error: String(errorMessage), + }; } - - return { - success: options.force || (totalErrors === 0 && !tooManyWarnings), - }; }, ); diff --git a/packages/builder/src/utils/eslint-utils.spec.ts b/packages/builder/src/utils/eslint-utils.spec.ts index 9c591ba9d..f305bc672 100644 --- a/packages/builder/src/utils/eslint-utils.spec.ts +++ b/packages/builder/src/utils/eslint-utils.spec.ts @@ -170,11 +170,29 @@ describe('eslint-utils', () => { ).resolves.not.toThrow(); }); + it('should not throw if an eslint.config.ts file is used with ESLint Flat Config', async () => { + await expect( + resolveAndInstantiateESLint('./eslint.config.ts', {} as any, true), + ).resolves.not.toThrow(); + }); + + it('should not throw if an eslint.config.mts file is used with ESLint Flat Config', async () => { + await expect( + resolveAndInstantiateESLint('./eslint.config.mts', {} as any, true), + ).resolves.not.toThrow(); + }); + + it('should not throw if an eslint.config.cts file is used with ESLint Flat Config', async () => { + await expect( + resolveAndInstantiateESLint('./eslint.config.cts', {} as any, true), + ).resolves.not.toThrow(); + }); + it('should throw if an eslintrc file is used with ESLint Flat Config', async () => { await expect( resolveAndInstantiateESLint('./.eslintrc.json', {} as any, true), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"When using the new Flat Config with ESLint, all configs must be named eslint.config.js or eslint.config.mjs or eslint.config.cjs, and .eslintrc files may not be used. See https://eslint.org/docs/latest/use/configure/configuration-files"`, + `"When using the new Flat Config with ESLint, all configs must be named eslint.config.js or eslint.config.mjs or eslint.config.cjs or eslint.config.ts or eslint.config.mts or eslint.config.cts, and .eslintrc files may not be used. See https://eslint.org/docs/latest/use/configure/configuration-files"`, ); }); diff --git a/packages/builder/src/utils/eslint-utils.ts b/packages/builder/src/utils/eslint-utils.ts index 3d3fe9e45..2f002b687 100644 --- a/packages/builder/src/utils/eslint-utils.ts +++ b/packages/builder/src/utils/eslint-utils.ts @@ -5,6 +5,9 @@ export const supportedFlatConfigNames = [ 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', + 'eslint.config.ts', + 'eslint.config.mts', + 'eslint.config.cts', ]; async function resolveESLintClass( @@ -20,7 +23,6 @@ async function resolveESLintClass( if (!useFlatConfig) { return eslint.ESLint; } - // eslint-disable-next-line @typescript-eslint/no-var-requires const { FlatESLint } = require('eslint/use-at-your-own-risk'); return FlatESLint; } catch { diff --git a/packages/bundled-angular-compiler/CHANGELOG.md b/packages/bundled-angular-compiler/CHANGELOG.md index ea6ecdb1c..bff6dd250 100644 --- a/packages/bundled-angular-compiler/CHANGELOG.md +++ b/packages/bundled-angular-compiler/CHANGELOG.md @@ -1,3 +1,7 @@ +## 19.6.0 (2025-05-27) + +This was a version bump only for bundled-angular-compiler to align it with other projects, there were no code changes. + ## 19.5.0 (2025-05-25) This was a version bump only for bundled-angular-compiler to align it with other projects, there were no code changes. diff --git a/packages/bundled-angular-compiler/package.json b/packages/bundled-angular-compiler/package.json index 1464184e2..0e345e939 100644 --- a/packages/bundled-angular-compiler/package.json +++ b/packages/bundled-angular-compiler/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/bundled-angular-compiler", - "version": "19.5.0", + "version": "19.6.0", "description": "A CJS bundled version of @angular/compiler", "license": "MIT", "main": "dist/index.js", diff --git a/packages/eslint-plugin-template/CHANGELOG.md b/packages/eslint-plugin-template/CHANGELOG.md index 4b92162aa..f0a5799ff 100644 --- a/packages/eslint-plugin-template/CHANGELOG.md +++ b/packages/eslint-plugin-template/CHANGELOG.md @@ -1,3 +1,13 @@ +## 19.6.0 (2025-05-27) + +### 🩹 Fixes + +- respect existing eslint.config.ts, eslint.config.cts, eslint.config.mts files ([#2458](https://github.com/angular-eslint/angular-eslint/pull/2458)) + +### ❤️ Thank You + +- James Henry @JamesHenry + ## 19.5.0 (2025-05-25) ### 🚀 Features diff --git a/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md b/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md index 998a28a25..7f75ff50a 100644 --- a/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md +++ b/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md @@ -1842,6 +1842,60 @@ The rule does not have any configuration options. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``` +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +Test + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +Test + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` +
diff --git a/packages/eslint-plugin-template/package.json b/packages/eslint-plugin-template/package.json index 710d8d19f..6e10e9987 100644 --- a/packages/eslint-plugin-template/package.json +++ b/packages/eslint-plugin-template/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/eslint-plugin-template", - "version": "19.5.0", + "version": "19.6.0", "description": "ESLint plugin for Angular Templates", "license": "MIT", "main": "dist/index.js", diff --git a/packages/eslint-plugin-template/src/utils/is-interactive-element/get-interactive-element-ax-object-schemas.ts b/packages/eslint-plugin-template/src/utils/is-interactive-element/get-interactive-element-ax-object-schemas.ts index 02b2367b0..382df7bc6 100644 --- a/packages/eslint-plugin-template/src/utils/is-interactive-element/get-interactive-element-ax-object-schemas.ts +++ b/packages/eslint-plugin-template/src/utils/is-interactive-element/get-interactive-element-ax-object-schemas.ts @@ -14,7 +14,6 @@ let interactiveElementAXObjectSchemas: AXObjectSchema[] | null = null; export function getInteractiveElementAXObjectSchemas(): AXObjectSchema[] { if (interactiveElementAXObjectSchemas === null) { // This package doesn't have type definitions. - // eslint-disable-next-line @typescript-eslint/no-var-requires const { AXObjects, elementAXObjects } = require('axobject-query'); // This set will contain all possible roles in ARIA, which are diff --git a/packages/eslint-plugin-template/src/utils/is-semantic-role-element.ts b/packages/eslint-plugin-template/src/utils/is-semantic-role-element.ts index a91ac647d..e1a3efc36 100644 --- a/packages/eslint-plugin-template/src/utils/is-semantic-role-element.ts +++ b/packages/eslint-plugin-template/src/utils/is-semantic-role-element.ts @@ -20,7 +20,6 @@ export function isSemanticRoleElement( elementAttributes: (TmplAstTextAttribute | TmplAstBoundAttribute)[], ): boolean { if (axElements === null || axRoles === null) { - // eslint-disable-next-line @typescript-eslint/no-var-requires const { AXObjectRoles, elementAXObjects } = require('axobject-query'); axElements = elementAXObjects; axRoles = AXObjectRoles; diff --git a/packages/eslint-plugin-template/tests/rules/prefer-template-literal/cases.ts b/packages/eslint-plugin-template/tests/rules/prefer-template-literal/cases.ts index d9a888132..5c2660a3a 100644 --- a/packages/eslint-plugin-template/tests/rules/prefer-template-literal/cases.ts +++ b/packages/eslint-plugin-template/tests/rules/prefer-template-literal/cases.ts @@ -892,4 +892,54 @@ export const invalid: readonly InvalidTestCase[] = [ `, }), + + // Test cases for reported bugs + + // Bug 1: Simple long string test case + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fix concatenation with long URL string', + annotatedSource: ` + Test + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + Test + + `, + }), + + // Test cases for specific reported bugs that currently fail + + // Test case 1: Add a simple case with the actual failing 108-character string + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fix exactly 108 char string (reproduces bug)', + annotatedSource: ` + Test + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + Test + + `, + }), + + // Test case demonstrating multiple autofix passes for chained concatenations + // convertAnnotatedSourceToFailureCase({ + // messageId, + // description: + // 'should handle chained concatenations of literals requiring multiple autofix passes', + // annotatedSource: ` + // {{ 'first' + 'second' + 'third' }} + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // `, + // annotatedOutputs: [ + // // TODO: this is where we should end up for this source, but what should the interim fixes be? + // ` + // {{ 'firstsecondthird' }} + + // `, + // ], + // }), ]; diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index b88f82c61..e0eeb4d26 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -1,3 +1,18 @@ +## 19.6.0 (2025-05-27) + +### 🚀 Features + +- **eslint-plugin:** [prefer-inject] add new rule ([#2461](https://github.com/angular-eslint/angular-eslint/pull/2461)) + +### 🩹 Fixes + +- **eslint-plugin:** [use-lifecycle-interface] do not report if the method uses override ([#2463](https://github.com/angular-eslint/angular-eslint/pull/2463)) +- **eslint-plugin:** [sort-keys-in-type-decorator] preserve unconfigured properties during autofix ([#2456](https://github.com/angular-eslint/angular-eslint/pull/2456)) + +### ❤️ Thank You + +- James Henry @JamesHenry + ## 19.5.0 (2025-05-25) ### 🚀 Features diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index bade7d022..17b7d1d12 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -65,6 +65,7 @@ Please see https://github.com/angular-eslint/angular-eslint for full usage instr | [`no-pipe-impure`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-pipe-impure.md) | Disallows the declaration of impure pipes | | | :bulb: | | [`no-queries-metadata-property`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-queries-metadata-property.md) | Disallows usage of the `queries` metadata property. See more at https://angular.dev/style-guide#style-05-12. | | | | | [`pipe-prefix`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/pipe-prefix.md) | Enforce consistent prefix for pipes. | | | | +| [`prefer-inject`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/prefer-inject.md) | Prefer using the inject() function over constructor parameter injection | | | | | [`prefer-on-push-component-change-detection`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/prefer-on-push-component-change-detection.md) | Ensures component's `changeDetection` is set to `ChangeDetectionStrategy.OnPush` | | | :bulb: | | [`prefer-output-emitter-ref`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/prefer-output-emitter-ref.md) | Use `OutputEmitterRef` instead of `@Output()` | | | | | [`prefer-output-readonly`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/prefer-output-readonly.md) | Prefer to declare `@Output`, `OutputEmitterRef` and `OutputRef` as `readonly` since they are not supposed to be reassigned | | | :bulb: | diff --git a/packages/eslint-plugin/docs/rules/prefer-inject.md b/packages/eslint-plugin/docs/rules/prefer-inject.md new file mode 100644 index 000000000..3f6dcfd7e --- /dev/null +++ b/packages/eslint-plugin/docs/rules/prefer-inject.md @@ -0,0 +1,352 @@ + + +
+ +# `@angular-eslint/prefer-inject` + +Prefer using the inject() function over constructor parameter injection + +- Type: suggestion + +
+ +## Rule Options + +The rule does not have any configuration options. + +
+ +## Usage Examples + +> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit. + +
+ +
+❌ - Toggle examples of incorrect code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Injectable() +class UserService { + constructor(private http: HttpClient) {} + ~~~~~~~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class MyComponent { + constructor( + private userService: UserService, + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + private http: HttpClient + ~~~~~~~~~~~~~~~~~~~~~~~~ + ) {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Injectable() +class ConfigService { + constructor( + @Inject(CONFIG_TOKEN) private config: AppConfig, + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + @Optional() private logger?: LoggerService + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ) {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class MyComponent extends BaseComponent { + constructor( + private service: MyService, + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + ) { + super(); + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class MyComponent { + constructor(elementRef: ElementRef) {} + ~~~~~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +
+ +--- + +
+ +
+✅ - Toggle examples of correct code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +class PlainClass { + constructor(private value: string) {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Injectable() +class UserService { + private http = inject(HttpClient); +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class MyComponent { + constructor() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class MyComponent extends BaseComponent { + constructor() { + super(); + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/prefer-inject": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Injectable() +class Logger { + constructor(level: string) {} +} +``` + +
+ +
diff --git a/packages/eslint-plugin/docs/rules/sort-keys-in-type-decorator.md b/packages/eslint-plugin/docs/rules/sort-keys-in-type-decorator.md index d4793e734..bf1dcc06e 100644 --- a/packages/eslint-plugin/docs/rules/sort-keys-in-type-decorator.md +++ b/packages/eslint-plugin/docs/rules/sort-keys-in-type-decorator.md @@ -479,6 +479,51 @@ class Test {} class Test {} ``` +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/sort-keys-in-type-decorator": [ + "error", + { + "Component": [ + "selector", + "imports", + "standalone", + "templateUrl", + "styleUrl", + "encapsulation", + "changeDetection" + ] + } + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({ + styleUrl: './app.component.css', + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + selector: 'app-root', + templateUrl: './app.component.html', + providers: [MyService, myProvider] +}) +class Test { +} +``` +
diff --git a/packages/eslint-plugin/docs/rules/use-lifecycle-interface.md b/packages/eslint-plugin/docs/rules/use-lifecycle-interface.md index ef95caf2e..ea49f05e0 100644 --- a/packages/eslint-plugin/docs/rules/use-lifecycle-interface.md +++ b/packages/eslint-plugin/docs/rules/use-lifecycle-interface.md @@ -199,6 +199,46 @@ class Test extends Component { } ``` +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/use-lifecycle-interface": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive() +class FoobarBase implements OnDestroy { + ngOnDestroy(): void { + /* some base logic here */ + } +} + +@Component() +class FoobarComponent extends FoobarBase { + ngOnDestroy(): void { + ~~~~~~~~~~~ + super.ngOnDestroy(); + /* some concrete logic here */ + } +} +``` +
@@ -356,6 +396,122 @@ class Test extends Component implements ng.OnInit, ng.OnDestroy { class Test {} ``` +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/use-lifecycle-interface": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive() +class FoobarBase implements OnDestroy { + ngOnDestroy(): void { + /* some base logic here */ + } +} + +@Component() +class FoobarComponent extends FoobarBase { + override ngOnDestroy(): void { + super.ngOnDestroy(); + /* some concrete logic here */ + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/use-lifecycle-interface": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +class BaseClass { + ngOnInit(): void { + /* base initialization */ + } +} + +@Component() +class DerivedComponent extends BaseClass { + override ngOnInit(): void { + super.ngOnInit(); + /* derived initialization */ + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/use-lifecycle-interface": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive() +class BaseDirective implements OnInit { + ngOnInit(): void { + /* base initialization */ + } +} + +@Component() +class DerivedComponent extends BaseDirective { + override ngOnInit(): void { + super.ngOnInit(); + /* derived initialization */ + } +} +``` +
diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 200271483..98fe9f39e 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/eslint-plugin", - "version": "19.5.0", + "version": "19.6.0", "description": "ESLint plugin for Angular applications, following https://angular.dev/style-guide", "license": "MIT", "main": "dist/index.js", diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index e34434e1a..e2bd36762 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -27,6 +27,7 @@ "@angular-eslint/no-pipe-impure": "error", "@angular-eslint/no-queries-metadata-property": "error", "@angular-eslint/pipe-prefix": "error", + "@angular-eslint/prefer-inject": "error", "@angular-eslint/prefer-on-push-component-change-detection": "error", "@angular-eslint/prefer-output-emitter-ref": "error", "@angular-eslint/prefer-output-readonly": "error", diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 5c6b3824b..0f1b2e0da 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -85,6 +85,9 @@ import preferOutputEmitterRef, { import preferOutputReadonly, { RULE_NAME as preferOutputReadonlyRuleName, } from './rules/prefer-output-readonly'; +import preferInject, { + RULE_NAME as preferInjectRuleName, +} from './rules/prefer-inject'; import preferSignals, { RULE_NAME as preferSignalsRuleName, } from './rules/prefer-signals'; @@ -160,6 +163,7 @@ export = { preferOnPushComponentChangeDetection, [preferSignalsRuleName]: preferSignals, [preferStandaloneRuleName]: preferStandalone, + [preferInjectRuleName]: preferInject, [preferOutputEmitterRefRuleName]: preferOutputEmitterRef, [preferOutputReadonlyRuleName]: preferOutputReadonly, [relativeUrlPrefixRuleName]: relativeUrlPrefix, diff --git a/packages/eslint-plugin/src/rules/prefer-inject.ts b/packages/eslint-plugin/src/rules/prefer-inject.ts new file mode 100644 index 000000000..7883f461a --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-inject.ts @@ -0,0 +1,107 @@ +import { ASTUtils, Selectors, toPattern } from '@angular-eslint/utils'; +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { createESLintRule } from '../utils/create-eslint-rule'; + +export type Options = []; + +export type MessageIds = 'preferInject'; +export const RULE_NAME = 'prefer-inject'; + +export default createESLintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: + 'Prefer using the inject() function over constructor parameter injection', + }, + schema: [], + messages: { + preferInject: + "Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject", + }, + }, + defaultOptions: [], + create(context) { + const angularDecoratorsPattern = toPattern([ + 'Component', + 'Directive', + 'Injectable', + 'Pipe', + ]); + + function shouldReportParameter(param: TSESTree.Parameter): boolean { + let actualParam = param; + let hasModifier = false; + + if (param.type === AST_NODE_TYPES.TSParameterProperty) { + actualParam = param.parameter; + hasModifier = true; + } + + const decorators = ( + (param.type === AST_NODE_TYPES.TSParameterProperty + ? param.parameter + : param) as TSESTree.Parameter + ).decorators; + if ( + decorators?.some((d) => { + const name = ASTUtils.getDecoratorName(d); + return ( + name === 'Inject' || + name === 'Optional' || + name === 'Self' || + name === 'SkipSelf' || + name === 'Host' + ); + }) + ) { + return true; + } + + if (hasModifier) { + return true; + } + + const typeAnnotation = ( + actualParam as TSESTree.Identifier | TSESTree.AssignmentPattern + ).typeAnnotation; + if (typeAnnotation) { + switch (typeAnnotation.typeAnnotation.type) { + case AST_NODE_TYPES.TSStringKeyword: + case AST_NODE_TYPES.TSNumberKeyword: + case AST_NODE_TYPES.TSBooleanKeyword: + case AST_NODE_TYPES.TSBigIntKeyword: + case AST_NODE_TYPES.TSSymbolKeyword: + case AST_NODE_TYPES.TSAnyKeyword: + case AST_NODE_TYPES.TSUnknownKeyword: + return false; + default: + return true; + } + } + + return false; + } + + return { + [`${Selectors.decoratorDefinition( + angularDecoratorsPattern, + )} > ClassBody > MethodDefinition[kind="constructor"]`]( + node: TSESTree.MethodDefinition & { + parent: TSESTree.ClassBody & { parent: TSESTree.ClassDeclaration }; + }, + ) { + const params = (node.value as TSESTree.FunctionExpression).params ?? []; + if (params.length === 0) { + return; + } + for (const param of params) { + if (shouldReportParameter(param)) { + context.report({ node: param, messageId: 'preferInject' }); + } + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/sort-keys-in-type-decorator.ts b/packages/eslint-plugin/src/rules/sort-keys-in-type-decorator.ts index 1048743c6..db075bffa 100644 --- a/packages/eslint-plugin/src/rules/sort-keys-in-type-decorator.ts +++ b/packages/eslint-plugin/src/rules/sort-keys-in-type-decorator.ts @@ -230,9 +230,13 @@ function reportAndFix( const propNames = properties.map( (p) => (p.key as TSESTree.Identifier).name, ); - const filteredOrder = expectedOrder.filter((name) => + const configuredProps = expectedOrder.filter((name) => propNames.includes(name), ); + const unconfiguredProps = propNames.filter( + (name) => !expectedOrder.includes(name), + ); + const filteredOrder = [...configuredProps, ...unconfiguredProps]; const propInfoMap = CommentUtils.extractPropertyComments( sourceCode, diff --git a/packages/eslint-plugin/src/rules/use-lifecycle-interface.ts b/packages/eslint-plugin/src/rules/use-lifecycle-interface.ts index fc08d605f..4d4ebfea8 100644 --- a/packages/eslint-plugin/src/rules/use-lifecycle-interface.ts +++ b/packages/eslint-plugin/src/rules/use-lifecycle-interface.ts @@ -32,14 +32,23 @@ export default createESLintRule({ ]); return { - [`MethodDefinition[key.name=${angularLifecycleMethodsPattern}]`]({ - key, - parent: { parent }, - }: TSESTree.MethodDefinition & { parent: TSESTree.ClassBody } & { - parent: TSESTree.ClassDeclaration; - }) { + [`MethodDefinition[key.name=${angularLifecycleMethodsPattern}]`]( + node: TSESTree.MethodDefinition & { parent: TSESTree.ClassBody } & { + parent: TSESTree.ClassDeclaration; + }, + ) { + const { + key, + parent: { parent }, + } = node; + if (!ASTUtils.getAngularClassDecorator(parent)) return; + // Do not report the method if it has the override keyword because it implies the base class is responsible for the implementation + if (node.override) { + return; + } + const declaredLifecycleInterfaces = ASTUtils.getDeclaredAngularLifecycleInterfaces(parent); const methodName = (key as TSESTree.Identifier) diff --git a/packages/eslint-plugin/tests/rules/prefer-inject/cases.ts b/packages/eslint-plugin/tests/rules/prefer-inject/cases.ts new file mode 100644 index 000000000..60e399f5a --- /dev/null +++ b/packages/eslint-plugin/tests/rules/prefer-inject/cases.ts @@ -0,0 +1,123 @@ +import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/test-utils'; +import type { + InvalidTestCase, + ValidTestCase, +} from '@typescript-eslint/rule-tester'; +import type { MessageIds, Options } from '../../../src/rules/prefer-inject'; + +const messageId: MessageIds = 'preferInject'; + +export const valid: readonly (string | ValidTestCase)[] = [ + // Non Angular class + ` + class PlainClass { + constructor(private value: string) {} + } + `, + // Using inject() + ` + @Injectable() + class UserService { + private http = inject(HttpClient); + } + `, + // Empty constructor + ` + @Component({}) + class MyComponent { + constructor() {} + } + `, + // Constructor only calling super + ` + @Component({}) + class MyComponent extends BaseComponent { + constructor() { + super(); + } + } + `, + // Primitive parameter without @Inject + ` + @Injectable() + class Logger { + constructor(level: string) {} + } + `, +]; + +export const invalid: readonly InvalidTestCase[] = [ + convertAnnotatedSourceToFailureCase({ + description: 'basic constructor injection', + annotatedSource: ` + @Injectable() + class UserService { + constructor(private http: HttpClient) {} + ~~~~~~~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + }), + convertAnnotatedSourceToFailureCase({ + description: 'multiple dependencies', + annotatedSource: ` + @Component({}) + class MyComponent { + constructor( + private userService: UserService, + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + private http: HttpClient + ^^^^^^^^^^^^^^^^^^^^^^^^ + ) {} + } + `, + messages: [ + { char: '~', messageId }, + { char: '^', messageId }, + ], + }), + convertAnnotatedSourceToFailureCase({ + description: 'with injection decorators', + annotatedSource: ` + @Injectable() + class ConfigService { + constructor( + @Inject(CONFIG_TOKEN) private config: AppConfig, + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + @Optional() private logger?: LoggerService + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) {} + } + `, + messages: [ + { char: '~', messageId }, + { char: '^', messageId }, + ], + }), + convertAnnotatedSourceToFailureCase({ + description: 'mixed with super call', + annotatedSource: ` + @Component({}) + class MyComponent extends BaseComponent { + constructor( + private service: MyService, + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + ) { + super(); + } + } + `, + messageId, + }), + convertAnnotatedSourceToFailureCase({ + description: 'class with non-primitive parameter', + annotatedSource: ` + @Component({}) + class MyComponent { + constructor(elementRef: ElementRef) {} + ~~~~~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + }), +]; diff --git a/packages/eslint-plugin/tests/rules/prefer-inject/spec.ts b/packages/eslint-plugin/tests/rules/prefer-inject/spec.ts new file mode 100644 index 000000000..a523395e3 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/prefer-inject/spec.ts @@ -0,0 +1,10 @@ +import { RuleTester } from '@angular-eslint/test-utils'; +import rule, { RULE_NAME } from '../../../src/rules/prefer-inject'; +import { invalid, valid } from './cases'; + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid, + invalid, +}); diff --git a/packages/eslint-plugin/tests/rules/sort-keys-in-type-decorator/cases.ts b/packages/eslint-plugin/tests/rules/sort-keys-in-type-decorator/cases.ts index 4201c14e2..909868291 100644 --- a/packages/eslint-plugin/tests/rules/sort-keys-in-type-decorator/cases.ts +++ b/packages/eslint-plugin/tests/rules/sort-keys-in-type-decorator/cases.ts @@ -603,4 +603,48 @@ export const invalid: readonly InvalidTestCase[] = [ class Test {} `, }), + convertAnnotatedSourceToFailureCase({ + description: + 'should preserve unconfigured properties like providers when sorting', + annotatedSource: ` + @Component({ + styleUrl: './app.component.css', + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + selector: 'app-root', + templateUrl: './app.component.html', + providers: [MyService, myProvider] + }) + class Test { + } + `, + messageId: 'incorrectOrder', + data: { + decorator: 'Component', + expectedOrder: 'selector, templateUrl, styleUrl', + }, + options: [ + { + Component: [ + 'selector', + 'imports', + 'standalone', + 'templateUrl', + 'styleUrl', + 'encapsulation', + 'changeDetection', + // providers is intentionally not configured here to cover: https://github.com/angular-eslint/angular-eslint/issues/2455 + ], + }, + ], + annotatedOutput: ` + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrl: './app.component.css', + providers: [MyService, myProvider] + }) + class Test { + } + `, + }), ]; diff --git a/packages/eslint-plugin/tests/rules/use-lifecycle-interface/cases.ts b/packages/eslint-plugin/tests/rules/use-lifecycle-interface/cases.ts index 7d84bfed3..b482c71b2 100644 --- a/packages/eslint-plugin/tests/rules/use-lifecycle-interface/cases.ts +++ b/packages/eslint-plugin/tests/rules/use-lifecycle-interface/cases.ts @@ -44,6 +44,56 @@ export const valid: readonly (string | ValidTestCase)[] = [ } `, 'class Test {}', + // Test case for override keyword - base class with interface + ` + @Directive() + class FoobarBase implements OnDestroy { + ngOnDestroy(): void { + /* some base logic here */ + } + } + + @Component() + class FoobarComponent extends FoobarBase { + override ngOnDestroy(): void { + super.ngOnDestroy(); + /* some concrete logic here */ + } + } + `, + // Test case for override keyword - non-Angular base class + ` + class BaseClass { + ngOnInit(): void { + /* base initialization */ + } + } + + @Component() + class DerivedComponent extends BaseClass { + override ngOnInit(): void { + super.ngOnInit(); + /* derived initialization */ + } + } + `, + // Test case for override keyword - Angular base class without interface + ` + @Directive() + class BaseDirective implements OnInit { + ngOnInit(): void { + /* base initialization */ + } + } + + @Component() + class DerivedComponent extends BaseDirective { + override ngOnInit(): void { + super.ngOnInit(); + /* derived initialization */ + } + } + `, ]; export const invalid: readonly InvalidTestCase[] = [ @@ -248,4 +298,48 @@ export const invalid: readonly InvalidTestCase[] = [ } `, }), + convertAnnotatedSourceToFailureCase({ + description: + 'it should fail if lifecycle method is declared without implementing its interface even when extending a base class (no override keyword)', + annotatedSource: ` + @Directive() + class FoobarBase implements OnDestroy { + ngOnDestroy(): void { + /* some base logic here */ + } + } + + @Component() + class FoobarComponent extends FoobarBase { + ngOnDestroy(): void { + ~~~~~~~~~~~ + super.ngOnDestroy(); + /* some concrete logic here */ + } + } + `, + messageId, + data: { + interfaceName: ASTUtils.AngularLifecycleInterfaces.OnDestroy, + methodName: ASTUtils.AngularLifecycleMethods.ngOnDestroy, + }, + annotatedOutput: `import { OnDestroy } from '@angular/core'; + + @Directive() + class FoobarBase implements OnDestroy { + ngOnDestroy(): void { + /* some base logic here */ + } + } + + @Component() + class FoobarComponent extends FoobarBase implements OnDestroy { + ngOnDestroy(): void { + + super.ngOnDestroy(); + /* some concrete logic here */ + } + } + `, + }), ]; diff --git a/packages/schematics/CHANGELOG.md b/packages/schematics/CHANGELOG.md index da5821382..c6ef0b1bb 100644 --- a/packages/schematics/CHANGELOG.md +++ b/packages/schematics/CHANGELOG.md @@ -1,3 +1,13 @@ +## 19.6.0 (2025-05-27) + +### 🩹 Fixes + +- respect existing eslint.config.ts, eslint.config.cts, eslint.config.mts files ([#2458](https://github.com/angular-eslint/angular-eslint/pull/2458)) + +### ❤️ Thank You + +- James Henry @JamesHenry + ## 19.5.0 (2025-05-25) ### 🚀 Features diff --git a/packages/schematics/package.json b/packages/schematics/package.json index 2444d993a..4a360acb9 100644 --- a/packages/schematics/package.json +++ b/packages/schematics/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/schematics", - "version": "19.5.0", + "version": "19.6.0", "description": "Angular Schematics for angular-eslint", "license": "MIT", "main": "dist/index.js", diff --git a/packages/schematics/src/devkit-imports.ts b/packages/schematics/src/devkit-imports.ts index 0601e5e33..2d5acd633 100644 --- a/packages/schematics/src/devkit-imports.ts +++ b/packages/schematics/src/devkit-imports.ts @@ -14,7 +14,6 @@ process.env.NX_PROJECT_GRAPH_CACHE_DIRECTORY = join( '.nx-cache', ); -/* eslint-disable no-restricted-imports */ export { convertNxGenerator, offsetFromRoot, @@ -23,4 +22,3 @@ export { } from '@nx/devkit'; export type { ProjectConfiguration, Tree } from '@nx/devkit'; export { wrapAngularDevkitSchematic } from '@nx/devkit/ngcli-adapter'; -/* eslint-enable no-restricted-imports */ diff --git a/packages/schematics/src/ng-add/index.ts b/packages/schematics/src/ng-add/index.ts index 142701c7d..6e5d0b718 100644 --- a/packages/schematics/src/ng-add/index.ts +++ b/packages/schematics/src/ng-add/index.ts @@ -16,7 +16,6 @@ import { export const FIXED_ESLINT_V8_VERSION = '8.57.0'; export const FIXED_TYPESCRIPT_ESLINT_V7_VERSION = '7.11.0'; -// eslint-disable-next-line @typescript-eslint/no-var-requires const packageJSON = require('../../package.json'); function addAngularESLintPackages( diff --git a/packages/schematics/src/utils.ts b/packages/schematics/src/utils.ts index 27e2fe751..edfeabda0 100644 --- a/packages/schematics/src/utils.ts +++ b/packages/schematics/src/utils.ts @@ -13,6 +13,9 @@ export const supportedFlatConfigNames = [ 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', + 'eslint.config.ts', + 'eslint.config.mts', + 'eslint.config.cts', ]; /** @@ -143,9 +146,12 @@ export function addESLintTargetToProject( if (existingProjectConfig.root !== '') { if (shouldUseFlatConfig(tree)) { const rootConfigPath = resolveRootESLintConfigPath(tree); - if (!rootConfigPath || !rootConfigPath.endsWith('js')) { + if ( + !rootConfigPath || + (!rootConfigPath.endsWith('js') && !rootConfigPath.endsWith('ts')) + ) { throw new Error( - 'Root ESLint config must be a JavaScript file (.js,.mjs,.cjs) when using Flat Config', + 'Root ESLint config must be a JavaScript/TypeScript file (.js,.mjs,.cjs,.ts,.mts,.cts) when using Flat Config', ); } const { ext } = determineNewProjectESLintConfigContentAndExtension( @@ -211,7 +217,6 @@ export function visitNotIgnoredFiles( type ProjectType = 'application' | 'library'; -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types function setESLintProjectBasedOnProjectType( projectRoot: string, projectType: ProjectType, @@ -232,7 +237,6 @@ function setESLintProjectBasedOnProjectType( return project; } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function createRootESLintConfig(prefix: string | null) { let codeRules; if (prefix) { @@ -276,7 +280,6 @@ export function createRootESLintConfig(prefix: string | null) { }; } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function createStringifiedRootESLintConfig( prefix: string | null, isESM: boolean, @@ -567,7 +570,7 @@ export function determineTargetProjectName( * Method will check if angular project architect has e2e configuration to determine if e2e setup */ function determineTargetProjectHasE2E( - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any angularJSON: any, projectName: string, ): boolean { @@ -661,6 +664,15 @@ export function resolveRootESLintConfigPath(tree: Tree): string | null { if (tree.exists('eslint.config.cjs')) { return 'eslint.config.cjs'; } + if (tree.exists('eslint.config.ts')) { + return 'eslint.config.ts'; + } + if (tree.exists('eslint.config.mts')) { + return 'eslint.config.mts'; + } + if (tree.exists('eslint.config.cts')) { + return 'eslint.config.cts'; + } return null; } diff --git a/packages/schematics/tests/ng-add/index.test.ts b/packages/schematics/tests/ng-add/index.test.ts index dfea74685..9ce12678c 100644 --- a/packages/schematics/tests/ng-add/index.test.ts +++ b/packages/schematics/tests/ng-add/index.test.ts @@ -9,7 +9,6 @@ import { FIXED_TYPESCRIPT_ESLINT_V7_VERSION, } from '../../src/ng-add'; -// eslint-disable-next-line @typescript-eslint/no-var-requires const packageJSON = require('../../package.json'); const eslintVersion = packageJSON.devDependencies['eslint']; diff --git a/packages/schematics/tests/utils.test.ts b/packages/schematics/tests/utils.test.ts index d72c84a87..0427f5783 100644 --- a/packages/schematics/tests/utils.test.ts +++ b/packages/schematics/tests/utils.test.ts @@ -1,5 +1,8 @@ import { UnitTestTree } from '@angular-devkit/schematics/testing'; -import { determineNewProjectESLintConfigContentAndExtension } from '../src/utils'; +import { + determineNewProjectESLintConfigContentAndExtension, + resolveRootESLintConfigPath, +} from '../src/utils'; import { Tree } from '@angular-devkit/schematics'; import { join } from 'node:path'; @@ -148,3 +151,63 @@ describe('determineNewProjectESLintConfigContentAndExtension', () => { }); }); }); + +describe('resolveRootESLintConfigPath', () => { + let tree: Tree; + + beforeEach(() => { + tree = new UnitTestTree(Tree.empty()); + }); + + it('should return .eslintrc.json if it exists', () => { + tree.create('.eslintrc.json', '{}'); + expect(resolveRootESLintConfigPath(tree)).toBe('.eslintrc.json'); + }); + + it('should return eslint.config.js if it exists and no .eslintrc.json', () => { + tree.create('eslint.config.js', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('eslint.config.js'); + }); + + it('should return eslint.config.mjs if it exists and no previous configs', () => { + tree.create('eslint.config.mjs', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('eslint.config.mjs'); + }); + + it('should return eslint.config.cjs if it exists and no previous configs', () => { + tree.create('eslint.config.cjs', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('eslint.config.cjs'); + }); + + it('should return eslint.config.ts if it exists and no previous configs', () => { + tree.create('eslint.config.ts', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('eslint.config.ts'); + }); + + it('should return eslint.config.mts if it exists and no previous configs', () => { + tree.create('eslint.config.mts', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('eslint.config.mts'); + }); + + it('should return eslint.config.cts if it exists and no previous configs', () => { + tree.create('eslint.config.cts', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('eslint.config.cts'); + }); + + it('should return null if no config file exists', () => { + expect(resolveRootESLintConfigPath(tree)).toBe(null); + }); + + it('should prioritize .eslintrc.json over flat config files', () => { + tree.create('.eslintrc.json', '{}'); + tree.create('eslint.config.js', ''); + tree.create('eslint.config.ts', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('.eslintrc.json'); + }); + + it('should prioritize JS over TS flat config files', () => { + tree.create('eslint.config.js', ''); + tree.create('eslint.config.ts', ''); + expect(resolveRootESLintConfigPath(tree)).toBe('eslint.config.js'); + }); +}); diff --git a/packages/template-parser/CHANGELOG.md b/packages/template-parser/CHANGELOG.md index bbad5c345..d9e3ed0ea 100644 --- a/packages/template-parser/CHANGELOG.md +++ b/packages/template-parser/CHANGELOG.md @@ -1,3 +1,13 @@ +## 19.6.0 (2025-05-27) + +### 🩹 Fixes + +- respect existing eslint.config.ts, eslint.config.cts, eslint.config.mts files ([#2458](https://github.com/angular-eslint/angular-eslint/pull/2458)) + +### ❤️ Thank You + +- James Henry @JamesHenry + ## 19.5.0 (2025-05-25) This was a version bump only for template-parser to align it with other projects, there were no code changes. diff --git a/packages/template-parser/package.json b/packages/template-parser/package.json index 933048cdb..79d8a60ea 100644 --- a/packages/template-parser/package.json +++ b/packages/template-parser/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/template-parser", - "version": "19.5.0", + "version": "19.6.0", "description": "Angular Template parser for ESLint", "license": "MIT", "main": "dist/index.js", diff --git a/packages/template-parser/src/index.ts b/packages/template-parser/src/index.ts index fa38d423f..513327a73 100644 --- a/packages/template-parser/src/index.ts +++ b/packages/template-parser/src/index.ts @@ -322,7 +322,6 @@ export function parse(code: string, options: ParserOptions): AST { } // NOTE - we cannot migrate this to an import statement because it will make TSC copy the package.json to the dist folder -// eslint-disable-next-line @typescript-eslint/no-var-requires export const version: string = require('../package.json').version; export const meta = { diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index 7f0665eb6..c55e5605c 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,3 +1,7 @@ +## 19.6.0 (2025-05-27) + +This was a version bump only for @angular-eslint/test-utils to align it with other projects, there were no code changes. + ## 19.5.0 (2025-05-25) This was a version bump only for @angular-eslint/test-utils to align it with other projects, there were no code changes. diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f88b9518b..ca071dd4f 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/test-utils", - "version": "19.5.0", + "version": "19.6.0", "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 80dc755a2..e0230fabe 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -1,3 +1,7 @@ +## 19.6.0 (2025-05-27) + +This was a version bump only for @angular-eslint/utils to align it with other projects, there were no code changes. + ## 19.5.0 (2025-05-25) ### 🚀 Features diff --git a/packages/utils/package.json b/packages/utils/package.json index a90c8e969..e5d7ed7a5 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@angular-eslint/utils", - "version": "19.5.0", + "version": "19.6.0", "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts",