diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2749380..8ce251f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,12 @@ jobs: - name: Typecheck run: npm run typecheck - - name: Run tests + - name: Unit tests (reference-spec layer) run: npm run test:coverage + - name: Integration tests (real biome binary) + run: npm run test:integration + - name: Upload coverage to Codecov if: matrix.node-version == '22' uses: codecov/codecov-action@v5 @@ -47,6 +50,61 @@ jobs: files: ./coverage/lcov.info fail_ci_if_error: false + biome-latest: + name: Integration tests against latest Biome + runs-on: ubuntu-latest + # Early warning when a new Biome release changes plugin behavior. + # Non-blocking: a breaking upstream release should not fail unrelated PRs. + continue-on-error: true + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install latest Biome + run: | + npm install --no-save @biomejs/biome@latest + npx biome --version + + - name: Integration tests + run: npm run test:integration + + biome-min: + name: Integration tests against minimum supported Biome (2.5.0) + runs-on: ubuntu-latest + # Guards the peerDependencies range: native AST node patterns and plugin + # rewrites both require Biome 2.5.0. + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Biome 2.5.0 + run: | + npm install --no-save @biomejs/biome@2.5.0 + npx biome --version + + - name: Integration tests + run: npm run test:integration + lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35fe395..d7dc2fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,20 +2,27 @@ name: Release on: push: - tags: - - 'v*' + branches: [main] permissions: - contents: write + contents: read jobs: release: - name: Publish to npm + name: semantic-release runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + id-token: write steps: - name: Checkout uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v5 @@ -27,22 +34,11 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: npm test - - - name: Publish to npm - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Run all tests + run: npm run test:all - - name: Create GitHub Release - uses: actions/create-release@v1 + - name: Release + run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref_name }} - release_name: ${{ github.ref_name }} - body: | - See [CHANGELOG.md](https://github.com/ksom/biome-plugin-angular/blob/main/CHANGELOG.md) for details. - draft: false - prerelease: false + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 9ea7e24..ff01b89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1,20 @@ # biome-plugin-angular ## Projet -Plugin Biome pour Angular 17-21. Règles TypeScript en GritQL pour Biome 2.x. +Plugin Biome pour Angular 17-22. Règles TypeScript en GritQL pour Biome 2.5+. Port des règles @angular-eslint + nouvelles règles signal-based (Angular moderne). ## Commandes -- npm test : lance tous les tests (Vitest) +- npm test : tests unitaires (Vitest) — couche "reference spec" (pattern-matcher ts-morph) +- npm run test:integration : tests d'intégration avec le binaire biome réel (source de vérité) +- npm run test:all : les deux suites - npm run lint : lint du projet ## Conventions -- Un commit par règle : "feat(rules): add {rule-name}" -- Chaque règle a : un .grit, un test, un doc .md, et une entrée dans les presets +- Un commit par règle : "feat(rules): add {rule-name}" / "fix(rules): …" +- Chaque règle a : un .grit, un test d'intégration, un doc .md, une entrée dans rules-to-migrate.json - Utiliser les règles existantes comme template pour les nouvelles +- Toute nouvelle règle DOIT être vérifiée contre le binaire biome réel (test d'intégration), pas seulement le pattern-matcher ## Structure des règles - rules/high-priority/ — règles critiques (prefer-standalone, no-empty-lifecycle-method) @@ -19,31 +22,55 @@ Port des règles @angular-eslint + nouvelles règles signal-based (Angular moder - rules/quality/ — qualité de code (use-lifecycle-interface, contextual-lifecycle, etc.) - rules/modern/ — APIs signal-based Angular 17+ (prefer-signal-inputs, prefer-inject-function, etc.) -## Commandes de test -- npm test : tests unitaires (Vitest, 217 tests) -- npm run test:integration : tests d'intégration avec le binaire biome réel (68 tests, 5 skipped) -- npm run test:all : les deux suites - ## État actuel -- 20 règles dans biome-manifest.jsonc (format Biome 2.4.8+) -- 4 règles skippées dans rules-to-migrate.json (obsolètes ou inversées) -- Tests unitaires : 21 fichiers, 217 tests, tous verts -- Tests intégration : 20 fichiers, 68 tests verts, 5 skipped (limites GritQL) -- Catégorie "modern" : prefer-signal-inputs, prefer-output-function, prefer-signal-queries, - prefer-model-signal, prefer-host-property, prefer-inject-function - -## Limitations GritQL (Biome 2.4.8) -GritQL ne supporte pas le pattern `@Decorator\nclass` comme une unité. Conséquences : - -**Règles heuristiques** (fonctionnent mais sans vérification du décorateur) : -- no-component-suffix : flag toute classe `*Component` (pas seulement @Component) -- no-directive-suffix : flag toute classe `*Directive` (pas seulement @Directive) -- no-service-suffix : flag toute classe `*Service` (pas seulement @Injectable) -- prefer-inject-function : flag tout constructor avec access modifiers (pas seulement Angular) - -**Règle limitée** : -- sort-ngmodule-metadata-arrays : détecte la présence d'arrays, pas leur ordre de tri +- 26 règles dans biome-manifest.jsonc, toutes fonctionnelles et décorateur-aware +- Angular 17-22, Biome >= 2.5.0 (peerDep) — dev sur 2.5.12 +- sort-ngmodule-metadata-arrays supprimée (GritQL ne peut pas comparer l'ordre d'une liste) +- prefer-standalone ne signale que standalone: false (défaut standalone depuis Angular 19) +- contextual-lifecycle aligné sur upstream : @Injectable/@Pipe n'autorisent que ngOnDestroy ; + @Component ET @Directive autorisent tous les hooks +- prefer-inject-function signale TOUT paramètre de constructeur d'une classe décorée + (aligné sur @angular-eslint/prefer-inject), pas seulement ceux avec modificateur +- Tests d'intégration : source de vérité, incluent un smoke test consommateur (npm pack → install → lint) + et tests/integration/native-shapes.test.ts (implements, readonly, export default, --write) +- Consommation : "extends": ["biome-plugin-angular/presets/recommended.json"] (recommandé) + Le preset = plugin scoped **/*.ts (hors *.spec.ts) + unsafeParameterDecoratorsEnabled + + style/useImportType off. Sans ces deux réglages Biome : parse error sur @Inject(), + et `biome lint --write` casse le build Angular (import type → NG2003) +- Sévérités fixées dans les .grit (register_diagnostic) — linter.rules.plugins N'EXISTE PAS dans Biome +- Messages préfixés "[rule-name] …" (les métavariables ne sont PAS interpolées dans message) ; + suppression : // biome-ignore lint/plugin/: raison +- Auto-fix : seul no-empty-lifecycle-method, scindé en 2 fichiers de même stem + (rules/high-priority/no-empty-lifecycle-method.grit = classe implémentant l'interface, pas de + fix ; rules/high-priority/fixes/no-empty-lifecycle-method.grit = fix safe). Biome apparie + rewrites et diagnostics par ordre d'apparition → impossible de mélanger branches fixables et + non fixables dans un même pattern. Bug Biome : un diagnostic supprimé (biome-ignore) est + quand même réécrit par --write si un autre match du même fichier est fixé. + Les autres migrations pointent vers les schematics officiels (ng generate @angular/core:…) -**Règles non-fonctionnelles** (compilent mais ne détectent pas les violations) : -- contextual-lifecycle : nécessite le type de décorateur pour le contexte -- use-pipe-transform-interface : nécessite @Pipe + class comme unité +## GritQL (Biome 2.5+) — règles d'écriture de patterns (vérifiées empiriquement) +- TOUJOURS matcher classes et membres via les nœuds AST natifs Biome (PascalCase, champs du + js.ungram) : JsClassDeclaration(decorators, id, implements_clause, members), + JsExport(decorators, export_clause = JsClassDeclaration(...)), + JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(...)), + JsPropertyClassMember(modifiers, name, value), JsMethodClassMember(name, body), + JsConstructorClassMember(parameters), JsFunctionBody(statements), JsCallExpression(callee) + → couvre implements, extends, export default, readonly, override, async… + Les décorateurs placés avant `export` sont sur JsExport, d'où 3 branches par règle +- `$x <: within JsClassDeclaration(implements_clause = $impl)` remonte à la classe englobante +- Champ optionnel absent (implements_clause) : bindé quand même, `not contains` passe +- Snippets : n'utiliser que pour décorateurs/appels simples ; un snippet méthode ne matche pas + les modificateurs `override`/`protected` ; `async $hook() { $_ }` OK avec $hook en nom +- Regex = full match obligatoire (`r"^on[A-Z].*"`), `(?s)` pour multi-lignes ; groupes non + capturants `(?:)` sauf capture avec variable `r"(.*)Change$"($base)` +- `pattern foo($body) {…}` : NE PAS utiliser — `$body <: contains …` échoue silencieusement + depuis l'appelant. Inliner le `or` +- message = chaîne statique (pas d'interpolation de $var) +- Rewrite : `$m => .` supprime ; fix_kind = "safe"|"unsafe" dans register_diagnostic ; + écrire le rewrite AVANT register_diagnostic quand le pattern utilise `within` ; + supprimer un membre d'objet laisse une virgule orpheline (syntax error) +- Suppression : le commentaire biome-ignore doit être sur la ligne juste au-dessus du nœud + signalé (entre le décorateur et `export class` pour un diagnostic sur le nom de classe) +- Diagnostics plugin : category "plugin" dans le JSON, nom du plugin = stem du .grit + (suppression lint/plugin/) +- Bench rapide : dossier temp avec biome.json { plugins: [./x.grit] } + `biome lint --reporter=json` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 532c023..bf13392 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,8 +39,8 @@ tests/ rules/ # One unit test per rule integration/ # One integration test per rule (real biome binary) docs/rules/ # One .md file per rule -presets/ # recommended.json, strict.json -biome-manifest.jsonc # Biome 2.4.8+ plugin manifest +presets/ # recommended.json (plugin + Angular-safe Biome settings) +biome-manifest.jsonc # Biome 2.5+ plugin manifest cli.js # CLI entrypoint ``` @@ -66,14 +66,57 @@ cli.js # CLI entrypoint } ``` - **GritQL constraints** (Biome 2.4.8): + **Match classes and members through Biome's native AST nodes**, not code + snippets. Snippets only match the exact shape they spell out (a class snippet + misses `implements`, a method snippet misses `override`), while native nodes + expose every field: + + ```grit + or { + JsClassDeclaration(decorators = $decorators, id = $name, members = $members), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(id = $name, members = $members)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(id = $name, members = $members))) + } where { + $decorators <: contains `@Component($args)`, + ... + } + ``` + + Decorators written before `export` belong to the JsExport node, hence the + three branches. Field names come from Biome's grammar + (`xtask/codegen/js.ungram`): `JsPropertyClassMember(modifiers, name, value)`, + `JsMethodClassMember(name, body)`, `JsConstructorClassMember(parameters)`, + `implements_clause`, `JsFunctionBody(statements)`… + + **GritQL constraints** (Biome 2.5, verified against the binary): - No `/* */` block comments (causes compilation failure) - No `//` inline comments inside `or {}` / `where {}` blocks - - Use non-capturing regex groups `(?:...)` instead of `(...)` - - Regex must match full node text (e.g. `r"^on[A-Z].*"` not `r"^on[A-Z]"`) - - `@Decorator\nclass` patterns do not match at runtime - -3. **Register the rule** in `biome-manifest.jsonc`. + - Regex must match the full node text (e.g. `r"^on[A-Z].*"` not `r"^on[A-Z]"`); + use `(?s)` to span lines. Unused capture groups must be non-capturing `(?:...)`; + a capturing group needs a variable: `r"(.*)Change$"($base)` + - Diagnostic messages are static: metavariables are NOT interpolated. Start + every message with the rule name in brackets, `[rule-name] …`, so users can + write `biome-ignore lint/plugin/rule-name` (the plugin name is the .grit file stem) + - `pattern name($x) { … }` definitions are unreliable: a list bound inside a + definition cannot be searched with `contains` from the caller. Inline the `or` + - Snippets with a call need one variant per argument shape: `output()`, + `output($a)`, `output<$t>()`, `output<$t>($a)`… or match `JsCallExpression(callee = $c)` + - An absent optional field (`implements_clause` on a class without one) still + binds, and `not contains` on it succeeds + - Rewrites: `$node => .` deletes the node; declare `fix_kind = "safe"` in + `register_diagnostic` for `--write` to apply it, and write the rewrite BEFORE + `register_diagnostic` when the pattern uses `within`. Deleting an object + member leaves a dangling comma (syntax error) — do not rewrite object literals + - Biome pairs rewrites with diagnostics by order of appearance, so one pattern + cannot mix fixable and non-fixable branches. Split such a rule in two files + with the SAME stem (`rules//.grit` + `rules//fixes/.grit`): + the plugin name, hence the suppression name, comes from the stem + - Parameter decorators (`@Inject(X) x`) are a parse error unless + `javascript.parser.unsafeParameterDecoratorsEnabled` is on (the preset and + the test runner set it) + +3. **Register the rule** in `biome-manifest.jsonc`, `index.js` and + `tests/helpers/biome-runner.ts` (`RULE_PATHS`). 4. **Add the detection function** in `tests/helpers/pattern-matcher.ts` using `ts-morph` to mirror the GritQL pattern for unit testing. diff --git a/README.md b/README.md index 34e3f6f..cd3faa4 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,24 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js >=18](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](package.json) -> Biome 2.x plugin that ports `@angular-eslint` rules to GritQL and adds modern Angular signal-based rules. -> For Angular 17-21 projects migrating from ESLint to Biome. +> Biome 2.5+ plugin that ports `@angular-eslint` rules to GritQL and adds signal-era +> Angular rules. For Angular 17-22 projects migrating from ESLint to Biome. --- ## Features -- 20 GritQL rules for Biome 2.x plugin system -- Ports the most-used `@angular-eslint` rules -- 6 new rules for modern Angular (signals, inject, host properties) +- 26 GritQL rules for the Biome 2.5+ plugin system +- Ports the `@angular-eslint` recommended TypeScript rules and the most-used extras +- Signal-era rules: `input()`, `output()`, `model()`, signal queries, `inject()`, `host: {}` +- Rules cover every real-world class shape (`implements`, `extends`, `export default`, + `readonly` members, parameter decorators, modifiers) +- An **Angular-safe preset**: enables parameter decorators, turns off the Biome rule that + breaks Angular DI, and scopes the plugin to `.ts` files +- Safe auto-fix for empty lifecycle methods; pointers to the official Angular schematics + for the larger migrations - CLI helper: `biome-angular init|merge|remove|rules` -- `recommended` and `strict` presets +- Every rule verified against the real Biome binary (integration + packed-package tests) - Zero runtime dependencies --- @@ -27,7 +33,7 @@ | Tool | Version | | ----------------- | --------- | | Node.js | >= 18 | -| `@biomejs/biome` | >= 2.4.8 | +| `@biomejs/biome` | >= 2.5.0 | | Angular | >= 17 | --- @@ -42,7 +48,7 @@ npm install --save-dev biome-plugin-angular @biomejs/biome ## Getting Started -### Option A — CLI (recommended) +### Option A — CLI ```bash # Create a biome.json from scratch @@ -54,16 +60,86 @@ npx biome-angular merge ### Option B — Manual configuration -Add the plugin to your `biome.json`: +Extend the shipped preset from your `biome.json`: ```jsonc { - "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", - "plugins": ["biome-plugin-angular"] + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` -Biome will load `biome-manifest.jsonc` from the plugin package automatically. +The preset is the recommended path because it also applies three settings that any +Angular project needs from Biome itself: + +```jsonc +{ + "plugins": [ + // Biome resolves plugin entries as paths relative to the config file. + // The plugin runs on TypeScript only and skips spec files. + { "path": "./node_modules/biome-plugin-angular", "includes": ["**/*.ts", "!**/*.spec.ts"] } + ], + "javascript": { + // Angular constructors use parameter decorators (@Inject, @Optional, @Attribute…). + // Biome reports them as a parse error — and skips the file — unless this is on. + "parser": { "unsafeParameterDecoratorsEnabled": true } + }, + "linter": { + "rules": { + // Biome's recommended useImportType rewrites `import { HttpClient }` to + // `import type { HttpClient }` when the token is only used as a constructor + // parameter type. Angular then fails to build with NG2003 ("No suitable + // injection token"). Biome's own docs recommend disabling it for Angular. + "style": { "useImportType": "off" } + } + } +} +``` + +If you prefer to list the plugin yourself, copy the block above rather than only the +`plugins` entry. + +### Running + +```bash +npx biome lint # report +npx biome lint --write # also apply safe fixes (empty lifecycle methods are removed) +``` + +### Suppressing a diagnostic + +Every message starts with its rule name in brackets. Use that name in a suppression +comment placed on the line **directly above the flagged code**. For a diagnostic on the +class name, that is between the decorator and the `class` keyword: + +```typescript +@Component({ selector: 'app-legacy', template: '' }) +// biome-ignore lint/plugin/no-component-suffix: legacy naming kept on purpose +export class LegacyComponent { + // biome-ignore lint/plugin/prefer-signal-inputs: migrated in a later PR + @Input() name = ''; +} +``` + +A diagnostic inside the decorator (`prefer-standalone`, `component-selector`…) takes the +comment above the decorator. `// biome-ignore-all lint/plugin/: reason` at the top +of a file suppresses the rule for the whole file. + +### Severities + +Biome has no per-rule severity configuration for plugins — each rule's severity is fixed +inside its `.grit` file (`register_diagnostic`). Rules about correctness report +**errors**; convention and migration rules report **warnings**. To change a severity or +disable a rule, copy the `.grit` files you want from the package and list them +individually: + +```jsonc +{ + "plugins": [ + "./node_modules/biome-plugin-angular/rules/high-priority/prefer-standalone.grit", + "./node_modules/biome-plugin-angular/rules/conventions/no-input-rename.grit" + ] +} +``` --- @@ -71,87 +147,103 @@ Biome will load `biome-manifest.jsonc` from the plugin package automatically. ### High Priority -| Rule | ESLint equivalent | Severity | Notes | -| ---- | ----------------- | :------: | ----- | -| [`prefer-standalone`](docs/rules/prefer-standalone.md) | `@angular-eslint/prefer-standalone` | error | | -| [`no-empty-lifecycle-method`](docs/rules/no-empty-lifecycle-method.md) | `@angular-eslint/no-empty-lifecycle-method` | warn | | +| Rule | ESLint equivalent | Severity | Fix | Notes | +| ---- | ----------------- | :------: | :-: | ----- | +| [`prefer-standalone`](docs/rules/prefer-standalone.md) | `@angular-eslint/prefer-standalone` | error | | flags only explicit `standalone: false`; migrate with `ng generate @angular/core:standalone` | +| [`no-empty-lifecycle-method`](docs/rules/no-empty-lifecycle-method.md) | `@angular-eslint/no-empty-lifecycle-method` | warn | safe | deletes the method when its interface is not implemented; otherwise asks to remove both | ### Conventions | Rule | ESLint equivalent | Severity | Notes | | ---- | ----------------- | :------: | ----- | -| [`no-component-suffix`](docs/rules/no-component-suffix.md) | _(new — Angular v20+)_ | warn | heuristic* | -| [`no-directive-suffix`](docs/rules/no-directive-suffix.md) | _(new — Angular v20+)_ | warn | heuristic* | -| [`no-service-suffix`](docs/rules/no-service-suffix.md) | _(new — Angular v20+)_ | warn | heuristic* | -| [`component-selector`](docs/rules/component-selector.md) | `@angular-eslint/component-selector` | warn | | -| [`no-input-rename`](docs/rules/no-input-rename.md) | `@angular-eslint/no-input-rename` | error | | -| [`no-output-rename`](docs/rules/no-output-rename.md) | `@angular-eslint/no-output-rename` | error | | -| [`no-output-on-prefix`](docs/rules/no-output-on-prefix.md) | `@angular-eslint/no-output-on-prefix` | error | | -| [`pipe-prefix`](docs/rules/pipe-prefix.md) | `@angular-eslint/pipe-prefix` | warn | | -| [`sort-ngmodule-metadata-arrays`](docs/rules/sort-ngmodule-metadata-arrays.md) | `@angular-eslint/sort-ngmodule-metadata-arrays` | warn | limited** | +| [`no-component-suffix`](docs/rules/no-component-suffix.md) | _(new — Angular v20+)_ | warn | decorator-gated | +| [`no-directive-suffix`](docs/rules/no-directive-suffix.md) | _(new — Angular v20+)_ | warn | decorator-gated | +| [`no-service-suffix`](docs/rules/no-service-suffix.md) | _(new — Angular v20+)_ | warn | decorator-gated | +| [`component-selector`](docs/rules/component-selector.md) | `@angular-eslint/component-selector` | warn | `app-` prefix hardcoded* | +| [`directive-selector`](docs/rules/directive-selector.md) | `@angular-eslint/directive-selector` | warn | `[appCamelCase]`, prefix hardcoded* | +| [`no-input-rename`](docs/rules/no-input-rename.md) | `@angular-eslint/no-input-rename` | error | decorator and `input()`/`model()` aliases | +| [`no-output-rename`](docs/rules/no-output-rename.md) | `@angular-eslint/no-output-rename` | error | decorator and `output()` aliases | +| [`no-output-on-prefix`](docs/rules/no-output-on-prefix.md) | `@angular-eslint/no-output-on-prefix` | error | decorator and `output()` | +| [`no-output-native`](docs/rules/no-output-native.md) | `@angular-eslint/no-output-native` | error | decorator and `output()` | +| [`pipe-prefix`](docs/rules/pipe-prefix.md) | `@angular-eslint/pipe-prefix` | warn | `app` prefix hardcoded* | ### Quality | Rule | ESLint equivalent | Severity | Notes | | ---- | ----------------- | :------: | ----- | -| [`use-lifecycle-interface`](docs/rules/use-lifecycle-interface.md) | `@angular-eslint/use-lifecycle-interface` | warn | | -| [`use-pipe-transform-interface`](docs/rules/use-pipe-transform-interface.md) | `@angular-eslint/use-pipe-transform-interface` | warn | non-functional*** | -| [`contextual-lifecycle`](docs/rules/contextual-lifecycle.md) | `@angular-eslint/contextual-lifecycle` | error | non-functional*** | +| [`use-lifecycle-interface`](docs/rules/use-lifecycle-interface.md) | `@angular-eslint/use-lifecycle-interface` | warn | per class | +| [`use-pipe-transform-interface`](docs/rules/use-pipe-transform-interface.md) | `@angular-eslint/use-pipe-transform-interface` | warn | per class | +| [`contextual-lifecycle`](docs/rules/contextual-lifecycle.md) | `@angular-eslint/contextual-lifecycle` | error | | +| [`no-conflicting-lifecycle`](docs/rules/no-conflicting-lifecycle.md) | `@angular-eslint/no-conflicting-lifecycle` | error | `ngDoCheck` + `ngOnChanges` | +| [`no-lifecycle-call`](docs/rules/no-lifecycle-call.md) | `@angular-eslint/no-lifecycle-call` | error | `super.ngXxx()` allowed | +| [`no-async-lifecycle-method`](docs/rules/no-async-lifecycle-method.md) | `@angular-eslint/no-async-lifecycle-method` | warn | | +| [`no-pipe-impure`](docs/rules/no-pipe-impure.md) | `@angular-eslint/no-pipe-impure` | warn | | +| [`no-attribute-decorator`](docs/rules/no-attribute-decorator.md) | `@angular-eslint/no-attribute-decorator` | warn | suggests `HostAttributeToken` | ### Modern (Angular 17+) | Rule | ESLint equivalent | Severity | Notes | | ---- | ----------------- | :------: | ----- | -| [`prefer-signal-inputs`](docs/rules/prefer-signal-inputs.md) | _(new)_ | warn | Angular 17.1+ | -| [`prefer-output-function`](docs/rules/prefer-output-function.md) | _(new)_ | warn | Angular 17.3+ | -| [`prefer-signal-queries`](docs/rules/prefer-signal-queries.md) | _(new)_ | warn | Angular 17.2+ | -| [`prefer-model-signal`](docs/rules/prefer-model-signal.md) | _(new)_ | warn | Angular 17.2+ | +| [`prefer-signal-inputs`](docs/rules/prefer-signal-inputs.md) | _(new)_ | warn | Angular 17.1+, `ng generate @angular/core:signal-input-migration` | +| [`prefer-output-function`](docs/rules/prefer-output-function.md) | _(new)_ | warn | Angular 17.3+, `ng generate @angular/core:output-migration` | +| [`prefer-signal-queries`](docs/rules/prefer-signal-queries.md) | _(new)_ | warn | Angular 17.2+, `ng generate @angular/core:signal-queries-migration` | +| [`prefer-model-signal`](docs/rules/prefer-model-signal.md) | _(new)_ | warn | Angular 17.2+, requires paired `@Input()` | | [`prefer-host-property`](docs/rules/prefer-host-property.md) | _(new)_ | warn | | -| [`prefer-inject-function`](docs/rules/prefer-inject-function.md) | _(new)_ | warn | heuristic* | +| [`prefer-inject-function`](docs/rules/prefer-inject-function.md) | `@angular-eslint/prefer-inject` | warn | any constructor parameter, `ng generate @angular/core:inject` | -### GritQL Limitations +\* Biome plugins take no options, so the selector/pipe prefix cannot be configured. +Projects not using the Angular CLI default `app` prefix should copy and adapt the `.grit` +file. -Biome's GritQL engine (2.4.8) cannot match `@Decorator` + `class` as a single pattern unit. This affects some rules: +### Auto-fix and migrations -- **\* heuristic**: Rules match by class name or constructor shape without verifying the Angular decorator. Accurate in Angular projects; may false-positive in non-Angular code. -- **\*\* limited**: `sort-ngmodule-metadata-arrays` detects the presence of metadata arrays but cannot verify sort order. -- **\*\*\* non-functional**: Rules compile but cannot detect violations at runtime. Kept for forward-compatibility. +Biome 2.5 plugins can rewrite code. This plugin only ships a rewrite where it is +mechanical and safe: `no-empty-lifecycle-method` deletes an empty hook when the class does +not implement the matching interface (when it does, deleting the method alone would fail +compilation with TS2420, so the diagnostic asks to remove both). The other migrations +change templates or module declarations as well, so the rules point to the official +Angular schematics instead, which handle both sides. ---- +Biome caveat (2.5.12): plugin rewrites are applied per file. When one empty hook in a file +is fixed, a `biome-ignore` comment on another empty hook **in the same file** does not +protect it from `--write`. Prefer `biome-ignore-all` on that file, or give the hook a +statement, until Biome resolves this. -## CLI Reference +The official schematics: +```bash +ng generate @angular/core:standalone +ng generate @angular/core:inject +ng generate @angular/core:signal-input-migration +ng generate @angular/core:output-migration +ng generate @angular/core:signal-queries-migration ``` -biome-angular init [--strict] [--force] - Create a new biome.json preconfigured with this plugin. - --strict Use the strict preset (all rules as errors) - --force Overwrite existing biome.json -biome-angular merge [--strict] - Add this plugin to your existing biome.json without overwriting. - -biome-angular remove - Remove all biome-plugin-angular rules and plugin reference from biome.json. +### Skipped rules -biome-angular rules - Print all available rules and their implementation status. -``` +`component-class-suffix` and `directive-class-suffix` are intentionally not ported: the +Angular v20+ style guide dropped class suffixes, and the inverse rules +(`no-*-suffix`) are shipped instead. See [`rules-to-migrate.json`](rules-to-migrate.json) +for the reasoning behind every skipped rule. --- -## Presets +## CLI Reference -Two presets are available: +``` +biome-angular init [--force] + Create a new biome.json that extends biome-plugin-angular/presets/recommended.json. + --force Overwrite existing biome.json -| Preset | Description | -| ------ | ----------- | -| `recommended` | All high-priority rules; `no-empty-lifecycle-method` as warn | -| `strict` | All high-priority rules as errors | +biome-angular merge + Add the preset to the "extends" array of your existing biome.json. -Reference them in `biome.json`: +biome-angular remove + Remove the plugin from biome.json ("extends" and "plugins" forms, plus config + written by pre-1.0 versions of this CLI). -```json -{ "extends": ["biome-plugin-angular/presets/strict"] } +biome-angular rules + Print all available rules and their implementation status. ``` --- @@ -165,6 +257,10 @@ once the corresponding rules are available in this plugin: npm uninstall eslint @angular-eslint/eslint-plugin @angular-eslint/template-parser ``` +Note: this plugin covers TypeScript rules only. `@angular-eslint/template-*` +(HTML template) rules have no Biome equivalent yet, and Biome does not lint Angular +templates. + See [`rules-to-migrate.json`](rules-to-migrate.json) for the full migration tracking list. --- diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index 57b96f0..b026d6c 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -1,11 +1,12 @@ // Biome 2.x plugin manifest — required entry point when loading via // `"plugins": ["biome-plugin-angular"]` in biome.json. -// Biome 2.4.8+ format: version (integer) + rules (array of .grit paths). +// Biome 2.5+ format: version (integer) + rules (array of .grit paths). { "version": 1, "rules": [ "./rules/high-priority/prefer-standalone.grit", "./rules/high-priority/no-empty-lifecycle-method.grit", + "./rules/high-priority/fixes/no-empty-lifecycle-method.grit", "./rules/conventions/no-service-suffix.grit", "./rules/conventions/no-directive-suffix.grit", "./rules/conventions/no-component-suffix.grit", @@ -14,10 +15,16 @@ "./rules/conventions/no-output-rename.grit", "./rules/conventions/no-output-on-prefix.grit", "./rules/conventions/pipe-prefix.grit", - "./rules/conventions/sort-ngmodule-metadata-arrays.grit", + "./rules/conventions/directive-selector.grit", + "./rules/conventions/no-output-native.grit", "./rules/quality/use-lifecycle-interface.grit", "./rules/quality/use-pipe-transform-interface.grit", "./rules/quality/contextual-lifecycle.grit", + "./rules/quality/no-conflicting-lifecycle.grit", + "./rules/quality/no-lifecycle-call.grit", + "./rules/quality/no-async-lifecycle-method.grit", + "./rules/quality/no-pipe-impure.grit", + "./rules/quality/no-attribute-decorator.grit", "./rules/modern/prefer-signal-inputs.grit", "./rules/modern/prefer-output-function.grit", "./rules/modern/prefer-signal-queries.grit", diff --git a/biome.json b/biome.json index e6f6e99..30d868b 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -26,7 +26,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "correctness": { "noUnusedVariables": "error" }, diff --git a/cli.js b/cli.js index 4b00972..1429f02 100755 --- a/cli.js +++ b/cli.js @@ -4,9 +4,15 @@ * * Commands: * init — Scaffold a biome.json with biome-plugin-angular configured - * merge — Merge plugin rules into an existing biome.json + * merge — Add the plugin to an existing biome.json * remove — Remove biome-plugin-angular from an existing biome.json * rules — List all available rules and their status + * + * The generated configuration extends the shipped preset + * (biome-plugin-angular/presets/recommended.json), which loads the plugin and + * applies the Angular-safe Biome settings (parameter decorators enabled, + * style/useImportType off). Per-rule severities are set inside each .grit + * rule (register_diagnostic) — there is no per-rule configuration for plugins. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; @@ -16,6 +22,14 @@ import { fileURLToPath } from 'node:url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PLUGIN_NAME = 'biome-plugin-angular'; +const PRESET = `${PLUGIN_NAME}/presets/recommended.json`; +const BIOME_SCHEMA = 'https://biomejs.dev/schemas/2.5.12/schema.json'; + +/** True when a plugins/extends entry points at this package (any form). */ +function referencesPlugin(entry) { + const value = typeof entry === 'string' ? entry : entry?.path; + return typeof value === 'string' && value.includes(PLUGIN_NAME); +} // --------------------------------------------------------------------------- // Helpers @@ -51,7 +65,6 @@ function writeJson(filePath, data) { // --------------------------------------------------------------------------- function cmdInit(args) { - const preset = args.includes('--strict') ? 'strict' : 'recommended'; const targetPath = resolve(process.cwd(), 'biome.json'); if (existsSync(targetPath) && !args.includes('--force')) { @@ -60,45 +73,40 @@ function cmdInit(args) { ); } - const presetConfig = readJson(join(__dirname, 'presets', `${preset}.json`)); - delete presetConfig.description; - presetConfig.$schema = 'https://biomejs.dev/schemas/2.0.0/schema.json'; - - writeJson(targetPath, presetConfig); - success(`Created biome.json with ${PLUGIN_NAME} (${preset} preset).`); + writeJson(targetPath, { + $schema: BIOME_SCHEMA, + extends: [PRESET], + }); + success(`Created biome.json extending ${PRESET}.`); log(''); log('Next steps:'); - log(` 1. npx @biomejs/biome check .`); - log(` 2. Review docs: https://github.com/yourusername/${PLUGIN_NAME}#rules`); + log(' 1. npx @biomejs/biome lint .'); + log(` 2. Review docs: https://github.com/ksom/${PLUGIN_NAME}#rules`); } -function cmdMerge(args) { +function cmdMerge() { const targetPath = resolve(process.cwd(), 'biome.json'); if (!existsSync(targetPath)) { error(`biome.json not found. Run 'biome-angular init' to create one.`); } - const preset = args.includes('--strict') ? 'strict' : 'recommended'; const existing = readJson(targetPath); - const presetConfig = readJson(join(__dirname, 'presets', `${preset}.json`)); - // Merge plugins array - if (!existing.plugins) existing.plugins = []; - if (!existing.plugins.includes(PLUGIN_NAME)) { - existing.plugins.push(PLUGIN_NAME); + const alreadyConfigured = + (Array.isArray(existing.plugins) && existing.plugins.some(referencesPlugin)) || + (Array.isArray(existing.extends) && existing.extends.some(referencesPlugin)); + if (!alreadyConfigured) { + if (!Array.isArray(existing.extends)) existing.extends = []; + existing.extends.push(PRESET); } - // Merge linter rules - existing.linter = existing.linter ?? {}; - existing.linter.rules = existing.linter.rules ?? {}; - existing.linter.rules.plugins = { - ...presetConfig.linter?.rules?.plugins, - ...(existing.linter.rules.plugins ?? {}), - }; - writeJson(targetPath, existing); - success(`Merged ${PLUGIN_NAME} (${preset} preset) into biome.json.`); + success( + alreadyConfigured + ? `${PLUGIN_NAME} is already configured in biome.json.` + : `Added ${PLUGIN_NAME} to biome.json.`, + ); } function cmdRemove() { @@ -110,13 +118,15 @@ function cmdRemove() { const config = readJson(targetPath); - // Remove from plugins - if (Array.isArray(config.plugins)) { - config.plugins = config.plugins.filter((p) => p !== PLUGIN_NAME); - if (config.plugins.length === 0) delete config.plugins; + for (const key of ['plugins', 'extends']) { + if (Array.isArray(config[key])) { + config[key] = config[key].filter((entry) => !referencesPlugin(entry)); + if (config[key].length === 0) delete config[key]; + } } - // Remove plugin rules + // Clean up the invalid linter.rules.plugins key written by pre-1.0 versions + // of this CLI (Biome rejects it as an unknown key). if (config.linter?.rules?.plugins) { for (const key of Object.keys(config.linter.rules.plugins)) { if (key.startsWith('angular/')) { @@ -137,9 +147,9 @@ function cmdRules() { const { typescript: rules } = readJson(rulesPath); const statusIcon = { done: '✅', todo: '⬜', 'in-progress': '🔄', skipped: '⏭️ ' }; - const categoryOrder = { 'high-priority': 0, conventions: 1, quality: 2 }; + const categoryOrder = { 'high-priority': 0, conventions: 1, quality: 2, modern: 3 }; - rules.sort((a, b) => categoryOrder[a.category] - categoryOrder[b.category]); + rules.sort((a, b) => (categoryOrder[a.category] ?? 99) - (categoryOrder[b.category] ?? 99)); let currentCategory = null; for (const rule of rules) { @@ -148,7 +158,8 @@ function cmdRules() { log(`\n ${currentCategory.toUpperCase()}`); } const icon = statusIcon[rule.status] ?? '❓'; - log(` ${icon} angular/${rule.name.padEnd(34)} (${rule.eslintRule})`); + const origin = rule.eslintRule ?? 'new signal-based rule'; + log(` ${icon} angular/${rule.name.padEnd(34)} (${origin})`); } const done = rules.filter((r) => r.status === 'done').length; @@ -166,7 +177,7 @@ switch (command) { cmdInit(rest); break; case 'merge': - cmdMerge(rest); + cmdMerge(); break; case 'remove': cmdRemove(); @@ -179,8 +190,8 @@ switch (command) { biome-angular — Biome plugin for Angular Usage: - biome-angular init [--strict] [--force] - biome-angular merge [--strict] + biome-angular init [--force] + biome-angular merge biome-angular remove biome-angular rules diff --git a/docs/rules/component-selector.md b/docs/rules/component-selector.md index 1b2d188..d37f154 100644 --- a/docs/rules/component-selector.md +++ b/docs/rules/component-selector.md @@ -2,7 +2,7 @@ **Category:** conventions **ESLint equivalent:** `@angular-eslint/component-selector` -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -10,10 +10,11 @@ Enforces that `@Component` selectors follow the Angular Style Guide conventions: - **Type:** element selector (not attribute `[...]` or class `.`) - **Style:** kebab-case -- **Prefix:** must start with a configurable prefix (default: `app-`) +- **Prefix:** must start with `app-` (hardcoded — see Configuration) These conventions ensure selectors are consistent, predictable, and avoid collisions -with native HTML elements. +with native HTML elements. Comma-separated selector lists (`'app-a, app-b'`) are accepted +when every entry is valid. ## Examples @@ -56,36 +57,34 @@ export class Step2FormComponent {} ## Configuration -The default prefix is `app`. Configure a custom prefix via rule options (planned): +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): ```json { - "linter": { - "rules": { - "plugins": { - "angular/component-selector": ["warn", { "prefix": "acme", "style": "kebab-case", "type": "element" }] - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` -For library projects, use a shorter domain-specific prefix (e.g. `mat-` for Angular Material). - -## Migration from @angular-eslint +Or extend the recommended preset: ```json { - "linter": { - "rules": { - "plugins": { - "angular/component-selector": "warn" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no options, so the prefix is **not configurable**: it is hardcoded to +`app`. If your project uses a different prefix (e.g. `acme-`, or `mat-` for a library), +copy `rules/conventions/component-selector.grit` into your project, adjust the regex, and +point your `plugins` array at the forked file. + +The severity is fixed to `warn` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/component-selector"` from your ESLint config and enable the +plugin as shown above. + ## Further Reading - [Angular Style Guide — Component selectors](https://angular.dev/style-guide#component-selectors) diff --git a/docs/rules/contextual-lifecycle.md b/docs/rules/contextual-lifecycle.md index 1f13ec0..dbf73fa 100644 --- a/docs/rules/contextual-lifecycle.md +++ b/docs/rules/contextual-lifecycle.md @@ -2,73 +2,88 @@ **Category:** quality **ESLint equivalent:** `@angular-eslint/contextual-lifecycle` -**Severity (recommended):** error +**Severity:** error ## Overview Angular lifecycle hooks are only invoked for the class types that support them. Using a hook in the wrong context is a silent bug — Angular won't call it, and no error will be thrown. +`@Injectable` and `@Pipe` classes may only declare `ngOnDestroy` — Angular never calls any other lifecycle hook on them. `@Component` and `@Directive` classes support **all** hooks (directives do receive view and content hooks), so they are not checked by this rule. + ### Context rules | Hook | `@Component` | `@Directive` | `@Injectable` | `@Pipe` | |------|:-----------:|:------------:|:-------------:|:-------:| -| `ngOnInit` | ✅ | ✅ | ✅ | ✅ | | `ngOnDestroy` | ✅ | ✅ | ✅ | ✅ | -| `ngOnChanges` | ✅ | ✅ | ✅ | ❌ | -| `ngDoCheck` | ✅ | ✅ | ✅ | ❌ | +| `ngOnInit` | ✅ | ✅ | ❌ | ❌ | +| `ngOnChanges` | ✅ | ✅ | ❌ | ❌ | +| `ngDoCheck` | ✅ | ✅ | ❌ | ❌ | | `ngAfterContentInit` | ✅ | ✅ | ❌ | ❌ | | `ngAfterContentChecked` | ✅ | ✅ | ❌ | ❌ | -| `ngAfterViewInit` | ✅ | ❌ | ❌ | ❌ | -| `ngAfterViewChecked` | ✅ | ❌ | ❌ | ❌ | +| `ngAfterViewInit` | ✅ | ✅ | ❌ | ❌ | +| `ngAfterViewChecked` | ✅ | ✅ | ❌ | ❌ | ## Examples ### Flagged ```typescript -// ❌ view hooks in an Injectable +// ❌ view hook in an @Injectable — will never be called @Injectable({ providedIn: 'root' }) export class UserStore { - ngAfterViewInit() { ... } // will never be called + ngAfterViewInit() { ... } } -// ❌ view hook in a Directive -@Directive({ selector: '[appFoo]', standalone: true }) -export class FooDirective { - ngAfterViewInit() { ... } // only @Component has a view +// ❌ ngOnInit in a @Pipe — only ngOnDestroy is supported +@Pipe({ name: 'appTruncate' }) +export class TruncatePipe { + ngOnInit() { ... } } ``` ### Preferred ```typescript -// ✅ view hooks only in @Component -@Component({ selector: 'app-foo', standalone: true, template: '' }) -export class FooComponent implements AfterViewInit { - ngAfterViewInit() { this.chart.render(); } +// ✅ ngOnDestroy is the one hook Angular calls on @Injectable and @Pipe +@Injectable({ providedIn: 'root' }) +export class UserStore { + ngOnDestroy() { this.cleanup(); } } -// ✅ content hooks in @Directive are fine -@Directive({ selector: '[appFoo]', standalone: true }) -export class FooDirective implements AfterContentInit { - ngAfterContentInit() { this.init(); } +// ✅ all hooks are fine in @Component and @Directive +@Directive({ selector: '[appFoo]' }) +export class FooDirective { + ngAfterViewInit() { this.init(); } } ``` +## Detection + +- Every class shape is matched (`export`, `export default`, `extends`, `implements`). +- Hooks are located as method members, so any signature shape is caught (`: void`, + `async`, parameters, modifiers) and the diagnostic points at the offending method. + ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + ```json { - "linter": { - "rules": { - "plugins": { - "angular/contextual-lifecycle": "error" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +Biome plugins take no per-rule options; this rule's severity is fixed to `error` inside +the rule itself. + ## Further Reading - [Angular lifecycle hooks docs](https://angular.dev/guide/components/lifecycle) diff --git a/docs/rules/directive-selector.md b/docs/rules/directive-selector.md new file mode 100644 index 0000000..fb82621 --- /dev/null +++ b/docs/rules/directive-selector.md @@ -0,0 +1,70 @@ +# angular/directive-selector + +**Category:** conventions +**ESLint equivalent:** `@angular-eslint/directive-selector` +**Severity:** warn + +## Overview + +Enforces that `@Directive` selectors follow the Angular CLI defaults: + +- **Type:** attribute selector (`[...]`) +- **Style:** camelCase +- **Prefix:** `app` (hardcoded — see Configuration) + +Comma-separated selector lists are accepted when every entry is valid. + +## Examples + +### Incorrect + +```typescript +import { Directive } from '@angular/core'; + +@Directive({ selector: '[highlight]' }) // ⚠️ no prefix +export class Highlight {} + +@Directive({ selector: '[app-highlight]' }) // ⚠️ kebab-case +export class Highlight {} + +@Directive({ selector: 'app-highlight' }) // ⚠️ element selector +export class Highlight {} +``` + +### Correct + +```typescript +import { Directive } from '@angular/core'; + +@Directive({ selector: '[appHighlight]' }) +export class Highlight {} + +@Directive({ selector: '[appTooltip], [appTooltipTop]' }) +export class Tooltip {} +``` + +## Configuration + +Biome plugins take no options, so the prefix is **not configurable**: it is hardcoded to +`app`. If your project uses a different prefix, copy +`rules/conventions/directive-selector.grit` into your project, adjust the regex, and +point your `plugins` array at the forked file. + +Suppress a single occurrence with `// biome-ignore lint/plugin/directive-selector: reason` placed on +the line directly above the flagged code. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/directive-selector"` from your ESLint config and enable the +plugin in `biome.json`: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +## Further Reading + +- [Angular Style Guide — Directive selectors](https://angular.dev/style-guide) +- [@angular-eslint/directive-selector](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/directive-selector.md) diff --git a/docs/rules/no-async-lifecycle-method.md b/docs/rules/no-async-lifecycle-method.md new file mode 100644 index 0000000..b9c9303 --- /dev/null +++ b/docs/rules/no-async-lifecycle-method.md @@ -0,0 +1,72 @@ +# angular/no-async-lifecycle-method + +**Category:** quality +**ESLint equivalent:** `@angular-eslint/no-async-lifecycle-method` +**Severity:** warn + +## Overview + +Angular never awaits a lifecycle hook. An `async ngOnInit()` returns a promise nobody +observes: a rejection is silently lost, and change detection runs before the awaited work +completes, so the view renders with stale data. Keep the hook synchronous and start the +asynchronous work from it. + +Any modifier combination is matched (`protected async`, `public override async`…), with or +without a return type or parameters. + +## Examples + +### Incorrect + +```typescript +import { Component, OnInit } from '@angular/core'; + +@Component({ selector: 'app-root', template: '' }) +export class App implements OnInit { + async ngOnInit() { // ⚠️ nobody awaits this + this.user = await this.api.load(); + } +} +``` + +### Correct + +```typescript +import { Component, OnInit } from '@angular/core'; + +@Component({ selector: 'app-root', template: '' }) +export class App implements OnInit { + ngOnInit(): void { + void this.load(); // ✅ hook stays synchronous + } + + private async load() { + this.user = await this.api.load(); + } +} +``` + +Or lean on signals and `resource()` / `toSignal()` so the template reacts when the data +arrives, with no lifecycle hook at all. + +## Configuration + +Suppress a single occurrence with `// biome-ignore lint/plugin/no-async-lifecycle-method: reason` placed on +the line directly above the flagged code. +The severity is fixed to `warn` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/no-async-lifecycle-method"` from your ESLint config and enable +the plugin in `biome.json`: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +## Further Reading + +- [Angular Lifecycle hooks](https://angular.dev/guide/components/lifecycle) +- [@angular-eslint/no-async-lifecycle-method](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-async-lifecycle-method.md) diff --git a/docs/rules/no-attribute-decorator.md b/docs/rules/no-attribute-decorator.md new file mode 100644 index 0000000..712aa6f --- /dev/null +++ b/docs/rules/no-attribute-decorator.md @@ -0,0 +1,58 @@ +# angular/no-attribute-decorator + +**Category:** quality +**ESLint equivalent:** `@angular-eslint/no-attribute-decorator` +**Severity:** warn + +## Overview + +Flags the `@Attribute()` parameter decorator. It only works on constructor parameters, +which ties the class to constructor injection and blocks the move to `inject()`. Since +Angular 17.4, `HostAttributeToken` reads the same static host attribute through +`inject()`. + +## Examples + +### Incorrect + +```typescript +import { Attribute, Directive } from '@angular/core'; + +@Directive({ selector: '[appRole]' }) +export class Role { + constructor(@Attribute('role') private readonly role: string) {} // ⚠️ +} +``` + +### Correct + +```typescript +import { Directive, HostAttributeToken, inject } from '@angular/core'; + +@Directive({ selector: '[appRole]' }) +export class Role { + private readonly role = inject(new HostAttributeToken('role'), { optional: true }); +} +``` + +## Configuration + +Suppress a single occurrence with `// biome-ignore lint/plugin/no-attribute-decorator: reason` placed on +the line directly above the flagged code. +The severity is fixed to `warn` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/no-attribute-decorator"` from your ESLint config and enable the +plugin in `biome.json`: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +## Further Reading + +- [Angular HostAttributeToken](https://angular.dev/api/core/HostAttributeToken) +- [@angular-eslint/no-attribute-decorator](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-attribute-decorator.md) diff --git a/docs/rules/no-component-suffix.md b/docs/rules/no-component-suffix.md index 4341ec5..6d46d73 100644 --- a/docs/rules/no-component-suffix.md +++ b/docs/rules/no-component-suffix.md @@ -2,7 +2,7 @@ **Category:** conventions **ESLint equivalent:** _(new rule — no ESLint equivalent, aligns with Angular v20+ style guide)_ -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -10,6 +10,12 @@ Detects `@Component`-decorated classes whose name ends with `Component` and sugg removing the suffix. This rule reflects the **Angular v20+ style guide** update that recommends naming classes after the concept they represent rather than their Angular role. +The check is decorator-gated: only classes decorated with `@Component` are flagged. +Plain TypeScript, NestJS, or React classes named `*Component` are **not** flagged. + +Every class shape is matched: `export`, `export default`, `extends`, generics and +`implements` clauses. + > "We're updating our style guide to no longer recommend adding suffixes like `Component`, > `Directive`, `Service`, or `Pipe` to class names." > @@ -53,29 +59,29 @@ Run an automated rename across the project: find src -name '*.ts' | xargs sed -i 's/class \([A-Z][A-Za-z]*\)Component/class \1/g' ``` -Or use the Angular DevKit schematic when available: +## Severity rationale -```bash -ng generate @angular/core:remove-class-suffixes -``` +This rule ships with a fixed `warn` severity (not `error`) because many existing Angular +projects use the old naming convention. The goal is to nudge teams during migration, not +to block builds. Biome plugins take no options, so the severity cannot be changed in +`biome.json`; to enforce it as an error, fork the `.grit` file and remove the +`severity = "warn"` argument. -## Severity rationale +## Configuration -This rule ships as `warn` (not `error`) in the `recommended` preset because many -existing Angular projects use the old naming convention. The goal is to nudge teams -during migration, not to block builds. +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` -Set it to `error` in the `strict` preset or when starting a new Angular v20+ project: +Or extend the recommended preset: ```json { - "linter": { - "rules": { - "plugins": { - "angular/no-component-suffix": "error" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` diff --git a/docs/rules/no-conflicting-lifecycle.md b/docs/rules/no-conflicting-lifecycle.md new file mode 100644 index 0000000..54b3f11 --- /dev/null +++ b/docs/rules/no-conflicting-lifecycle.md @@ -0,0 +1,63 @@ +# angular/no-conflicting-lifecycle + +**Category:** quality +**ESLint equivalent:** `@angular-eslint/no-conflicting-lifecycle` +**Severity:** error + +## Overview + +A class must not declare both `ngDoCheck` and `ngOnChanges`. Angular calls `ngDoCheck` +immediately after `ngOnChanges` on every change-detection cycle, so both hooks react to +the same input changes and the class does its work twice. Pick one: `ngOnChanges` when +you only need to react to input changes, `ngDoCheck` when you need custom change +detection. + +The check is per class, on any class (decorated or not), and reads the method members +directly — signature shape (`: void`, parameters, modifiers) does not matter. + +## Examples + +### Incorrect + +```typescript +import { Component, DoCheck, OnChanges, SimpleChanges } from '@angular/core'; + +@Component({ selector: 'app-root', template: '' }) +export class App implements DoCheck, OnChanges { // ❌ both hooks + ngOnChanges(changes: SimpleChanges) { this.sync(); } + ngDoCheck() { this.sync(); } +} +``` + +### Correct + +```typescript +import { Component, OnChanges, SimpleChanges } from '@angular/core'; + +@Component({ selector: 'app-root', template: '' }) +export class App implements OnChanges { + ngOnChanges(changes: SimpleChanges) { this.sync(); } +} +``` + +## Configuration + +Suppress a single occurrence with `// biome-ignore lint/plugin/no-conflicting-lifecycle: reason` placed on +the line directly above the flagged code. +The severity is fixed to `error` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/no-conflicting-lifecycle"` from your ESLint config and enable +the plugin in `biome.json`: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +## Further Reading + +- [Angular Lifecycle hooks](https://angular.dev/guide/components/lifecycle) +- [@angular-eslint/no-conflicting-lifecycle](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-conflicting-lifecycle.md) diff --git a/docs/rules/no-directive-suffix.md b/docs/rules/no-directive-suffix.md index 6987767..a2767fd 100644 --- a/docs/rules/no-directive-suffix.md +++ b/docs/rules/no-directive-suffix.md @@ -2,7 +2,7 @@ **Category:** conventions **ESLint equivalent:** _(new rule — aligns with Angular v20+ style guide)_ -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -10,6 +10,12 @@ Detects `@Directive`-decorated classes whose name ends with `Directive` and sugg removing the suffix. This rule is the directive counterpart of `angular/no-component-suffix` and reflects the **Angular v20+ style guide** update. +The check is decorator-gated: only classes decorated with `@Directive` are flagged. +Plain TypeScript classes named `*Directive` are **not** flagged. + +Every class shape is matched: `export`, `export default`, `extends`, generics and +`implements` clauses. + > "We're updating our style guide to no longer recommend adding suffixes like `Component`, > `Directive`, `Service`, or `Pipe` to class names." > @@ -50,23 +56,26 @@ export class ClickOutside {} ## Severity rationale -Ships as `warn` in `recommended` to ease migration of existing codebases. -Use `error` in `strict` or for new Angular v20+ projects. +Ships with a fixed `warn` severity to ease migration of existing codebases. Biome +plugins take no options, so the severity cannot be changed in `biome.json`; to enforce +it as an error, fork the `.grit` file and remove the `severity = "warn"` argument. ## Migration from @angular-eslint If you previously used `@angular-eslint/directive-class-suffix` (which enforced the -*opposite* convention), remove it and add this rule instead: +*opposite* convention), remove it and enable the plugin in `biome.json`: + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: ```json { - "linter": { - "rules": { - "plugins": { - "angular/no-directive-suffix": "warn" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` diff --git a/docs/rules/no-empty-lifecycle-method.md b/docs/rules/no-empty-lifecycle-method.md index f90f131..cdf71bf 100644 --- a/docs/rules/no-empty-lifecycle-method.md +++ b/docs/rules/no-empty-lifecycle-method.md @@ -2,7 +2,7 @@ **Category:** high-priority **ESLint equivalent:** `@angular-eslint/no-empty-lifecycle-method` -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -10,6 +10,31 @@ Disallows implementing Angular lifecycle interfaces with empty method bodies. Empty lifecycle methods add noise, mislead readers into expecting side effects, and should simply be omitted. +The rule matches every empty hook regardless of signature shape: with or without a +`: void` return type, and — for `ngOnChanges`, the only hook that takes an argument — +with or without a parameter (`ngOnChanges(changes: SimpleChanges): void {}` is flagged). +Modifier prefixes such as `async`, `protected`, or `override` are also matched. + +## Auto-fix + +When the class does **not** implement the hook's interface, the rule ships a **safe fix** +that deletes the empty method: + +```bash +npx biome lint --write +``` + +When the class does implement it (`class App implements OnInit`), deleting the method +alone would fail compilation (TS2420: class incorrectly implements interface), so no fix +is offered and the message asks to remove the method **and** the interface from the +`implements` clause. Run the formatter after `--write` to tidy the blank line left +behind. + +**Biome caveat (2.5.12):** plugin rewrites are applied per file. When one empty hook in a +file is fixed, a `biome-ignore` comment on another empty hook in the same file does not +protect it from `--write`. Use `biome-ignore-all` on that file, or give the hook a +statement, until Biome resolves this. + ## Covered lifecycle hooks | Method | Interface | @@ -38,6 +63,15 @@ export class AppComponent implements OnInit, OnDestroy { } ``` +```typescript +import { Component, OnChanges, SimpleChanges } from '@angular/core'; + +@Component({ selector: 'app-root', standalone: true, template: '' }) +export class AppComponent implements OnChanges { + ngOnChanges(changes: SimpleChanges): void {} // empty — typed variants are matched too +} +``` + ### Correct ```typescript @@ -75,18 +109,26 @@ consistent with the original `@angular-eslint` behavior. ## Migration from @angular-eslint +Remove `"@angular-eslint/no-empty-lifecycle-method"` from your ESLint config and enable +the plugin in `biome.json`: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/no-empty-lifecycle-method": "warn" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular Lifecycle hooks](https://angular.dev/guide/components/lifecycle) diff --git a/docs/rules/no-input-rename.md b/docs/rules/no-input-rename.md index 54ee377..3bcb974 100644 --- a/docs/rules/no-input-rename.md +++ b/docs/rules/no-input-rename.md @@ -2,7 +2,7 @@ **Category:** conventions **ESLint equivalent:** `@angular-eslint/no-input-rename` -**Severity (recommended):** error +**Severity:** error ## Overview @@ -10,9 +10,11 @@ Disallows renaming `@Input()` properties via the decorator argument. Providing a creates a disconnect between the component's internal property name and its public API, making the component harder to use and the codebase harder to search. -**Two alias forms are detected:** +**Detected forms:** - `@Input('alias')` — string argument - `@Input({ alias: 'name' })` — alias key in options object +- `input(default, { alias })`, `input.required({ alias })`, `model(default, { alias })`, + `model.required({ alias })` — signal inputs, with or without type arguments ## Examples @@ -45,12 +47,13 @@ export class UserComponent { ## Signal-based inputs -The rule applies to `@Input()` decorator only. Signal-based inputs using the `input()` -function are not covered by this rule. - ```typescript -// Not covered by this rule -value = input('', { alias: 'externalValue' }); +value = input('', { alias: 'externalValue' }); // ❌ aliased signal input +count = input.required({ alias: 'cnt' }); // ❌ +name = model('', { alias: 'nm' }); // ❌ + +value = input(''); // ✅ +count = input.required(); // ✅ ``` ## Why avoid renaming? @@ -62,18 +65,26 @@ value = input('', { alias: 'externalValue' }); ## Migration from @angular-eslint +Remove `"@angular-eslint/no-input-rename"` from your ESLint config and enable the +plugin in `biome.json`: + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/no-input-rename": "error" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `error` inside +the rule itself. + ## Further Reading - [Angular Input properties](https://angular.dev/guide/components/inputs) diff --git a/docs/rules/no-lifecycle-call.md b/docs/rules/no-lifecycle-call.md new file mode 100644 index 0000000..5ec19d4 --- /dev/null +++ b/docs/rules/no-lifecycle-call.md @@ -0,0 +1,80 @@ +# angular/no-lifecycle-call + +**Category:** quality +**ESLint equivalent:** `@angular-eslint/no-lifecycle-call` +**Severity:** error + +## Overview + +Lifecycle hooks (`ngOnInit`, `ngOnDestroy`, `ngOnChanges`…) are called by Angular. +Calling one by hand runs it a second time, out of order, or before the class is fully +initialized. Move the shared logic into a regular method and call that instead. + +Calls through `super` (`super.ngOnInit()`) are allowed: that is how a derived hook chains +to its parent implementation. + +The rule matches any `object.ngXxx(...)` call, including calls on other instances such as +`fixture.componentInstance.ngOnDestroy()` in tests. The shipped preset excludes +`*.spec.ts` files from the plugin; add a `biome-ignore` comment or copy the preset without +the exclusion if you want tests linted too. + +## Examples + +### Incorrect + +```typescript +import { Component, OnInit } from '@angular/core'; + +@Component({ selector: 'app-root', template: '' }) +export class App implements OnInit { + ngOnInit() { this.load(); } + + reload() { + this.ngOnInit(); // ❌ runs the hook by hand + } +} +``` + +### Correct + +```typescript +import { Component, OnInit } from '@angular/core'; + +@Component({ selector: 'app-root', template: '' }) +export class App implements OnInit { + ngOnInit() { this.load(); } + + reload() { + this.load(); // ✅ shared logic in a regular method + } +} + +export class Child extends App { + override ngOnInit() { + super.ngOnInit(); // ✅ chaining to the parent hook is fine + this.extra(); + } +} +``` + +## Configuration + +Suppress a single occurrence with `// biome-ignore lint/plugin/no-lifecycle-call: reason` placed on +the line directly above the flagged code. +The severity is fixed to `error` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/no-lifecycle-call"` from your ESLint config and enable the +plugin in `biome.json`: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +## Further Reading + +- [Angular Lifecycle hooks](https://angular.dev/guide/components/lifecycle) +- [@angular-eslint/no-lifecycle-call](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-lifecycle-call.md) diff --git a/docs/rules/no-output-native.md b/docs/rules/no-output-native.md new file mode 100644 index 0000000..75bb1b9 --- /dev/null +++ b/docs/rules/no-output-native.md @@ -0,0 +1,67 @@ +# angular/no-output-native + +**Category:** conventions +**ESLint equivalent:** `@angular-eslint/no-output-native` +**Severity:** error + +## Overview + +Disallows naming an output after a native DOM event (`click`, `change`, `focus`, +`submit`, `scroll`…). When a parent binds `(change)="..."` on the component, it receives +**both** the component's output and the browser's native `change` event bubbling from the +host element — the handler runs twice, with two different event shapes. + +The rule covers every output form: + +- `@Output()` decorator, on initialized, typed, definite-assignment or `readonly` properties +- `output()` and `outputFromObservable()` signal-based outputs, with or without type arguments + +## Examples + +### Incorrect + +```typescript +import { Component, Output, EventEmitter, output } from '@angular/core'; + +@Component({ selector: 'app-picker', template: '' }) +export class Picker { + @Output() change = new EventEmitter(); // ❌ native event name + readonly focus = output(); // ❌ native event name + readonly select = output(); // ❌ native event name +} +``` + +### Correct + +```typescript +import { Component, Output, EventEmitter, output } from '@angular/core'; + +@Component({ selector: 'app-picker', template: '' }) +export class Picker { + @Output() valueChange = new EventEmitter(); + readonly focused = output(); + readonly itemSelected = output(); +} +``` + +## Configuration + +Suppress a single occurrence with `// biome-ignore lint/plugin/no-output-native: reason` placed on +the line directly above the flagged code. +The severity is fixed to `error` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/no-output-native"` from your ESLint config and enable the +plugin in `biome.json`: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +## Further Reading + +- [Angular Output properties](https://angular.dev/guide/components/outputs) +- [@angular-eslint/no-output-native](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-output-native.md) diff --git a/docs/rules/no-output-on-prefix.md b/docs/rules/no-output-on-prefix.md index e1f32de..351c886 100644 --- a/docs/rules/no-output-on-prefix.md +++ b/docs/rules/no-output-on-prefix.md @@ -2,11 +2,11 @@ **Category:** conventions **ESLint equivalent:** `@angular-eslint/no-output-on-prefix` -**Severity (recommended):** error +**Severity:** error ## Overview -Disallows naming `@Output()` properties with an `on` prefix followed by an uppercase +Disallows naming outputs with an `on` prefix followed by an uppercase letter (e.g. `onSubmit`, `onChange`, `onClick`). In Angular templates, event bindings already use the `(event)="onHandler()"` convention — the `on` prefix belongs to the *handler method in the parent*, not to the output itself. @@ -20,9 +20,12 @@ import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-form', standalone: true, template: '' }) export class FormComponent { - @Output() onSubmit = new EventEmitter(); // ❌ - @Output() onChange = new EventEmitter(); // ❌ - @Output() onClick = new EventEmitter(); // ❌ + @Output() onSubmit = new EventEmitter(); // ❌ initialized + @Output() onChange: EventEmitter; // ❌ typed only + @Output() onClick!: EventEmitter; // ❌ definite assignment + @Output('click') onPress = new EventEmitter(); // ❌ decorator argument + readonly onSave = output(); // ❌ signal output + readonly onLoad = outputFromObservable(this.load$); // ❌ observable output } ``` @@ -51,22 +54,34 @@ export class FormComponent { - `onion` is **not** flagged — the `on` prefix rule only applies when followed by an uppercase letter. - `@Input()` properties named `onSomething` are not flagged — this rule targets `@Output()` only. -- Signal-based outputs (`output()`) are not covered. +- Signal-based outputs (`output()`, `outputFromObservable()`) are covered, with or + without type arguments. +- Initialized (`= new EventEmitter()`), typed (`: EventEmitter`), definite-assignment + (`!: EventEmitter`) and `readonly` properties are all matched, with or without a + decorator argument. ## Migration from @angular-eslint +Remove `"@angular-eslint/no-output-on-prefix"` from your ESLint config and enable the +plugin in `biome.json`: + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/no-output-on-prefix": "error" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `error` inside +the rule itself. + ## Further Reading - [Angular Output properties](https://angular.dev/guide/components/outputs) diff --git a/docs/rules/no-output-rename.md b/docs/rules/no-output-rename.md index ad0dbb1..04544e7 100644 --- a/docs/rules/no-output-rename.md +++ b/docs/rules/no-output-rename.md @@ -2,7 +2,7 @@ **Category:** conventions **ESLint equivalent:** `@angular-eslint/no-output-rename` -**Severity (recommended):** error +**Severity:** error ## Overview @@ -10,9 +10,11 @@ Disallows renaming `@Output()` properties via the decorator argument. Providing for an output creates a mismatch between the EventEmitter property name and the event name used in parent template bindings, making event flows harder to trace. -**Two alias forms are detected:** +**Detected forms:** - `@Output('alias')` — string argument - `@Output({ alias: 'name' })` — alias key in options object +- `output({ alias })`, `outputFromObservable(source, { alias })` — signal outputs, with or + without type arguments ## Examples @@ -58,28 +60,35 @@ export class ButtonComponent { ## Signal-based outputs -The rule applies to `@Output()` decorator only. Signal-based outputs using the -`output()` function are not covered by this rule. - ```typescript -// Not covered by this rule -clicked = output({ alias: 'clickEvent' }); +saved = output({ alias: 'onSaved' }); // ❌ aliased signal output +loaded = outputFromObservable(this.load$, { alias: 'ld' }); // ❌ + +saved = output(); // ✅ ``` ## Migration from @angular-eslint +Remove `"@angular-eslint/no-output-rename"` from your ESLint config and enable the +plugin in `biome.json`: + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/no-output-rename": "error" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `error` inside +the rule itself. + ## Further Reading - [Angular Output properties](https://angular.dev/guide/components/outputs) diff --git a/docs/rules/no-pipe-impure.md b/docs/rules/no-pipe-impure.md new file mode 100644 index 0000000..6887ed4 --- /dev/null +++ b/docs/rules/no-pipe-impure.md @@ -0,0 +1,63 @@ +# angular/no-pipe-impure + +**Category:** quality +**ESLint equivalent:** `@angular-eslint/no-pipe-impure` +**Severity:** warn + +## Overview + +Flags `@Pipe({ pure: false })`. An impure pipe runs on **every** change-detection cycle +instead of only when its inputs change, which is one of the most common Angular +performance pitfalls. Most impure pipes can be replaced by a pure pipe over an immutable +input, or by computing the value in the component (a `computed()` signal is ideal). + +## Examples + +### Incorrect + +```typescript +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'appFilter', pure: false }) // ⚠️ runs on every cycle +export class FilterPipe implements PipeTransform { + transform(items: Item[], term: string) { + return items.filter((i) => i.name.includes(term)); + } +} +``` + +### Correct + +```typescript +import { Component, computed, input } from '@angular/core'; + +@Component({ selector: 'app-list', template: '@for (i of filtered(); track i.id) { … }' }) +export class List { + readonly items = input([]); + readonly term = input(''); + readonly filtered = computed(() => this.items().filter((i) => i.name.includes(this.term()))); +} +``` + +## Configuration + +Suppress a single occurrence with `// biome-ignore lint/plugin/no-pipe-impure: reason` placed on +the line directly above the flagged code +(for example on a pipe that genuinely depends on external mutable state). +The severity is fixed to `warn` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/no-pipe-impure"` from your ESLint config and enable the plugin +in `biome.json`: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +## Further Reading + +- [Angular Pipes — pure and impure](https://angular.dev/guide/templates/pipes#change-detection-with-pipes) +- [@angular-eslint/no-pipe-impure](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-pipe-impure.md) diff --git a/docs/rules/no-service-suffix.md b/docs/rules/no-service-suffix.md index 5a79e44..69b702b 100644 --- a/docs/rules/no-service-suffix.md +++ b/docs/rules/no-service-suffix.md @@ -2,13 +2,19 @@ **Category:** conventions **ESLint equivalent:** _(new rule — aligns with Angular v20+ style guide)_ -**Severity (recommended):** warn +**Severity:** warn ## Overview Detects `@Injectable`-decorated classes whose name ends with `Service` and suggests using a descriptive name that expresses the class's responsibility instead. +The check is decorator-gated: only classes decorated with `@Injectable` are flagged. +Plain TypeScript or NestJS classes named `*Service` are **not** flagged. + +Every class shape is matched: `export`, `export default`, `extends`, generics and +`implements` clauses. + > "We're updating our style guide to no longer recommend adding suffixes like `Component`, > `Directive`, `Service`, or `Pipe` to class names." > @@ -77,20 +83,26 @@ export class SessionStore {} ## Severity rationale -Ships as `warn` in `recommended` because renaming services is a significant migration -effort in large codebases. Use `error` in `strict` or for new Angular v20+ projects. +Ships with a fixed `warn` severity because renaming services is a significant migration +effort in large codebases. Biome plugins take no options, so the severity cannot be +changed in `biome.json`; to enforce it as an error, fork the `.grit` file and remove +the `severity = "warn"` argument. ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/no-service-suffix": "warn" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` diff --git a/docs/rules/pipe-prefix.md b/docs/rules/pipe-prefix.md index 410d164..bf7dad6 100644 --- a/docs/rules/pipe-prefix.md +++ b/docs/rules/pipe-prefix.md @@ -2,13 +2,14 @@ **Category:** conventions **ESLint equivalent:** `@angular-eslint/pipe-prefix` -**Severity (recommended):** warn +**Severity:** warn ## Overview -Enforces that the `name` field in `@Pipe` decorators starts with a configurable prefix -(default: `app`). Namespacing pipe names prevents collisions with Angular's built-in -pipes (`date`, `currency`, `async`, etc.) and with third-party library pipes. +Enforces that the `name` field in `@Pipe` decorators starts with the `app` prefix +(hardcoded — see Configuration). Namespacing pipe names prevents collisions with +Angular's built-in pipes (`date`, `currency`, `async`, etc.) and with third-party +library pipes. ## Examples @@ -61,35 +62,34 @@ Usage in templates: ## Configuration -The default prefix is `app`. For library or multi-team projects, configure a custom -prefix via rule options (planned): +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): ```json { - "linter": { - "rules": { - "plugins": { - "angular/pipe-prefix": ["warn", { "prefix": "acme" }] - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` -## Migration from @angular-eslint +Or extend the recommended preset: ```json { - "linter": { - "rules": { - "plugins": { - "angular/pipe-prefix": "warn" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no options, so the prefix is **not configurable**: it is hardcoded to +`app` (pipe names must match `app` followed by an uppercase letter, e.g. `appTruncate`). +For a different prefix, copy `rules/conventions/pipe-prefix.grit` into your project, +adjust the regex, and point your `plugins` array at the forked file. + +The severity is fixed to `warn` inside the rule itself. + +## Migration from @angular-eslint + +Remove `"@angular-eslint/pipe-prefix"` from your ESLint config and enable the plugin as +shown above. + ## Further Reading - [Angular Pipes](https://angular.dev/guide/pipes) diff --git a/docs/rules/prefer-host-property.md b/docs/rules/prefer-host-property.md index d1801d2..d90af43 100644 --- a/docs/rules/prefer-host-property.md +++ b/docs/rules/prefer-host-property.md @@ -2,7 +2,7 @@ **Category:** modern (signal-based APIs) **ESLint equivalent:** INVERSE of `@angular-eslint/no-host-metadata-property` -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -50,18 +50,25 @@ export class ButtonComponent { ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + ```json { - "linter": { - "rules": { - "plugins": { - "angular/prefer-host-property": "warn" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular style guide — host property](https://angular.dev/style-guide#style-06-03) diff --git a/docs/rules/prefer-inject-function.md b/docs/rules/prefer-inject-function.md index fbf8524..463a03a 100644 --- a/docs/rules/prefer-inject-function.md +++ b/docs/rules/prefer-inject-function.md @@ -1,14 +1,20 @@ # angular/prefer-inject-function **Category:** modern (signal-based APIs) -**ESLint equivalent:** _(new rule — no direct ESLint equivalent)_ -**Severity (recommended):** warn +**ESLint equivalent:** `@angular-eslint/prefer-inject` +**Severity:** warn ## Overview Prefer the `inject()` function over constructor-based dependency injection. The `inject()` function (stable since Angular 14, recommended in Angular 16+) works in any injection context and avoids lengthy constructor parameter lists. -**Detection:** flags constructors with access-modifier parameters (`private`, `protected`, `public`, `readonly`) — the classic Angular DI pattern. +**Detection:** flags any constructor that takes at least one parameter inside a class decorated with `@Component`, `@Directive`, `@Injectable`, or `@Pipe` — Angular injects every constructor parameter of those classes, with or without an access modifier. Plain TypeScript or NestJS classes using parameter properties are **not** flagged. Every class shape is matched (`export`, `extends`, `implements`…). + +**Migration:** Angular ships a schematic that rewrites constructor injection to `inject()` calls, including `@Inject`, `@Optional`, `@Self` and `@Attribute` parameters: + +```bash +ng generate @angular/core:inject +``` Benefits: - Works in functional injection contexts (guards, resolvers, interceptors) @@ -61,18 +67,25 @@ export const authGuard = () => { ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + ```json { - "linter": { - "rules": { - "plugins": { - "angular/prefer-inject-function": "warn" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular inject() docs](https://angular.dev/api/core/inject) diff --git a/docs/rules/prefer-model-signal.md b/docs/rules/prefer-model-signal.md index ae4a11b..7938273 100644 --- a/docs/rules/prefer-model-signal.md +++ b/docs/rules/prefer-model-signal.md @@ -2,13 +2,13 @@ **Category:** modern (signal-based APIs) **ESLint equivalent:** _(new rule — no direct ESLint equivalent)_ -**Severity (recommended):** warn +**Severity:** warn ## Overview Prefer `model()` for two-way binding over the `@Input()` + `@Output() xyzChange` pattern. The `model()` signal function (stable since Angular 17.2) reduces boilerplate for the two-way binding convention. -**Detection:** this rule flags `@Output()` properties whose name ends with `Change` when the corresponding `@Input()` property exists in the same class. +**Detection:** this rule flags an `@Output()` property whose name ends with `Change` only when a matching `@Input()` property with the base name (e.g. `value` for `valueChange`) exists in the **same file**. Unpaired `*Change` outputs — such as Material-style `selectionChange` events with no corresponding input — are **not** flagged. Initialized (`= ...`), typed (`: T`), definite-assignment (`!: T`) and `readonly` properties are all matched. ## Examples @@ -19,8 +19,20 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-counter', standalone: true, template: '' }) export class CounterComponent { - @Input() value = 0; // ⚠️ two-way binding pair - @Output() valueChange = new EventEmitter(); // ⚠️ use model() instead + @Input() value = 0; // paired input + @Output() valueChange = new EventEmitter(); // ⚠️ flagged — use model() instead +} +``` + +### Not flagged + +```typescript +import { Component, Output, EventEmitter } from '@angular/core'; + +@Component({ selector: 'app-list', standalone: true, template: '' }) +export class ListComponent { + // No matching @Input() named `selection` — this is a plain event, not two-way binding + @Output() selectionChange = new EventEmitter(); } ``` @@ -44,18 +56,25 @@ export class CounterComponent { ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + ```json { - "linter": { - "rules": { - "plugins": { - "angular/prefer-model-signal": "warn" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular model() signal docs](https://angular.dev/guide/signals/model) diff --git a/docs/rules/prefer-output-function.md b/docs/rules/prefer-output-function.md index 7fb75c8..3cc4c86 100644 --- a/docs/rules/prefer-output-function.md +++ b/docs/rules/prefer-output-function.md @@ -2,7 +2,7 @@ **Category:** modern (signal-based APIs) **ESLint equivalent:** _(new rule — no direct ESLint equivalent)_ -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -41,18 +41,25 @@ export class ButtonComponent { ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/prefer-output-function": "warn" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular output() docs](https://angular.dev/guide/components/output-fn) diff --git a/docs/rules/prefer-signal-inputs.md b/docs/rules/prefer-signal-inputs.md index 743300a..dd00a6a 100644 --- a/docs/rules/prefer-signal-inputs.md +++ b/docs/rules/prefer-signal-inputs.md @@ -2,7 +2,7 @@ **Category:** modern (signal-based APIs) **ESLint equivalent:** _(new rule — no direct ESLint equivalent)_ -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -53,18 +53,25 @@ export class GreetingComponent { ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/prefer-signal-inputs": "warn" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular signal inputs docs](https://angular.dev/guide/signals/inputs) diff --git a/docs/rules/prefer-signal-queries.md b/docs/rules/prefer-signal-queries.md index ad36c4a..c9bc6ad 100644 --- a/docs/rules/prefer-signal-queries.md +++ b/docs/rules/prefer-signal-queries.md @@ -2,7 +2,7 @@ **Category:** modern (signal-based APIs) **ESLint equivalent:** _(new rule — no direct ESLint equivalent)_ -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -51,18 +51,25 @@ title = computed(() => this.chartData().title); ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + +```json +{ + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + ```json { - "linter": { - "rules": { - "plugins": { - "angular/prefer-signal-queries": "warn" - } - } - } + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular signal queries docs](https://angular.dev/guide/signals/queries) diff --git a/docs/rules/prefer-standalone.md b/docs/rules/prefer-standalone.md index 41ca091..fe5a36c 100644 --- a/docs/rules/prefer-standalone.md +++ b/docs/rules/prefer-standalone.md @@ -2,16 +2,20 @@ **Category:** high-priority **ESLint equivalent:** `@angular-eslint/prefer-standalone` -**Severity (recommended):** error +**Severity:** error ## Overview -Enforces that Angular components, directives, and pipes declare `standalone: true` in their -decorator metadata. This rule is critical for Angular 17+ projects migrating away from -NgModule-based architecture. +Flags explicit `standalone: false` in `@Component`, `@Directive`, and `@Pipe` decorator +metadata. Since Angular 19, standalone is the **default** — declaring `standalone: false` +opts back into the NgModule system, and the v20 style guide recommends omitting the +property entirely. -In Angular 19, `standalone: true` is the default, but this rule still flags explicit -`standalone: false` declarations (which opt back into the NgModule system). +This rule only flags the explicit opt-out. Omitting the property (idiomatic Angular 19+) +and the redundant `standalone: true` are both accepted. + +Diagnostic message: *"Avoid standalone: false. Standalone is the default since Angular 19 — +migrate this declarable off NgModules and remove the property."* ## Examples @@ -20,18 +24,7 @@ In Angular 19, `standalone: true` is the default, but this rule still flags expl ```typescript import { Component } from '@angular/core'; -// Missing standalone: true -@Component({ - selector: 'app-root', - template: '

Hello

', -}) -export class AppComponent {} -``` - -```typescript -import { Component } from '@angular/core'; - -// Explicitly opting out +// Explicitly opting out of standalone @Component({ selector: 'app-root', standalone: false, @@ -43,7 +36,7 @@ export class AppComponent {} ```typescript import { Directive } from '@angular/core'; -@Directive({ selector: '[appHighlight]' }) +@Directive({ selector: '[appHighlight]', standalone: false }) export class HighlightDirective {} ``` @@ -52,9 +45,9 @@ export class HighlightDirective {} ```typescript import { Component } from '@angular/core'; +// Omitting standalone is idiomatic since Angular 19 (standalone by default) @Component({ selector: 'app-root', - standalone: true, template: '

Hello

', }) export class AppComponent {} @@ -63,6 +56,7 @@ export class AppComponent {} ```typescript import { Directive } from '@angular/core'; +// Explicit standalone: true is redundant but not flagged @Directive({ selector: '[appHighlight]', standalone: true }) export class HighlightDirective {} ``` @@ -70,7 +64,7 @@ export class HighlightDirective {} ```typescript import { Pipe, PipeTransform } from '@angular/core'; -@Pipe({ name: 'truncate', standalone: true }) +@Pipe({ name: 'truncate' }) export class TruncatePipe implements PipeTransform { transform(value: string, limit: number): string { return value.substring(0, limit); @@ -80,20 +74,26 @@ export class TruncatePipe implements PipeTransform { ## Migration from @angular-eslint -Replace `"@angular-eslint/prefer-standalone": "error"` with the Biome rule in your `biome.json`: +Remove `"@angular-eslint/prefer-standalone"` from your ESLint config and enable the +plugin in `biome.json`: ```json { - "linter": { - "rules": { - "plugins": { - "angular/prefer-standalone": "error" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } ``` +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +Biome plugins take no per-rule options; this rule's severity is fixed to `error` inside +the rule itself. + ## Further Reading - [Angular Standalone Components Guide](https://angular.dev/guide/components/importing) diff --git a/docs/rules/sort-ngmodule-metadata-arrays.md b/docs/rules/sort-ngmodule-metadata-arrays.md deleted file mode 100644 index 495ee64..0000000 --- a/docs/rules/sort-ngmodule-metadata-arrays.md +++ /dev/null @@ -1,82 +0,0 @@ -# angular/sort-ngmodule-metadata-arrays - -**Category:** conventions -**ESLint equivalent:** `@angular-eslint/sort-ngmodule-metadata-arrays` -**Severity (recommended):** warn - -## Overview - -Enforces alphabetical (case-insensitive) ordering of entries in NgModule metadata arrays: -`declarations`, `imports`, `exports`, `providers`, and `bootstrap`. - -Sorted arrays: -- Make entries easy to locate at a glance -- Make duplicate entries easier to spot -- Reduce git merge conflicts when multiple developers add entries - -## Examples - -### Incorrect - -```typescript -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { AppComponent } from './app.component'; -import { HeaderComponent } from './header.component'; - -@NgModule({ - declarations: [HeaderComponent, AppComponent], // ❌ H before A - imports: [RouterModule, BrowserModule], // ❌ R before B - bootstrap: [AppComponent], -}) -export class AppModule {} -``` - -### Correct - -```typescript -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { AppComponent } from './app.component'; -import { HeaderComponent } from './header.component'; - -@NgModule({ - declarations: [AppComponent, HeaderComponent], // ✅ A before H - imports: [BrowserModule, RouterModule], // ✅ B before R - bootstrap: [AppComponent], -}) -export class AppModule {} -``` - -## Sorting semantics - -- **Case-insensitive**: `aComponent` sorts before `BComponent` -- **Applies to**: `declarations`, `imports`, `exports`, `providers`, `bootstrap` -- **Single-element arrays** are always valid (nothing to sort) -- **Complex expressions** (e.g. `forwardRef(() => Foo)`) are sorted by their full source text - -## Note on standalone components - -This rule is primarily relevant for NgModule-based codebases. If your project uses -100% standalone components (Angular 17+), you will rarely encounter `@NgModule`. - -## Migration from @angular-eslint - -```json -{ - "linter": { - "rules": { - "plugins": { - "angular/sort-ngmodule-metadata-arrays": "warn" - } - } - } -} -``` - -## Further Reading - -- [Angular NgModule](https://angular.dev/guide/ngmodules) -- [@angular-eslint/sort-ngmodule-metadata-arrays](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/sort-ngmodule-metadata-arrays.md) diff --git a/docs/rules/use-lifecycle-interface.md b/docs/rules/use-lifecycle-interface.md index 87c88be..a459ebf 100644 --- a/docs/rules/use-lifecycle-interface.md +++ b/docs/rules/use-lifecycle-interface.md @@ -2,12 +2,14 @@ **Category:** quality **ESLint equivalent:** `@angular-eslint/use-lifecycle-interface` -**Severity (recommended):** warn +**Severity:** warn ## Overview Classes that implement Angular lifecycle methods should explicitly declare the corresponding interface. This makes intent clear and catches method signature mistakes at compile time. +**How it works:** the check is per class. Each lifecycle method is matched (any signature: `: void`, `async`, parameters, modifiers) and the `implements` clause of its own class must name the matching interface. Importing the interface without implementing it is flagged. + | Method | Required interface | |--------|-------------------| | `ngOnChanges` | `OnChanges` | @@ -53,18 +55,25 @@ export class AppComponent implements OnInit, OnDestroy { ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + ```json { - "linter": { - "rules": { - "plugins": { - "angular/use-lifecycle-interface": "warn" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular lifecycle hooks docs](https://angular.dev/guide/components/lifecycle) diff --git a/docs/rules/use-pipe-transform-interface.md b/docs/rules/use-pipe-transform-interface.md index 1b8864f..b2dfa69 100644 --- a/docs/rules/use-pipe-transform-interface.md +++ b/docs/rules/use-pipe-transform-interface.md @@ -2,12 +2,14 @@ **Category:** quality **ESLint equivalent:** `@angular-eslint/use-pipe-transform-interface` -**Severity (recommended):** warn +**Severity:** warn ## Overview Classes decorated with `@Pipe` should implement the `PipeTransform` interface. This makes the `transform()` method type-safe and communicates intent clearly. +**How it works:** the check is per class. The rule reads the `implements` clause of every `@Pipe`-decorated class and flags the class when `PipeTransform` is missing from it, whatever else the file imports or declares. + ## Examples ### Flagged @@ -38,18 +40,25 @@ export class TruncatePipe implements PipeTransform { ## Configuration +Enable the plugin in `biome.json` (Biome plugins cannot be enabled per rule): + ```json { - "linter": { - "rules": { - "plugins": { - "angular/use-pipe-transform-interface": "warn" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] +} +``` + +Or extend the recommended preset: + +```json +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` +Biome plugins take no per-rule options; this rule's severity is fixed to `warn` inside +the rule itself. + ## Further Reading - [Angular pipes docs](https://angular.dev/guide/pipes) diff --git a/index.js b/index.js index c933982..5b744cb 100644 --- a/index.js +++ b/index.js @@ -16,14 +16,27 @@ export const pluginManifest = join(__dirname, 'biome-manifest.jsonc'); /** Preset config paths */ export const presets = { recommended: join(__dirname, 'presets', 'recommended.json'), - strict: join(__dirname, 'presets', 'strict.json'), }; /** GritQL rule file paths keyed by rule name */ export const rules = { // high-priority 'prefer-standalone': join(__dirname, 'rules', 'high-priority', 'prefer-standalone.grit'), - 'no-empty-lifecycle-method': join(__dirname, 'rules', 'high-priority', 'no-empty-lifecycle-method.grit'), + 'no-empty-lifecycle-method': join( + __dirname, + 'rules', + 'high-priority', + 'no-empty-lifecycle-method.grit', + ), + // Second half of the same rule (same plugin name): the safe-fix cases. + // Load both files to get the complete rule. + 'no-empty-lifecycle-method/fix': join( + __dirname, + 'rules', + 'high-priority', + 'fixes', + 'no-empty-lifecycle-method.grit', + ), // conventions 'no-component-suffix': join(__dirname, 'rules', 'conventions', 'no-component-suffix.grit'), 'no-directive-suffix': join(__dirname, 'rules', 'conventions', 'no-directive-suffix.grit'), @@ -33,11 +46,27 @@ export const rules = { 'no-output-rename': join(__dirname, 'rules', 'conventions', 'no-output-rename.grit'), 'no-output-on-prefix': join(__dirname, 'rules', 'conventions', 'no-output-on-prefix.grit'), 'pipe-prefix': join(__dirname, 'rules', 'conventions', 'pipe-prefix.grit'), - 'sort-ngmodule-metadata-arrays': join(__dirname, 'rules', 'conventions', 'sort-ngmodule-metadata-arrays.grit'), + 'directive-selector': join(__dirname, 'rules', 'conventions', 'directive-selector.grit'), + 'no-output-native': join(__dirname, 'rules', 'conventions', 'no-output-native.grit'), // quality 'use-lifecycle-interface': join(__dirname, 'rules', 'quality', 'use-lifecycle-interface.grit'), - 'use-pipe-transform-interface': join(__dirname, 'rules', 'quality', 'use-pipe-transform-interface.grit'), + 'use-pipe-transform-interface': join( + __dirname, + 'rules', + 'quality', + 'use-pipe-transform-interface.grit', + ), 'contextual-lifecycle': join(__dirname, 'rules', 'quality', 'contextual-lifecycle.grit'), + 'no-conflicting-lifecycle': join(__dirname, 'rules', 'quality', 'no-conflicting-lifecycle.grit'), + 'no-lifecycle-call': join(__dirname, 'rules', 'quality', 'no-lifecycle-call.grit'), + 'no-async-lifecycle-method': join( + __dirname, + 'rules', + 'quality', + 'no-async-lifecycle-method.grit', + ), + 'no-pipe-impure': join(__dirname, 'rules', 'quality', 'no-pipe-impure.grit'), + 'no-attribute-decorator': join(__dirname, 'rules', 'quality', 'no-attribute-decorator.grit'), // modern 'prefer-signal-inputs': join(__dirname, 'rules', 'modern', 'prefer-signal-inputs.grit'), 'prefer-output-function': join(__dirname, 'rules', 'modern', 'prefer-output-function.grit'), diff --git a/package-lock.json b/package-lock.json index 1bae9a1..d43c6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "biome-plugin-angular", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "biome-plugin-angular", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "bin": { "biome-angular": "cli.js" }, "devDependencies": { - "@biomejs/biome": "^2.0.0", + "@biomejs/biome": "^2.5.12", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.6", @@ -28,6 +28,9 @@ }, "engines": { "node": ">=18.0.0" + }, + "peerDependencies": { + "@biomejs/biome": ">=2.5.0 <3" } }, "node_modules/@actions/core": { @@ -176,9 +179,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz", - "integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.12.tgz", + "integrity": "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -192,20 +195,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.8", - "@biomejs/cli-darwin-x64": "2.4.8", - "@biomejs/cli-linux-arm64": "2.4.8", - "@biomejs/cli-linux-arm64-musl": "2.4.8", - "@biomejs/cli-linux-x64": "2.4.8", - "@biomejs/cli-linux-x64-musl": "2.4.8", - "@biomejs/cli-win32-arm64": "2.4.8", - "@biomejs/cli-win32-x64": "2.4.8" + "@biomejs/cli-darwin-arm64": "2.5.12", + "@biomejs/cli-darwin-x64": "2.5.12", + "@biomejs/cli-linux-arm64": "2.5.12", + "@biomejs/cli-linux-arm64-musl": "2.5.12", + "@biomejs/cli-linux-x64": "2.5.12", + "@biomejs/cli-linux-x64-musl": "2.5.12", + "@biomejs/cli-win32-arm64": "2.5.12", + "@biomejs/cli-win32-x64": "2.5.12" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz", - "integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.12.tgz", + "integrity": "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg==", "cpu": [ "arm64" ], @@ -220,9 +223,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz", - "integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.12.tgz", + "integrity": "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ==", "cpu": [ "x64" ], @@ -237,9 +240,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz", - "integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.12.tgz", + "integrity": "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ==", "cpu": [ "arm64" ], @@ -254,9 +257,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz", - "integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.12.tgz", + "integrity": "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ==", "cpu": [ "arm64" ], @@ -271,9 +274,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz", - "integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.12.tgz", + "integrity": "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA==", "cpu": [ "x64" ], @@ -288,9 +291,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz", - "integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.12.tgz", + "integrity": "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w==", "cpu": [ "x64" ], @@ -305,9 +308,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz", - "integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.12.tgz", + "integrity": "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A==", "cpu": [ "arm64" ], @@ -322,9 +325,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz", - "integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==", + "version": "2.5.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.12.tgz", + "integrity": "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 71603d0..5822a27 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "biome-plugin-angular", "version": "1.0.0", - "description": "Biome 2.x plugin for Angular — GritQL port of @angular-eslint rules", + "description": "Biome 2.5+ plugin for Angular 17-22: GritQL port of @angular-eslint rules plus signal-era rules, with an Angular-safe preset", "type": "module", "main": "index.js", "bin": { @@ -25,22 +25,28 @@ "gritql", "plugin", "angular-eslint", - "typescript" + "typescript", + "signals" ], "author": "Virgile Vivier ", "license": "MIT", "files": [ "rules/", "presets/", + "docs/", "index.js", "cli.js", "biome-manifest.jsonc", + "rules-to-migrate.json", "README.md", "CHANGELOG.md", "LICENSE" ], + "peerDependencies": { + "@biomejs/biome": ">=2.5.0 <3" + }, "devDependencies": { - "@biomejs/biome": "^2.0.0", + "@biomejs/biome": "^2.5.12", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.6", diff --git a/presets/recommended.json b/presets/recommended.json index 418711b..4336a70 100644 --- a/presets/recommended.json +++ b/presets/recommended.json @@ -1,30 +1,20 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", - "description": "Recommended biome-plugin-angular rules — suitable for most Angular 17+ projects", - "plugins": ["biome-plugin-angular"], + "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json", + "plugins": [ + { + "path": "./node_modules/biome-plugin-angular", + "includes": ["**/*.ts", "!**/*.spec.ts"] + } + ], + "javascript": { + "parser": { + "unsafeParameterDecoratorsEnabled": true + } + }, "linter": { "rules": { - "plugins": { - "angular/prefer-standalone": "error", - "angular/no-empty-lifecycle-method": "warn", - "angular/no-component-suffix": "warn", - "angular/no-directive-suffix": "warn", - "angular/no-service-suffix": "warn", - "angular/component-selector": "warn", - "angular/no-input-rename": "error", - "angular/no-output-rename": "error", - "angular/no-output-on-prefix": "error", - "angular/pipe-prefix": "warn", - "angular/sort-ngmodule-metadata-arrays": "warn", - "angular/use-lifecycle-interface": "warn", - "angular/use-pipe-transform-interface": "warn", - "angular/contextual-lifecycle": "error", - "angular/prefer-signal-inputs": "warn", - "angular/prefer-output-function": "warn", - "angular/prefer-signal-queries": "warn", - "angular/prefer-model-signal": "warn", - "angular/prefer-host-property": "warn", - "angular/prefer-inject-function": "warn" + "style": { + "useImportType": "off" } } } diff --git a/presets/strict.json b/presets/strict.json deleted file mode 100644 index 47c1c98..0000000 --- a/presets/strict.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", - "description": "Strict biome-plugin-angular rules — enforces all Angular conventions as errors", - "plugins": ["biome-plugin-angular"], - "linter": { - "rules": { - "plugins": { - "angular/prefer-standalone": "error", - "angular/no-empty-lifecycle-method": "error", - "angular/no-component-suffix": "error", - "angular/no-directive-suffix": "error", - "angular/no-service-suffix": "error", - "angular/component-selector": "error", - "angular/no-input-rename": "error", - "angular/no-output-rename": "error", - "angular/no-output-on-prefix": "error", - "angular/pipe-prefix": "error", - "angular/sort-ngmodule-metadata-arrays": "error", - "angular/use-lifecycle-interface": "error", - "angular/use-pipe-transform-interface": "error", - "angular/contextual-lifecycle": "error", - "angular/prefer-signal-inputs": "error", - "angular/prefer-output-function": "error", - "angular/prefer-signal-queries": "error", - "angular/prefer-model-signal": "error", - "angular/prefer-host-property": "error", - "angular/prefer-inject-function": "error" - } - } - } -} diff --git a/rules-to-migrate.json b/rules-to-migrate.json index 913d804..69abde2 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -1,5 +1,4 @@ { - "$schema": "./rules-to-migrate.schema.json", "typescript": [ { "name": "prefer-standalone", @@ -29,6 +28,33 @@ "docs": null, "skipReason": "Angular v20+ style guide no longer recommends class name suffixes. Replaced by angular/no-component-suffix. See https://github.com/angular/angular/discussions/59522" }, + { + "name": "no-component-suffix", + "eslintRule": null, + "category": "conventions", + "priority": 2, + "status": "done", + "gritFile": "./rules/conventions/no-component-suffix.grit", + "docs": "./docs/rules/no-component-suffix.md" + }, + { + "name": "no-directive-suffix", + "eslintRule": null, + "category": "conventions", + "priority": 2, + "status": "done", + "gritFile": "./rules/conventions/no-directive-suffix.grit", + "docs": "./docs/rules/no-directive-suffix.md" + }, + { + "name": "no-service-suffix", + "eslintRule": null, + "category": "conventions", + "priority": 2, + "status": "done", + "gritFile": "./rules/conventions/no-service-suffix.grit", + "docs": "./docs/rules/no-service-suffix.md" + }, { "name": "directive-class-suffix", "eslintRule": "@angular-eslint/directive-class-suffix", @@ -53,10 +79,9 @@ "eslintRule": "@angular-eslint/directive-selector", "category": "conventions", "priority": 2, - "status": "todo", - "gritFile": null, - "docs": null, - "notes": "Enforces directive selector style: prefix, type (attribute), casing" + "status": "done", + "gritFile": "./rules/conventions/directive-selector.grit", + "docs": "./docs/rules/directive-selector.md" }, { "name": "no-input-rename", @@ -85,6 +110,15 @@ "gritFile": "./rules/conventions/no-output-on-prefix.grit", "docs": "./docs/rules/no-output-on-prefix.md" }, + { + "name": "no-output-native", + "eslintRule": "@angular-eslint/no-output-native", + "category": "conventions", + "priority": 2, + "status": "done", + "gritFile": "./rules/conventions/no-output-native.grit", + "docs": "./docs/rules/no-output-native.md" + }, { "name": "pipe-prefix", "eslintRule": "@angular-eslint/pipe-prefix", @@ -99,9 +133,8 @@ "eslintRule": "@angular-eslint/sort-ngmodule-metadata-arrays", "category": "conventions", "priority": 2, - "status": "done", - "gritFile": "./rules/conventions/sort-ngmodule-metadata-arrays.grit", - "docs": "./docs/rules/sort-ngmodule-metadata-arrays.md" + "status": "skipped", + "skipReason": "GritQL cannot compare list order, so the rule could only detect the presence of metadata arrays — 100% false positives on sorted code. Removed until order comparison is expressible. The upstream rule was also deprecated and later removed from @angular-eslint." }, { "name": "use-lifecycle-interface", @@ -140,6 +173,51 @@ "gritFile": "./rules/quality/contextual-lifecycle.grit", "docs": "./docs/rules/contextual-lifecycle.md" }, + { + "name": "no-conflicting-lifecycle", + "eslintRule": "@angular-eslint/no-conflicting-lifecycle", + "category": "quality", + "priority": 3, + "status": "done", + "gritFile": "./rules/quality/no-conflicting-lifecycle.grit", + "docs": "./docs/rules/no-conflicting-lifecycle.md" + }, + { + "name": "no-lifecycle-call", + "eslintRule": "@angular-eslint/no-lifecycle-call", + "category": "quality", + "priority": 3, + "status": "done", + "gritFile": "./rules/quality/no-lifecycle-call.grit", + "docs": "./docs/rules/no-lifecycle-call.md" + }, + { + "name": "no-async-lifecycle-method", + "eslintRule": "@angular-eslint/no-async-lifecycle-method", + "category": "quality", + "priority": 3, + "status": "done", + "gritFile": "./rules/quality/no-async-lifecycle-method.grit", + "docs": "./docs/rules/no-async-lifecycle-method.md" + }, + { + "name": "no-pipe-impure", + "eslintRule": "@angular-eslint/no-pipe-impure", + "category": "quality", + "priority": 3, + "status": "done", + "gritFile": "./rules/quality/no-pipe-impure.grit", + "docs": "./docs/rules/no-pipe-impure.md" + }, + { + "name": "no-attribute-decorator", + "eslintRule": "@angular-eslint/no-attribute-decorator", + "category": "quality", + "priority": 3, + "status": "done", + "gritFile": "./rules/quality/no-attribute-decorator.grit", + "docs": "./docs/rules/no-attribute-decorator.md" + }, { "name": "no-inputs-metadata-property", "eslintRule": "@angular-eslint/no-inputs-metadata-property", diff --git a/rules/conventions/component-selector.grit b/rules/conventions/component-selector.grit index 99f9610..ac55ef7 100644 --- a/rules/conventions/component-selector.grit +++ b/rules/conventions/component-selector.grit @@ -1,7 +1,12 @@ +// Port of @angular-eslint/component-selector with the Angular CLI defaults: +// element selectors, kebab-case, `app` prefix. Comma-separated selector +// lists are accepted when every entry is valid. Biome plugins take no +// options, so a project using another prefix must copy this file and edit +// the regex. language js `@Component({ $args })` where { - $args <: contains `selector: $sel`, - $sel <: not r"^['\"]app-[a-z][a-z0-9]*(?:-[a-z0-9]+)*['\"]$", - register_diagnostic(span = $sel, message = "Angular component selector must be kebab-case with the 'app-' prefix.", severity = "warn") + $args <: contains `selector: $selector`, + $selector <: not r"^['\"]app-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\s*,\s*app-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*['\"]$", + register_diagnostic(span = $selector, message = "[component-selector] Angular component selector must be kebab-case with the 'app-' prefix.", severity = "warn") } diff --git a/rules/conventions/directive-selector.grit b/rules/conventions/directive-selector.grit new file mode 100644 index 0000000..3576ff1 --- /dev/null +++ b/rules/conventions/directive-selector.grit @@ -0,0 +1,12 @@ +// Port of @angular-eslint/directive-selector with the Angular CLI defaults: +// attribute selectors, camelCase, `app` prefix (`[appHighlight]`). +// Comma-separated selector lists are accepted when every entry is valid. +// Biome plugins take no options, so a project using another prefix must copy +// this file and edit the regex. +language js + +`@Directive({ $args })` where { + $args <: contains `selector: $selector`, + $selector <: not r"^['\"]\[app[A-Z][A-Za-z0-9]*\](?:\s*,\s*\[app[A-Z][A-Za-z0-9]*\])*['\"]$", + register_diagnostic(span = $selector, message = "[directive-selector] Angular directive selector must be a camelCase attribute with the 'app' prefix, e.g. '[appHighlight]'.", severity = "warn") +} diff --git a/rules/conventions/no-component-suffix.grit b/rules/conventions/no-component-suffix.grit index ba325da..bb2a1ed 100644 --- a/rules/conventions/no-component-suffix.grit +++ b/rules/conventions/no-component-suffix.grit @@ -1,9 +1,18 @@ -// Limitation: GritQL cannot match @Component\nclass as one unit, -// so this matches ANY class ending in "Component" (heuristic). -// In Angular projects this is accurate; may false-positive in non-Angular code. +// Port of the Angular v20+ style-guide guidance: drop the "Component" suffix. +// Only classes decorated with @Component are flagged (plain TS classes are not). +// +// Classes are matched through Biome's native AST nodes instead of code +// snippets so that every class shape is covered: `export`, `export default`, +// `extends`, generics and `implements` clauses. Decorators written before +// `export` are attached to the JsExport node, hence the three branches. language js -`class $name { $_ }` where { +or { + JsClassDeclaration(decorators = $decorators, id = $name), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(id = $name)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(id = $name))) +} where { + $decorators <: contains `@Component($args)`, $name <: r".*Component$", - register_diagnostic(span = $name, message = "Angular v20+: remove the 'Component' suffix from class names. Name classes after what they represent.", severity = "warn") + register_diagnostic(span = $name, message = "[no-component-suffix] Angular v20+: remove the 'Component' suffix from class names. Name classes after what they represent.", severity = "warn") } diff --git a/rules/conventions/no-directive-suffix.grit b/rules/conventions/no-directive-suffix.grit index 04d099a..8592ee7 100644 --- a/rules/conventions/no-directive-suffix.grit +++ b/rules/conventions/no-directive-suffix.grit @@ -1,9 +1,18 @@ -// Limitation: GritQL cannot match @Directive\nclass as one unit, -// so this matches ANY class ending in "Directive" (heuristic). -// In Angular projects this is accurate; may false-positive in non-Angular code. +// Port of the Angular v20+ style-guide guidance: drop the "Directive" suffix. +// Only classes decorated with @Directive are flagged (plain TS classes are not). +// +// Classes are matched through Biome's native AST nodes instead of code +// snippets so that every class shape is covered: `export`, `export default`, +// `extends`, generics and `implements` clauses. Decorators written before +// `export` are attached to the JsExport node, hence the three branches. language js -`class $name { $_ }` where { +or { + JsClassDeclaration(decorators = $decorators, id = $name), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(id = $name)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(id = $name))) +} where { + $decorators <: contains `@Directive($args)`, $name <: r".*Directive$", - register_diagnostic(span = $name, message = "Angular v20+: remove the 'Directive' suffix from class names. Name classes after what they represent.", severity = "warn") + register_diagnostic(span = $name, message = "[no-directive-suffix] Angular v20+: remove the 'Directive' suffix from class names. Name classes after what they represent.", severity = "warn") } diff --git a/rules/conventions/no-input-rename.grit b/rules/conventions/no-input-rename.grit index daed0c9..0946074 100644 --- a/rules/conventions/no-input-rename.grit +++ b/rules/conventions/no-input-rename.grit @@ -1,13 +1,33 @@ +// Port of @angular-eslint/no-input-rename. +// Covers the @Input() decorator (string alias or `alias` option) and the +// signal-based input() / input.required() / model() / model.required() +// functions, with or without type arguments. language js or { `@Input($alias)` where { $alias <: r"^['\"].*['\"]$", - register_diagnostic(span = $alias, message = "Avoid renaming @Input() properties. Use the property name as the public API.") + register_diagnostic(span = $alias, message = "[no-input-rename] Avoid renaming @Input() properties. Use the property name as the public API.") }, - `@Input({ $opts })` where { $opts <: contains `alias: $_`, - register_diagnostic(span = $opts, message = "Avoid renaming @Input() properties. Use the property name as the public API.") + register_diagnostic(span = $opts, message = "[no-input-rename] Avoid renaming @Input() properties. Use the property name as the public API.") + }, + or { + `input($opts)`, + `input($default, $opts)`, + `input<$type>($opts)`, + `input<$type>($default, $opts)`, + `input.required($opts)`, + `input.required<$type>($opts)`, + `model($opts)`, + `model($default, $opts)`, + `model<$type>($opts)`, + `model<$type>($default, $opts)`, + `model.required($opts)`, + `model.required<$type>($opts)` + } where { + $opts <: contains `alias: $_`, + register_diagnostic(span = $opts, message = "[no-input-rename] Avoid aliasing signal inputs. Use the property name as the public API.") } } diff --git a/rules/conventions/no-output-native.grit b/rules/conventions/no-output-native.grit new file mode 100644 index 0000000..9595594 --- /dev/null +++ b/rules/conventions/no-output-native.grit @@ -0,0 +1,18 @@ +// Port of @angular-eslint/no-output-native. +// An output named like a native DOM event (click, change, focus, submit…) +// fires together with the browser event of the same name on the host +// element, so parents get two events for one binding. Covers both the +// @Output() decorator and the signal-based output() / outputFromObservable() +// functions, on every property shape (initialized, typed, readonly…). +language js + +JsPropertyClassMember(modifiers = $modifiers, name = $name, value = $value) where { + or { + $modifiers <: contains `@Output($args)`, + $value <: JsInitializerClause(expression = JsCallExpression(callee = $callee)) where { + $callee <: or { `output`, `outputFromObservable` } + } + }, + $name <: r"^(?:abort|afterprint|animationend|animationiteration|animationstart|appinstalled|audioprocess|audioend|audiostart|beforeprint|beforeunload|beginEvent|blocked|blur|boundary|cached|canplay|canplaythrough|change|chargingchange|chargingtimechange|checking|click|close|complete|compositionend|compositionstart|compositionupdate|contextmenu|copy|cut|dblclick|devicechange|devicelight|devicemotion|deviceorientation|deviceproximity|dischargingtimechange|DOMAttributeNameChanged|DOMAttrModified|DOMCharacterDataModified|DOMContentLoaded|DOMFocusIn|DOMFocusOut|DOMNodeInserted|DOMNodeInsertedIntoDocument|DOMNodeRemoved|DOMNodeRemovedFromDocument|DOMSubtreeModified|downloading|drag|dragend|dragenter|dragleave|dragover|dragstart|drop|durationchange|emptied|end|ended|endEvent|error|focus|focusin|focusout|fullscreenchange|fullscreenerror|gamepadconnected|gamepaddisconnected|gotpointercapture|hashchange|lostpointercapture|input|invalid|keydown|keypress|keyup|languagechange|levelchange|load|loadeddata|loadedmetadata|loadend|loadstart|mark|message|messageerror|mousedown|mouseenter|mouseleave|mousemove|mouseout|mouseover|mouseup|nomatch|notificationclick|noupdate|obsolete|offline|online|open|orientationchange|pagehide|pageshow|paste|pause|pointercancel|pointerdown|pointerenter|pointerleave|pointerlockchange|pointerlockerror|pointermove|pointerout|pointerover|pointerup|play|playing|popstate|progress|push|pushsubscriptionchange|ratechange|readystatechange|repeatEvent|reset|resize|resourcetimingbufferfull|result|resume|scroll|seeked|seeking|select|selectstart|selectionchange|show|slotchange|soundend|soundstart|speechend|speechstart|stalled|start|storage|submit|success|suspend|SVGAbort|SVGError|SVGLoad|SVGResize|SVGScroll|SVGUnload|SVGZoom|timeout|timeupdate|touchcancel|touchend|touchmove|touchstart|transitionend|unload|updateready|upgradeneeded|userproximity|voiceschanged|versionchange|visibilitychange|volumechange|vrdisplayconnected|vrdisplaydisconnected|vrdisplaypresentchange|waiting|wheel)$", + register_diagnostic(span = $name, message = "[no-output-native] Output name collides with a native DOM event: parents would receive both the component event and the browser event. Pick a specific name (e.g. 'itemSelected' instead of 'select').") +} diff --git a/rules/conventions/no-output-on-prefix.grit b/rules/conventions/no-output-on-prefix.grit index 657cd29..773fdfb 100644 --- a/rules/conventions/no-output-on-prefix.grit +++ b/rules/conventions/no-output-on-prefix.grit @@ -1,6 +1,17 @@ +// Port of @angular-eslint/no-output-on-prefix. +// Matches class properties through Biome's native property node, so every +// shape is covered: initialized, typed, definite-assignment (!), `readonly`, +// with or without a decorator argument. Covers both the @Output() decorator +// and the signal-based output() / outputFromObservable() functions. language js -`@Output() $name = $init` where { +JsPropertyClassMember(modifiers = $modifiers, name = $name, value = $value) where { + or { + $modifiers <: contains `@Output($args)`, + $value <: JsInitializerClause(expression = JsCallExpression(callee = $callee)) where { + $callee <: or { `output`, `outputFromObservable` } + } + }, $name <: r"^on[A-Z].*", - register_diagnostic(span = $name, message = "@Output() property names must not start with 'on' followed by an uppercase letter.") + register_diagnostic(span = $name, message = "[no-output-on-prefix] Output names must not start with 'on' followed by an uppercase letter: the 'on' prefix belongs to the parent's handler, e.g. (submitted)=\"onSubmit()\".") } diff --git a/rules/conventions/no-output-rename.grit b/rules/conventions/no-output-rename.grit index cf52b2e..cd504f3 100644 --- a/rules/conventions/no-output-rename.grit +++ b/rules/conventions/no-output-rename.grit @@ -1,13 +1,25 @@ +// Port of @angular-eslint/no-output-rename. +// Covers the @Output() decorator (string alias or `alias` option) and the +// signal-based output() / outputFromObservable() functions, with or without +// type arguments. language js or { `@Output($alias)` where { $alias <: r"^['\"].*['\"]$", - register_diagnostic(span = $alias, message = "Avoid renaming @Output() properties. Use the property name as the event name.") + register_diagnostic(span = $alias, message = "[no-output-rename] Avoid renaming @Output() properties. Use the property name as the event name.") }, - `@Output({ $opts })` where { $opts <: contains `alias: $_`, - register_diagnostic(span = $opts, message = "Avoid renaming @Output() properties. Use the property name as the event name.") + register_diagnostic(span = $opts, message = "[no-output-rename] Avoid renaming @Output() properties. Use the property name as the event name.") + }, + or { + `output($opts)`, + `output<$type>($opts)`, + `outputFromObservable($source, $opts)`, + `outputFromObservable<$type>($source, $opts)` + } where { + $opts <: contains `alias: $_`, + register_diagnostic(span = $opts, message = "[no-output-rename] Avoid aliasing signal outputs. Use the property name as the event name.") } } diff --git a/rules/conventions/no-service-suffix.grit b/rules/conventions/no-service-suffix.grit index 80a6a57..531cc08 100644 --- a/rules/conventions/no-service-suffix.grit +++ b/rules/conventions/no-service-suffix.grit @@ -1,9 +1,18 @@ -// Limitation: GritQL cannot match @Injectable\nclass as one unit, -// so this matches ANY class ending in "Service" (heuristic). -// In Angular projects this is accurate; may false-positive in non-Angular code. +// Port of the Angular v20+ style-guide guidance: drop the "Service" suffix. +// Only classes decorated with @Injectable are flagged (plain TS classes are not). +// +// Classes are matched through Biome's native AST nodes instead of code +// snippets so that every class shape is covered: `export`, `export default`, +// `extends`, generics and `implements` clauses. Decorators written before +// `export` are attached to the JsExport node, hence the three branches. language js -`class $name { $_ }` where { +or { + JsClassDeclaration(decorators = $decorators, id = $name), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(id = $name)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(id = $name))) +} where { + $decorators <: contains `@Injectable($args)`, $name <: r".*Service$", - register_diagnostic(span = $name, message = "Angular v20+: remove the 'Service' suffix. Use a descriptive name (Store, Api, Client, Facade…).", severity = "warn") + register_diagnostic(span = $name, message = "[no-service-suffix] Angular v20+: remove the 'Service' suffix. Use a descriptive name (Store, Api, Client, Facade…).", severity = "warn") } diff --git a/rules/conventions/pipe-prefix.grit b/rules/conventions/pipe-prefix.grit index fdc2d6d..5ff3686 100644 --- a/rules/conventions/pipe-prefix.grit +++ b/rules/conventions/pipe-prefix.grit @@ -1,7 +1,10 @@ +// Port of @angular-eslint/pipe-prefix with the Angular CLI default prefix +// `app` (camelCase: `appTruncate`). Biome plugins take no options, so a +// project using another prefix must copy this file and edit the regex. language js `@Pipe({ $args })` where { $args <: contains `name: $pipeName`, $pipeName <: not r"^['\"]app[A-Z].*", - register_diagnostic(span = $pipeName, message = "Pipe name must start with the configured prefix (default: 'app').", severity = "warn") + register_diagnostic(span = $pipeName, message = "[pipe-prefix] Pipe name must start with the 'app' prefix, e.g. 'appTruncate'.", severity = "warn") } diff --git a/rules/conventions/sort-ngmodule-metadata-arrays.grit b/rules/conventions/sort-ngmodule-metadata-arrays.grit deleted file mode 100644 index f41e2a6..0000000 --- a/rules/conventions/sort-ngmodule-metadata-arrays.grit +++ /dev/null @@ -1,15 +0,0 @@ -// Limitation: GritQL cannot perform algorithmic sort checks. -// This rule flags the PRESENCE of metadata arrays, not their sort order. -// Useful as a reminder; cannot detect already-sorted arrays. -language js - -`@NgModule({ $args })` where { - $args <: or { - contains `declarations: [$_]`, - contains `imports: [$_]`, - contains `exports: [$_]`, - contains `providers: [$_]`, - contains `bootstrap: [$_]` - }, - register_diagnostic(span = $args, message = "NgModule metadata arrays (declarations, imports, exports, providers) must be sorted alphabetically.", severity = "warn") -} diff --git a/rules/high-priority/fixes/no-empty-lifecycle-method.grit b/rules/high-priority/fixes/no-empty-lifecycle-method.grit new file mode 100644 index 0000000..e820776 --- /dev/null +++ b/rules/high-priority/fixes/no-empty-lifecycle-method.grit @@ -0,0 +1,57 @@ +// Second half of no-empty-lifecycle-method — see the sibling file in the +// parent directory for the full rationale. This half handles classes that do +// NOT implement the hook's interface: the empty method is deleted with a safe +// fix (`biome lint --write`). The rewrite must precede register_diagnostic for +// Biome to apply it when the pattern uses `within`. +language js + +or { + JsMethodClassMember(name = `ngOnChanges`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `OnChanges`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngOnChanges is empty and can be removed.", severity = "warn", fix_kind = "safe") + }, + JsMethodClassMember(name = `ngOnInit`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `OnInit`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngOnInit is empty and can be removed.", severity = "warn", fix_kind = "safe") + }, + JsMethodClassMember(name = `ngDoCheck`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `DoCheck`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngDoCheck is empty and can be removed.", severity = "warn", fix_kind = "safe") + }, + JsMethodClassMember(name = `ngAfterContentInit`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterContentInit`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterContentInit is empty and can be removed.", severity = "warn", fix_kind = "safe") + }, + JsMethodClassMember(name = `ngAfterContentChecked`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterContentChecked`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterContentChecked is empty and can be removed.", severity = "warn", fix_kind = "safe") + }, + JsMethodClassMember(name = `ngAfterViewInit`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterViewInit`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterViewInit is empty and can be removed.", severity = "warn", fix_kind = "safe") + }, + JsMethodClassMember(name = `ngAfterViewChecked`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterViewChecked`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterViewChecked is empty and can be removed.", severity = "warn", fix_kind = "safe") + }, + JsMethodClassMember(name = `ngOnDestroy`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `OnDestroy`, + $method => ., + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngOnDestroy is empty and can be removed.", severity = "warn", fix_kind = "safe") + } +} diff --git a/rules/high-priority/no-empty-lifecycle-method.grit b/rules/high-priority/no-empty-lifecycle-method.grit index 8faa104..60cc061 100644 --- a/rules/high-priority/no-empty-lifecycle-method.grit +++ b/rules/high-priority/no-empty-lifecycle-method.grit @@ -1,28 +1,61 @@ +// Port of @angular-eslint/no-empty-lifecycle-method. +// Matches lifecycle methods through Biome's native method node so that any +// signature is covered (`: void`, parameters, `async`, `protected`, +// `override`…). A method whose body has no statement is empty — a body that +// only holds a comment counts as empty, like the upstream rule. +// +// The rule is split in two files with the SAME stem (Biome derives the plugin +// name — used by `biome-ignore lint/plugin/` — from the file stem): +// - rules/high-priority/no-empty-lifecycle-method.grit (this file): the class +// implements the hook's interface. Deleting the method alone breaks +// compilation (TS2420), so the diagnostic asks to remove both and ships +// no fix. +// - rules/high-priority/fixes/no-empty-lifecycle-method.grit: the interface +// is not implemented, the method is deleted with a safe fix. +// They cannot live in one pattern: Biome pairs rewrites with diagnostics by +// order of appearance in the file, so mixing fixable and non-fixable branches +// attaches the fix to the wrong diagnostic. language js or { - `ngOnInit() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngOnChanges`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `OnChanges`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngOnChanges is empty: remove it together with OnChanges in the implements clause.", severity = "warn") }, - `ngOnDestroy() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngOnInit`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `OnInit`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngOnInit is empty: remove it together with OnInit in the implements clause.", severity = "warn") }, - `ngOnChanges() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngDoCheck`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `DoCheck`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngDoCheck is empty: remove it together with DoCheck in the implements clause.", severity = "warn") }, - `ngDoCheck() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngAfterContentInit`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `AfterContentInit`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterContentInit is empty: remove it together with AfterContentInit in the implements clause.", severity = "warn") }, - `ngAfterContentInit() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngAfterContentChecked`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `AfterContentChecked`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterContentChecked is empty: remove it together with AfterContentChecked in the implements clause.", severity = "warn") }, - `ngAfterContentChecked() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngAfterViewInit`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `AfterViewInit`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterViewInit is empty: remove it together with AfterViewInit in the implements clause.", severity = "warn") }, - `ngAfterViewInit() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngAfterViewChecked`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `AfterViewChecked`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngAfterViewChecked is empty: remove it together with AfterViewChecked in the implements clause.", severity = "warn") }, - `ngAfterViewChecked() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") + JsMethodClassMember(name = `ngOnDestroy`, body = JsFunctionBody(statements = [])) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: contains `OnDestroy`, + register_diagnostic(span = $method, message = "[no-empty-lifecycle-method] Angular lifecycle method ngOnDestroy is empty: remove it together with OnDestroy in the implements clause.", severity = "warn") } } diff --git a/rules/high-priority/prefer-standalone.grit b/rules/high-priority/prefer-standalone.grit index 3801056..4d03106 100644 --- a/rules/high-priority/prefer-standalone.grit +++ b/rules/high-priority/prefer-standalone.grit @@ -1,33 +1,17 @@ +// Port of @angular-eslint/prefer-standalone, aligned with Angular 19+ where +// standalone: true is the default and the v20 style guide recommends omitting +// the property entirely. Only an explicit standalone: false is flagged. +// +// No auto-fix: removing the property makes the class standalone, which +// breaks the NgModule that still declares it. Use the official migration +// (`ng generate @angular/core:standalone`) which updates both sides. language js or { - `@Component({ $args })` where { - $args <: not contains `standalone: $_`, - register_diagnostic(span = $args, message = "Angular components, directives, and pipes should declare standalone: true (Angular 17+).") - }, - - `@Component({ $args })` where { - $args <: contains `standalone: false`, - register_diagnostic(span = $args, message = "Angular components, directives, and pipes should declare standalone: true (Angular 17+).") - }, - - `@Directive({ $args })` where { - $args <: not contains `standalone: $_`, - register_diagnostic(span = $args, message = "Angular components, directives, and pipes should declare standalone: true (Angular 17+).") - }, - - `@Directive({ $args })` where { - $args <: contains `standalone: false`, - register_diagnostic(span = $args, message = "Angular components, directives, and pipes should declare standalone: true (Angular 17+).") - }, - - `@Pipe({ $args })` where { - $args <: not contains `standalone: $_`, - register_diagnostic(span = $args, message = "Angular components, directives, and pipes should declare standalone: true (Angular 17+).") - }, - - `@Pipe({ $args })` where { - $args <: contains `standalone: false`, - register_diagnostic(span = $args, message = "Angular components, directives, and pipes should declare standalone: true (Angular 17+).") - } + `@Component({ $args })`, + `@Directive({ $args })`, + `@Pipe({ $args })` +} where { + $args <: contains `standalone: false` as $standalone, + register_diagnostic(span = $standalone, message = "[prefer-standalone] Avoid standalone: false. Standalone is the default since Angular 19 — migrate this declarable off NgModules (`ng generate @angular/core:standalone`) and remove the property.") } diff --git a/rules/modern/prefer-host-property.grit b/rules/modern/prefer-host-property.grit index 5001c8d..9e785e1 100644 --- a/rules/modern/prefer-host-property.grit +++ b/rules/modern/prefer-host-property.grit @@ -1,16 +1,8 @@ +// Angular v20+ style guide: declare host bindings and listeners in the +// `host` property of @Component/@Directive instead of the @HostBinding and +// @HostListener decorators. language js -or { - `@HostBinding($args)` where { - register_diagnostic(span = $match, message = "Prefer host: {} in @Component/@Directive over @HostBinding/@HostListener decorators.", severity = "warn") - }, - `@HostListener($args)` where { - register_diagnostic(span = $match, message = "Prefer host: {} in @Component/@Directive over @HostBinding/@HostListener decorators.", severity = "warn") - }, - `@HostBinding()` where { - register_diagnostic(span = $match, message = "Prefer host: {} in @Component/@Directive over @HostBinding/@HostListener decorators.", severity = "warn") - }, - `@HostListener()` where { - register_diagnostic(span = $match, message = "Prefer host: {} in @Component/@Directive over @HostBinding/@HostListener decorators.", severity = "warn") - } +or { `@HostBinding($args)`, `@HostListener($args)` } as $decorator where { + register_diagnostic(span = $decorator, message = "[prefer-host-property] Prefer host: {} in @Component/@Directive over @HostBinding/@HostListener decorators.", severity = "warn") } diff --git a/rules/modern/prefer-inject-function.grit b/rules/modern/prefer-inject-function.grit index b3ed653..01b5307 100644 --- a/rules/modern/prefer-inject-function.grit +++ b/rules/modern/prefer-inject-function.grit @@ -1,9 +1,21 @@ -// Limitation: GritQL cannot match @Injectable/@Component\nclass as one unit, -// so this matches ANY constructor with access-modifier params (heuristic). -// In Angular projects this is accurate; may false-positive in non-Angular code. +// Port of @angular-eslint/prefer-inject: prefer inject() over constructor +// parameter injection (Angular 14+). Every constructor parameter of a +// @Component/@Directive/@Injectable/@Pipe class is injected by Angular, with +// or without an access modifier, so any non-empty constructor is flagged. +// Plain TS or NestJS classes using parameter properties are not flagged. +// +// Classes are matched through Biome's native AST nodes so that every shape is +// covered (`export`, `export default`, `extends`, `implements`). language js -`constructor($params) { $_ }` where { - $params <: contains or { `private`, `protected`, `public`, `readonly` }, - register_diagnostic(span = $params, message = "Prefer inject() over constructor-based dependency injection (Angular 14+).", severity = "warn") +or { + JsClassDeclaration(decorators = $decorators, members = $members), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(members = $members)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(members = $members))) +} where { + $decorators <: contains or { `@Component($args)`, `@Directive($args)`, `@Injectable($args)`, `@Pipe($args)` }, + $members <: contains JsConstructorClassMember(parameters = $params) where { + $params <: contains or { TsPropertyParameter(), JsFormalParameter() } + }, + register_diagnostic(span = $params, message = "[prefer-inject-function] Prefer inject() over constructor parameter injection (Angular 14+). Run `ng generate @angular/core:inject` to migrate.", severity = "warn") } diff --git a/rules/modern/prefer-model-signal.grit b/rules/modern/prefer-model-signal.grit index 545dca5..43860bc 100644 --- a/rules/modern/prefer-model-signal.grit +++ b/rules/modern/prefer-model-signal.grit @@ -1,6 +1,17 @@ +// Port of the Angular 17.2+ model() guidance: an @Output() named `xChange` +// paired with an @Input() named `x` is the two-way binding pattern that +// model() replaces. The pairing is checked per file via a regex capture on the +// output name. Unpaired *Change outputs (e.g. Material-style selectionChange +// events) are NOT flagged. +// Properties are matched through Biome's native property node, so every +// shape is covered (initialized, typed, definite-assignment, `readonly`). language js -`@Output() $name = $init` where { - $name <: r".*Change$", - register_diagnostic(span = $name, message = "Prefer model() over the @Input() + @Output() xyzChange two-way binding pattern (Angular 17.2+).", severity = "warn") +JsPropertyClassMember(modifiers = $modifiers, name = $name) where { + $modifiers <: contains `@Output($args)`, + $name <: r"(.*)Change$"($base), + $program <: contains JsPropertyClassMember(modifiers = $inputModifiers, name = $base) where { + $inputModifiers <: contains `@Input($inputArgs)` + }, + register_diagnostic(span = $name, message = "[prefer-model-signal] Prefer model() over the @Input() + @Output() xyzChange two-way binding pattern (Angular 17.2+).", severity = "warn") } diff --git a/rules/modern/prefer-output-function.grit b/rules/modern/prefer-output-function.grit index ff8c121..2aa3acd 100644 --- a/rules/modern/prefer-output-function.grit +++ b/rules/modern/prefer-output-function.grit @@ -1,10 +1,8 @@ +// Prefer the output() function (Angular 17.3+) over @Output() + EventEmitter. +// The official migration `ng generate @angular/core:output-migration` +// converts decorators and their usages together. language js -or { - `@Output()` where { - register_diagnostic(span = $match, message = "Prefer the output() function over @Output() + EventEmitter (Angular 17.3+).", severity = "warn") - }, - `@Output($args)` where { - register_diagnostic(span = $match, message = "Prefer the output() function over @Output() + EventEmitter (Angular 17.3+).", severity = "warn") - } +`@Output($args)` as $decorator where { + register_diagnostic(span = $decorator, message = "[prefer-output-function] Prefer the output() function over @Output() + EventEmitter (Angular 17.3+). Run `ng generate @angular/core:output-migration` to migrate.", severity = "warn") } diff --git a/rules/modern/prefer-signal-inputs.grit b/rules/modern/prefer-signal-inputs.grit index d2672a6..6d3eab3 100644 --- a/rules/modern/prefer-signal-inputs.grit +++ b/rules/modern/prefer-signal-inputs.grit @@ -1,10 +1,8 @@ +// Prefer the input() signal function (Angular 17.1+) over the @Input() +// decorator. The official migration `ng generate @angular/core:signal-input-migration` +// converts decorators and their template usages together. language js -or { - `@Input()` where { - register_diagnostic(span = $match, message = "Prefer the input() signal function over @Input() decorator (Angular 17.1+).", severity = "warn") - }, - `@Input($args)` where { - register_diagnostic(span = $match, message = "Prefer the input() signal function over @Input() decorator (Angular 17.1+).", severity = "warn") - } +`@Input($args)` as $decorator where { + register_diagnostic(span = $decorator, message = "[prefer-signal-inputs] Prefer the input() signal function over the @Input() decorator (Angular 17.1+). Run `ng generate @angular/core:signal-input-migration` to migrate.", severity = "warn") } diff --git a/rules/modern/prefer-signal-queries.grit b/rules/modern/prefer-signal-queries.grit index d93f032..ccaaad9 100644 --- a/rules/modern/prefer-signal-queries.grit +++ b/rules/modern/prefer-signal-queries.grit @@ -1,16 +1,9 @@ +// Prefer the signal-based query functions (Angular 17.2+) over the +// @ViewChild/@ViewChildren/@ContentChild/@ContentChildren decorators. The +// official migration `ng generate @angular/core:signal-queries-migration` +// converts decorators and their usages together. language js -or { - `@ViewChild($args)` where { - register_diagnostic(span = $match, message = "Prefer signal-based query functions (viewChild, viewChildren, contentChild, contentChildren) over decorator-based queries (Angular 17.2+).", severity = "warn") - }, - `@ViewChildren($args)` where { - register_diagnostic(span = $match, message = "Prefer signal-based query functions (viewChild, viewChildren, contentChild, contentChildren) over decorator-based queries (Angular 17.2+).", severity = "warn") - }, - `@ContentChild($args)` where { - register_diagnostic(span = $match, message = "Prefer signal-based query functions (viewChild, viewChildren, contentChild, contentChildren) over decorator-based queries (Angular 17.2+).", severity = "warn") - }, - `@ContentChildren($args)` where { - register_diagnostic(span = $match, message = "Prefer signal-based query functions (viewChild, viewChildren, contentChild, contentChildren) over decorator-based queries (Angular 17.2+).", severity = "warn") - } +or { `@ViewChild($args)`, `@ViewChildren($args)`, `@ContentChild($args)`, `@ContentChildren($args)` } as $decorator where { + register_diagnostic(span = $decorator, message = "[prefer-signal-queries] Prefer signal-based query functions (viewChild, viewChildren, contentChild, contentChildren) over decorator-based queries (Angular 17.2+). Run `ng generate @angular/core:signal-queries-migration` to migrate.", severity = "warn") } diff --git a/rules/quality/contextual-lifecycle.grit b/rules/quality/contextual-lifecycle.grit index 79ad5c2..7ff82b1 100644 --- a/rules/quality/contextual-lifecycle.grit +++ b/rules/quality/contextual-lifecycle.grit @@ -1,85 +1,22 @@ -// Limitation: Biome GritQL does not match @Decorator\nclass patterns at runtime. -// This rule compiles but violations will NOT be detected. -// Kept in manifest for forward-compatibility when Biome adds decorator+class support. +// Port of @angular-eslint/contextual-lifecycle. +// @Injectable and @Pipe classes may only declare ngOnDestroy — Angular never +// calls any other lifecycle hook on them. @Component and @Directive may use +// all hooks (directives DO receive view/content hooks), so they are not checked. +// +// Classes are matched through Biome's native AST nodes so that every shape is +// covered (`export`, `export default`, `extends`, `implements`). The offending +// hook is located as a method member, so any signature (`: void`, `async`, +// parameters, modifiers) is detected and the diagnostic points at the method. language js or { - `@Injectable($args) -class $name { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }`, - `ngAfterContentInit() { $_ }`, - `ngAfterContentChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") + JsClassDeclaration(decorators = $decorators, members = $members), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(members = $members)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(members = $members))) +} where { + $decorators <: contains or { `@Injectable($args)`, `@Pipe($args)` }, + $members <: contains JsMethodClassMember(name = $hook) where { + $hook <: r"^ng(?:OnChanges|OnInit|DoCheck|AfterContentInit|AfterContentChecked|AfterViewInit|AfterViewChecked)$" }, - `@Injectable($args) -class $name extends $ext { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }`, - `ngAfterContentInit() { $_ }`, - `ngAfterContentChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") - }, - `@Injectable($args) -class $name implements $ifaces { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }`, - `ngAfterContentInit() { $_ }`, - `ngAfterContentChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") - }, - - `@Directive($args) -class $name { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") - }, - `@Directive($args) -class $name extends $ext { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") - }, - `@Directive($args) -class $name implements $ifaces { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") - }, - - `@Pipe($args) -class $name { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }`, - `ngAfterContentInit() { $_ }`, - `ngAfterContentChecked() { $_ }`, - `ngOnChanges($_) { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") - }, - `@Pipe($args) -class $name implements $ifaces { $body }` where { - $body <: contains or { - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }`, - `ngAfterContentInit() { $_ }`, - `ngAfterContentChecked() { $_ }`, - `ngOnChanges($_) { $_ }` - }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: view/content hooks are only available in @Component, not in @Directive, @Injectable, or @Pipe.") - } + register_diagnostic(span = $hook, message = "[contextual-lifecycle] Lifecycle hook used in the wrong context: @Injectable and @Pipe classes only support ngOnDestroy. Other hooks are never called on them.") } diff --git a/rules/quality/no-async-lifecycle-method.grit b/rules/quality/no-async-lifecycle-method.grit new file mode 100644 index 0000000..96816ba --- /dev/null +++ b/rules/quality/no-async-lifecycle-method.grit @@ -0,0 +1,16 @@ +// Port of @angular-eslint/no-async-lifecycle-method. +// Angular does not await lifecycle hooks: an `async ngOnInit()` returns a +// promise nobody observes, so errors are swallowed and the view may render +// before the awaited work is done. Start the async work from the hook +// instead, and keep the hook itself synchronous. +// +// The method is matched through Biome's native node (any signature), then +// the `async` keyword is detected on the method text: Biome's regex operator +// requires a full match, hence the (?s) flag and the trailing `.*`. +language js + +JsMethodClassMember(name = $hook) as $method where { + $hook <: r"^ng(?:OnChanges|OnInit|DoCheck|AfterContentInit|AfterContentChecked|AfterViewInit|AfterViewChecked|OnDestroy)$", + $method <: r"(?s)^(?:(?:public|protected|private|override|static)\s+)*async\s.*$", + register_diagnostic(span = $hook, message = "[no-async-lifecycle-method] Lifecycle hooks must not be async: Angular never awaits them, so rejections are lost and the view can render before the awaited work completes.", severity = "warn") +} diff --git a/rules/quality/no-attribute-decorator.grit b/rules/quality/no-attribute-decorator.grit new file mode 100644 index 0000000..c604092 --- /dev/null +++ b/rules/quality/no-attribute-decorator.grit @@ -0,0 +1,9 @@ +// Port of @angular-eslint/no-attribute-decorator. +// @Attribute() only works on constructor parameters, which ties the class to +// constructor injection. Angular 17.4+ exposes the same value through +// inject(new HostAttributeToken('name')), which composes with inject(). +language js + +`@Attribute($args)` as $decorator where { + register_diagnostic(span = $decorator, message = "[no-attribute-decorator] Prefer inject(new HostAttributeToken('name')) over the @Attribute() decorator (Angular 17.4+).", severity = "warn") +} diff --git a/rules/quality/no-conflicting-lifecycle.grit b/rules/quality/no-conflicting-lifecycle.grit new file mode 100644 index 0000000..e72e4aa --- /dev/null +++ b/rules/quality/no-conflicting-lifecycle.grit @@ -0,0 +1,14 @@ +// Port of @angular-eslint/no-conflicting-lifecycle. +// ngDoCheck and ngOnChanges must not be used on the same class: DoCheck runs +// right after OnChanges on every change-detection cycle, so both hooks react +// to the same input changes and the class does duplicate work. +language js + +or { + JsClassDeclaration(id = $name, members = $body), + JsClassExportDefaultDeclaration(id = $name, members = $body) +} where { + $body <: contains JsMethodClassMember(name = `ngDoCheck`), + $body <: contains JsMethodClassMember(name = `ngOnChanges`), + register_diagnostic(span = $name, message = "[no-conflicting-lifecycle] ngDoCheck and ngOnChanges must not be declared on the same class: DoCheck runs after every OnChanges and duplicates its work.") +} diff --git a/rules/quality/no-lifecycle-call.grit b/rules/quality/no-lifecycle-call.grit new file mode 100644 index 0000000..2453c0e --- /dev/null +++ b/rules/quality/no-lifecycle-call.grit @@ -0,0 +1,12 @@ +// Port of @angular-eslint/no-lifecycle-call. +// Lifecycle hooks are called by Angular; calling them by hand (this.ngOnInit(), +// fixture.componentInstance.ngOnDestroy()…) runs the hook twice or out of +// order. Calls through `super` are allowed — that is how a derived hook +// chains to its parent implementation. +language js + +`$object.$hook($args)` as $call where { + $hook <: r"^ng(?:OnChanges|OnInit|DoCheck|AfterContentInit|AfterContentChecked|AfterViewInit|AfterViewChecked|OnDestroy)$", + $object <: not `super`, + register_diagnostic(span = $call, message = "[no-lifecycle-call] Do not call lifecycle hooks explicitly. Angular calls it; explicit calls run it twice or out of order.") +} diff --git a/rules/quality/no-pipe-impure.grit b/rules/quality/no-pipe-impure.grit new file mode 100644 index 0000000..c12355d --- /dev/null +++ b/rules/quality/no-pipe-impure.grit @@ -0,0 +1,9 @@ +// Port of @angular-eslint/no-pipe-impure. +// An impure pipe (pure: false) runs on every change-detection cycle instead of +// only when its inputs change, which is a common source of performance issues. +language js + +`@Pipe({ $args })` where { + $args <: contains `pure: false` as $impure, + register_diagnostic(span = $impure, message = "[no-pipe-impure] Impure pipes (pure: false) re-run on every change detection cycle. Prefer a pure pipe, or compute the value in the component.", severity = "warn") +} diff --git a/rules/quality/use-lifecycle-interface.grit b/rules/quality/use-lifecycle-interface.grit index a40f090..d24710c 100644 --- a/rules/quality/use-lifecycle-interface.grit +++ b/rules/quality/use-lifecycle-interface.grit @@ -1,111 +1,51 @@ +// Port of @angular-eslint/use-lifecycle-interface. +// Every lifecycle method must have its interface (OnInit for ngOnInit, …) in +// the implements clause of ITS class. The method is matched first, then the +// enclosing class node is read through `within`, so the check is per class +// (not per file) and any method signature is covered (`: void`, `async`, +// parameters, modifiers). One branch per hook: diagnostic messages are +// static strings, and each one names the missing interface. language js or { - `class $name { $body }` where { - $body <: contains or { - `ngOnInit() { $_ }`, - `ngOnDestroy() { $_ }`, - `ngOnChanges($_) { $_ }`, - `ngDoCheck() { $_ }`, - `ngAfterContentInit() { $_ }`, - `ngAfterContentChecked() { $_ }`, - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext { $body }` where { - $body <: contains or { - `ngOnInit() { $_ }`, - `ngOnDestroy() { $_ }`, - `ngOnChanges($_) { $_ }`, - `ngDoCheck() { $_ }`, - `ngAfterContentInit() { $_ }`, - `ngAfterContentChecked() { $_ }`, - `ngAfterViewInit() { $_ }`, - `ngAfterViewChecked() { $_ }` - }, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - - `class $name implements $ifaces { $body }` where { - $body <: contains `ngOnInit()`, - $ifaces <: not contains `OnInit`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngOnInit()`, - $ifaces <: not contains `OnInit`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name implements $ifaces { $body }` where { - $body <: contains `ngOnDestroy()`, - $ifaces <: not contains `OnDestroy`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngOnDestroy()`, - $ifaces <: not contains `OnDestroy`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name implements $ifaces { $body }` where { - $body <: contains `ngOnChanges($_)`, - $ifaces <: not contains `OnChanges`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngOnChanges($_)`, - $ifaces <: not contains `OnChanges`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name implements $ifaces { $body }` where { - $body <: contains `ngDoCheck()`, - $ifaces <: not contains `DoCheck`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngDoCheck()`, - $ifaces <: not contains `DoCheck`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name implements $ifaces { $body }` where { - $body <: contains `ngAfterContentInit()`, - $ifaces <: not contains `AfterContentInit`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngAfterContentInit()`, - $ifaces <: not contains `AfterContentInit`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name implements $ifaces { $body }` where { - $body <: contains `ngAfterContentChecked()`, - $ifaces <: not contains `AfterContentChecked`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngAfterContentChecked()`, - $ifaces <: not contains `AfterContentChecked`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name implements $ifaces { $body }` where { - $body <: contains `ngAfterViewInit()`, - $ifaces <: not contains `AfterViewInit`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngAfterViewInit()`, - $ifaces <: not contains `AfterViewInit`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name implements $ifaces { $body }` where { - $body <: contains `ngAfterViewChecked()`, - $ifaces <: not contains `AfterViewChecked`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") - }, - `class $name extends $ext implements $ifaces { $body }` where { - $body <: contains `ngAfterViewChecked()`, - $ifaces <: not contains `AfterViewChecked`, - register_diagnostic(span = $name, message = "Classes with Angular lifecycle methods should implement the corresponding interface (e.g. OnInit, OnDestroy).", severity = "warn") + JsMethodClassMember(name = `ngOnChanges`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `OnChanges`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngOnChanges: implement the OnChanges interface.", severity = "warn") + }, + JsMethodClassMember(name = `ngOnInit`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `OnInit`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngOnInit: implement the OnInit interface.", severity = "warn") + }, + JsMethodClassMember(name = `ngDoCheck`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `DoCheck`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngDoCheck: implement the DoCheck interface.", severity = "warn") + }, + JsMethodClassMember(name = `ngAfterContentInit`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterContentInit`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngAfterContentInit: implement the AfterContentInit interface.", severity = "warn") + }, + JsMethodClassMember(name = `ngAfterContentChecked`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterContentChecked`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngAfterContentChecked: implement the AfterContentChecked interface.", severity = "warn") + }, + JsMethodClassMember(name = `ngAfterViewInit`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterViewInit`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngAfterViewInit: implement the AfterViewInit interface.", severity = "warn") + }, + JsMethodClassMember(name = `ngAfterViewChecked`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `AfterViewChecked`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngAfterViewChecked: implement the AfterViewChecked interface.", severity = "warn") + }, + JsMethodClassMember(name = `ngOnDestroy`) as $method where { + $method <: within or { JsClassDeclaration(implements_clause = $impl), JsClassExportDefaultDeclaration(implements_clause = $impl) }, + $impl <: not contains `OnDestroy`, + register_diagnostic(span = $method, message = "[use-lifecycle-interface] Lifecycle method ngOnDestroy: implement the OnDestroy interface.", severity = "warn") } } diff --git a/rules/quality/use-pipe-transform-interface.grit b/rules/quality/use-pipe-transform-interface.grit index f5861dd..24d5266 100644 --- a/rules/quality/use-pipe-transform-interface.grit +++ b/rules/quality/use-pipe-transform-interface.grit @@ -1,28 +1,15 @@ -// Limitation: Biome GritQL does not match @Decorator\nclass patterns at runtime. -// This rule compiles but violations will NOT be detected. -// Kept in manifest for forward-compatibility when Biome adds decorator+class support. +// Port of @angular-eslint/use-pipe-transform-interface. +// A @Pipe class must list PipeTransform in its implements clause. The check is +// per class: the implements clause is read from Biome's native class node, so +// a file that imports PipeTransform for another pipe does not exempt this one. language js or { - `@Pipe($args) -class $name { $body }` where { - register_diagnostic(span = $name, message = "@Pipe classes must implement the PipeTransform interface.", severity = "warn") - }, - - `@Pipe($args) -class $name extends $ext { $body }` where { - register_diagnostic(span = $name, message = "@Pipe classes must implement the PipeTransform interface.", severity = "warn") - }, - - `@Pipe($args) -class $name implements $ifaces { $body }` where { - $ifaces <: not contains `PipeTransform`, - register_diagnostic(span = $name, message = "@Pipe classes must implement the PipeTransform interface.", severity = "warn") - }, - - `@Pipe($args) -class $name extends $ext implements $ifaces { $body }` where { - $ifaces <: not contains `PipeTransform`, - register_diagnostic(span = $name, message = "@Pipe classes must implement the PipeTransform interface.", severity = "warn") - } + JsClassDeclaration(decorators = $decorators, id = $name, implements_clause = $implements), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(id = $name, implements_clause = $implements)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(id = $name, implements_clause = $implements))) +} where { + $decorators <: contains `@Pipe($args)`, + $implements <: not contains `PipeTransform`, + register_diagnostic(span = $name, message = "[use-pipe-transform-interface] @Pipe classes must implement the PipeTransform interface.", severity = "warn") } diff --git a/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 58ae781..0a34f2d 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -42,9 +42,13 @@ let fileCounter = 0; /** * Maps a rule name to its .grit file path. */ -const RULE_PATHS: Record = { +const RULE_PATHS: Record = { 'prefer-standalone': 'rules/high-priority/prefer-standalone.grit', - 'no-empty-lifecycle-method': 'rules/high-priority/no-empty-lifecycle-method.grit', + // One rule, two files with the same stem (non-fixable and fixable cases). + 'no-empty-lifecycle-method': [ + 'rules/high-priority/no-empty-lifecycle-method.grit', + 'rules/high-priority/fixes/no-empty-lifecycle-method.grit', + ], 'no-service-suffix': 'rules/conventions/no-service-suffix.grit', 'no-directive-suffix': 'rules/conventions/no-directive-suffix.grit', 'no-component-suffix': 'rules/conventions/no-component-suffix.grit', @@ -53,10 +57,16 @@ const RULE_PATHS: Record = { 'no-output-rename': 'rules/conventions/no-output-rename.grit', 'no-output-on-prefix': 'rules/conventions/no-output-on-prefix.grit', 'pipe-prefix': 'rules/conventions/pipe-prefix.grit', - 'sort-ngmodule-metadata-arrays': 'rules/conventions/sort-ngmodule-metadata-arrays.grit', + 'directive-selector': 'rules/conventions/directive-selector.grit', + 'no-output-native': 'rules/conventions/no-output-native.grit', 'use-lifecycle-interface': 'rules/quality/use-lifecycle-interface.grit', 'use-pipe-transform-interface': 'rules/quality/use-pipe-transform-interface.grit', 'contextual-lifecycle': 'rules/quality/contextual-lifecycle.grit', + 'no-conflicting-lifecycle': 'rules/quality/no-conflicting-lifecycle.grit', + 'no-lifecycle-call': 'rules/quality/no-lifecycle-call.grit', + 'no-async-lifecycle-method': 'rules/quality/no-async-lifecycle-method.grit', + 'no-pipe-impure': 'rules/quality/no-pipe-impure.grit', + 'no-attribute-decorator': 'rules/quality/no-attribute-decorator.grit', 'prefer-signal-inputs': 'rules/modern/prefer-signal-inputs.grit', 'prefer-output-function': 'rules/modern/prefer-output-function.grit', 'prefer-signal-queries': 'rules/modern/prefer-signal-queries.grit', @@ -77,14 +87,20 @@ function getConfigDir(rule: string): string { const configDir = join(TEMP_DIR, `config-${rule}`); mkdirSync(configDir, { recursive: true }); - const gritPath = resolve(ROOT, ruleRelPath); + const gritPaths = (Array.isArray(ruleRelPath) ? ruleRelPath : [ruleRelPath]).map((p) => + resolve(ROOT, p), + ); writeFileSync( join(configDir, 'biome.json'), JSON.stringify( { - $schema: 'https://biomejs.dev/schemas/2.4.8/schema.json', - plugins: [gritPath], + $schema: 'https://biomejs.dev/schemas/2.5.12/schema.json', + plugins: gritPaths, + // Angular constructors use parameter decorators (@Inject, @Optional, + // @Attribute…), which Biome only parses with this flag — the shipped + // preset enables it the same way. + javascript: { parser: { unsafeParameterDecoratorsEnabled: true } }, formatter: { enabled: false }, assist: { enabled: false }, linter: { @@ -106,10 +122,7 @@ function getConfigDir(rule: string): string { * Only the specified rule is loaded, so diagnostics come exclusively * from that single GritQL rule. */ -export async function runBiomeCheck( - rule: string, - code: string, -): Promise { +export async function runBiomeCheck(rule: string, code: string): Promise { const inputFile = join(TEMP_DIR, `input_${fileCounter++}.ts`); writeFileSync(inputFile, code); @@ -121,12 +134,7 @@ export async function runBiomeCheck( try { ({ stdout, stderr } = await execFileAsync( BIOME_BIN, - [ - 'lint', - '--reporter=json', - `--config-path=${configDir}`, - inputFile, - ], + ['lint', '--reporter=json', `--config-path=${configDir}`, inputFile], { maxBuffer: 4 * 1024 * 1024, timeout: 25_000, @@ -140,12 +148,50 @@ export async function runBiomeCheck( const raw = stdout || stderr; + let parsed: { diagnostics?: BiomeDiagnostic[] }; + try { + parsed = JSON.parse(raw) as { diagnostics?: BiomeDiagnostic[] }; + } catch { + // Never swallow a biome failure: an unparseable output means the binary + // crashed, the config is invalid, or the plugin failed to compile. If we + // returned [] here, every "valid" test would pass vacuously. + throw new Error( + `biome produced no parseable JSON for rule "${rule}".\nstdout/stderr:\n${raw || '(empty)'}\nstderr:\n${stderr || '(empty)'}`, + ); + } + + const diagnostics = parsed.diagnostics ?? []; + + // Fixtures must be valid TypeScript and the config must load: any non-plugin + // diagnostic (parse error, configuration error…) is a broken test, not a + // rule result. + const nonPlugin = diagnostics.filter((d) => d.category !== 'plugin'); + if (nonPlugin.length > 0) { + throw new Error( + `biome emitted non-plugin diagnostics for rule "${rule}" (broken fixture or config?): ${JSON.stringify(nonPlugin, null, 2)}`, + ); + } + + return { diagnostics, raw }; +} + +/** + * Runs `biome lint --write` on a snippet with a single rule loaded and returns + * the file content after Biome applied the rule's safe fixes. + */ +export async function runBiomeFix(rule: string, code: string): Promise { + const inputFile = join(TEMP_DIR, `fix_${fileCounter++}.ts`); + writeFileSync(inputFile, code); + const configDir = getConfigDir(rule); try { - const parsed = JSON.parse(raw) as { diagnostics?: BiomeDiagnostic[] }; - return { diagnostics: parsed.diagnostics ?? [], raw }; + await execFileAsync(BIOME_BIN, ['lint', '--write', `--config-path=${configDir}`, inputFile], { + maxBuffer: 4 * 1024 * 1024, + timeout: 25_000, + }); } catch { - return { diagnostics: [], raw }; + // Non-zero exit when diagnostics remain; the file is still rewritten. } + return readFileSync(inputFile, 'utf8'); } export { createPatternMatcher } from './pattern-matcher.js'; diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index 8e7c00e..22cdfd1 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -15,7 +15,6 @@ describe('createPatternMatcher', () => { expect(typeof matcher.detectOutputRename).toBe('function'); expect(typeof matcher.detectOutputOnPrefix).toBe('function'); expect(typeof matcher.detectMissingPipePrefix).toBe('function'); - expect(typeof matcher.detectUnsortedNgModuleArrays).toBe('function'); expect(typeof matcher.detectMissingLifecycleInterface).toBe('function'); expect(typeof matcher.detectMissingPipeTransform).toBe('function'); expect(typeof matcher.detectContextualLifecycleViolations).toBe('function'); @@ -25,6 +24,13 @@ describe('createPatternMatcher', () => { expect(typeof matcher.detectTwoWayBindingPairs).toBe('function'); expect(typeof matcher.detectHostDecorators).toBe('function'); expect(typeof matcher.detectConstructorInjection).toBe('function'); + expect(typeof matcher.detectInvalidDirectiveSelector).toBe('function'); + expect(typeof matcher.detectNativeOutputNames).toBe('function'); + expect(typeof matcher.detectConflictingLifecycle).toBe('function'); + expect(typeof matcher.detectLifecycleCalls).toBe('function'); + expect(typeof matcher.detectAsyncLifecycleMethods).toBe('function'); + expect(typeof matcher.detectImpurePipes).toBe('function'); + expect(typeof matcher.detectAttributeDecorators).toBe('function'); }); it('returned functions work correctly via the factory', () => { @@ -32,7 +38,7 @@ describe('createPatternMatcher', () => { const violations = matcher.detectMissingStandalone(` import { Component } from '@angular/core'; - @Component({ selector: 'app-root', template: '' }) + @Component({ selector: 'app-root', standalone: false, template: '' }) export class AppComponent {} `); diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index e50e5bf..96fa683 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -1,8 +1,26 @@ -import { Block, Project, type SourceFile } from 'ts-morph'; +import { + Block, + Node, + Project, + type PropertyDeclaration, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; /** - * Lightweight Angular pattern matcher using the TypeScript compiler API. - * Mirrors the logic expressed in the GritQL rules for unit testing purposes. + * Reference-semantics layer for the plugin's Angular rules. + * + * Each matcher re-implements a rule with ts-morph and documents the INTENDED + * semantics (equivalent to the upstream @angular-eslint rule), serving as a + * fast, engine-independent spec. The shipped behavior lives in the GritQL + * rules under rules/ and is verified by tests/integration/ — those + * integration tests are the authority on shipped behavior whenever the two + * layers disagree. + * + * The shipped rules match classes and members through Biome's native AST + * nodes, so `implements` clauses, `readonly` members and modifiers are + * covered on both layers. Get/set accessors remain a deliberate divergence: + * the reference implementations follow the intended semantics there. * * Each matcher returns an array of violation objects (empty = no violations). */ @@ -24,9 +42,10 @@ export interface StandaloneViolation { } /** - * Detects Angular @Component, @Directive, or @Pipe decorators that either: - * - omit the `standalone` property entirely, or - * - set `standalone: false` + * Detects Angular @Component, @Directive, or @Pipe decorators that explicitly + * set `standalone: false`. Since Angular 19 standalone is the default, so + * omitting the property (or setting `standalone: true`) is valid — only the + * explicit opt-out is flagged. */ export function detectMissingStandalone(code: string): StandaloneViolation[] { const sourceFile = createSourceFile(code); @@ -38,14 +57,10 @@ export function detectMissingStandalone(code: string): StandaloneViolation[] { if (!decorator) continue; const args = decorator.getArguments(); - if (args.length === 0) { - violations.push({ decorator: decoratorName, className: cls.getName() ?? 'unknown' }); - continue; - } + if (args.length === 0) continue; const argsText = args[0].getText(); - const hasStandaloneTrue = /standalone\s*:\s*true/.test(argsText); - if (!hasStandaloneTrue) { + if (/standalone\s*:\s*false/.test(argsText)) { violations.push({ decorator: decoratorName, className: cls.getName() ?? 'unknown' }); } } @@ -339,7 +354,10 @@ export function detectMissingPipePrefix(code: string, prefix = 'app'): PipePrefi if (!nameMatch) continue; const pipeName = nameMatch[1]; - if (!pipeName.startsWith(prefix)) { + // The prefix must be followed by an uppercase letter (lowerCamelCase pipe + // name): "appUpper" is valid, "apple" is a violation. + const prefixPattern = new RegExp(`^${prefix}[A-Z]`); + if (!prefixPattern.test(pipeName)) { violations.push({ className: cls.getName() ?? 'unknown', pipeName, expectedPrefix: prefix }); } } @@ -347,65 +365,6 @@ export function detectMissingPipePrefix(code: string, prefix = 'app'): PipePrefi return violations; } -// --------------------------------------------------------------------------- -// sort-ngmodule-metadata-arrays -// --------------------------------------------------------------------------- - -export interface UnsortedArrayViolation { - className: string; - arrayName: string; - elements: string[]; - sortedElements: string[]; -} - -const NGMODULE_ARRAY_PROPS = ['declarations', 'imports', 'exports', 'providers', 'bootstrap']; - -/** - * Detects NgModule metadata arrays that are not sorted alphabetically - * (case-insensitive). Checks: declarations, imports, exports, providers, bootstrap. - */ -export function detectUnsortedNgModuleArrays(code: string): UnsortedArrayViolation[] { - const sourceFile = createSourceFile(code); - const violations: UnsortedArrayViolation[] = []; - - for (const cls of sourceFile.getClasses()) { - const decorator = cls.getDecorator('NgModule'); - if (!decorator) continue; - - const args = decorator.getArguments(); - if (args.length === 0) continue; - - const argsText = args[0].getText(); - - for (const arrayProp of NGMODULE_ARRAY_PROPS) { - // Extract the flat array content (handles simple identifier lists) - const arrayMatch = argsText.match(new RegExp(`\\b${arrayProp}\\s*:\\s*\\[([^\\]]+)\\]`)); - if (!arrayMatch) continue; - - const elements = arrayMatch[1] - .split(',') - .map((e) => e.trim()) - .filter((e) => e.length > 0); - - if (elements.length < 2) continue; - - const sorted = [...elements].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - - const isSorted = elements.every((el, i) => el === sorted[i]); - if (!isSorted) { - violations.push({ - className: cls.getName() ?? 'unknown', - arrayName: arrayProp, - elements, - sortedElements: sorted, - }); - } - } - } - - return violations; -} - // --------------------------------------------------------------------------- // no-directive-suffix // --------------------------------------------------------------------------- @@ -542,19 +501,6 @@ export function detectMissingPipeTransform(code: string): PipeTransformViolation // contextual-lifecycle // --------------------------------------------------------------------------- -// View hooks only allowed in @Component -const VIEW_ONLY_HOOKS = new Set(['ngAfterViewInit', 'ngAfterViewChecked']); -// Content hooks allowed in @Component and @Directive -const CONTENT_HOOKS = new Set(['ngAfterContentInit', 'ngAfterContentChecked']); -// Hooks not allowed in @Pipe -const INVALID_PIPE_HOOKS = new Set([ - 'ngAfterViewInit', - 'ngAfterViewChecked', - 'ngAfterContentInit', - 'ngAfterContentChecked', - 'ngOnChanges', -]); - export interface ContextualLifecycleViolation { className: string; methodName: string; @@ -564,44 +510,39 @@ export interface ContextualLifecycleViolation { /** * Detects lifecycle methods used in wrong Angular class contexts. + * + * Upstream @angular-eslint semantics: @Injectable and @Pipe classes may only + * declare ngOnDestroy — Angular never calls any other hook on them. Any other + * hook (ngOnChanges, ngOnInit, ngDoCheck, ngAfterContentInit/Checked, + * ngAfterViewInit/Checked) is a violation. @Component and @Directive allow + * ALL hooks — directives DO receive view and content hooks. */ export function detectContextualLifecycleViolations(code: string): ContextualLifecycleViolation[] { const sourceFile = createSourceFile(code); const violations: ContextualLifecycleViolation[] = []; for (const cls of sourceFile.getClasses()) { - const _isComponent = !!cls.getDecorator('Component'); - const isDirective = !!cls.getDecorator('Directive'); - const isPipe = !!cls.getDecorator('Pipe'); - const isInjectable = !!cls.getDecorator('Injectable'); + const decoratorName = cls.getDecorator('Injectable') + ? 'Injectable' + : cls.getDecorator('Pipe') + ? 'Pipe' + : null; + + // @Component and @Directive (and undecorated classes) allow all hooks. + if (!decoratorName) continue; for (const method of cls.getMethods()) { const name = method.getName(); if (!LIFECYCLE_INTERFACE_MAP[name]) continue; // not a lifecycle hook + if (name === 'ngOnDestroy') continue; // the only hook allowed here - if (isPipe && INVALID_PIPE_HOOKS.has(name)) { - violations.push({ - className: cls.getName() ?? 'unknown', - methodName: name, - decoratorName: 'Pipe', - reason: `@Pipe classes cannot use ${name}`, - }); - } else if (isInjectable && (VIEW_ONLY_HOOKS.has(name) || CONTENT_HOOKS.has(name))) { - violations.push({ - className: cls.getName() ?? 'unknown', - methodName: name, - decoratorName: 'Injectable', - reason: `@Injectable classes cannot use view/content lifecycle hook ${name}`, - }); - } else if (isDirective && VIEW_ONLY_HOOKS.has(name)) { - violations.push({ - className: cls.getName() ?? 'unknown', - methodName: name, - decoratorName: 'Directive', - reason: `@Directive cannot use view lifecycle hook ${name} (only @Component can)`, - }); - } + violations.push({ + className: cls.getName() ?? 'unknown', + methodName: name, + decoratorName, + reason: `@${decoratorName} classes may only declare ngOnDestroy — ${name} is never called`, + }); } } @@ -815,28 +756,288 @@ export interface ConstructorInjectionViolation { parameterType: string; } +const DI_DECORATORS = ['Component', 'Directive', 'Injectable', 'Pipe']; + /** - * Detects constructor parameters with access modifiers (the Angular DI pattern). - * Suggests replacing with inject() function calls. + * Detects constructor parameters in Angular DI classes (@Component, @Directive, + * @Injectable, @Pipe). Angular injects every constructor parameter of those + * classes, with or without an access modifier, so each one is a violation. + * Undecorated classes are not Angular DI and are ignored. */ export function detectConstructorInjection(code: string): ConstructorInjectionViolation[] { const sourceFile = createSourceFile(code); const violations: ConstructorInjectionViolation[] = []; for (const cls of sourceFile.getClasses()) { + if (!DI_DECORATORS.some((d) => cls.getDecorator(d))) continue; + for (const ctor of cls.getConstructors()) { for (const param of ctor.getParameters()) { - const modifiers = param.getModifiers().map((m) => m.getText()); - const hasAccessModifier = modifiers.some((m) => - ['private', 'protected', 'public', 'readonly'].includes(m), - ); + const typeNode = param.getTypeNode(); + violations.push({ + className: cls.getName() ?? 'unknown', + parameterName: param.getName(), + parameterType: typeNode?.getText() ?? 'unknown', + }); + } + } + } + + return violations; +} + +// --------------------------------------------------------------------------- +// directive-selector +// --------------------------------------------------------------------------- + +const DIRECTIVE_SELECTOR_ENTRY = /^\[app[A-Z][A-Za-z0-9]*\]$/; + +/** + * Detects @Directive selectors that are not camelCase attribute selectors with + * the `app` prefix. Comma-separated lists are valid when every entry is. + */ +export function detectInvalidDirectiveSelector(code: string): SelectorViolation[] { + const sourceFile = createSourceFile(code); + const violations: SelectorViolation[] = []; + + for (const cls of sourceFile.getClasses()) { + const decorator = cls.getDecorator('Directive'); + const arg = decorator?.getArguments()[0]; + if (!arg || !Node.isObjectLiteralExpression(arg)) continue; + const prop = arg.getProperty('selector'); + if (!prop || !Node.isPropertyAssignment(prop)) continue; + const init = prop.getInitializer(); + if (!init || !Node.isStringLiteral(init)) continue; + + const selector = init.getLiteralText(); + const valid = selector.split(',').every((s) => DIRECTIVE_SELECTOR_ENTRY.test(s.trim())); + if (!valid) { + violations.push({ + className: cls.getName() ?? 'unknown', + selector, + reason: 'not [appCamelCase]', + }); + } + } + + return violations; +} + +// --------------------------------------------------------------------------- +// no-output-native +// --------------------------------------------------------------------------- - if (hasAccessModifier) { - const typeNode = param.getTypeNode(); +const NATIVE_EVENT_NAMES = new Set([ + 'abort', + 'blur', + 'change', + 'click', + 'close', + 'contextmenu', + 'copy', + 'cut', + 'dblclick', + 'drag', + 'dragend', + 'dragenter', + 'dragleave', + 'dragover', + 'dragstart', + 'drop', + 'error', + 'focus', + 'focusin', + 'focusout', + 'input', + 'invalid', + 'keydown', + 'keypress', + 'keyup', + 'load', + 'message', + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseout', + 'mouseover', + 'mouseup', + 'open', + 'paste', + 'pause', + 'play', + 'progress', + 'reset', + 'resize', + 'scroll', + 'select', + 'submit', + 'toggle', + 'touchcancel', + 'touchend', + 'touchmove', + 'touchstart', + 'transitionend', + 'wheel', +]); + +const OUTPUT_FUNCTIONS = new Set(['output', 'outputFromObservable']); + +/** True when a class property is an output: @Output() or output()/outputFromObservable(). */ +function isOutputProperty(prop: PropertyDeclaration): boolean { + if (prop.getDecorator('Output')) return true; + const init = prop.getInitializer(); + if (!init || !Node.isCallExpression(init)) return false; + return OUTPUT_FUNCTIONS.has(init.getExpression().getText()); +} + +export interface OutputNativeViolation { + className: string; + propertyName: string; +} + +/** + * Detects outputs (decorator or signal-based) named after a native DOM event. + */ +export function detectNativeOutputNames(code: string): OutputNativeViolation[] { + const sourceFile = createSourceFile(code); + const violations: OutputNativeViolation[] = []; + + for (const cls of sourceFile.getClasses()) { + for (const prop of cls.getProperties()) { + if (isOutputProperty(prop) && NATIVE_EVENT_NAMES.has(prop.getName())) { + violations.push({ className: cls.getName() ?? 'unknown', propertyName: prop.getName() }); + } + } + } + + return violations; +} + +// --------------------------------------------------------------------------- +// no-conflicting-lifecycle +// --------------------------------------------------------------------------- + +export interface ConflictingLifecycleViolation { + className: string; +} + +/** Detects classes declaring both ngDoCheck and ngOnChanges. */ +export function detectConflictingLifecycle(code: string): ConflictingLifecycleViolation[] { + const sourceFile = createSourceFile(code); + const violations: ConflictingLifecycleViolation[] = []; + + for (const cls of sourceFile.getClasses()) { + const names = new Set(cls.getMethods().map((m) => m.getName())); + if (names.has('ngDoCheck') && names.has('ngOnChanges')) { + violations.push({ className: cls.getName() ?? 'unknown' }); + } + } + + return violations; +} + +// --------------------------------------------------------------------------- +// no-lifecycle-call +// --------------------------------------------------------------------------- + +export interface LifecycleCallViolation { + hook: string; + receiver: string; +} + +/** + * Detects explicit lifecycle hook calls (`obj.ngOnInit()`). Calls through + * `super` are allowed: that is how a derived hook chains to its parent. + */ +export function detectLifecycleCalls(code: string): LifecycleCallViolation[] { + const sourceFile = createSourceFile(code); + const violations: LifecycleCallViolation[] = []; + + for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee)) continue; + const hook = callee.getName(); + if (!LIFECYCLE_HOOKS.has(hook)) continue; + const receiver = callee.getExpression().getText(); + if (receiver === 'super') continue; + violations.push({ hook, receiver }); + } + + return violations; +} + +// --------------------------------------------------------------------------- +// no-async-lifecycle-method +// --------------------------------------------------------------------------- + +export interface AsyncLifecycleViolation { + className: string; + methodName: string; +} + +/** Detects lifecycle hooks declared with the async modifier. */ +export function detectAsyncLifecycleMethods(code: string): AsyncLifecycleViolation[] { + const sourceFile = createSourceFile(code); + const violations: AsyncLifecycleViolation[] = []; + + for (const cls of sourceFile.getClasses()) { + for (const method of cls.getMethods()) { + if (LIFECYCLE_HOOKS.has(method.getName()) && method.isAsync()) { + violations.push({ className: cls.getName() ?? 'unknown', methodName: method.getName() }); + } + } + } + + return violations; +} + +// --------------------------------------------------------------------------- +// no-pipe-impure +// --------------------------------------------------------------------------- + +export interface ImpurePipeViolation { + className: string; +} + +/** Detects @Pipe({ pure: false }). */ +export function detectImpurePipes(code: string): ImpurePipeViolation[] { + const sourceFile = createSourceFile(code); + const violations: ImpurePipeViolation[] = []; + + for (const cls of sourceFile.getClasses()) { + const arg = cls.getDecorator('Pipe')?.getArguments()[0]; + if (!arg || !Node.isObjectLiteralExpression(arg)) continue; + const pure = arg.getProperty('pure'); + if (pure && Node.isPropertyAssignment(pure) && pure.getInitializer()?.getText() === 'false') { + violations.push({ className: cls.getName() ?? 'unknown' }); + } + } + + return violations; +} + +// --------------------------------------------------------------------------- +// no-attribute-decorator +// --------------------------------------------------------------------------- + +export interface AttributeDecoratorViolation { + className: string; + parameterName: string; +} + +/** Detects @Attribute() on constructor parameters. */ +export function detectAttributeDecorators(code: string): AttributeDecoratorViolation[] { + const sourceFile = createSourceFile(code); + const violations: AttributeDecoratorViolation[] = []; + + for (const cls of sourceFile.getClasses()) { + for (const ctor of cls.getConstructors()) { + for (const param of ctor.getParameters()) { + if (param.getDecorator('Attribute')) { violations.push({ className: cls.getName() ?? 'unknown', parameterName: param.getName(), - parameterType: typeNode?.getText() ?? 'unknown', }); } } @@ -859,7 +1060,6 @@ export function createPatternMatcher() { detectOutputRename, detectOutputOnPrefix, detectMissingPipePrefix, - detectUnsortedNgModuleArrays, // Quality rules detectMissingLifecycleInterface, detectMissingPipeTransform, @@ -871,5 +1071,13 @@ export function createPatternMatcher() { detectTwoWayBindingPairs, detectHostDecorators, detectConstructorInjection, + // Rules added with the native-AST rewrite + detectInvalidDirectiveSelector, + detectNativeOutputNames, + detectConflictingLifecycle, + detectLifecycleCalls, + detectAsyncLifecycleMethods, + detectImpurePipes, + detectAttributeDecorators, }; } diff --git a/tests/integration/component-selector.test.ts b/tests/integration/component-selector.test.ts index 6e4edf0..4d40917 100644 --- a/tests/integration/component-selector.test.ts +++ b/tests/integration/component-selector.test.ts @@ -4,49 +4,96 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/component-selector [integration]', () => { describe('violations', () => { it('flags PascalCase selector (no prefix)', async () => { - const { diagnostics, raw } = await runBiomeCheck('component-selector', ` + const { diagnostics, raw } = await runBiomeCheck( + 'component-selector', + ` import { Component } from '@angular/core'; @Component({ selector: 'Root', standalone: true, template: '' }) export class App {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("kebab-case with the 'app-' prefix"); }); it('flags camelCase selector without prefix', async () => { - const { diagnostics, raw } = await runBiomeCheck('component-selector', ` + const { diagnostics, raw } = await runBiomeCheck( + 'component-selector', + ` import { Component } from '@angular/core'; @Component({ selector: 'appRoot', standalone: true, template: '' }) export class App {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("kebab-case with the 'app-' prefix"); }); it('flags kebab-case selector without app- prefix', async () => { - const { diagnostics, raw } = await runBiomeCheck('component-selector', ` + const { diagnostics, raw } = await runBiomeCheck( + 'component-selector', + ` import { Component } from '@angular/core'; @Component({ selector: 'my-root', standalone: true, template: '' }) export class App {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("kebab-case with the 'app-' prefix"); + }); + + // Actual behavior: the regex only accepts bare `app-` element selectors, + // so an attribute-selector component (a legitimate Angular pattern, e.g. + // button components) is flagged. Consistent with an element-only selector + // policy, but stricter than @angular-eslint's configurable "type" option. + it('flags attribute-selector component (button[appButton]) — element-only policy', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'component-selector', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'button[appButton]', standalone: true, template: '' }) + export class Button {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("kebab-case with the 'app-' prefix"); + }); + + it('accepts multi-selector list of valid app- selectors', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'component-selector', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-a, app-b', standalone: true, template: '' }) + export class AB {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); describe('valid', () => { it('accepts app- prefixed kebab-case selector', async () => { - const { diagnostics, raw } = await runBiomeCheck('component-selector', ` + const { diagnostics, raw } = await runBiomeCheck( + 'component-selector', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts multi-segment app- selector', async () => { - const { diagnostics, raw } = await runBiomeCheck('component-selector', ` + const { diagnostics, raw } = await runBiomeCheck( + 'component-selector', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-user-profile', standalone: true, template: '' }) export class UserProfile {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/consumer-smoke.test.ts b/tests/integration/consumer-smoke.test.ts new file mode 100644 index 0000000..45005b9 --- /dev/null +++ b/tests/integration/consumer-smoke.test.ts @@ -0,0 +1,242 @@ +import { execFile } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '../..'); +const BIOME_BIN = resolve(ROOT, 'node_modules/.bin/biome'); + +/** + * End-to-end consumer test: packs the plugin exactly as npm would publish it, + * installs it into a fresh project, and lints through the documented + * onboarding path (`plugins: ["./node_modules/biome-plugin-angular"]`). + * This is the only test that exercises the biome-manifest.jsonc resolution + * and the `files` whitelist of package.json — unit and per-rule integration + * tests bypass both by loading .grit files directly. + */ +describe('consumer smoke test [integration]', () => { + let consumerDir: string; + + beforeAll(async () => { + consumerDir = mkdtempSync(join(tmpdir(), 'biome-angular-consumer-')); + + const { stdout } = await execFileAsync( + 'npm', + ['pack', '--pack-destination', consumerDir, '--json'], + { cwd: ROOT, maxBuffer: 16 * 1024 * 1024 }, + ); + const [{ filename }] = JSON.parse(stdout) as [{ filename: string }]; + + await execFileAsync( + 'npm', + ['install', '--no-audit', '--no-fund', join(consumerDir, filename)], + { + cwd: consumerDir, + maxBuffer: 16 * 1024 * 1024, + }, + ); + }, 120_000); + + afterAll(() => { + try { + rmSync(consumerDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + async function lint( + file: string, + ): Promise<{ diagnostics: Array<{ category: string; message: string }> }> { + let stdout = ''; + let stderr = ''; + try { + ({ stdout, stderr } = await execFileAsync(BIOME_BIN, ['lint', '--reporter=json', file], { + cwd: consumerDir, + maxBuffer: 4 * 1024 * 1024, + timeout: 30_000, + })); + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string }; + stdout = err.stdout ?? ''; + stderr = err.stderr ?? ''; + } + const raw = stdout || stderr; + const parsed = JSON.parse(raw) as { + diagnostics?: Array<{ category: string; message: string }>; + }; + return { diagnostics: parsed.diagnostics ?? [] }; + } + + it('lints through the installed package and fires plugin rules', async () => { + writeFileSync( + join(consumerDir, 'biome.json'), + JSON.stringify( + { + plugins: ['./node_modules/biome-plugin-angular'], + formatter: { enabled: false }, + linter: { enabled: true, rules: { recommended: false } }, + }, + null, + 2, + ), + ); + writeFileSync( + join(consumerDir, 'violation.ts'), + [ + "import { Component } from '@angular/core';", + "@Component({ selector: 'app-user', standalone: false, template: '' })", + 'export class UserComponent {}', + '', + ].join('\n'), + ); + + const { diagnostics } = await lint('violation.ts'); + const messages = diagnostics.map((d) => d.message).join('\n'); + + expect(diagnostics.length, messages).toBeGreaterThanOrEqual(2); + for (const d of diagnostics) { + expect(d.category).toBe('plugin'); + } + expect(messages).toContain('standalone: false'); + expect(messages).toContain("'Component' suffix"); + }); + + it('cli init generates a config biome accepts', async () => { + rmSync(join(consumerDir, 'biome.json'), { force: true }); + await execFileAsync('node', ['node_modules/biome-plugin-angular/cli.js', 'init'], { + cwd: consumerDir, + }); + const written = JSON.parse(readFileSync(join(consumerDir, 'biome.json'), 'utf8')); + expect(written.extends).toEqual(['biome-plugin-angular/presets/recommended.json']); + + const { diagnostics } = await lint('violation.ts'); + // Same violations as above — proves the generated config both loads and + // activates the plugin. A config error would surface as a non-plugin + // diagnostic or an unparseable run. + expect(diagnostics.length).toBeGreaterThanOrEqual(2); + for (const d of diagnostics) { + expect(d.category).toBe('plugin'); + } + }); + + it('cli merge adds the preset once and cli remove strips it', async () => { + writeFileSync( + join(consumerDir, 'biome.json'), + JSON.stringify({ formatter: { enabled: false } }, null, 2), + ); + const cli = ['node_modules/biome-plugin-angular/cli.js']; + await execFileAsync('node', [...cli, 'merge'], { cwd: consumerDir }); + await execFileAsync('node', [...cli, 'merge'], { cwd: consumerDir }); + let cfg = JSON.parse(readFileSync(join(consumerDir, 'biome.json'), 'utf8')); + expect(cfg.extends).toEqual(['biome-plugin-angular/presets/recommended.json']); + expect(cfg.formatter).toEqual({ enabled: false }); + + await execFileAsync('node', [...cli, 'remove'], { cwd: consumerDir }); + cfg = JSON.parse(readFileSync(join(consumerDir, 'biome.json'), 'utf8')); + expect(cfg.extends).toBeUndefined(); + expect(cfg.formatter).toEqual({ enabled: false }); + }); + + it('preset works via extends from the package', async () => { + writeFileSync( + join(consumerDir, 'biome.json'), + JSON.stringify( + { + extends: ['biome-plugin-angular/presets/recommended.json'], + formatter: { enabled: false }, + linter: { enabled: true, rules: { recommended: false } }, + }, + null, + 2, + ), + ); + + const { diagnostics } = await lint('violation.ts'); + expect(diagnostics.length).toBeGreaterThanOrEqual(2); + for (const d of diagnostics) { + expect(d.category).toBe('plugin'); + } + }); + + it('preset keeps Angular code parseable and buildable', async () => { + writeFileSync( + join(consumerDir, 'biome.json'), + JSON.stringify({ extends: ['biome-plugin-angular/presets/recommended.json'] }, null, 2), + ); + // Real-world Angular: parameter decorators in the constructor (a parse + // error in Biome's default configuration), a class that implements a + // lifecycle interface, and a type-only usage of an injected token that + // Biome's useImportType would otherwise rewrite to `import type` — which + // breaks Angular DI at build time (NG2003). + writeFileSync( + join(consumerDir, 'real-world.ts'), + [ + "import { Component, Inject, OnInit } from '@angular/core';", + "import { DOCUMENT } from '@angular/common';", + "import { HttpClient } from '@angular/common/http';", + "@Component({ selector: 'app-user', template: '' })", + 'export class UserComponent implements OnInit {', + ' constructor(@Inject(DOCUMENT) private doc: Document, private http: HttpClient) {}', + ' ngOnInit(): void {}', + ' ngOnDestroy(): void {}', + '}', + '', + ].join('\n'), + ); + + const { diagnostics } = await lint('real-world.ts'); + const messages = diagnostics.map((d) => d.message); + expect( + diagnostics.every((d) => d.category !== 'parse'), + messages.join('\n'), + ).toBe(true); + expect(messages.some((m) => m.includes('[no-component-suffix]'))).toBe(true); + expect(messages.some((m) => m.includes('[prefer-inject-function]'))).toBe(true); + expect(messages.some((m) => m.includes('[no-empty-lifecycle-method]'))).toBe(true); + expect(diagnostics.some((d) => d.category === 'lint/style/useImportType')).toBe(false); + + // --write applies the plugin's safe fix and nothing that breaks DI. + try { + await execFileAsync(BIOME_BIN, ['lint', '--write', 'real-world.ts'], { cwd: consumerDir }); + } catch { + // remaining diagnostics make biome exit non-zero; the file is written + } + const fixed = readFileSync(join(consumerDir, 'real-world.ts'), 'utf8'); + // ngOnInit is kept: the class implements OnInit, deleting it would break the build. + expect(fixed).toContain('ngOnInit(): void {}'); + // ngOnDestroy is removed: OnDestroy is not implemented. + expect(fixed).not.toContain('ngOnDestroy'); + expect(fixed).not.toContain('import type'); + }); + + it('spec files are excluded by the preset', async () => { + writeFileSync( + join(consumerDir, 'user.spec.ts'), + [ + "import { Component } from '@angular/core';", + "@Component({ selector: 'host', standalone: false, template: '' })", + 'export class HostComponent {}', + '', + ].join('\n'), + ); + const { diagnostics } = await lint('user.spec.ts'); + expect(diagnostics.filter((d) => d.category === 'plugin')).toHaveLength(0); + }); + + it('cli rules lists rules from the installed package without crashing', async () => { + const { stdout } = await execFileAsync( + 'node', + ['node_modules/biome-plugin-angular/cli.js', 'rules'], + { cwd: consumerDir }, + ); + expect(stdout).toContain('angular/prefer-standalone'); + expect(stdout).toContain('rules migrated'); + }); +}); diff --git a/tests/integration/contextual-lifecycle.test.ts b/tests/integration/contextual-lifecycle.test.ts index 8a627f0..d797d79 100644 --- a/tests/integration/contextual-lifecycle.test.ts +++ b/tests/integration/contextual-lifecycle.test.ts @@ -2,64 +2,144 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/contextual-lifecycle [integration]', () => { - // Biome GritQL cannot match @Decorator\nclass patterns (decorator + class as one unit). - // This rule requires knowing the decorator type to determine context, which is not possible. + // Semantics follow @angular-eslint/contextual-lifecycle: @Injectable and @Pipe + // only support ngOnDestroy. @Component and @Directive support all hooks + // (directives DO receive view/content hooks), so they are never flagged. describe('violations', () => { - it.skip('flags ngAfterViewInit in @Injectable', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` + it('flags ngAfterViewInit in @Injectable', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class UserStore { ngAfterViewInit() { console.log('view init'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('wrong context'); }); - it.skip('flags ngAfterViewInit in @Directive', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` - import { Directive } from '@angular/core'; - @Directive({ selector: '[appFoo]', standalone: true }) - export class Foo { - ngAfterViewInit() { console.log('view init'); } + it('flags ngOnInit in @Injectable (services only support ngOnDestroy)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Injectable } from '@angular/core'; + @Injectable({ providedIn: 'root' }) + export class UserStore { + ngOnInit() { console.log('init'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('wrong context'); }); - it.skip('flags ngAfterViewInit in @Pipe', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` - import { Pipe, PipeTransform } from '@angular/core'; + it('flags ngAfterViewInit in @Pipe', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Pipe } from '@angular/core'; @Pipe({ name: 'appFoo', standalone: true }) - export class FooPipe implements PipeTransform { + export class FooPipe { transform(v: string) { return v; } ngAfterViewInit() { console.log('view init'); } } - `); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('wrong context'); + }); + + it('flags hooks with return types and modifiers (ngOnChanges(changes): void)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Injectable } from '@angular/core'; + @Injectable() + export class WatchService { + ngOnChanges(changes: SimpleChanges): void { this.react(changes); } + react(c: unknown) {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('flags ngOnInit in @Injectable that has an implements clause', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Injectable, OnInit } from '@angular/core'; + @Injectable({ providedIn: 'root' }) + export class UserStore implements OnInit { + ngOnInit() { console.log('init'); } + } + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); }); describe('valid', () => { it('accepts ngAfterViewInit in @Component', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` import { Component, AfterViewInit } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App implements AfterViewInit { ngAfterViewInit() { console.log('view init'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); - it('accepts ngOnInit in @Injectable', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` - import { Injectable, OnInit } from '@angular/core'; + it('accepts ngAfterViewInit in @Directive (directives receive view hooks)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Directive } from '@angular/core'; + @Directive({ selector: '[appFocus]' }) + export class Focus { + ngAfterViewInit() { this.focus(); } + focus() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts ngOnDestroy in @Injectable', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) - export class UserStore implements OnInit { - ngOnInit() { console.log('init'); } + export class CleanupService { + ngOnDestroy() { this.teardown(); } + teardown() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts ngOnDestroy in @Pipe', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Pipe } from '@angular/core'; + @Pipe({ name: 'appAsyncish', standalone: true }) + export class Asyncish { + transform(v: string) { return v; } + ngOnDestroy() { this.cleanup(); } + cleanup() {} } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/directive-selector.test.ts b/tests/integration/directive-selector.test.ts new file mode 100644 index 0000000..126e5f5 --- /dev/null +++ b/tests/integration/directive-selector.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck } from '../helpers/biome-runner.js'; + +describe('angular/directive-selector [integration]', () => { + describe('violations', () => { + it('flags attribute selector without the app prefix', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'directive-selector', + ` + import { Directive } from '@angular/core'; + @Directive({ selector: '[highlight]' }) + export class Highlight {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("camelCase attribute with the 'app' prefix"); + }); + + it('flags kebab-case attribute selector', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'directive-selector', + ` + import { Directive } from '@angular/core'; + @Directive({ selector: '[app-highlight]' }) + export class Highlight {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('flags element selector on a directive', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'directive-selector', + ` + import { Directive } from '@angular/core'; + @Directive({ selector: 'app-highlight' }) + export class Highlight {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + }); + + describe('valid', () => { + it('accepts camelCase attribute selector with app prefix', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'directive-selector', + ` + import { Directive } from '@angular/core'; + @Directive({ selector: '[appHighlight]' }) + export class Highlight {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts a comma-separated list of valid selectors', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'directive-selector', + ` + import { Directive } from '@angular/core'; + @Directive({ selector: '[appTooltip], [appTooltipTop]' }) + export class Tooltip {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('ignores @Component selectors', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'directive-selector', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + }); +}); diff --git a/tests/integration/native-shapes.test.ts b/tests/integration/native-shapes.test.ts new file mode 100644 index 0000000..d219e8d --- /dev/null +++ b/tests/integration/native-shapes.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck, runBiomeFix } from '../helpers/biome-runner.js'; + +/** + * Shapes that the original snippet-based rules could not match and that the + * native-AST-node rewrite covers: `implements` clauses, `export default`, + * generics, `readonly` members, modifiers, and constructor parameters without + * access modifiers. Each case is the real-world form produced by the Angular + * CLI or found in typical codebases. + */ +describe('native AST node coverage [integration]', () => { + describe('classes with implements clauses', () => { + it('no-component-suffix flags a CLI-style component (implements OnInit)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-component-suffix', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-user', templateUrl: './user.component.html' }) + export class UserComponent implements OnInit { + ngOnInit(): void { this.load(); } + load() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('no-service-suffix flags a service with implements + extends + generics', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-service-suffix', + ` + import { Injectable, OnDestroy } from '@angular/core'; + @Injectable({ providedIn: 'root' }) + export class UserService extends BaseStore implements OnDestroy { + ngOnDestroy() { this.teardown(); } + teardown() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('no-directive-suffix flags an export default directive', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-directive-suffix', + ` + import { Directive, OnInit } from '@angular/core'; + @Directive({ selector: '[appHighlight]' }) + export default class HighlightDirective implements OnInit { + ngOnInit() { this.paint(); } + paint() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('prefer-inject-function flags constructor DI in a class with implements', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` + import { Component, OnInit } from '@angular/core'; + import { HttpClient } from '@angular/common/http'; + @Component({ selector: 'app-user', template: '' }) + export class User implements OnInit { + constructor(private readonly http: HttpClient) {} + ngOnInit() { this.http.get('/'); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('prefer-inject-function flags a parameter without access modifier', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` + import { Injectable } from '@angular/core'; + import { HttpClient } from '@angular/common/http'; + @Injectable({ providedIn: 'root' }) + export class Api { + private http: HttpClient; + constructor(http: HttpClient) { this.http = http; } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('prefer-inject-function flags parameter-decorated DI (@Inject, @Optional)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` + import { Component, Inject, Optional } from '@angular/core'; + import { DOCUMENT } from '@angular/common'; + @Component({ selector: 'app-user', template: '' }) + export class User { + constructor(@Inject(DOCUMENT) private doc: Document, @Optional() private logger?: Logger) {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + }); + + describe('readonly members', () => { + it('no-output-on-prefix flags @Output() readonly onSubmit', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-on-prefix', + ` + import { Component, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-form', template: '' }) + export class Form { + @Output() readonly onSubmit = new EventEmitter(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + + it('no-output-on-prefix flags signal outputs (output, outputFromObservable)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-on-prefix', + ` + import { Component, output } from '@angular/core'; + import { outputFromObservable } from '@angular/core/rxjs-interop'; + @Component({ selector: 'app-form', template: '' }) + export class Form { + readonly onSave = output(); + readonly onLoad = outputFromObservable(this.load$); + readonly saved = output(); + private readonly load$ = new Subject(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); + }); + + it('prefer-model-signal flags a readonly @Input()/@Output() pair', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-model-signal', + ` + import { Component, Input, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-counter', template: '' }) + export class Counter { + @Input() readonly value = 0; + @Output() readonly valueChange = new EventEmitter(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + }); + + describe('signal input/output aliases', () => { + it('no-input-rename flags aliased input(), input.required() and model()', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-input-rename', + ` + import { Component, input, model } from '@angular/core'; + @Component({ selector: 'app-user', template: '' }) + export class User { + readonly name = input('', { alias: 'userName' }); + readonly age = input.required({ alias: 'userAge' }); + readonly value = model('', { alias: 'val' }); + readonly plain = input(''); + readonly required = input.required(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(3); + expect(diagnostics[0].message).toContain('signal inputs'); + }); + + it('no-output-rename flags aliased output() and outputFromObservable()', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-rename', + ` + import { Component, output } from '@angular/core'; + import { outputFromObservable } from '@angular/core/rxjs-interop'; + @Component({ selector: 'app-user', template: '' }) + export class User { + readonly saved = output({ alias: 'onSaved' }); + readonly loaded = outputFromObservable(this.load$, { alias: 'ld' }); + readonly plain = output(); + private readonly load$ = new Subject(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); + expect(diagnostics[0].message).toContain('signal outputs'); + }); + }); + + describe('lifecycle methods with modifiers', () => { + it('no-empty-lifecycle-method flags protected override / async / comment-only bodies', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-empty-lifecycle-method', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App extends Base { + protected override ngAfterViewInit(): void {} + async ngOnDestroy() {} + ngOnInit() { + // TODO + } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(3); + }); + + it('no-empty-lifecycle-method safe fix removes the hook when its interface is not implemented', async () => { + const fixed = await runBiomeFix( + 'no-empty-lifecycle-method', + [ + "import { Component, OnDestroy } from '@angular/core';", + "@Component({ selector: 'app-root', template: '' })", + 'export class App implements OnDestroy {', + ' ngOnInit(): void {}', + ' ngOnDestroy(): void { this.sub.unsubscribe(); }', + '}', + '', + ].join('\n'), + ); + expect(fixed).not.toContain('ngOnInit'); + expect(fixed).toContain('ngOnDestroy(): void { this.sub.unsubscribe(); }'); + }); + + it('no-empty-lifecycle-method offers no fix when the interface is implemented (TS2420 otherwise)', async () => { + const source = [ + "import { Component, OnInit } from '@angular/core';", + "@Component({ selector: 'app-root', template: '' })", + 'export class App implements OnInit {', + ' ngOnInit(): void {}', + '}', + '', + ].join('\n'); + const { diagnostics } = await runBiomeCheck('no-empty-lifecycle-method', source); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain('together with OnInit in the implements clause'); + const fixed = await runBiomeFix('no-empty-lifecycle-method', source); + expect(fixed).toBe(source); + }); + + it('use-lifecycle-interface flags a protected override hook missing its interface', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App extends Base implements OnInit { + override ngOnInit() { super.ngOnInit(); } + protected override ngAfterViewInit(): void { this.x(); } + x() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('AfterViewInit'); + }); + + it('contextual-lifecycle flags a hook in an @Injectable with implements and modifiers', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` + import { Injectable, OnInit, OnDestroy } from '@angular/core'; + @Injectable({ providedIn: 'root' }) + export class Store implements OnInit, OnDestroy { + async ngOnInit(): Promise { await this.load(); } + ngOnDestroy() { this.stop(); } + load() { return Promise.resolve(); } + stop() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + }); +}); diff --git a/tests/integration/no-async-lifecycle-method.test.ts b/tests/integration/no-async-lifecycle-method.test.ts new file mode 100644 index 0000000..06ba1a2 --- /dev/null +++ b/tests/integration/no-async-lifecycle-method.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck } from '../helpers/biome-runner.js'; + +describe('angular/no-async-lifecycle-method [integration]', () => { + describe('violations', () => { + it('flags async ngOnInit', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-async-lifecycle-method', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements OnInit { + async ngOnInit() { await this.load(); } + load() { return Promise.resolve(); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('must not be async'); + }); + + it('flags async hooks with return types, parameters and modifiers', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-async-lifecycle-method', + ` + import { Component, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements OnChanges, OnDestroy { + async ngOnChanges(changes: SimpleChanges): Promise { await this.react(changes); } + protected async ngOnDestroy(): Promise { await this.flush(); } + react(c: unknown) { return Promise.resolve(); } + flush() { return Promise.resolve(); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); + }); + }); + + describe('valid', () => { + it('accepts a synchronous hook that starts async work', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-async-lifecycle-method', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements OnInit { + ngOnInit(): void { void this.load(); } + async load() { await Promise.resolve(); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('ignores async methods that are not lifecycle hooks', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-async-lifecycle-method', + ` + export class Loader { + async ngLikeButNotAHook() { await Promise.resolve(); } + async refresh(): Promise { await Promise.resolve(); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + }); +}); diff --git a/tests/integration/no-attribute-decorator.test.ts b/tests/integration/no-attribute-decorator.test.ts new file mode 100644 index 0000000..a16cc77 --- /dev/null +++ b/tests/integration/no-attribute-decorator.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck } from '../helpers/biome-runner.js'; + +describe('angular/no-attribute-decorator [integration]', () => { + describe('violations', () => { + it('flags @Attribute() on a constructor parameter', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-attribute-decorator', + ` + import { Attribute, Directive } from '@angular/core'; + @Directive({ selector: '[appRole]' }) + export class Role { + constructor(@Attribute('role') private role: string) {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('HostAttributeToken'); + }); + }); + + describe('valid', () => { + it('accepts inject(new HostAttributeToken())', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-attribute-decorator', + ` + import { Directive, HostAttributeToken, inject } from '@angular/core'; + @Directive({ selector: '[appRole]' }) + export class Role { + private readonly role = inject(new HostAttributeToken('role'), { optional: true }); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + }); +}); diff --git a/tests/integration/no-component-suffix.test.ts b/tests/integration/no-component-suffix.test.ts index e7e9cdb..6e38e2f 100644 --- a/tests/integration/no-component-suffix.test.ts +++ b/tests/integration/no-component-suffix.test.ts @@ -4,40 +4,77 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/no-component-suffix [integration]', () => { describe('violations', () => { it('flags class named AppComponent', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-component-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-component-suffix', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); it('flags class named UserListComponent', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-component-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-component-suffix', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-user-list', standalone: true, template: '' }) export class UserListComponent {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); + + it('flags a suffixed component that extends a base class', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-component-suffix', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-admin', standalone: true, template: '' }) + export class AdminComponent extends BaseView {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Component'); + }); }); describe('valid', () => { + it('accepts an undecorated class ending in Component (not Angular)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-component-suffix', + ` + export class WidgetComponent { + render() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + it('accepts class named App (no suffix)', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-component-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-component-suffix', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts class named UserList', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-component-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-component-suffix', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-user-list', standalone: true, template: '' }) export class UserList {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/no-conflicting-lifecycle.test.ts b/tests/integration/no-conflicting-lifecycle.test.ts new file mode 100644 index 0000000..aaddd92 --- /dev/null +++ b/tests/integration/no-conflicting-lifecycle.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck } from '../helpers/biome-runner.js'; + +describe('angular/no-conflicting-lifecycle [integration]', () => { + describe('violations', () => { + it('flags a class declaring both ngDoCheck and ngOnChanges', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-conflicting-lifecycle', + ` + import { Component, DoCheck, OnChanges, SimpleChanges } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements DoCheck, OnChanges { + ngDoCheck(): void { this.check(); } + ngOnChanges(changes: SimpleChanges): void { this.react(changes); } + check() {} + react(c: unknown) {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('ngDoCheck and ngOnChanges'); + }); + + it('flags a directive with both hooks', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-conflicting-lifecycle', + ` + import { Directive } from '@angular/core'; + @Directive({ selector: '[appWatch]' }) + export class Watch { + ngOnChanges() { this.sync(); } + ngDoCheck() { this.sync(); } + sync() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); + }); + + describe('valid', () => { + it('accepts ngOnChanges alone', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-conflicting-lifecycle', + ` + import { Component, OnChanges } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements OnChanges { + ngOnChanges() { this.react(); } + react() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts ngDoCheck alone', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-conflicting-lifecycle', + ` + import { Component, DoCheck } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements DoCheck { + ngDoCheck() { this.check(); } + check() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts the two hooks split across two classes', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-conflicting-lifecycle', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-a', template: '' }) + export class A { ngDoCheck() { this.x(); } x() {} } + @Component({ selector: 'app-b', template: '' }) + export class B { ngOnChanges() { this.y(); } y() {} } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + }); +}); diff --git a/tests/integration/no-directive-suffix.test.ts b/tests/integration/no-directive-suffix.test.ts index bd96ff1..2090979 100644 --- a/tests/integration/no-directive-suffix.test.ts +++ b/tests/integration/no-directive-suffix.test.ts @@ -4,22 +4,41 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/no-directive-suffix [integration]', () => { describe('violations', () => { it('flags class named HighlightDirective', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-directive-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-directive-suffix', + ` import { Directive } from '@angular/core'; @Directive({ selector: '[appHighlight]', standalone: true }) export class HighlightDirective {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Directive'); }); }); describe('valid', () => { + it('accepts an undecorated class ending in Directive (not Angular)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-directive-suffix', + ` + export class ParserDirective { + parse() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + it('accepts class named Highlight', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-directive-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-directive-suffix', + ` import { Directive } from '@angular/core'; @Directive({ selector: '[appHighlight]', standalone: true }) export class Highlight {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/no-empty-lifecycle-method.test.ts b/tests/integration/no-empty-lifecycle-method.test.ts index 2ae7d21..939bf7c 100644 --- a/tests/integration/no-empty-lifecycle-method.test.ts +++ b/tests/integration/no-empty-lifecycle-method.test.ts @@ -4,49 +4,76 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/no-empty-lifecycle-method [integration]', () => { describe('violations', () => { it('flags empty ngOnInit', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-empty-lifecycle-method', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-empty-lifecycle-method', + ` import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent implements OnInit { ngOnInit() {} } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); it('flags empty ngOnDestroy', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-empty-lifecycle-method', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-empty-lifecycle-method', + ` import { Component, OnDestroy } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent implements OnDestroy { ngOnDestroy() {} } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); + it('flags empty hooks with return types and params (CLI-generated style)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-empty-lifecycle-method', + ` + import { Component, OnInit, OnChanges, SimpleChanges } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class AppComponent implements OnInit, OnChanges { + ngOnInit(): void {} + ngOnChanges(changes: SimpleChanges): void {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); + }); + it('flags multiple empty lifecycle methods', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-empty-lifecycle-method', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-empty-lifecycle-method', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent { ngOnInit() {} ngOnDestroy() {} } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); }); }); describe('valid', () => { it('accepts ngOnInit with a statement', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-empty-lifecycle-method', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-empty-lifecycle-method', + ` import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent implements OnInit { ngOnInit() { console.log('init'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/no-input-rename.test.ts b/tests/integration/no-input-rename.test.ts index c1dcb5c..d0d533b 100644 --- a/tests/integration/no-input-rename.test.ts +++ b/tests/integration/no-input-rename.test.ts @@ -4,48 +4,91 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/no-input-rename [integration]', () => { describe('violations', () => { it('flags @Input with a string alias', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-input-rename', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-input-rename', + ` import { Component, Input } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Input('myAlias') value: string = ''; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Avoid renaming @Input()'); }); it('flags @Input with alias in options object', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-input-rename', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-input-rename', + ` import { Component, Input } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Input({ alias: 'externalName' }) internalProp: string = ''; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Avoid renaming @Input()'); + }); + + it('flags alias even when combined with other options (alias + required)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-input-rename', + ` + import { Component, Input } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @Input({ alias: 'x', required: true }) value!: string; + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Avoid renaming @Input()'); }); }); describe('valid', () => { it('accepts @Input() without alias', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-input-rename', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-input-rename', + ` import { Component, Input } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Input() value: string = ''; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts @Input({ required: true }) without alias', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-input-rename', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-input-rename', + ` import { Component, Input } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Input({ required: true }) label!: string; } - `); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts @Input({ transform: booleanAttribute }) without alias', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-input-rename', + ` + import { Component, Input, booleanAttribute } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @Input({ transform: booleanAttribute }) disabled = false; + } + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/no-lifecycle-call.test.ts b/tests/integration/no-lifecycle-call.test.ts new file mode 100644 index 0000000..7b97263 --- /dev/null +++ b/tests/integration/no-lifecycle-call.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck } from '../helpers/biome-runner.js'; + +describe('angular/no-lifecycle-call [integration]', () => { + describe('violations', () => { + it('flags this.ngOnInit() called from another method', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-lifecycle-call', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements OnInit { + ngOnInit() { this.load(); } + reload() { this.ngOnInit(); } + load() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('lifecycle hooks explicitly'); + }); + + it('flags a hook called on another instance (e.g. in a test)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-lifecycle-call', + ` + it('destroys', () => { + const fixture = TestBed.createComponent(App); + fixture.componentInstance.ngOnDestroy(); + fixture.componentInstance.ngOnChanges({}); + }); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); + }); + }); + + describe('valid', () => { + it('accepts super.ngOnInit() in a derived hook', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-lifecycle-call', + ` + import { Component, OnInit } from '@angular/core'; + import { Base } from './base'; + @Component({ selector: 'app-root', template: '' }) + export class App extends Base implements OnInit { + override ngOnInit() { + super.ngOnInit(); + this.load(); + } + load() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts hook declarations and unrelated method calls', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-lifecycle-call', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App implements OnInit { + ngOnInit() { this.init(); } + init() { this.service.ngOnInitLike(); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + }); +}); diff --git a/tests/integration/no-output-native.test.ts b/tests/integration/no-output-native.test.ts new file mode 100644 index 0000000..20a2edd --- /dev/null +++ b/tests/integration/no-output-native.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck } from '../helpers/biome-runner.js'; + +describe('angular/no-output-native [integration]', () => { + describe('violations', () => { + it('flags @Output() named like a native DOM event', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-native', + ` + import { Component, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App { + @Output() change = new EventEmitter(); + @Output() readonly click = new EventEmitter(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); + expect(diagnostics[0].message).toContain('native DOM event'); + }); + + it('flags signal outputs named like a native DOM event', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-native', + ` + import { Component, output } from '@angular/core'; + import { outputFromObservable } from '@angular/core/rxjs-interop'; + @Component({ selector: 'app-root', template: '' }) + export class App { + readonly focus = output(); + readonly submit = output(); + readonly scroll = outputFromObservable(this.scroll$); + private readonly scroll$ = new Subject(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(3); + }); + }); + + describe('valid', () => { + it('accepts outputs with specific names', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-native', + ` + import { Component, Output, EventEmitter, output } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App { + @Output() valueChange = new EventEmitter(); + readonly itemSelected = output(); + readonly clicked = output(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('ignores plain properties and inputs named like events', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-native', + ` + import { Component, Input, input } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App { + @Input() change = ''; + readonly focus = input(false); + click = 0; + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + }); +}); diff --git a/tests/integration/no-output-on-prefix.test.ts b/tests/integration/no-output-on-prefix.test.ts index a184cc7..8062d64 100644 --- a/tests/integration/no-output-on-prefix.test.ts +++ b/tests/integration/no-output-on-prefix.test.ts @@ -4,48 +4,75 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/no-output-on-prefix [integration]', () => { describe('violations', () => { it('flags @Output() onClick', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-output-on-prefix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-on-prefix', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() onClick = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); it('flags @Output() onSubmit', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-output-on-prefix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-on-prefix', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() onSubmit = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); + + it('flags typed and definite-assignment outputs (onLoad: T, onCancel!: T)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-on-prefix', + ` + import { Component, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @Output() onLoad: EventEmitter; + @Output() onCancel!: EventEmitter; + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); + }); }); describe('valid', () => { it('accepts @Output() without on prefix', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-output-on-prefix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-on-prefix', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() clicked = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('does not flag "on" not followed by uppercase (onion)', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-output-on-prefix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-on-prefix', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() onion = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/no-output-rename.test.ts b/tests/integration/no-output-rename.test.ts index f27f40b..ebaf7b8 100644 --- a/tests/integration/no-output-rename.test.ts +++ b/tests/integration/no-output-rename.test.ts @@ -4,37 +4,63 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/no-output-rename [integration]', () => { describe('violations', () => { it('flags @Output with a string alias', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-output-rename', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-rename', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output('clickEvent') clicked = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Avoid renaming @Output()'); }); it('flags @Output with alias in options object', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-output-rename', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-rename', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output({ alias: 'submitEvent' }) onSubmit = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Avoid renaming @Output()'); + }); + + it('flags aliased @Output on a property with an explicit type annotation', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-rename', + ` + import { Component, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @Output('valueChange') changed: EventEmitter = new EventEmitter(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Avoid renaming @Output()'); }); }); describe('valid', () => { it('accepts @Output() without alias', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-output-rename', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-output-rename', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() clicked = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/no-pipe-impure.test.ts b/tests/integration/no-pipe-impure.test.ts new file mode 100644 index 0000000..b3e1250 --- /dev/null +++ b/tests/integration/no-pipe-impure.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { runBiomeCheck } from '../helpers/biome-runner.js'; + +describe('angular/no-pipe-impure [integration]', () => { + describe('violations', () => { + it('flags pure: false', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-pipe-impure', + ` + import { Pipe, PipeTransform } from '@angular/core'; + @Pipe({ name: 'appFilter', pure: false }) + export class FilterPipe implements PipeTransform { + transform(items: string[]) { return items; } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Impure pipes'); + }); + }); + + describe('valid', () => { + it('accepts pure: true and the default', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-pipe-impure', + ` + import { Pipe, PipeTransform } from '@angular/core'; + @Pipe({ name: 'appUpper', pure: true }) + export class UpperPipe implements PipeTransform { + transform(v: string) { return v.toUpperCase(); } + } + @Pipe({ name: 'appLower' }) + export class LowerPipe implements PipeTransform { + transform(v: string) { return v.toLowerCase(); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('ignores pure: false outside @Pipe', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-pipe-impure', + ` + const options = { pure: false }; + export function configure() { return options; } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + }); +}); diff --git a/tests/integration/no-service-suffix.test.ts b/tests/integration/no-service-suffix.test.ts index 7c69d8f..d42a553 100644 --- a/tests/integration/no-service-suffix.test.ts +++ b/tests/integration/no-service-suffix.test.ts @@ -4,22 +4,41 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/no-service-suffix [integration]', () => { describe('violations', () => { it('flags class named UserService', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-service-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-service-suffix', + ` import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class UserService {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Service'); }); }); describe('valid', () => { + it('accepts an undecorated class ending in Service (e.g. NestJS)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'no-service-suffix', + ` + export class HttpClientService { + send() {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + it('accepts class named UserStore', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-service-suffix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'no-service-suffix', + ` import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class UserStore {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/pipe-prefix.test.ts b/tests/integration/pipe-prefix.test.ts index 73e3ad0..e84b89d 100644 --- a/tests/integration/pipe-prefix.test.ts +++ b/tests/integration/pipe-prefix.test.ts @@ -7,37 +7,80 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/pipe-prefix [integration]', () => { describe('violations', () => { it('flags pipe name without app prefix', async () => { - const { diagnostics, raw } = await runBiomeCheck('pipe-prefix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'pipe-prefix', + ` import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'truncate', standalone: true }) export class TruncatePipe implements PipeTransform { transform(v: string) { return v; } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("start with the 'app' prefix"); }); it('flags pipe name with wrong prefix', async () => { - const { diagnostics, raw } = await runBiomeCheck('pipe-prefix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'pipe-prefix', + ` import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'myTruncate', standalone: true }) export class TruncatePipe implements PipeTransform { transform(v: string) { return v; } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("start with the 'app' prefix"); + }); + + // Actual behavior (verified): 'apple' starts with "app" but the regex + // requires an uppercase letter right after the prefix (app[A-Z]), so a + // word that merely begins with the letters a-p-p is still flagged. + it('flags name that starts with "app" but is not app-prefixed camelCase (apple)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'pipe-prefix', + ` + import { Pipe, PipeTransform } from '@angular/core'; + @Pipe({ name: 'apple', standalone: true }) + export class ApplePipe implements PipeTransform { + transform(v: string) { return v; } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain("start with the 'app' prefix"); }); }); describe('valid', () => { it('accepts pipe name starting with appUppercase', async () => { - const { diagnostics, raw } = await runBiomeCheck('pipe-prefix', ` + const { diagnostics, raw } = await runBiomeCheck( + 'pipe-prefix', + ` import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'appTruncate', standalone: true }) export class TruncatePipe implements PipeTransform { transform(v: string) { return v; } } - `); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts short app-prefixed name (appUpper)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'pipe-prefix', + ` + import { Pipe, PipeTransform } from '@angular/core'; + @Pipe({ name: 'appUpper', standalone: true }) + export class UpperPipe implements PipeTransform { + transform(v: string) { return v.toUpperCase(); } + } + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/prefer-host-property.test.ts b/tests/integration/prefer-host-property.test.ts index 380b582..d049af9 100644 --- a/tests/integration/prefer-host-property.test.ts +++ b/tests/integration/prefer-host-property.test.ts @@ -4,43 +4,92 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/prefer-host-property [integration]', () => { describe('violations', () => { it('flags @HostBinding decorator', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-host-property', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-host-property', + ` import { Component, HostBinding } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @HostBinding('class.active') isActive = false; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer host: {}'); }); it('flags @HostListener decorator', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-host-property', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-host-property', + ` import { Component, HostListener } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @HostListener('click') onClick() { console.log('clicked'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer host: {}'); }); it('flags both @HostBinding and @HostListener', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-host-property', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-host-property', + ` import { Component, HostBinding, HostListener } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @HostBinding('class.active') isActive = false; @HostListener('click') onClick() { console.log('clicked'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); }); + + // Verified: $args binds the full argument list, so the two-argument + // window-scoped form is caught too. + it('flags @HostListener with event name and args array (window:resize)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-host-property', + ` + import { Component, HostListener } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @HostListener('window:resize', ['$event']) + onResize(event: UIEvent) { console.log(event); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer host: {}'); + }); + + // Verified: the decorator pattern matches regardless of its target, so a + // @HostBinding placed on a getter is caught as well (no false negative). + it('flags @HostBinding on a getter', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-host-property', + ` + import { Component, HostBinding } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @HostBinding('class.active') + get active() { return true; } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer host: {}'); + }); }); describe('valid', () => { it('accepts host: {} metadata in @Component', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-host-property', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-host-property', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', @@ -52,7 +101,8 @@ describe('angular/prefer-host-property [integration]', () => { isActive = false; onClick() { console.log('clicked'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/prefer-inject-function.test.ts b/tests/integration/prefer-inject-function.test.ts index 8e8ae4d..dd60514 100644 --- a/tests/integration/prefer-inject-function.test.ts +++ b/tests/integration/prefer-inject-function.test.ts @@ -2,56 +2,81 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; // NOTE: The GritQL rule matches constructors with access-modifier parameters -// (private, protected, public, readonly). This is the standard Angular DI pattern. +// (private, protected, public, readonly) inside @Component/@Directive/ +// @Injectable/@Pipe classes only — plain TS classes are not flagged. describe('angular/prefer-inject-function [integration]', () => { describe('violations', () => { it('flags constructor with private parameter injection', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-inject-function', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` import { Component } from '@angular/core'; import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { constructor(private http: HttpClient) {} } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); it('flags constructor with readonly parameter injection', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-inject-function', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` import { Component } from '@angular/core'; import { Router } from '@angular/router'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { constructor(private readonly router: Router) {} } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); }); describe('valid', () => { + it('accepts parameter properties in an undecorated class (not Angular DI)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` + export class SmtpMailer { + constructor(private smtp: string, private port: number) {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + it('accepts inject() function pattern', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-inject-function', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` import { Component, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { private http = inject(HttpClient); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts constructor without injection parameters', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-inject-function', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-inject-function', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { constructor() {} } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/prefer-model-signal.test.ts b/tests/integration/prefer-model-signal.test.ts index d38234b..951bc5d 100644 --- a/tests/integration/prefer-model-signal.test.ts +++ b/tests/integration/prefer-model-signal.test.ts @@ -1,45 +1,83 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; -// NOTE: The GritQL rule matches @Output() properties whose name ends with "Change" -// (regex .*Change$). The ts-morph version additionally requires a matching @Input(). -// Integration tests validate the actual GritQL behavior. +// The rule flags an @Output() named `xChange` only when a matching @Input() +// named `x` exists in the same file (regex capture + program contains). describe('angular/prefer-model-signal [integration]', () => { describe('violations', () => { - it('flags @Output() valueChange (ends with Change)', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-model-signal', ` + it('flags a paired @Input() value + @Output() valueChange', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-model-signal', + ` import { Component, Input, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-counter', standalone: true, template: '' }) export class Counter { @Input() value = 0; @Output() valueChange = new EventEmitter(); } - `); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('model()'); + }); + + it('flags a pair where the input is typed without initializer', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-model-signal', + ` + import { Component, Input, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-counter', standalone: true, template: '' }) + export class Counter { + @Input() count: number; + @Output() countChange = new EventEmitter(); + } + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); }); describe('valid', () => { + it('accepts an unpaired *Change output (Material-style event)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-model-signal', + ` + import { Component, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-list', standalone: true, template: '' }) + export class List { + @Output() selectionChange = new EventEmitter(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + it('accepts model() signal', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-model-signal', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-model-signal', + ` import { Component, model } from '@angular/core'; @Component({ selector: 'app-counter', standalone: true, template: '' }) export class Counter { value = model(0); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts @Output() that does not end in Change', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-model-signal', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-model-signal', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() clicked = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/prefer-output-function.test.ts b/tests/integration/prefer-output-function.test.ts index f859c24..5196f15 100644 --- a/tests/integration/prefer-output-function.test.ts +++ b/tests/integration/prefer-output-function.test.ts @@ -4,38 +4,65 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/prefer-output-function [integration]', () => { describe('violations', () => { it('flags @Output() EventEmitter', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-output-function', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-output-function', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() clicked = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer the output() function'); }); it('flags multiple @Output() decorators', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-output-function', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-output-function', + ` import { Component, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Output() clicked = new EventEmitter(); @Output() changed = new EventEmitter(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(2); }); + + // Verified: the rule matches the bare decorator, so a readonly property + // (the common style for EventEmitter outputs) is still caught. + it('flags @Output() on a readonly property', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-output-function', + ` + import { Component, Output, EventEmitter } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @Output() readonly clicked = new EventEmitter(); + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer the output() function'); + }); }); describe('valid', () => { it('accepts output() function', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-output-function', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-output-function', + ` import { Component, output } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { clicked = output(); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/prefer-signal-inputs.test.ts b/tests/integration/prefer-signal-inputs.test.ts index 39645cf..ee4e693 100644 --- a/tests/integration/prefer-signal-inputs.test.ts +++ b/tests/integration/prefer-signal-inputs.test.ts @@ -4,48 +4,99 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/prefer-signal-inputs [integration]', () => { describe('violations', () => { it('flags @Input() decorator', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-inputs', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-inputs', + ` import { Component, Input } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Input() title: string = ''; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer the input() signal function'); }); it('flags @Input({ required: true })', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-inputs', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-inputs', + ` import { Component, Input } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @Input({ required: true }) title!: string; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer the input() signal function'); + }); + + // Verified: the rule matches the bare decorator, so readonly properties + // are still caught. + it('flags @Input() on a readonly property', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-inputs', + ` + import { Component, Input } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @Input() readonly title: string = ''; + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer the input() signal function'); + }); + + // Verified: @Input() on a get/set accessor pair is also caught (one + // diagnostic for the single decorated setter) — no accessor blind spot. + it('flags @Input() on a setter accessor', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-inputs', + ` + import { Component, Input } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + private _title = ''; + @Input() + set title(value: string) { this._title = value.trim(); } + get title(): string { return this._title; } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer the input() signal function'); }); }); describe('valid', () => { it('accepts input() signal', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-inputs', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-inputs', + ` import { Component, input } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { title = input(''); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts regular property without @Input', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-inputs', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-inputs', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { title: string = ''; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/prefer-signal-queries.test.ts b/tests/integration/prefer-signal-queries.test.ts index 44af268..8dd4997 100644 --- a/tests/integration/prefer-signal-queries.test.ts +++ b/tests/integration/prefer-signal-queries.test.ts @@ -4,48 +4,80 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/prefer-signal-queries [integration]', () => { describe('violations', () => { it('flags @ViewChild decorator', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-queries', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-queries', + ` import { Component, ViewChild, ElementRef } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @ViewChild('canvas') canvas!: ElementRef; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer signal-based query functions'); }); it('flags @ContentChild decorator', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-queries', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-queries', + ` import { Component, ContentChild, TemplateRef } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @ContentChild(TemplateRef) tpl?: TemplateRef; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer signal-based query functions'); }); it('flags @ViewChildren decorator', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-queries', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-queries', + ` import { Component, ViewChildren, QueryList } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { @ViewChildren('item') items!: QueryList; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer signal-based query functions'); + }); + + // Verified: the rule matches the bare decorator, so readonly query + // properties are still caught. + it('flags @ViewChild on a readonly property', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-queries', + ` + import { Component, ViewChild, ElementRef } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class App { + @ViewChild('ref') readonly ref!: ElementRef; + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('Prefer signal-based query functions'); }); }); describe('valid', () => { it('accepts viewChild() signal', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-signal-queries', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-signal-queries', + ` import { Component, viewChild, ElementRef } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class App { canvas = viewChild.required('canvas'); } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/prefer-standalone.test.ts b/tests/integration/prefer-standalone.test.ts index ee92e50..80b8c1b 100644 --- a/tests/integration/prefer-standalone.test.ts +++ b/tests/integration/prefer-standalone.test.ts @@ -2,62 +2,84 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/prefer-standalone [integration]', () => { + // Since Angular 19, standalone: true is the default and the v20 style guide + // recommends omitting the property. Only an explicit standalone: false is a + // violation; omission is idiomatic modern Angular. describe('violations', () => { - it('flags @Component without standalone property', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` - import { Component } from '@angular/core'; - @Component({ selector: 'app-root', template: '' }) - export class AppComponent {} - `); - expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); - }); - it('flags @Component with standalone: false', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: false, template: '' }) export class AppComponent {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('standalone: false'); }); - it('flags @Directive without standalone', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + it('flags @Directive with standalone: false', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` import { Directive } from '@angular/core'; - @Directive({ selector: '[appFoo]' }) - export class FooDirective {} - `); + @Directive({ selector: '[appFoo]', standalone: false }) + export class Foo {} + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); - it('flags @Pipe without standalone', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + it('flags @Pipe with standalone: false', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` import { Pipe, PipeTransform } from '@angular/core'; - @Pipe({ name: 'appFoo' }) + @Pipe({ name: 'appFoo', standalone: false }) export class FooPipe implements PipeTransform { transform(v: string) { return v; } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); }); describe('valid', () => { - it('accepts @Component with standalone: true', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + it('accepts @Component without standalone property (default since Angular 19)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class AppComponent {} + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts @Component with explicit standalone: true', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts @Directive with standalone: true', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` import { Directive } from '@angular/core'; @Directive({ selector: '[appFoo]', standalone: true }) export class Foo {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); }); diff --git a/tests/integration/sort-ngmodule-metadata-arrays.test.ts b/tests/integration/sort-ngmodule-metadata-arrays.test.ts deleted file mode 100644 index c420080..0000000 --- a/tests/integration/sort-ngmodule-metadata-arrays.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { runBiomeCheck } from '../helpers/biome-runner.js'; - -// NOTE: The GritQL rule detects presence of sortable arrays inside @NgModule. -// It may flag ANY NgModule containing declarations/imports/exports/providers/bootstrap, -// regardless of sort order — GritQL cannot perform algorithmic sort checks. -// The "valid" case uses a NgModule without any sortable arrays to avoid false flags. - -describe('angular/sort-ngmodule-metadata-arrays [integration]', () => { - describe('violations', () => { - it('flags @NgModule with a declarations array', async () => { - const { diagnostics, raw } = await runBiomeCheck('sort-ngmodule-metadata-arrays', ` - import { NgModule } from '@angular/core'; - @NgModule({ declarations: [BComponent, AComponent] }) - export class AppModule {} - `); - // The GritQL rule fires when a sortable array is present - expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); - }); - }); - - describe('valid', () => { - it('accepts @NgModule without any sortable array', async () => { - const { diagnostics, raw } = await runBiomeCheck('sort-ngmodule-metadata-arrays', ` - import { NgModule } from '@angular/core'; - @NgModule({}) - export class AppModule {} - `); - expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); - }); - }); -}); diff --git a/tests/integration/use-lifecycle-interface.test.ts b/tests/integration/use-lifecycle-interface.test.ts index 6b431aa..34e874a 100644 --- a/tests/integration/use-lifecycle-interface.test.ts +++ b/tests/integration/use-lifecycle-interface.test.ts @@ -2,51 +2,128 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/use-lifecycle-interface [integration]', () => { + // The rule matches each lifecycle METHOD and reads the implements clause of + // its enclosing class through Biome's native class node (per-class check). describe('violations', () => { it('flags ngOnInit without OnInit interface', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-lifecycle-interface', ` + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent { ngOnInit() { console.log('init'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('implement the OnInit interface'); }); it('flags ngOnDestroy without OnDestroy interface', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-lifecycle-interface', ` + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent { ngOnDestroy() { console.log('destroy'); } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('implement the OnDestroy interface'); + }); + + it('flags ngOnDestroy when class only implements OnInit (partial implements)', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class AppComponent implements OnInit { + ngOnInit() { console.log('init'); } + ngOnDestroy() { console.log('destroy'); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('implement the OnDestroy interface'); + }); + + it('flags hooks with return types and parameterized ngOnChanges', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class AppComponent { + ngOnChanges(changes: SimpleChanges): void { this.react(changes); } + react(c: unknown) {} + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('implement the OnChanges interface'); }); }); describe('valid', () => { it('accepts ngOnInit when OnInit is declared', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-lifecycle-interface', ` + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent implements OnInit { ngOnInit() { console.log('init'); } } - `); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts class implementing both OnInit and OnDestroy', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` + import { Component, OnInit, OnDestroy } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class AppComponent implements OnInit, OnDestroy { + ngOnInit() { console.log('init'); } + ngOnDestroy() { console.log('destroy'); } + } + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); it('accepts class with no lifecycle methods', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-lifecycle-interface', ` + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, template: '' }) export class AppComponent { title = 'hello'; } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); + + it('flags ngOnInit when OnInit is imported but not implemented', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-lifecycle-interface', + ` + import { Component, OnInit } from '@angular/core'; + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class AppComponent { + ngOnInit() { console.log('init'); } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); }); }); diff --git a/tests/integration/use-pipe-transform-interface.test.ts b/tests/integration/use-pipe-transform-interface.test.ts index 49ff06e..879c4a5 100644 --- a/tests/integration/use-pipe-transform-interface.test.ts +++ b/tests/integration/use-pipe-transform-interface.test.ts @@ -2,41 +2,109 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/use-pipe-transform-interface [integration]', () => { - // Biome GritQL cannot match @Pipe\nclass patterns (decorator + class as one unit). + // The rule reads the implements clause of each @Pipe class through Biome's + // native class node, so the check is per class, not per file. describe('violations', () => { - it.skip('flags @Pipe class with no implements clause', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-pipe-transform-interface', ` + it('flags @Pipe class with no implements clause', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` import { Pipe } from '@angular/core'; @Pipe({ name: 'appTruncate', standalone: true }) export class TruncatePipe { transform(value: string) { return value; } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('PipeTransform'); }); - it.skip('flags @Pipe class implementing other interface but not PipeTransform', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-pipe-transform-interface', ` + it('flags @Pipe class implementing other interface but not PipeTransform', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` import { Pipe } from '@angular/core'; @Pipe({ name: 'appFoo', standalone: true }) export class FooPipe implements Disposable { transform(v: string) { return v; } } - `); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('PipeTransform'); + }); + + it('flags @Pipe class that extends a base class without PipeTransform', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` + import { Pipe } from '@angular/core'; + import { BasePipe } from './base'; + @Pipe({ name: 'appDerived' }) + export class DerivedPipe extends BasePipe { + transform(v: string) { return v; } + } + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); }); describe('valid', () => { it('accepts @Pipe implementing PipeTransform', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-pipe-transform-interface', ` + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'appTruncate', standalone: true }) export class TruncatePipe implements PipeTransform { transform(value: string) { return value; } } - `); + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('accepts @Pipe implementing PipeTransform alongside other interfaces', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` + import { Pipe, PipeTransform, OnDestroy } from '@angular/core'; + @Pipe({ name: 'appMulti', standalone: true }) + export class MultiPipe implements PipeTransform, OnDestroy { + transform(value: string) { return value; } + ngOnDestroy() {} + } + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); + + it('accepts a file without any @Pipe', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` + export class NotAPipe { + transform(value: string) { return value; } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); + }); + + it('flags a non-implementing @Pipe even when the file imports PipeTransform', async () => { + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` + import { Pipe, PipeTransform } from '@angular/core'; + @Pipe({ name: 'appBad' }) + export class BadPipe { + transform(value: string) { return value; } + } + `, + ); + expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + }); }); }); diff --git a/tests/rules/contextual-lifecycle.test.ts b/tests/rules/contextual-lifecycle.test.ts index 6e066c9..e19761f 100644 --- a/tests/rules/contextual-lifecycle.test.ts +++ b/tests/rules/contextual-lifecycle.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { detectContextualLifecycleViolations } from '../helpers/pattern-matcher.js'; +// Upstream semantics: @Injectable and @Pipe classes may only declare +// ngOnDestroy — any other hook is a violation. @Component and @Directive +// allow ALL hooks (directives DO receive view and content hooks). describe('angular/contextual-lifecycle', () => { - describe('invalid — lifecycle hooks used in wrong contexts', () => { + describe('invalid — hooks other than ngOnDestroy in @Injectable / @Pipe', () => { it('flags ngAfterViewInit in @Injectable', () => { const code = ` import { Injectable } from '@angular/core'; @@ -36,37 +39,36 @@ describe('angular/contextual-lifecycle', () => { expect(violations[0].methodName).toBe('ngAfterContentInit'); }); - it('flags ngAfterViewInit in @Directive', () => { + it('flags ngOnInit in @Injectable (only ngOnDestroy is ever called)', () => { const code = ` - import { Directive } from '@angular/core'; + import { Injectable } from '@angular/core'; - @Directive({ selector: '[appFoo]', standalone: true }) - export class FooDirective { - ngAfterViewInit() { - // directives don't have view children - } + @Injectable({ providedIn: 'root' }) + export class UserStore { + ngOnInit() { this.load(); } } `; const violations = detectContextualLifecycleViolations(code); expect(violations).toHaveLength(1); - expect(violations[0].methodName).toBe('ngAfterViewInit'); - expect(violations[0].decoratorName).toBe('Directive'); + expect(violations[0].methodName).toBe('ngOnInit'); + expect(violations[0].decoratorName).toBe('Injectable'); }); - it('flags ngAfterViewChecked in @Directive', () => { + it('flags ngOnChanges and ngDoCheck in @Injectable', () => { const code = ` - import { Directive } from '@angular/core'; + import { Injectable } from '@angular/core'; - @Directive({ selector: '[appFoo]', standalone: true }) - export class FooDirective { - ngAfterViewChecked() { this.check(); } + @Injectable({ providedIn: 'root' }) + export class SomeStore { + ngOnChanges() { this.update(); } + ngDoCheck() { this.check(); } } `; const violations = detectContextualLifecycleViolations(code); - expect(violations).toHaveLength(1); - expect(violations[0].methodName).toBe('ngAfterViewChecked'); + expect(violations).toHaveLength(2); + expect(violations.map((v) => v.methodName)).toEqual(['ngOnChanges', 'ngDoCheck']); }); it('flags ngAfterViewInit in @Pipe', () => { @@ -99,6 +101,23 @@ describe('angular/contextual-lifecycle', () => { const violations = detectContextualLifecycleViolations(code); expect(violations).toHaveLength(1); }); + + it('flags ngOnInit in @Pipe', () => { + const code = ` + import { Pipe, PipeTransform } from '@angular/core'; + + @Pipe({ name: 'appFoo', standalone: true }) + export class FooPipe implements PipeTransform { + transform(v: string) { return v; } + ngOnInit() { this.init(); } + } + `; + + const violations = detectContextualLifecycleViolations(code); + expect(violations).toHaveLength(1); + expect(violations[0].methodName).toBe('ngOnInit'); + expect(violations[0].decoratorName).toBe('Pipe'); + }); }); describe('edge cases', () => { @@ -128,22 +147,36 @@ describe('angular/contextual-lifecycle', () => { expect(violations).toHaveLength(1); expect(violations[0].className).toBe('unknown'); }); + }); - it('uses "unknown" as className for anonymous class with @Directive violation', () => { + describe('valid — allowed lifecycle hooks per context', () => { + it('accepts ngOnDestroy in @Injectable', () => { const code = ` - import { Directive } from '@angular/core'; - @Directive({ selector: '[appFoo]', standalone: true }) - export default class { - ngAfterViewInit() {} + import { Injectable, OnDestroy } from '@angular/core'; + + @Injectable({ providedIn: 'root' }) + export class UserStore implements OnDestroy { + ngOnDestroy() { this.sub.unsubscribe(); } } `; - const violations = detectContextualLifecycleViolations(code); - expect(violations).toHaveLength(1); - expect(violations[0].className).toBe('unknown'); + + expect(detectContextualLifecycleViolations(code)).toHaveLength(0); + }); + + it('accepts ngOnDestroy in @Pipe', () => { + const code = ` + import { Pipe, PipeTransform, OnDestroy } from '@angular/core'; + + @Pipe({ name: 'appFoo', standalone: true }) + export class FooPipe implements PipeTransform, OnDestroy { + transform(v: string) { return v; } + ngOnDestroy() { this.cleanup(); } + } + `; + + expect(detectContextualLifecycleViolations(code)).toHaveLength(0); }); - }); - describe('valid — lifecycle hooks in correct contexts', () => { it('accepts ngAfterViewInit in @Component', () => { const code = ` import { Component, AfterViewInit } from '@angular/core'; @@ -170,6 +203,32 @@ describe('angular/contextual-lifecycle', () => { expect(detectContextualLifecycleViolations(code)).toHaveLength(0); }); + it('accepts ngAfterViewInit in @Directive (directives receive view hooks)', () => { + const code = ` + import { Directive, AfterViewInit } from '@angular/core'; + + @Directive({ selector: '[appFoo]', standalone: true }) + export class FooDirective implements AfterViewInit { + ngAfterViewInit() { this.init(); } + } + `; + + expect(detectContextualLifecycleViolations(code)).toHaveLength(0); + }); + + it('accepts ngAfterViewChecked in @Directive', () => { + const code = ` + import { Directive, AfterViewChecked } from '@angular/core'; + + @Directive({ selector: '[appFoo]', standalone: true }) + export class FooDirective implements AfterViewChecked { + ngAfterViewChecked() { this.check(); } + } + `; + + expect(detectContextualLifecycleViolations(code)).toHaveLength(0); + }); + it('accepts ngAfterContentInit in @Directive', () => { const code = ` import { Directive, AfterContentInit } from '@angular/core'; @@ -183,14 +242,11 @@ describe('angular/contextual-lifecycle', () => { expect(detectContextualLifecycleViolations(code)).toHaveLength(0); }); - it('accepts ngOnInit/ngOnDestroy in @Injectable', () => { + it('accepts lifecycle-named methods in undecorated classes', () => { const code = ` - import { Injectable, OnInit, OnDestroy } from '@angular/core'; - - @Injectable({ providedIn: 'root' }) - export class UserStore implements OnInit, OnDestroy { - ngOnInit() { this.load(); } - ngOnDestroy() { this.sub.unsubscribe(); } + export class PlainClass { + ngOnInit() { this.init(); } + ngAfterViewInit() { this.render(); } } `; diff --git a/tests/rules/directive-selector.test.ts b/tests/rules/directive-selector.test.ts new file mode 100644 index 0000000..d1f5025 --- /dev/null +++ b/tests/rules/directive-selector.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { detectInvalidDirectiveSelector } from '../helpers/pattern-matcher.js'; + +const directive = (selector: string) => ` + import { Directive } from '@angular/core'; + @Directive({ selector: '${selector}' }) + export class Highlight {} +`; + +describe('angular/directive-selector', () => { + describe('invalid', () => { + it('flags attribute selector without prefix', () => { + expect(detectInvalidDirectiveSelector(directive('[highlight]'))).toHaveLength(1); + }); + it('flags kebab-case attribute selector', () => { + expect(detectInvalidDirectiveSelector(directive('[app-highlight]'))).toHaveLength(1); + }); + it('flags element selector', () => { + expect(detectInvalidDirectiveSelector(directive('app-highlight'))).toHaveLength(1); + }); + it('flags a list where one entry is invalid', () => { + expect(detectInvalidDirectiveSelector(directive('[appA], [b]'))).toHaveLength(1); + }); + }); + + describe('valid', () => { + it('accepts [appCamelCase]', () => { + expect(detectInvalidDirectiveSelector(directive('[appHighlight]'))).toHaveLength(0); + }); + it('accepts a list of valid selectors', () => { + expect(detectInvalidDirectiveSelector(directive('[appA], [appB]'))).toHaveLength(0); + }); + it('ignores components', () => { + expect( + detectInvalidDirectiveSelector(` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App {} + `), + ).toHaveLength(0); + }); + }); +}); diff --git a/tests/rules/no-async-lifecycle-method.test.ts b/tests/rules/no-async-lifecycle-method.test.ts new file mode 100644 index 0000000..6bd60a6 --- /dev/null +++ b/tests/rules/no-async-lifecycle-method.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { detectAsyncLifecycleMethods } from '../helpers/pattern-matcher.js'; + +describe('angular/no-async-lifecycle-method', () => { + describe('invalid', () => { + it('flags async ngOnInit', () => { + const v = detectAsyncLifecycleMethods('class A { async ngOnInit() { await x(); } }'); + expect(v).toHaveLength(1); + expect(v[0].methodName).toBe('ngOnInit'); + }); + it('flags async hooks with modifiers and return types', () => { + expect( + detectAsyncLifecycleMethods( + 'class A { protected async ngOnDestroy(): Promise {} public override async ngOnChanges(c) {} }', + ), + ).toHaveLength(2); + }); + it('flags every async hook', () => { + expect( + detectAsyncLifecycleMethods('class A { async ngDoCheck() {} async ngAfterViewInit() {} }'), + ).toHaveLength(2); + }); + }); + + describe('valid', () => { + it('accepts synchronous hooks', () => { + expect( + detectAsyncLifecycleMethods('class A { ngOnInit(): void { void this.load(); } }'), + ).toHaveLength(0); + }); + it('accepts async methods that are not hooks', () => { + expect( + detectAsyncLifecycleMethods('class A { async load() {} async ngLike() {} }'), + ).toHaveLength(0); + }); + it('accepts a class without hooks', () => { + expect(detectAsyncLifecycleMethods('class A { x = 1; }')).toHaveLength(0); + }); + }); +}); diff --git a/tests/rules/no-attribute-decorator.test.ts b/tests/rules/no-attribute-decorator.test.ts new file mode 100644 index 0000000..070a6df --- /dev/null +++ b/tests/rules/no-attribute-decorator.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { detectAttributeDecorators } from '../helpers/pattern-matcher.js'; + +describe('angular/no-attribute-decorator', () => { + describe('invalid', () => { + it('flags @Attribute() on a constructor parameter', () => { + const v = detectAttributeDecorators(` + import { Attribute, Directive } from '@angular/core'; + @Directive({ selector: '[appRole]' }) + export class Role { constructor(@Attribute('role') private role: string) {} } + `); + expect(v).toHaveLength(1); + expect(v[0].parameterName).toBe('role'); + }); + it('flags each decorated parameter', () => { + expect( + detectAttributeDecorators( + "class A { constructor(@Attribute('a') a: string, @Attribute('b') b: string) {} }", + ), + ).toHaveLength(2); + }); + it('flags @Attribute() mixed with other parameter decorators', () => { + expect( + detectAttributeDecorators( + "class A { constructor(@Optional() @Attribute('a') a: string, @Inject(X) x: X) {} }", + ), + ).toHaveLength(1); + }); + }); + + describe('valid', () => { + it('accepts inject(new HostAttributeToken())', () => { + expect( + detectAttributeDecorators("class A { role = inject(new HostAttributeToken('role')); }"), + ).toHaveLength(0); + }); + it('accepts other parameter decorators', () => { + expect(detectAttributeDecorators('class A { constructor(@Inject(X) x: X) {} }')).toHaveLength( + 0, + ); + }); + it('accepts a class without constructor', () => { + expect(detectAttributeDecorators('class A { x = 1; }')).toHaveLength(0); + }); + }); +}); diff --git a/tests/rules/no-conflicting-lifecycle.test.ts b/tests/rules/no-conflicting-lifecycle.test.ts new file mode 100644 index 0000000..66eb724 --- /dev/null +++ b/tests/rules/no-conflicting-lifecycle.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { detectConflictingLifecycle } from '../helpers/pattern-matcher.js'; + +describe('angular/no-conflicting-lifecycle', () => { + describe('invalid', () => { + it('flags a class with ngDoCheck and ngOnChanges', () => { + const v = detectConflictingLifecycle(` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App { ngDoCheck() {} ngOnChanges() {} } + `); + expect(v).toHaveLength(1); + expect(v[0].className).toBe('App'); + }); + it('flags regardless of signature shape', () => { + expect( + detectConflictingLifecycle(` + export class A { async ngDoCheck(): Promise {} protected ngOnChanges(c: SimpleChanges): void {} } + `), + ).toHaveLength(1); + }); + it('flags each conflicting class in a file', () => { + expect( + detectConflictingLifecycle(` + export class A { ngDoCheck() {} ngOnChanges() {} } + export class B { ngDoCheck() {} ngOnChanges() {} } + `), + ).toHaveLength(2); + }); + }); + + describe('valid', () => { + it('accepts ngOnChanges alone', () => { + expect(detectConflictingLifecycle('export class A { ngOnChanges() {} }')).toHaveLength(0); + }); + it('accepts ngDoCheck alone', () => { + expect(detectConflictingLifecycle('export class A { ngDoCheck() {} }')).toHaveLength(0); + }); + it('accepts the hooks split across classes', () => { + expect( + detectConflictingLifecycle( + 'export class A { ngDoCheck() {} } export class B { ngOnChanges() {} }', + ), + ).toHaveLength(0); + }); + }); +}); diff --git a/tests/rules/no-empty-lifecycle-method.test.ts b/tests/rules/no-empty-lifecycle-method.test.ts index 40463ef..c5da647 100644 --- a/tests/rules/no-empty-lifecycle-method.test.ts +++ b/tests/rules/no-empty-lifecycle-method.test.ts @@ -53,6 +53,22 @@ describe('angular/no-empty-lifecycle-method', () => { expect(violations[0].methodName).toBe('ngOnChanges'); }); + it('flags empty lifecycle methods with a ": void" return type annotation', () => { + const code = ` + import { Component, OnInit, OnChanges, SimpleChanges } from '@angular/core'; + + @Component({ selector: 'app-root', standalone: true, template: '' }) + export class AppComponent implements OnInit, OnChanges { + ngOnInit(): void {} + ngOnChanges(changes: SimpleChanges): void {} + } + `; + + const violations = detectEmptyLifecycleMethods(code); + expect(violations).toHaveLength(2); + expect(violations.map((v) => v.methodName)).toEqual(['ngOnInit', 'ngOnChanges']); + }); + it('flags empty ngAfterViewInit', () => { const code = ` import { Component, AfterViewInit } from '@angular/core'; diff --git a/tests/rules/no-lifecycle-call.test.ts b/tests/rules/no-lifecycle-call.test.ts new file mode 100644 index 0000000..4707374 --- /dev/null +++ b/tests/rules/no-lifecycle-call.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { detectLifecycleCalls } from '../helpers/pattern-matcher.js'; + +describe('angular/no-lifecycle-call', () => { + describe('invalid', () => { + it('flags this.ngOnInit()', () => { + const v = detectLifecycleCalls('class A { reload() { this.ngOnInit(); } }'); + expect(v).toHaveLength(1); + expect(v[0]).toEqual({ hook: 'ngOnInit', receiver: 'this' }); + }); + it('flags calls on other instances', () => { + expect( + detectLifecycleCalls('fixture.componentInstance.ngOnDestroy(); cmp.ngOnChanges({});'), + ).toHaveLength(2); + }); + it('flags hooks called with arguments', () => { + expect(detectLifecycleCalls('this.ngOnChanges({ value: change });')).toHaveLength(1); + }); + }); + + describe('valid', () => { + it('accepts super.ngOnInit()', () => { + expect( + detectLifecycleCalls('class A extends B { ngOnInit() { super.ngOnInit(); } }'), + ).toHaveLength(0); + }); + it('accepts hook declarations', () => { + expect( + detectLifecycleCalls('class A { ngOnInit() { this.load(); } load() {} }'), + ).toHaveLength(0); + }); + it('ignores methods whose name only starts like a hook', () => { + expect(detectLifecycleCalls('this.ngOnInitLike();')).toHaveLength(0); + }); + }); +}); diff --git a/tests/rules/no-output-native.test.ts b/tests/rules/no-output-native.test.ts new file mode 100644 index 0000000..af5ffa8 --- /dev/null +++ b/tests/rules/no-output-native.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { detectNativeOutputNames } from '../helpers/pattern-matcher.js'; + +const component = (members: string) => ` + import { Component, Output, EventEmitter, output } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class App { ${members} } +`; + +describe('angular/no-output-native', () => { + describe('invalid', () => { + it('flags @Output() change', () => { + const v = detectNativeOutputNames(component('@Output() change = new EventEmitter();')); + expect(v).toHaveLength(1); + expect(v[0].propertyName).toBe('change'); + }); + it('flags readonly @Output() click', () => { + expect( + detectNativeOutputNames(component('@Output() readonly click = new EventEmitter();')), + ).toHaveLength(1); + }); + it('flags signal output focus', () => { + expect(detectNativeOutputNames(component('readonly focus = output();'))).toHaveLength( + 1, + ); + }); + it('flags outputFromObservable scroll', () => { + expect( + detectNativeOutputNames(component('readonly scroll = outputFromObservable(this.s$);')), + ).toHaveLength(1); + }); + }); + + describe('valid', () => { + it('accepts specific output names', () => { + expect( + detectNativeOutputNames( + component('@Output() valueChange = new EventEmitter(); readonly clicked = output();'), + ), + ).toHaveLength(0); + }); + it('ignores inputs and plain properties named like events', () => { + expect( + detectNativeOutputNames( + component('@Input() change = ""; click = 0; focus = input(false);'), + ), + ).toHaveLength(0); + }); + it('ignores undecorated classes without output calls', () => { + expect(detectNativeOutputNames('class X { change = 1; }')).toHaveLength(0); + }); + }); +}); diff --git a/tests/rules/no-pipe-impure.test.ts b/tests/rules/no-pipe-impure.test.ts new file mode 100644 index 0000000..28ce44b --- /dev/null +++ b/tests/rules/no-pipe-impure.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { detectImpurePipes } from '../helpers/pattern-matcher.js'; + +const pipe = (meta: string) => ` + import { Pipe } from '@angular/core'; + @Pipe({ ${meta} }) + export class P { transform(v: unknown) { return v; } } +`; + +describe('angular/no-pipe-impure', () => { + describe('invalid', () => { + it('flags pure: false', () => { + const v = detectImpurePipes(pipe("name: 'appFilter', pure: false")); + expect(v).toHaveLength(1); + expect(v[0].className).toBe('P'); + }); + it('flags pure: false whatever the property order', () => { + expect(detectImpurePipes(pipe("pure: false, name: 'appFilter'"))).toHaveLength(1); + }); + it('flags pure: false with standalone', () => { + expect( + detectImpurePipes(pipe("name: 'appFilter', standalone: true, pure: false")), + ).toHaveLength(1); + }); + }); + + describe('valid', () => { + it('accepts pure: true', () => { + expect(detectImpurePipes(pipe("name: 'appUpper', pure: true"))).toHaveLength(0); + }); + it('accepts the default (no pure property)', () => { + expect(detectImpurePipes(pipe("name: 'appUpper'"))).toHaveLength(0); + }); + it('ignores pure: false outside @Pipe', () => { + expect(detectImpurePipes('const options = { pure: false };')).toHaveLength(0); + }); + }); +}); diff --git a/tests/rules/pipe-prefix.test.ts b/tests/rules/pipe-prefix.test.ts index 5e4c5fd..31f9bf6 100644 --- a/tests/rules/pipe-prefix.test.ts +++ b/tests/rules/pipe-prefix.test.ts @@ -59,6 +59,23 @@ describe('angular/pipe-prefix', () => { expect(violations.map((v) => v.pipeName)).toEqual(['truncate', 'highlight']); }); + it('flags pipe name that merely starts with the prefix letters ("apple")', () => { + // The prefix must be followed by an uppercase letter: "apple" starts + // with "app" but is not a prefixed name — it is a violation. + const code = ` + import { Pipe, PipeTransform } from '@angular/core'; + + @Pipe({ name: 'apple', standalone: true }) + export class ApplePipe implements PipeTransform { + transform(v: string): string { return v; } + } + `; + + const violations = detectMissingPipePrefix(code); + expect(violations).toHaveLength(1); + expect(violations[0].pipeName).toBe('apple'); + }); + it('flags pipe name starting with wrong prefix', () => { const code = ` import { Pipe, PipeTransform } from '@angular/core'; @@ -98,6 +115,19 @@ describe('angular/pipe-prefix', () => { }); describe('valid', () => { + it('accepts prefix followed by an uppercase letter ("appUpper")', () => { + const code = ` + import { Pipe, PipeTransform } from '@angular/core'; + + @Pipe({ name: 'appUpper', standalone: true }) + export class UpperPipe implements PipeTransform { + transform(v: string): string { return v.toUpperCase(); } + } + `; + + expect(detectMissingPipePrefix(code)).toHaveLength(0); + }); + it('accepts pipe name starting with "app" prefix', () => { const code = ` import { Pipe, PipeTransform } from '@angular/core'; diff --git a/tests/rules/prefer-inject-function.test.ts b/tests/rules/prefer-inject-function.test.ts index 7afbe16..08ab071 100644 --- a/tests/rules/prefer-inject-function.test.ts +++ b/tests/rules/prefer-inject-function.test.ts @@ -103,16 +103,29 @@ describe('angular/prefer-inject-function', () => { expect(detectConstructorInjection(code)).toHaveLength(0); }); - it('accepts constructor without access modifiers (non-DI parameters)', () => { + it('flags a parameter without access modifier (Angular still injects it)', () => { const code = ` import { Component } from '@angular/core'; + import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-foo', standalone: true, template: '' }) export class FooComponent { - constructor(someValue: string) { - this.title = someValue; + private http: HttpClient; + constructor(http: HttpClient) { + this.http = http; } - title = ''; + } + `; + + const violations = detectConstructorInjection(code); + expect(violations).toHaveLength(1); + expect(violations[0].parameterName).toBe('http'); + }); + + it('accepts parameter properties in an undecorated class (not Angular DI)', () => { + const code = ` + export class SmtpMailer { + constructor(private smtp: string, private port: number) {} } `; @@ -121,6 +134,8 @@ describe('angular/prefer-inject-function', () => { it('uses "unknown" className and type for anonymous class with untyped param', () => { const code = ` + import { Injectable } from '@angular/core'; + @Injectable() export default class { constructor(private dep) {} } diff --git a/tests/rules/prefer-model-signal.test.ts b/tests/rules/prefer-model-signal.test.ts index 3d035fd..ab480d7 100644 --- a/tests/rules/prefer-model-signal.test.ts +++ b/tests/rules/prefer-model-signal.test.ts @@ -111,5 +111,18 @@ describe('angular/prefer-model-signal', () => { expect(detectTwoWayBindingPairs(code)).toHaveLength(0); }); + + it('does not flag unpaired Material-style selectionChange output', () => { + const code = ` + import { Component, Output, EventEmitter } from '@angular/core'; + + @Component({ selector: 'app-list', standalone: true, template: '' }) + export class ListComponent { + @Output() selectionChange = new EventEmitter(); + } + `; + + expect(detectTwoWayBindingPairs(code)).toHaveLength(0); + }); }); }); diff --git a/tests/rules/prefer-standalone.test.ts b/tests/rules/prefer-standalone.test.ts index efb4fbf..82146f4 100644 --- a/tests/rules/prefer-standalone.test.ts +++ b/tests/rules/prefer-standalone.test.ts @@ -4,53 +4,11 @@ import { detectMissingStandalone } from '../helpers/pattern-matcher.js'; describe('angular/prefer-standalone', () => { // ------------------------------------------------------------------------- // Invalid cases — must produce violations + // Since Angular 19 standalone is the default: only an explicit + // `standalone: false` opt-out is a violation. // ------------------------------------------------------------------------- describe('invalid', () => { - it('flags @Component() with no arguments at all', () => { - const code = ` - import { Component } from '@angular/core'; - - @Component() - export class AppComponent {} - `; - - const violations = detectMissingStandalone(code); - expect(violations).toHaveLength(1); - expect(violations[0].decorator).toBe('Component'); - expect(violations[0].className).toBe('AppComponent'); - }); - - it('flags @Directive() with no arguments at all', () => { - const code = ` - import { Directive } from '@angular/core'; - - @Directive() - export class FooDirective {} - `; - - const violations = detectMissingStandalone(code); - expect(violations).toHaveLength(1); - expect(violations[0].decorator).toBe('Directive'); - }); - - it('flags @Component without standalone property', () => { - const code = ` - import { Component } from '@angular/core'; - - @Component({ - selector: 'app-root', - template: '

Hello

', - }) - export class AppComponent {} - `; - - const violations = detectMissingStandalone(code); - expect(violations).toHaveLength(1); - expect(violations[0].decorator).toBe('Component'); - expect(violations[0].className).toBe('AppComponent'); - }); - it('flags @Component with standalone: false', () => { const code = ` import { Component } from '@angular/core'; @@ -65,13 +23,15 @@ describe('angular/prefer-standalone', () => { const violations = detectMissingStandalone(code); expect(violations).toHaveLength(1); + expect(violations[0].decorator).toBe('Component'); + expect(violations[0].className).toBe('AppComponent'); }); - it('flags @Directive without standalone property', () => { + it('flags @Directive with standalone: false', () => { const code = ` import { Directive } from '@angular/core'; - @Directive({ selector: '[appHighlight]' }) + @Directive({ selector: '[appHighlight]', standalone: false }) export class HighlightDirective {} `; @@ -80,11 +40,11 @@ describe('angular/prefer-standalone', () => { expect(violations[0].decorator).toBe('Directive'); }); - it('flags @Pipe without standalone property', () => { + it('flags @Pipe with standalone: false', () => { const code = ` import { Pipe, PipeTransform } from '@angular/core'; - @Pipe({ name: 'truncate' }) + @Pipe({ name: 'truncate', standalone: false }) export class TruncatePipe implements PipeTransform { transform(value: string, limit: number): string { return value.substring(0, limit); @@ -97,14 +57,14 @@ describe('angular/prefer-standalone', () => { expect(violations[0].decorator).toBe('Pipe'); }); - it('flags multiple decorators in the same file', () => { + it('flags multiple standalone: false decorators in the same file', () => { const code = ` import { Component, Directive } from '@angular/core'; - @Component({ selector: 'app-foo', template: '' }) + @Component({ selector: 'app-foo', standalone: false, template: '' }) export class FooComponent {} - @Directive({ selector: '[appBar]' }) + @Directive({ selector: '[appBar]', standalone: false }) export class BarDirective {} `; @@ -114,21 +74,10 @@ describe('angular/prefer-standalone', () => { }); describe('edge cases', () => { - it('uses "unknown" as className for anonymous class with no-args decorator', () => { - const code = ` - import { Component } from '@angular/core'; - @Component() - export default class {} - `; - const violations = detectMissingStandalone(code); - expect(violations).toHaveLength(1); - expect(violations[0].className).toBe('unknown'); - }); - - it('uses "unknown" as className for anonymous class missing standalone', () => { + it('uses "unknown" as className for anonymous class with standalone: false', () => { const code = ` import { Component } from '@angular/core'; - @Component({ selector: 'foo', template: '' }) + @Component({ selector: 'foo', standalone: false, template: '' }) export default class {} `; const violations = detectMissingStandalone(code); @@ -157,22 +106,47 @@ describe('angular/prefer-standalone', () => { expect(detectMissingStandalone(code)).toHaveLength(0); }); - it('accepts @Directive with standalone: true', () => { + it('accepts @Component without a standalone property (default is standalone)', () => { + const code = ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

Hello

', + }) + export class AppComponent {} + `; + + expect(detectMissingStandalone(code)).toHaveLength(0); + }); + + it('accepts @Component() with no arguments at all', () => { + const code = ` + import { Component } from '@angular/core'; + + @Component() + export class AppComponent {} + `; + + expect(detectMissingStandalone(code)).toHaveLength(0); + }); + + it('accepts @Directive without a standalone property', () => { const code = ` import { Directive } from '@angular/core'; - @Directive({ selector: '[appHighlight]', standalone: true }) + @Directive({ selector: '[appHighlight]' }) export class HighlightDirective {} `; expect(detectMissingStandalone(code)).toHaveLength(0); }); - it('accepts @Pipe with standalone: true', () => { + it('accepts @Pipe without a standalone property', () => { const code = ` import { Pipe, PipeTransform } from '@angular/core'; - @Pipe({ name: 'truncate', standalone: true }) + @Pipe({ name: 'truncate' }) export class TruncatePipe implements PipeTransform { transform(value: string, limit: number): string { return value.substring(0, limit); diff --git a/tests/rules/sort-ngmodule-metadata-arrays.test.ts b/tests/rules/sort-ngmodule-metadata-arrays.test.ts deleted file mode 100644 index 5f2f60f..0000000 --- a/tests/rules/sort-ngmodule-metadata-arrays.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { detectUnsortedNgModuleArrays } from '../helpers/pattern-matcher.js'; - -describe('angular/sort-ngmodule-metadata-arrays', () => { - describe('invalid', () => { - it('flags unsorted declarations array', () => { - const code = ` - import { NgModule } from '@angular/core'; - import { AppComponent } from './app.component'; - import { HeaderComponent } from './header.component'; - - @NgModule({ - declarations: [HeaderComponent, AppComponent], - }) - export class AppModule {} - `; - - const violations = detectUnsortedNgModuleArrays(code); - expect(violations).toHaveLength(1); - expect(violations[0].arrayName).toBe('declarations'); - expect(violations[0].className).toBe('AppModule'); - expect(violations[0].sortedElements).toEqual(['AppComponent', 'HeaderComponent']); - }); - - it('flags unsorted imports array', () => { - const code = ` - import { NgModule } from '@angular/core'; - import { BrowserModule } from '@angular/platform-browser'; - import { RouterModule } from '@angular/router'; - - @NgModule({ - imports: [RouterModule, BrowserModule], - }) - export class AppModule {} - `; - - const violations = detectUnsortedNgModuleArrays(code); - expect(violations).toHaveLength(1); - expect(violations[0].arrayName).toBe('imports'); - expect(violations[0].sortedElements).toEqual(['BrowserModule', 'RouterModule']); - }); - - it('flags unsorted exports array', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - exports: [UserComponent, ButtonComponent, AlertComponent], - }) - export class SharedModule {} - `; - - const violations = detectUnsortedNgModuleArrays(code); - expect(violations).toHaveLength(1); - expect(violations[0].arrayName).toBe('exports'); - expect(violations[0].sortedElements).toEqual([ - 'AlertComponent', - 'ButtonComponent', - 'UserComponent', - ]); - }); - - it('flags unsorted providers array', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - providers: [UserService, AuthService, LoggerService], - }) - export class AppModule {} - `; - - const violations = detectUnsortedNgModuleArrays(code); - expect(violations).toHaveLength(1); - expect(violations[0].arrayName).toBe('providers'); - }); - - it('flags multiple unsorted arrays in the same NgModule', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - declarations: [HeaderComponent, AppComponent], - imports: [RouterModule, BrowserModule], - }) - export class AppModule {} - `; - - const violations = detectUnsortedNgModuleArrays(code); - expect(violations).toHaveLength(2); - const arrayNames = violations.map((v) => v.arrayName); - expect(arrayNames).toContain('declarations'); - expect(arrayNames).toContain('imports'); - }); - }); - - describe('edge cases', () => { - it('ignores @NgModule() with no arguments', () => { - const code = ` - import { NgModule } from '@angular/core'; - @NgModule() - export class AppModule {} - `; - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - - it('uses "unknown" as className for anonymous class', () => { - const code = ` - import { NgModule } from '@angular/core'; - @NgModule({ declarations: [ZComponent, AComponent] }) - export default class {} - `; - const violations = detectUnsortedNgModuleArrays(code); - expect(violations).toHaveLength(1); - expect(violations[0].className).toBe('unknown'); - }); - }); - - describe('valid', () => { - it('accepts alphabetically sorted declarations', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - declarations: [AppComponent, HeaderComponent], - }) - export class AppModule {} - `; - - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - - it('accepts alphabetically sorted imports', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - imports: [BrowserModule, RouterModule], - }) - export class AppModule {} - `; - - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - - it('accepts NgModule with all arrays sorted', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - declarations: [AppComponent, HeaderComponent, UserComponent], - exports: [AppComponent, HeaderComponent], - imports: [BrowserModule, HttpClientModule, RouterModule], - providers: [AuthService, UserService], - }) - export class AppModule {} - `; - - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - - it('accepts single-element arrays (nothing to sort)', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - declarations: [AppComponent], - imports: [BrowserModule], - bootstrap: [AppComponent], - }) - export class AppModule {} - `; - - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - - it('accepts empty arrays', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - declarations: [], - imports: [], - }) - export class AppModule {} - `; - - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - - it('accepts case-insensitive alphabetical order (a < B)', () => { - const code = ` - import { NgModule } from '@angular/core'; - - @NgModule({ - declarations: [aComponent, BComponent], - }) - export class AppModule {} - `; - - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - - it('ignores @Component and @Directive decorators', () => { - const code = ` - import { Component } from '@angular/core'; - - @Component({ selector: 'app-root', standalone: true, template: '' }) - export class AppComponent {} - `; - - expect(detectUnsortedNgModuleArrays(code)).toHaveLength(0); - }); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index 593c012..381f342 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,9 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'lcov'], + // Coverage measures the reference-spec layer (ts-morph re-implementation + // of the rules) only — NOT the shipped GritQL rules under rules/, which + // are exercised by the integration suite against the real biome binary. include: ['tests/helpers/pattern-matcher.ts'], exclude: ['node_modules/**', 'coverage/**'], },