From 73969cc68dd334095329aec4b32bceff4ef04495 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:07:49 +0200 Subject: [PATCH 01/34] fix(rules): revive use-pipe-transform-interface via file-level PipeTransform check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GritQL metavariables cannot bind implements clauses, so decorator+class matching was impossible for implements-classes. Match the @Pipe decorator and require the file to reference PipeTransform instead — covers export, extends, and implements-other class shapes. The rule now actually fires on real Angular code (it previously never matched exported classes). --- .../quality/use-pipe-transform-interface.grit | 36 ++++-------- .../use-pipe-transform-interface.test.ts | 57 ++++++++++++++++++- 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/rules/quality/use-pipe-transform-interface.grit b/rules/quality/use-pipe-transform-interface.grit index f5861dd..dbf27c0 100644 --- a/rules/quality/use-pipe-transform-interface.grit +++ b/rules/quality/use-pipe-transform-interface.grit @@ -1,28 +1,14 @@ -// 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. +// +// Implementation note: GritQL metavariables cannot bind an `implements` clause +// (`class $n implements $i { $b }` never matches), so instead of matching +// decorator+class shapes we match the @Pipe decorator and require the file to +// reference PipeTransform. This covers every class form (export, extends, +// implements-other). Known false negatives: a file that imports PipeTransform +// without implementing it, or a multi-pipe file where only one pipe implements it. 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") - } +`@Pipe($args)` as $dec where { + $program <: not contains `PipeTransform`, + register_diagnostic(span = $dec, message = "@Pipe classes must implement the PipeTransform interface.", severity = "warn") } diff --git a/tests/integration/use-pipe-transform-interface.test.ts b/tests/integration/use-pipe-transform-interface.test.ts index 49ff06e..971a0d3 100644 --- a/tests/integration/use-pipe-transform-interface.test.ts +++ b/tests/integration/use-pipe-transform-interface.test.ts @@ -2,9 +2,11 @@ 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 matches the @Pipe decorator and requires the file to reference + // PipeTransform (GritQL cannot bind `implements` clauses, so decorator+class + // matching is impossible for implements-classes). Per-file semantics. describe('violations', () => { - it.skip('flags @Pipe class with no implements clause', async () => { + 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 }) @@ -13,9 +15,10 @@ describe('angular/use-pipe-transform-interface [integration]', () => { } `); 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 () => { + 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 }) @@ -24,6 +27,19 @@ describe('angular/use-pipe-transform-interface [integration]', () => { } `); 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); }); }); @@ -38,5 +54,40 @@ describe('angular/use-pipe-transform-interface [integration]', () => { `); 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); + }); + + // Per-file matching: a file that imports PipeTransform exempts every @Pipe + // in it, even one that does not implement the interface. Documented false + // negative of the file-level approach. + it.fails('flags a non-implementing @Pipe when the file imports PipeTransform (known FN)', 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); + }); }); }); From 5b64694407a4e8bf42cce6e1b446da26af7c9ffa Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:07:49 +0200 Subject: [PATCH 02/34] fix(rules): revive contextual-lifecycle with export-aware patterns Decorator+class snippets never matched exported classes because GritQL treats the export modifier as part of the statement; add export class variants. Align semantics with @angular-eslint: @Injectable and @Pipe only support ngOnDestroy; @Component and @Directive support all hooks (directives do receive view hooks). Detect hooks as bare identifiers so any signature shape matches (: void, async, parameters). --- rules/quality/contextual-lifecycle.grit | 110 ++++++------------ .../integration/contextual-lifecycle.test.ts | 86 +++++++++++--- 2 files changed, 105 insertions(+), 91 deletions(-) diff --git a/rules/quality/contextual-lifecycle.grit b/rules/quality/contextual-lifecycle.grit index 79ad5c2..b38fbbb 100644 --- a/rules/quality/contextual-lifecycle.grit +++ b/rules/quality/contextual-lifecycle.grit @@ -1,85 +1,43 @@ -// 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. +// +// Implementation notes: +// - Patterns include explicit `export class` variants: GritQL treats the export +// modifier as part of the matched statement. +// - Classes with an `implements` clause cannot be matched (GritQL metavariables +// cannot bind heritage clauses) — known false negative. +// - Hooks are detected as bare identifiers inside the class body so any +// signature shape matches (`: void`, `async`, parameters). 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.") - }, +class $name { $body }`, `@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.") - }, +export class $name { $body }`, `@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.") - }, - +class $name extends $ext { $body }`, + `@Injectable($args) +export class $name extends $ext { $body }`, `@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.") - }, +class $name { $body }`, `@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.") - } +export class $name { $body }`, + `@Pipe($args) +class $name extends $ext { $body }`, + `@Pipe($args) +export class $name extends $ext { $body }` +} where { + $body <: contains or { + `ngOnChanges`, + `ngOnInit`, + `ngDoCheck`, + `ngAfterContentInit`, + `ngAfterContentChecked`, + `ngAfterViewInit`, + `ngAfterViewChecked` + }, + register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: @Injectable and @Pipe classes only support ngOnDestroy. Other hooks are never called on them.") } diff --git a/tests/integration/contextual-lifecycle.test.ts b/tests/integration/contextual-lifecycle.test.ts index 8a627f0..42bd92e 100644 --- a/tests/integration/contextual-lifecycle.test.ts +++ b/tests/integration/contextual-lifecycle.test.ts @@ -2,10 +2,11 @@ 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 () => { + it('flags ngAfterViewInit in @Injectable', async () => { const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) @@ -14,29 +15,58 @@ describe('angular/contextual-lifecycle [integration]', () => { } `); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('wrong context'); }); - it.skip('flags ngAfterViewInit in @Directive', async () => { + it('flags ngOnInit in @Injectable (services only support ngOnDestroy)', 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'); } + 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 () => { + it('flags ngAfterViewInit in @Pipe', async () => { const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` - import { Pipe, PipeTransform } from '@angular/core'; + 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); + }); + + // GritQL metavariables cannot bind an `implements` clause, so decorated + // classes WITH `implements` are invisible to the rule. Documented false + // negative — flips to failing the day Biome supports it. + it.fails('flags ngOnInit in @Injectable that has an implements clause (known FN)', 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); }); }); @@ -52,12 +82,38 @@ describe('angular/contextual-lifecycle [integration]', () => { expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); - it('accepts ngOnInit in @Injectable', async () => { + it('accepts ngAfterViewInit in @Directive (directives receive view hooks)', async () => { const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` - import { Injectable, OnInit } from '@angular/core'; + 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); From 0a319c5adebd341a31d6340757d5c31ce000da38 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:10:22 +0200 Subject: [PATCH 03/34] fix(rules): make no-component-suffix decorator-aware Only flag classes decorated with @Component. Plain TS classes ending in Component (e.g. React or shared code in a monorepo) are no longer flagged. Uses export-aware decorator+class patterns including extends variants. --- rules/conventions/no-component-suffix.grit | 19 +++++++++++++++---- tests/integration/no-component-suffix.test.ts | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/rules/conventions/no-component-suffix.grit b/rules/conventions/no-component-suffix.grit index ba325da..c61f6d5 100644 --- a/rules/conventions/no-component-suffix.grit +++ b/rules/conventions/no-component-suffix.grit @@ -1,9 +1,20 @@ -// 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). +// Patterns include explicit `export class` variants — GritQL treats the export +// modifier as part of the matched statement. Known false negative: decorated +// classes with an `implements` clause cannot be matched by GritQL. language js -`class $name { $_ }` where { +or { + `@Component($a) +class $name { $_ }`, + `@Component($a) +export class $name { $_ }`, + `@Component($a) +class $name extends $ext { $_ }`, + `@Component($a) +export class $name extends $ext { $_ }` +} where { $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") } diff --git a/tests/integration/no-component-suffix.test.ts b/tests/integration/no-component-suffix.test.ts index e7e9cdb..5ab0a44 100644 --- a/tests/integration/no-component-suffix.test.ts +++ b/tests/integration/no-component-suffix.test.ts @@ -20,9 +20,28 @@ describe('angular/no-component-suffix [integration]', () => { `); 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', ` import { Component } from '@angular/core'; From 210c523c0ef165956b6affc6396f3a8c2fa8bd1c Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:10:22 +0200 Subject: [PATCH 04/34] fix(rules): make no-directive-suffix decorator-aware Only flag classes decorated with @Directive, using export-aware decorator+class patterns including extends variants. --- rules/conventions/no-directive-suffix.grit | 19 +++++++++++++++---- tests/integration/no-directive-suffix.test.ts | 10 ++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/rules/conventions/no-directive-suffix.grit b/rules/conventions/no-directive-suffix.grit index 04d099a..20e8e79 100644 --- a/rules/conventions/no-directive-suffix.grit +++ b/rules/conventions/no-directive-suffix.grit @@ -1,9 +1,20 @@ -// 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). +// Patterns include explicit `export class` variants — GritQL treats the export +// modifier as part of the matched statement. Known false negative: decorated +// classes with an `implements` clause cannot be matched by GritQL. language js -`class $name { $_ }` where { +or { + `@Directive($a) +class $name { $_ }`, + `@Directive($a) +export class $name { $_ }`, + `@Directive($a) +class $name extends $ext { $_ }`, + `@Directive($a) +export class $name extends $ext { $_ }` +} where { $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") } diff --git a/tests/integration/no-directive-suffix.test.ts b/tests/integration/no-directive-suffix.test.ts index bd96ff1..915d73b 100644 --- a/tests/integration/no-directive-suffix.test.ts +++ b/tests/integration/no-directive-suffix.test.ts @@ -10,10 +10,20 @@ describe('angular/no-directive-suffix [integration]', () => { 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', ` import { Directive } from '@angular/core'; From 0a827372fd0e905fad5382f1c5bf9d02708bff61 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:10:22 +0200 Subject: [PATCH 05/34] fix(rules): make no-service-suffix decorator-aware Only flag classes decorated with @Injectable. NestJS services and plain TS classes ending in Service are no longer flagged. Uses export-aware decorator+class patterns including extends variants. --- rules/conventions/no-service-suffix.grit | 19 +++++++++++++++---- tests/integration/no-service-suffix.test.ts | 10 ++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/rules/conventions/no-service-suffix.grit b/rules/conventions/no-service-suffix.grit index 80a6a57..420b06a 100644 --- a/rules/conventions/no-service-suffix.grit +++ b/rules/conventions/no-service-suffix.grit @@ -1,9 +1,20 @@ -// 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). +// Patterns include explicit `export class` variants — GritQL treats the export +// modifier as part of the matched statement. Known false negative: decorated +// classes with an `implements` clause cannot be matched by GritQL. language js -`class $name { $_ }` where { +or { + `@Injectable($a) +class $name { $_ }`, + `@Injectable($a) +export class $name { $_ }`, + `@Injectable($a) +class $name extends $ext { $_ }`, + `@Injectable($a) +export class $name extends $ext { $_ }` +} where { $name <: r".*Service$", register_diagnostic(span = $name, message = "Angular v20+: remove the 'Service' suffix. Use a descriptive name (Store, Api, Client, Facade…).", severity = "warn") } diff --git a/tests/integration/no-service-suffix.test.ts b/tests/integration/no-service-suffix.test.ts index 7c69d8f..a5e720d 100644 --- a/tests/integration/no-service-suffix.test.ts +++ b/tests/integration/no-service-suffix.test.ts @@ -10,10 +10,20 @@ describe('angular/no-service-suffix [integration]', () => { 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', ` import { Injectable } from '@angular/core'; From 5b4912e42461f7168b0d8f7752baf5558fe88a4c Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:11:14 +0200 Subject: [PATCH 06/34] fix(rules): make prefer-inject-function decorator-aware Only flag constructor parameter properties inside @Component/@Directive/ @Injectable/@Pipe classes. Plain TS classes using parameter properties for values (and NestJS constructor DI) are no longer flagged. --- rules/modern/prefer-inject-function.grit | 50 +++++++++++++++++-- .../prefer-inject-function.test.ts | 12 ++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/rules/modern/prefer-inject-function.grit b/rules/modern/prefer-inject-function.grit index b3ed653..363026d 100644 --- a/rules/modern/prefer-inject-function.grit +++ b/rules/modern/prefer-inject-function.grit @@ -1,9 +1,49 @@ -// 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 the Angular 14+ guidance: prefer inject() over constructor DI. +// Only flags constructors inside @Component/@Directive/@Injectable/@Pipe +// classes, so plain TS or NestJS classes using parameter properties are not +// flagged. Patterns include explicit `export class` variants — GritQL treats +// the export modifier as part of the matched statement. +// Known false negatives: decorated classes with an `implements` clause cannot +// be matched by GritQL, and DI without an access modifier +// (constructor(dep: Service) { this.dep = dep }) is undetectable statically. language js -`constructor($params) { $_ }` where { - $params <: contains or { `private`, `protected`, `public`, `readonly` }, +or { + `@Component($a) +class $name { $body }`, + `@Component($a) +export class $name { $body }`, + `@Component($a) +class $name extends $ext { $body }`, + `@Component($a) +export class $name extends $ext { $body }`, + `@Directive($a) +class $name { $body }`, + `@Directive($a) +export class $name { $body }`, + `@Directive($a) +class $name extends $ext { $body }`, + `@Directive($a) +export class $name extends $ext { $body }`, + `@Injectable($a) +class $name { $body }`, + `@Injectable($a) +export class $name { $body }`, + `@Injectable($a) +class $name extends $ext { $body }`, + `@Injectable($a) +export class $name extends $ext { $body }`, + `@Pipe($a) +class $name { $body }`, + `@Pipe($a) +export class $name { $body }`, + `@Pipe($a) +class $name extends $ext { $body }`, + `@Pipe($a) +export class $name extends $ext { $body }` +} where { + $body <: contains `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") } diff --git a/tests/integration/prefer-inject-function.test.ts b/tests/integration/prefer-inject-function.test.ts index 8e8ae4d..f778a25 100644 --- a/tests/integration/prefer-inject-function.test.ts +++ b/tests/integration/prefer-inject-function.test.ts @@ -2,7 +2,8 @@ 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', () => { @@ -32,6 +33,15 @@ describe('angular/prefer-inject-function [integration]', () => { }); 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', ` import { Component, inject } from '@angular/core'; From b463c0e5a325462430ca335871cde98b83899bd1 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:11:54 +0200 Subject: [PATCH 07/34] fix(rules): prefer-standalone only flags explicit standalone: false Since Angular 19 standalone is the default and the v20 style guide recommends omitting the property, so flagging its absence produced an error on every idiomatic modern component, directive, and pipe. --- rules/high-priority/prefer-standalone.grit | 38 +++++---------------- tests/integration/prefer-standalone.test.ts | 34 ++++++++++-------- 2 files changed, 28 insertions(+), 44 deletions(-) diff --git a/rules/high-priority/prefer-standalone.grit b/rules/high-priority/prefer-standalone.grit index 3801056..113ab05 100644 --- a/rules/high-priority/prefer-standalone.grit +++ b/rules/high-priority/prefer-standalone.grit @@ -1,33 +1,13 @@ +// 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. 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`, + register_diagnostic(span = $args, message = "Avoid standalone: false. Standalone is the default since Angular 19 — migrate this declarable off NgModules and remove the property.") } diff --git a/tests/integration/prefer-standalone.test.ts b/tests/integration/prefer-standalone.test.ts index ee92e50..81bdaf7 100644 --- a/tests/integration/prefer-standalone.test.ts +++ b/tests/integration/prefer-standalone.test.ts @@ -2,16 +2,10 @@ 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', ` import { Component } from '@angular/core'; @@ -19,21 +13,22 @@ describe('angular/prefer-standalone [integration]', () => { export class AppComponent {} `); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); + expect(diagnostics[0].message).toContain('standalone: false'); }); - it('flags @Directive without standalone', async () => { + 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 () => { + 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; } } @@ -43,7 +38,16 @@ describe('angular/prefer-standalone [integration]', () => { }); describe('valid', () => { - it('accepts @Component with standalone: true', async () => { + 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: '' }) From d5d006ef40662dce44ed31bdf89cb28145ba42d4 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:13:06 +0200 Subject: [PATCH 08/34] feat(rules)!: remove sort-ngmodule-metadata-arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GritQL cannot compare list order, so the rule could only detect the presence of NgModule metadata arrays and fired on every NgModule — including sorted and empty arrays — while its message claimed the array was unsorted. The upstream @angular-eslint rule was likewise deprecated and removed. Tracked as skipped in rules-to-migrate.json. --- biome-manifest.jsonc | 1 - docs/rules/sort-ngmodule-metadata-arrays.md | 82 ------- rules-to-migrate.json | 5 +- .../sort-ngmodule-metadata-arrays.grit | 15 -- tests/helpers/biome-runner.ts | 1 - .../sort-ngmodule-metadata-arrays.test.ts | 32 --- .../sort-ngmodule-metadata-arrays.test.ts | 215 ------------------ 7 files changed, 2 insertions(+), 349 deletions(-) delete mode 100644 docs/rules/sort-ngmodule-metadata-arrays.md delete mode 100644 rules/conventions/sort-ngmodule-metadata-arrays.grit delete mode 100644 tests/integration/sort-ngmodule-metadata-arrays.test.ts delete mode 100644 tests/rules/sort-ngmodule-metadata-arrays.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index 57b96f0..c3453e4 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -14,7 +14,6 @@ "./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/quality/use-lifecycle-interface.grit", "./rules/quality/use-pipe-transform-interface.grit", "./rules/quality/contextual-lifecycle.grit", 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/rules-to-migrate.json b/rules-to-migrate.json index 913d804..3362be4 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -99,9 +99,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", 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 58ae781..a613c54 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -53,7 +53,6 @@ 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', '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', 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/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); - }); - }); -}); From 7eab9ef75bde1eb644e5cf6936a78f4c9addc87b Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:16:14 +0200 Subject: [PATCH 09/34] fix(rules): no-empty-lifecycle-method matches : void and ngOnChanges(param) shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI generates ngOnInit(): void {} — the exact form the rule missed. Add return-type variants for every hook and parameterized variants for ngOnChanges, the only hook that takes an argument. --- .../no-empty-lifecycle-method.grit | 49 ++++++++++--------- .../no-empty-lifecycle-method.test.ts | 12 +++++ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/rules/high-priority/no-empty-lifecycle-method.grit b/rules/high-priority/no-empty-lifecycle-method.grit index 8faa104..1f5ed72 100644 --- a/rules/high-priority/no-empty-lifecycle-method.grit +++ b/rules/high-priority/no-empty-lifecycle-method.grit @@ -1,28 +1,29 @@ +// Port of @angular-eslint/no-empty-lifecycle-method. +// Each hook needs an explicit `: void` variant — GritQL snippets with a return +// type only match annotated methods and vice versa. ngOnChanges also gets +// parameterized variants (it is the only hook that takes an argument). +// Modifier prefixes (async, protected, override) match implicitly. language js or { - `ngOnInit() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - }, - `ngOnDestroy() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - }, - `ngOnChanges() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - }, - `ngDoCheck() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - }, - `ngAfterContentInit() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - }, - `ngAfterContentChecked() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - }, - `ngAfterViewInit() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - }, - `ngAfterViewChecked() {}` where { - register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") - } + `ngOnInit() {}`, + `ngOnInit(): void {}`, + `ngOnDestroy() {}`, + `ngOnDestroy(): void {}`, + `ngOnChanges() {}`, + `ngOnChanges(): void {}`, + `ngOnChanges($p) {}`, + `ngOnChanges($p): void {}`, + `ngDoCheck() {}`, + `ngDoCheck(): void {}`, + `ngAfterContentInit() {}`, + `ngAfterContentInit(): void {}`, + `ngAfterContentChecked() {}`, + `ngAfterContentChecked(): void {}`, + `ngAfterViewInit() {}`, + `ngAfterViewInit(): void {}`, + `ngAfterViewChecked() {}`, + `ngAfterViewChecked(): void {}` +} where { + register_diagnostic(span = $match, message = "Angular lifecycle method is empty and can be removed.", severity = "warn") } diff --git a/tests/integration/no-empty-lifecycle-method.test.ts b/tests/integration/no-empty-lifecycle-method.test.ts index 2ae7d21..effb61a 100644 --- a/tests/integration/no-empty-lifecycle-method.test.ts +++ b/tests/integration/no-empty-lifecycle-method.test.ts @@ -25,6 +25,18 @@ describe('angular/no-empty-lifecycle-method [integration]', () => { 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', ` import { Component } from '@angular/core'; From 050d713534a37de9cda0da8b384a94aca9cd53ec Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:16:14 +0200 Subject: [PATCH 10/34] fix(rules): no-output-on-prefix matches typed and definite-assignment outputs Add @Output() name: T and @Output() name!: T shapes and accept a decorator argument. readonly properties remain a documented false negative (GritQL cannot match the readonly modifier on properties). --- rules/conventions/no-output-on-prefix.grit | 10 +++++++++- tests/integration/no-output-on-prefix.test.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/rules/conventions/no-output-on-prefix.grit b/rules/conventions/no-output-on-prefix.grit index 657cd29..9994f10 100644 --- a/rules/conventions/no-output-on-prefix.grit +++ b/rules/conventions/no-output-on-prefix.grit @@ -1,6 +1,14 @@ +// Port of @angular-eslint/no-output-on-prefix. +// Matches initialized, typed, and definite-assignment (!) output properties, +// with or without a decorator argument. Known false negative: `readonly` +// properties cannot be matched by GritQL snippets. language js -`@Output() $name = $init` where { +or { + `@Output($a) $name = $init`, + `@Output($a) $name: $t`, + `@Output($a) $name!: $t` +} where { $name <: r"^on[A-Z].*", register_diagnostic(span = $name, message = "@Output() property names must not start with 'on' followed by an uppercase letter.") } diff --git a/tests/integration/no-output-on-prefix.test.ts b/tests/integration/no-output-on-prefix.test.ts index a184cc7..6f08110 100644 --- a/tests/integration/no-output-on-prefix.test.ts +++ b/tests/integration/no-output-on-prefix.test.ts @@ -24,6 +24,18 @@ describe('angular/no-output-on-prefix [integration]', () => { `); 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', () => { From 7bad0d8f22ce4f6272aca77b5d1c91a34a5b2d58 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:16:14 +0200 Subject: [PATCH 11/34] fix(rules): prefer-model-signal requires a paired @Input() Implement the input/output pairing the doc always described: capture the output base name via regex and require a matching @Input() in the file. Unpaired *Change outputs (Material-style events) are no longer flagged. --- rules/modern/prefer-model-signal.grit | 19 ++++++++++-- tests/integration/prefer-model-signal.test.ts | 31 ++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/rules/modern/prefer-model-signal.grit b/rules/modern/prefer-model-signal.grit index 545dca5..f493cd3 100644 --- a/rules/modern/prefer-model-signal.grit +++ b/rules/modern/prefer-model-signal.grit @@ -1,6 +1,21 @@ +// 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. +// Known false negative: `readonly` properties cannot be matched by GritQL. language js -`@Output() $name = $init` where { - $name <: r".*Change$", +or { + `@Output($a) $name = $init`, + `@Output($a) $name: $t`, + `@Output($a) $name!: $t` +} where { + $name <: r"(.*)Change$"($base), + $program <: contains or { + `@Input($ia) $base = $_`, + `@Input($ia) $base: $_`, + `@Input($ia) $base!: $_` + }, register_diagnostic(span = $name, message = "Prefer model() over the @Input() + @Output() xyzChange two-way binding pattern (Angular 17.2+).", severity = "warn") } diff --git a/tests/integration/prefer-model-signal.test.ts b/tests/integration/prefer-model-signal.test.ts index d38234b..675814a 100644 --- a/tests/integration/prefer-model-signal.test.ts +++ b/tests/integration/prefer-model-signal.test.ts @@ -1,13 +1,12 @@ 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 () => { + 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: '' }) @@ -17,10 +16,34 @@ describe('angular/prefer-model-signal [integration]', () => { } `); 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', ` import { Component, model } from '@angular/core'; From 771c8a1b25f0becc23c697f8fef67e2e10eb6cc8 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:18:28 +0200 Subject: [PATCH 12/34] fix(cli)!: emit valid Biome config; drop fictional severity mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Biome has no linter.rules.plugins key — configs generated by init/merge were rejected with 'unknown key' and severity per rule can only be set inside each .grit via register_diagnostic. init now writes plugins: ['./node_modules/biome-plugin-angular'] (plugin entries are paths relative to the config, verified against the packed tarball, and extends of the preset resolves them from the project root). The strict preset is removed since severity differentiation is impossible; remove cleans up the invalid key written by earlier versions. --- cli.js | 69 ++++++++++++++++++++-------------------- index.js | 2 -- presets/recommended.json | 30 +---------------- presets/strict.json | 31 ------------------ 4 files changed, 35 insertions(+), 97 deletions(-) delete mode 100644 presets/strict.json diff --git a/cli.js b/cli.js index 4b00972..f28dbfc 100755 --- a/cli.js +++ b/cli.js @@ -4,9 +4,13 @@ * * 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 + * + * Note: Biome resolves plugin entries as paths relative to the config file, + * and per-rule severities are set inside each .grit rule (register_diagnostic), + * not in biome.json — there is no per-rule severity configuration for plugins. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; @@ -16,6 +20,7 @@ import { fileURLToPath } from 'node:url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PLUGIN_NAME = 'biome-plugin-angular'; +const PLUGIN_PATH = `./node_modules/${PLUGIN_NAME}`; // --------------------------------------------------------------------------- // Helpers @@ -51,7 +56,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 +64,37 @@ 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, { + plugins: [PLUGIN_PATH], + }); + success(`Created biome.json with ${PLUGIN_NAME}.`); 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); + if (!Array.isArray(existing.plugins)) existing.plugins = []; + const alreadyConfigured = existing.plugins.some((p) => p.includes(PLUGIN_NAME)); + if (!alreadyConfigured) { + existing.plugins.push(PLUGIN_PATH); } - // 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 +106,13 @@ function cmdRemove() { const config = readJson(targetPath); - // Remove from plugins if (Array.isArray(config.plugins)) { - config.plugins = config.plugins.filter((p) => p !== PLUGIN_NAME); + config.plugins = config.plugins.filter((p) => !p.includes(PLUGIN_NAME)); if (config.plugins.length === 0) delete config.plugins; } - // 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 +133,11 @@ 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 +146,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 +165,7 @@ switch (command) { cmdInit(rest); break; case 'merge': - cmdMerge(rest); + cmdMerge(); break; case 'remove': cmdRemove(); @@ -179,8 +178,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/index.js b/index.js index c933982..3c3ff07 100644 --- a/index.js +++ b/index.js @@ -16,7 +16,6 @@ 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 */ @@ -33,7 +32,6 @@ 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'), // 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'), diff --git a/presets/recommended.json b/presets/recommended.json index 418711b..6545376 100644 --- a/presets/recommended.json +++ b/presets/recommended.json @@ -1,31 +1,3 @@ { - "$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"], - "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" - } - } - } + "plugins": ["./node_modules/biome-plugin-angular"] } 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" - } - } - } -} From 60f2bf7185547cf733077fe3a3e57fe693574d76 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:19:01 +0200 Subject: [PATCH 13/34] fix(pkg): ship rules-to-migrate.json and docs, declare biome peer dependency The rules CLI command reads rules-to-migrate.json but the file was excluded from the npm tarball (verified crash on the installed package), and all README rule-doc links 404'd on npmjs.com without docs/. Declare @biomejs/biome >=2.4.8 <3 as a peer dependency instead of only a dev dependency, and drop the reference to a schema file that never existed. --- package.json | 5 +++++ rules-to-migrate.json | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 71603d0..6004a07 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,18 @@ "files": [ "rules/", "presets/", + "docs/", "index.js", "cli.js", "biome-manifest.jsonc", + "rules-to-migrate.json", "README.md", "CHANGELOG.md", "LICENSE" ], + "peerDependencies": { + "@biomejs/biome": ">=2.4.8 <3" + }, "devDependencies": { "@biomejs/biome": "^2.0.0", "@semantic-release/changelog": "^6.0.3", diff --git a/rules-to-migrate.json b/rules-to-migrate.json index 3362be4..1018b5a 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", From cd207c223971f41aa92d19bcd9dfd160fc6dc762 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:20:39 +0200 Subject: [PATCH 14/34] test(integration): fail loudly when biome output is unusable The runner returned an empty diagnostics array when biome crashed or produced unparseable output, letting every 'valid' test pass vacuously. Throw instead, and reject non-plugin diagnostics (parse/config errors) so a broken fixture can never masquerade as a passing test. --- tests/helpers/biome-runner.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index a613c54..cb9b185 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -139,12 +139,31 @@ export async function runBiomeCheck( const raw = stdout || stderr; + let parsed: { diagnostics?: BiomeDiagnostic[] }; try { - const parsed = JSON.parse(raw) as { diagnostics?: BiomeDiagnostic[] }; - return { diagnostics: parsed.diagnostics ?? [], raw }; + parsed = JSON.parse(raw) as { diagnostics?: BiomeDiagnostic[] }; } catch { - return { diagnostics: [], raw }; + // 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 }; } export { createPatternMatcher } from './pattern-matcher.js'; From 4b288f5fd312c51975efc10d10b5ffbb19084895 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:22:05 +0200 Subject: [PATCH 15/34] test(integration): add packed-tarball consumer smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packs the plugin as npm would publish it, installs it into a fresh project, and lints through every documented onboarding path: direct plugins entry, cli init, and extends of the preset. This is the only test that exercises biome-manifest.jsonc resolution and the package files whitelist — all other tests load .grit files directly. --- tests/integration/consumer-smoke.test.ts | 149 +++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/integration/consumer-smoke.test.ts diff --git a/tests/integration/consumer-smoke.test.ts b/tests/integration/consumer-smoke.test.ts new file mode 100644 index 0000000..3f02161 --- /dev/null +++ b/tests/integration/consumer-smoke.test.ts @@ -0,0 +1,149 @@ +import { execFile } from 'node:child_process'; +import { mkdtempSync, 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 { 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('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('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'); + }); +}); From 4ccdb511717c0d2093a25e42d861de8a41bff728 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:23:04 +0200 Subject: [PATCH 16/34] ci: run integration tests, track latest Biome, wire up semantic-release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI never ran the integration suite — the only tests exercising the real biome binary. Add it to the test matrix plus a non-blocking job against @biomejs/biome@latest as an early warning for upstream changes. The release workflow published on tag push with plain npm publish and the archived actions/create-release, while package.json configured semantic-release that nothing ever invoked — so CHANGELOG.md stayed empty and versions never bumped. Release now runs semantic-release on push to main after the full test suite. --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++- .github/workflows/release.yml | 36 ++++++++++++++++------------------- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2749380..08c3642 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,34 @@ 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 + 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 }} From daf034fb809448328ffd71dc2440a1159ada8b47 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:25:24 +0200 Subject: [PATCH 17/34] test(unit): align reference-spec layer with shipped rule semantics The ts-morph pattern-matcher had drifted from the GritQL rules in both directions, certifying behavior the plugin does not have. Relabel it as the reference-semantics spec (integration tests on the real binary are the authority), delete the sort-ngmodule matcher, align prefer-standalone (only standalone: false), contextual-lifecycle (upstream semantics), and pipe-prefix (prefix must be followed by an uppercase letter). --- tests/helpers/pattern-matcher.test.ts | 3 +- tests/helpers/pattern-matcher.ts | 156 +++++------------- tests/rules/contextual-lifecycle.test.ts | 124 ++++++++++---- tests/rules/no-empty-lifecycle-method.test.ts | 16 ++ tests/rules/pipe-prefix.test.ts | 30 ++++ tests/rules/prefer-model-signal.test.ts | 13 ++ tests/rules/prefer-standalone.test.ts | 110 +++++------- vitest.config.ts | 3 + 8 files changed, 240 insertions(+), 215 deletions(-) diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index 8e7c00e..06af967 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'); @@ -32,7 +31,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..d45e63d 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -1,8 +1,20 @@ import { Block, Project, type SourceFile } 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. + * + * Deliberate divergences from the shipped GritQL rules: the GritQL engine + * cannot match `readonly` properties, `implements` clauses, or get/set + * accessors. The reference implementations here do NOT mimic those engine + * limitations — they follow the intended semantics instead, so some cases + * flagged here are known false negatives of the shipped rules. * * Each matcher returns an array of violation objects (empty = no violations). */ @@ -24,9 +36,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 +51,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 +348,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 +359,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 +495,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 +504,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`, + }); } } @@ -859,7 +794,6 @@ export function createPatternMatcher() { detectOutputRename, detectOutputOnPrefix, detectMissingPipePrefix, - detectUnsortedNgModuleArrays, // Quality rules detectMissingLifecycleInterface, detectMissingPipeTransform, 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/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/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-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/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/**'], }, From ea35ce6ac9661b499788df9ffef8ed643f165bf2 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:25:25 +0200 Subject: [PATCH 18/34] docs: describe actual consumption path, severities, and engine limitations README now shows the only valid configs (plugins path or extends of the preset), documents that severities are fixed in the .grit files, drops the removed strict preset and sort-ngmodule rule, and replaces the 'heuristic/non-functional' caveats with the real remaining limitations (implements clauses and readonly properties, both pinned by it.fails tests). CLAUDE.md gets the verified GritQL pattern-writing rules. Track the three suffix rules in rules-to-migrate.json. --- CLAUDE.md | 61 ++++++++++++----------- README.md | 113 +++++++++++++++++++++++++----------------- cli.js | 4 +- index.js | 14 +++++- rules-to-migrate.json | 27 ++++++++++ 5 files changed, 139 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ea7e24..b13e70a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,13 +5,16 @@ Plugin Biome pour Angular 17-21. Règles TypeScript en GritQL pour Biome 2.x. 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,29 @@ 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 - -**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é +- 19 règles dans biome-manifest.jsonc, toutes fonctionnelles et décorateur-aware +- 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 +- Tests d'intégration : source de vérité, incluent un smoke test consommateur (npm pack → install → lint) +- Consommation : "plugins": ["./node_modules/biome-plugin-angular"] (chemin relatif au config) + ou extends de biome-plugin-angular/presets/recommended.json +- Sévérités fixées dans les .grit (register_diagnostic) — linter.rules.plugins N'EXISTE PAS dans Biome + +## GritQL (Biome 2.4.8+) — règles d'écriture de patterns (vérifiées empiriquement) +- `@Decorator($args)\nclass $name { $body }` matche — MAIS il faut une variante explicite + `export class` pour matcher les classes exportées (l'ancien "GritQL ne matche pas + décorateur+classe" était un mauvais diagnostic : il manquait juste `export`) +- Variantes nécessaires par forme de classe : {class, export class} × {simple, extends $ext} +- IMPOSSIBLE à matcher (limitations moteur, false negatives documentés par tests it.fails) : + - clause `implements` avec métavariable (`class $n implements $i { $b }` ne matche JAMAIS ; + littéral `implements Bar` OK) — ni `within`, ni `contains`, ni `$...` ne contournent + - propriétés `readonly` (`@Output() readonly $n = $i` ne matche jamais, même en littéral avec nom) +- Les snippets méthode avec type de retour sont distincts : `ngOnInit() {}` ≠ `ngOnInit(): void {}` + (les deux variantes sont nécessaires) ; les modificateurs (async, protected) matchent implicitement +- Identifiant nu en `contains` (ex: `ngOnInit`) matche toute occurrence, signature incluse +- Capture regex disponible : `$name <: r"(.*)Change$"($base)` puis réutiliser $base +- `$program <: contains …` permet des vérifications au niveau fichier +- Les diagnostics plugin ont category "plugin" dans le JSON (`--reporter=json`) diff --git a/README.md b/README.md index 34e3f6f..2df02a4 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ ## Features -- 20 GritQL rules for Biome 2.x plugin system +- 19 GritQL rules for the Biome 2.x plugin system - Ports the most-used `@angular-eslint` rules -- 6 new rules for modern Angular (signals, inject, host properties) +- 6 rules for modern Angular (signals, inject, host properties) - 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 --- @@ -42,7 +42,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 +54,44 @@ npx biome-angular merge ### Option B — Manual configuration -Add the plugin to your `biome.json`: +Add the plugin to your `biome.json`. Biome resolves plugin entries as **paths +relative to the config file** (not npm package names), so reference the +installed package directory: ```jsonc { - "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", - "plugins": ["biome-plugin-angular"] + "plugins": ["./node_modules/biome-plugin-angular"] } ``` -Biome will load `biome-manifest.jsonc` from the plugin package automatically. +Or extend the shipped preset (which contains exactly that plugins entry): + +```jsonc +{ + "extends": ["biome-plugin-angular/presets/recommended.json"] +} +``` + +Biome loads `biome-manifest.jsonc` from the plugin directory and activates all +19 rules. + +### 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 +(`no-input-rename`, `no-output-rename`, `no-output-on-prefix`, +`prefer-standalone`, `contextual-lifecycle`) 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" + ] +} +``` --- @@ -73,30 +101,29 @@ Biome will load `biome-manifest.jsonc` from the plugin package automatically. | Rule | ESLint equivalent | Severity | Notes | | ---- | ----------------- | :------: | ----- | -| [`prefer-standalone`](docs/rules/prefer-standalone.md) | `@angular-eslint/prefer-standalone` | error | | +| [`prefer-standalone`](docs/rules/prefer-standalone.md) | `@angular-eslint/prefer-standalone` | error | flags only explicit `standalone: false` | | [`no-empty-lifecycle-method`](docs/rules/no-empty-lifecycle-method.md) | `@angular-eslint/no-empty-lifecycle-method` | warn | | ### 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-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* | | [`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** | +| [`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-pipe-transform-interface`](docs/rules/use-pipe-transform-interface.md) | `@angular-eslint/use-pipe-transform-interface` | warn | per-file check | +| [`contextual-lifecycle`](docs/rules/contextual-lifecycle.md) | `@angular-eslint/contextual-lifecycle` | error | | ### Modern (Angular 17+) @@ -105,33 +132,43 @@ Biome will load `biome-manifest.jsonc` from the plugin package automatically. | [`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-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) | _(new)_ | warn | decorator-gated | + +\* 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. + +### Known engine limitations -### GritQL Limitations +Biome's GritQL engine cannot match certain TypeScript constructs, which causes +**false negatives** (never false positives) in specific shapes: -Biome's GritQL engine (2.4.8) cannot match `@Decorator` + `class` as a single pattern unit. This affects some rules: +- **`implements` clauses**: decorated classes with an `implements` clause are + invisible to decorator+class patterns. Affects the suffix rules, + `contextual-lifecycle`, and `prefer-inject-function`. +- **`readonly` properties**: `@Output() readonly x = …` cannot be matched. + Affects `no-output-on-prefix` and `prefer-model-signal`. -- **\* 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. +Each limitation is pinned by an `it.fails` integration test, so the suite will +flag the day a Biome release lifts it. --- ## CLI Reference ``` -biome-angular init [--strict] [--force] +biome-angular init [--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] +biome-angular merge 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. + Remove the plugin reference from biome.json (also cleans config + written by pre-1.0 versions of this CLI). biome-angular rules Print all available rules and their implementation status. @@ -139,23 +176,6 @@ biome-angular rules --- -## Presets - -Two presets are available: - -| Preset | Description | -| ------ | ----------- | -| `recommended` | All high-priority rules; `no-empty-lifecycle-method` as warn | -| `strict` | All high-priority rules as errors | - -Reference them in `biome.json`: - -```json -{ "extends": ["biome-plugin-angular/presets/strict"] } -``` - ---- - ## Migration from @angular-eslint If you are migrating from ESLint + `@angular-eslint`, you can remove the following packages @@ -165,6 +185,9 @@ 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. + See [`rules-to-migrate.json`](rules-to-migrate.json) for the full migration tracking list. --- diff --git a/cli.js b/cli.js index f28dbfc..49231b4 100755 --- a/cli.js +++ b/cli.js @@ -135,9 +135,7 @@ function cmdRules() { const statusIcon = { done: '✅', todo: '⬜', 'in-progress': '🔄', skipped: '⏭️ ' }; const categoryOrder = { 'high-priority': 0, conventions: 1, quality: 2, modern: 3 }; - rules.sort( - (a, b) => (categoryOrder[a.category] ?? 99) - (categoryOrder[b.category] ?? 99), - ); + rules.sort((a, b) => (categoryOrder[a.category] ?? 99) - (categoryOrder[b.category] ?? 99)); let currentCategory = null; for (const rule of rules) { diff --git a/index.js b/index.js index 3c3ff07..97c4f2b 100644 --- a/index.js +++ b/index.js @@ -22,7 +22,12 @@ export const presets = { 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', + ), // conventions 'no-component-suffix': join(__dirname, 'rules', 'conventions', 'no-component-suffix.grit'), 'no-directive-suffix': join(__dirname, 'rules', 'conventions', 'no-directive-suffix.grit'), @@ -34,7 +39,12 @@ export const rules = { 'pipe-prefix': join(__dirname, 'rules', 'conventions', 'pipe-prefix.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'), // modern 'prefer-signal-inputs': join(__dirname, 'rules', 'modern', 'prefer-signal-inputs.grit'), diff --git a/rules-to-migrate.json b/rules-to-migrate.json index 1018b5a..2d3d63d 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -28,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", From 85cddddd7883d7265fdce5c16c2a981d84de7a98 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:28:07 +0200 Subject: [PATCH 19/34] fix(rules): use-lifecycle-interface checks interfaces at file level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implements-clause branches (over half the rule) never matched — GritQL cannot bind an implements clause — so any class with an implements clause was invisible, including the partial-implements case the rule exists for. Match each lifecycle method instead and require the corresponding interface to be referenced in the file. Diagnostics now name the exact missing interface. Also matches : void and parameterized ngOnChanges signatures. --- rules/quality/use-lifecycle-interface.grit | 132 +++++------------- .../use-lifecycle-interface.test.ts | 98 +++++++++++-- 2 files changed, 123 insertions(+), 107 deletions(-) diff --git a/rules/quality/use-lifecycle-interface.grit b/rules/quality/use-lifecycle-interface.grit index a40f090..6eff9c7 100644 --- a/rules/quality/use-lifecycle-interface.grit +++ b/rules/quality/use-lifecycle-interface.grit @@ -1,111 +1,45 @@ +// Port of @angular-eslint/use-lifecycle-interface. +// +// Implementation note: GritQL metavariables cannot bind an `implements` clause, +// so class-shape branches like `class $n implements $i { $b }` never match. +// Instead, match each lifecycle METHOD and require the file to reference the +// corresponding interface (import + implements both appear as identifiers). +// Known false negative: a file that imports the interface without implementing +// it. Each hook needs a `: void` variant (annotated methods are distinct nodes); +// ngOnChanges also gets parameterized variants. 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") + or { `ngOnInit() { $_ }`, `ngOnInit(): void { $_ }` } where { + $program <: not contains `OnInit`, + register_diagnostic(span = $match, message = "Lifecycle method ngOnInit: implement the OnInit interface.", 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") + or { `ngOnDestroy() { $_ }`, `ngOnDestroy(): void { $_ }` } where { + $program <: not contains `OnDestroy`, + register_diagnostic(span = $match, message = "Lifecycle method ngOnDestroy: implement the OnDestroy interface.", 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") + or { `ngOnChanges() { $_ }`, `ngOnChanges(): void { $_ }`, `ngOnChanges($p) { $_ }`, `ngOnChanges($p): void { $_ }` } where { + $program <: not contains `OnChanges`, + register_diagnostic(span = $match, message = "Lifecycle method ngOnChanges: implement the OnChanges interface.", 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") + or { `ngDoCheck() { $_ }`, `ngDoCheck(): void { $_ }` } where { + $program <: not contains `DoCheck`, + register_diagnostic(span = $match, message = "Lifecycle method ngDoCheck: implement the DoCheck interface.", 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") + or { `ngAfterContentInit() { $_ }`, `ngAfterContentInit(): void { $_ }` } where { + $program <: not contains `AfterContentInit`, + register_diagnostic(span = $match, message = "Lifecycle method ngAfterContentInit: implement the AfterContentInit interface.", 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") + or { `ngAfterContentChecked() { $_ }`, `ngAfterContentChecked(): void { $_ }` } where { + $program <: not contains `AfterContentChecked`, + register_diagnostic(span = $match, message = "Lifecycle method ngAfterContentChecked: implement the AfterContentChecked interface.", 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") + or { `ngAfterViewInit() { $_ }`, `ngAfterViewInit(): void { $_ }` } where { + $program <: not contains `AfterViewInit`, + register_diagnostic(span = $match, message = "Lifecycle method ngAfterViewInit: implement the AfterViewInit interface.", 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") + or { `ngAfterViewChecked() { $_ }`, `ngAfterViewChecked(): void { $_ }` } where { + $program <: not contains `AfterViewChecked`, + register_diagnostic(span = $match, message = "Lifecycle method ngAfterViewChecked: implement the AfterViewChecked interface.", severity = "warn") } } diff --git a/tests/integration/use-lifecycle-interface.test.ts b/tests/integration/use-lifecycle-interface.test.ts index 6b431aa..4229b7e 100644 --- a/tests/integration/use-lifecycle-interface.test.ts +++ b/tests/integration/use-lifecycle-interface.test.ts @@ -2,51 +2,133 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/use-lifecycle-interface [integration]', () => { + // The rule matches lifecycle METHODS and requires the corresponding + // interface to be referenced somewhere in the file (GritQL cannot bind + // implements clauses, so per-class checking is impossible). Per-file + // semantics: a file importing the interface without implementing it is a + // known false negative. 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); }); + + // Per-file matching: importing the interface exempts every class in the + // file even without an implements clause. Documented false negative. + it.fails('flags ngOnInit when OnInit is imported but not implemented (known FN)', 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); + }); }); }); From 099def66000fcd98a6dc40fe29eff6c9fa927e67 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:28:07 +0200 Subject: [PATCH 20/34] test(integration): assert diagnostic messages and add real-world fixtures Every violation test now asserts on the diagnostic message, not just the count. New fixtures: attribute and multi-selector components, alias options objects, transform inputs, readonly outputs and queries, getter host bindings, setter inputs, and partial-implements lifecycle classes. Known engine gaps (multi-selector false positive, per-file interface check) are pinned with it.fails sentinels that flip when Biome lifts the limitation. --- tests/helpers/biome-runner.ts | 14 +--- tests/integration/component-selector.test.ts | 70 ++++++++++++++++--- tests/integration/consumer-smoke.test.ts | 30 +++++--- .../integration/contextual-lifecycle.test.ts | 63 ++++++++++++----- tests/integration/no-component-suffix.test.ts | 42 +++++++---- tests/integration/no-directive-suffix.test.ts | 21 ++++-- .../no-empty-lifecycle-method.test.ts | 35 +++++++--- tests/integration/no-input-rename.test.ts | 59 +++++++++++++--- tests/integration/no-output-on-prefix.test.ts | 35 +++++++--- tests/integration/no-output-rename.test.ts | 38 ++++++++-- tests/integration/no-service-suffix.test.ts | 21 ++++-- tests/integration/pipe-prefix.test.ts | 55 +++++++++++++-- .../integration/prefer-host-property.test.ts | 66 ++++++++++++++--- .../prefer-inject-function.test.ts | 35 +++++++--- tests/integration/prefer-model-signal.test.ts | 35 +++++++--- .../prefer-output-function.test.ts | 39 +++++++++-- .../integration/prefer-signal-inputs.test.ts | 67 +++++++++++++++--- .../integration/prefer-signal-queries.test.ts | 48 ++++++++++--- tests/integration/prefer-standalone.test.ts | 42 +++++++---- .../use-pipe-transform-interface.test.ts | 49 +++++++++---- 20 files changed, 674 insertions(+), 190 deletions(-) diff --git a/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index cb9b185..bf6a2e8 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, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -105,10 +105,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); @@ -120,12 +117,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, diff --git a/tests/integration/component-selector.test.ts b/tests/integration/component-selector.test.ts index 6e4edf0..b59dc4f 100644 --- a/tests/integration/component-selector.test.ts +++ b/tests/integration/component-selector.test.ts @@ -4,49 +4,99 @@ 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"); + }); + + // Known false positive: each selector in the list is valid kebab-case with + // the app- prefix, but the regex validates the whole string instead of each + // comma-separated selector. Should eventually be fixed. + it.fails('accepts multi-selector list of valid app- selectors (known FP)', 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 index 3f02161..932b5b6 100644 --- a/tests/integration/consumer-smoke.test.ts +++ b/tests/integration/consumer-smoke.test.ts @@ -33,10 +33,14 @@ describe('consumer smoke test [integration]', () => { ); 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, - }); + await execFileAsync( + 'npm', + ['install', '--no-audit', '--no-fund', join(consumerDir, filename)], + { + cwd: consumerDir, + maxBuffer: 16 * 1024 * 1024, + }, + ); }, 120_000); afterAll(() => { @@ -47,22 +51,26 @@ describe('consumer smoke test [integration]', () => { } }); - async function lint(file: string): Promise<{ diagnostics: Array<{ category: string; message: string }> }> { + 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 }, - )); + ({ 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 }> }; + const parsed = JSON.parse(raw) as { + diagnostics?: Array<{ category: string; message: string }>; + }; return { diagnostics: parsed.diagnostics ?? [] }; } diff --git a/tests/integration/contextual-lifecycle.test.ts b/tests/integration/contextual-lifecycle.test.ts index 42bd92e..da6ce0e 100644 --- a/tests/integration/contextual-lifecycle.test.ts +++ b/tests/integration/contextual-lifecycle.test.ts @@ -7,51 +7,63 @@ describe('angular/contextual-lifecycle [integration]', () => { // (directives DO receive view/content hooks), so they are never flagged. describe('violations', () => { it('flags ngAfterViewInit in @Injectable', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` + 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('flags ngOnInit in @Injectable (services only support ngOnDestroy)', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` + 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('flags ngAfterViewInit in @Pipe', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` import { Pipe } from '@angular/core'; @Pipe({ name: 'appFoo', standalone: true }) 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', ` + 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); }); @@ -59,55 +71,69 @@ describe('angular/contextual-lifecycle [integration]', () => { // classes WITH `implements` are invisible to the rule. Documented false // negative — flips to failing the day Biome supports it. it.fails('flags ngOnInit in @Injectable that has an implements clause (known FN)', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` + 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 ngAfterViewInit in @Directive (directives receive view hooks)', async () => { - const { diagnostics, raw } = await runBiomeCheck('contextual-lifecycle', ` + 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', ` + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) 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', ` + const { diagnostics, raw } = await runBiomeCheck( + 'contextual-lifecycle', + ` import { Pipe } from '@angular/core'; @Pipe({ name: 'appAsyncish', standalone: true }) export class Asyncish { @@ -115,7 +141,8 @@ describe('angular/contextual-lifecycle [integration]', () => { ngOnDestroy() { this.cleanup(); } cleanup() {} } - `); + `, + ); 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 5ab0a44..6e38e2f 100644 --- a/tests/integration/no-component-suffix.test.ts +++ b/tests/integration/no-component-suffix.test.ts @@ -4,29 +4,38 @@ 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', ` + 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'); }); @@ -34,29 +43,38 @@ describe('angular/no-component-suffix [integration]', () => { describe('valid', () => { it('accepts an undecorated class ending in Component (not Angular)', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-component-suffix', ` + 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-directive-suffix.test.ts b/tests/integration/no-directive-suffix.test.ts index 915d73b..2090979 100644 --- a/tests/integration/no-directive-suffix.test.ts +++ b/tests/integration/no-directive-suffix.test.ts @@ -4,11 +4,14 @@ 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'); }); @@ -16,20 +19,26 @@ describe('angular/no-directive-suffix [integration]', () => { describe('valid', () => { it('accepts an undecorated class ending in Directive (not Angular)', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-directive-suffix', ` + 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 effb61a..939bf7c 100644 --- a/tests/integration/no-empty-lifecycle-method.test.ts +++ b/tests/integration/no-empty-lifecycle-method.test.ts @@ -4,61 +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', ` + 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-output-on-prefix.test.ts b/tests/integration/no-output-on-prefix.test.ts index 6f08110..8062d64 100644 --- a/tests/integration/no-output-on-prefix.test.ts +++ b/tests/integration/no-output-on-prefix.test.ts @@ -4,60 +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', ` + 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-service-suffix.test.ts b/tests/integration/no-service-suffix.test.ts index a5e720d..d42a553 100644 --- a/tests/integration/no-service-suffix.test.ts +++ b/tests/integration/no-service-suffix.test.ts @@ -4,11 +4,14 @@ 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'); }); @@ -16,20 +19,26 @@ describe('angular/no-service-suffix [integration]', () => { describe('valid', () => { it('accepts an undecorated class ending in Service (e.g. NestJS)', async () => { - const { diagnostics, raw } = await runBiomeCheck('no-service-suffix', ` + 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..67f40c8 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 configured 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 configured 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 configured 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 f778a25..dd60514 100644 --- a/tests/integration/prefer-inject-function.test.ts +++ b/tests/integration/prefer-inject-function.test.ts @@ -8,60 +8,75 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; 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', ` + 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 675814a..951bc5d 100644 --- a/tests/integration/prefer-model-signal.test.ts +++ b/tests/integration/prefer-model-signal.test.ts @@ -7,62 +7,77 @@ import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/prefer-model-signal [integration]', () => { describe('violations', () => { it('flags a paired @Input() value + @Output() valueChange', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-model-signal', ` + 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', ` + 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', ` + 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 81bdaf7..80b8c1b 100644 --- a/tests/integration/prefer-standalone.test.ts +++ b/tests/integration/prefer-standalone.test.ts @@ -7,61 +7,79 @@ describe('angular/prefer-standalone [integration]', () => { // violation; omission is idiomatic modern Angular. describe('violations', () => { 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 with standalone: false', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` import { Directive } from '@angular/core'; @Directive({ selector: '[appFoo]', standalone: false }) export class Foo {} - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); it('flags @Pipe with standalone: false', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + const { diagnostics, raw } = await runBiomeCheck( + 'prefer-standalone', + ` import { Pipe, PipeTransform } from '@angular/core'; @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 without standalone property (default since Angular 19)', async () => { - const { diagnostics, raw } = await runBiomeCheck('prefer-standalone', ` + 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', ` + 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/use-pipe-transform-interface.test.ts b/tests/integration/use-pipe-transform-interface.test.ts index 971a0d3..ae5966b 100644 --- a/tests/integration/use-pipe-transform-interface.test.ts +++ b/tests/integration/use-pipe-transform-interface.test.ts @@ -7,72 +7,90 @@ describe('angular/use-pipe-transform-interface [integration]', () => { // matching is impossible for implements-classes). Per-file semantics. describe('violations', () => { it('flags @Pipe class with no implements clause', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-pipe-transform-interface', ` + 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('flags @Pipe class implementing other interface but not PipeTransform', async () => { - const { diagnostics, raw } = await runBiomeCheck('use-pipe-transform-interface', ` + 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', ` + 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', ` + 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', ` + const { diagnostics, raw } = await runBiomeCheck( + 'use-pipe-transform-interface', + ` export class NotAPipe { transform(value: string) { return value; } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); @@ -80,13 +98,16 @@ describe('angular/use-pipe-transform-interface [integration]', () => { // in it, even one that does not implement the interface. Documented false // negative of the file-level approach. it.fails('flags a non-implementing @Pipe when the file imports PipeTransform (known FN)', 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: 'appBad' }) export class BadPipe { transform(value: string) { return value; } } - `); + `, + ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); }); From 2014514d47fc88996086eb9888a145729b401080 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:30:31 +0200 Subject: [PATCH 21/34] docs(rules): align all rule docs with shipped behavior Every doc now shows the only valid Biome config, states the fixed severity from its .grit, and documents the real matching semantics: flipped prefer-standalone, functional contextual-lifecycle and use-pipe-transform-interface, decorator gating, per-file checks, and the implements/readonly engine limitations. Removed the fictional configurable-prefix claims and an unverifiable schematic reference. --- docs/rules/component-selector.md | 38 ++++++------ docs/rules/contextual-lifecycle.md | 71 ++++++++++++++-------- docs/rules/no-component-suffix.md | 41 +++++++------ docs/rules/no-directive-suffix.md | 32 ++++++---- docs/rules/no-empty-lifecycle-method.md | 38 +++++++++--- docs/rules/no-input-rename.md | 24 +++++--- docs/rules/no-output-on-prefix.md | 36 +++++++---- docs/rules/no-output-rename.md | 24 +++++--- docs/rules/no-service-suffix.md | 33 +++++++--- docs/rules/pipe-prefix.md | 42 ++++++------- docs/rules/prefer-host-property.md | 23 ++++--- docs/rules/prefer-inject-function.md | 30 ++++++--- docs/rules/prefer-model-signal.md | 43 +++++++++---- docs/rules/prefer-output-function.md | 23 ++++--- docs/rules/prefer-signal-inputs.md | 23 ++++--- docs/rules/prefer-signal-queries.md | 23 ++++--- docs/rules/prefer-standalone.md | 58 +++++++++--------- docs/rules/use-lifecycle-interface.md | 23 ++++--- docs/rules/use-pipe-transform-interface.md | 30 ++++++--- 19 files changed, 417 insertions(+), 238 deletions(-) diff --git a/docs/rules/component-selector.md b/docs/rules/component-selector.md index 1b2d188..b107a61 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,7 +10,7 @@ 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. @@ -56,36 +56,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..40b61f6 100644 --- a/docs/rules/contextual-lifecycle.md +++ b/docs/rules/contextual-lifecycle.md @@ -2,73 +2,90 @@ **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(); } } ``` +## Known limitations + +- Classes with an `implements` clause are not matched (GritQL metavariables cannot bind + heritage clauses), so a violating `@Injectable` class declared as + `class UserStore implements OnInit { ... }` is a **false negative**. +- Hooks are detected as bare identifiers inside the class body, so any signature shape + is caught (`: void`, `async`, parameters). + ## 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/no-component-suffix.md b/docs/rules/no-component-suffix.md index 4341ec5..0b0b0a1 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,13 @@ 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. + +**Known false negative:** decorated classes with an `implements` clause +(e.g. `class HeaderComponent implements OnInit`) cannot be matched by GritQL and are +not flagged. + > "We're updating our style guide to no longer recommend adding suffixes like `Component`, > `Directive`, `Service`, or `Pipe` to class names." > @@ -53,29 +60,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-directive-suffix.md b/docs/rules/no-directive-suffix.md index 6987767..241102e 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,13 @@ 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. + +**Known false negative:** decorated classes with an `implements` clause +(e.g. `class HighlightDirective implements OnInit`) cannot be matched by GritQL and are +not flagged. + > "We're updating our style guide to no longer recommend adding suffixes like `Component`, > `Directive`, `Service`, or `Pipe` to class names." > @@ -50,23 +57,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..e50b2ce 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,11 @@ 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. + ## Covered lifecycle hooks | Method | Interface | @@ -38,6 +43,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 +89,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..cd7cd0a 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 @@ -62,18 +62,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-output-on-prefix.md b/docs/rules/no-output-on-prefix.md index e1f32de..9cda628 100644 --- a/docs/rules/no-output-on-prefix.md +++ b/docs/rules/no-output-on-prefix.md @@ -2,7 +2,7 @@ **Category:** conventions **ESLint equivalent:** `@angular-eslint/no-output-on-prefix` -**Severity (recommended):** error +**Severity:** error ## Overview @@ -20,9 +20,10 @@ 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 } ``` @@ -52,21 +53,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. +- Initialized (`= new EventEmitter()`), typed (`: EventEmitter`), and + definite-assignment (`!: EventEmitter`) properties are all matched, with or without + a decorator argument. +- **Known false negative:** `readonly` output properties + (`@Output() readonly onSubmit = ...`) cannot be matched by GritQL and are not flagged. ## 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..328b7a9 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 @@ -68,18 +68,26 @@ clicked = output({ alias: 'clickEvent' }); ## 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-service-suffix.md b/docs/rules/no-service-suffix.md index 5a79e44..e54a3b2 100644 --- a/docs/rules/no-service-suffix.md +++ b/docs/rules/no-service-suffix.md @@ -2,13 +2,20 @@ **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. + +**Known false negative:** decorated classes with an `implements` clause +(e.g. `class UserService implements OnDestroy`) cannot be matched by GritQL and are +not flagged. + > "We're updating our style guide to no longer recommend adding suffixes like `Component`, > `Directive`, `Service`, or `Pipe` to class names." > @@ -77,20 +84,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..ec7dcb6 100644 --- a/docs/rules/prefer-inject-function.md +++ b/docs/rules/prefer-inject-function.md @@ -2,13 +2,18 @@ **Category:** modern (signal-based APIs) **ESLint equivalent:** _(new rule — no direct ESLint equivalent)_ -**Severity (recommended):** warn +**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 constructors with access-modifier parameters (`private`, `protected`, `public`, `readonly`) — the classic Angular DI pattern — but only inside classes decorated with `@Component`, `@Directive`, `@Injectable`, or `@Pipe`. Plain TypeScript or NestJS classes using parameter properties are **not** flagged. + +**Known false negatives:** + +- Decorated classes with an `implements` clause cannot be matched by GritQL and are not flagged. +- DI without an access modifier (`constructor(dep: Service) { this.dep = dep }`) is undetectable statically and is not flagged. Benefits: - Works in functional injection contexts (guards, resolvers, interceptors) @@ -61,18 +66,25 @@ export const authGuard = () => { ## 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-inject-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 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..d8c5953 100644 --- a/docs/rules/prefer-model-signal.md +++ b/docs/rules/prefer-model-signal.md @@ -2,13 +2,15 @@ **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`), and definite-assignment (`!: T`) properties are all matched. + +**Known false negative:** `readonly` properties (e.g. `@Output() readonly valueChange = ...`) cannot be matched by GritQL and are not flagged. ## Examples @@ -19,8 +21,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 +58,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/use-lifecycle-interface.md b/docs/rules/use-lifecycle-interface.md index 87c88be..11b8d3d 100644 --- a/docs/rules/use-lifecycle-interface.md +++ b/docs/rules/use-lifecycle-interface.md @@ -2,7 +2,7 @@ **Category:** quality **ESLint equivalent:** `@angular-eslint/use-lifecycle-interface` -**Severity (recommended):** warn +**Severity:** warn ## Overview @@ -53,18 +53,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..6811d10 100644 --- a/docs/rules/use-pipe-transform-interface.md +++ b/docs/rules/use-pipe-transform-interface.md @@ -2,12 +2,19 @@ **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 file-level. The rule flags every `@Pipe` decorator in a file that never references `PipeTransform` anywhere. (GritQL cannot match a decorator + `implements` clause as one unit, so the implementation cannot inspect each class's heritage clause directly.) + +**Known false negatives:** + +- A file that imports (or otherwise mentions) `PipeTransform` without actually implementing it is not flagged. +- In a file containing several pipes, if only one of them implements `PipeTransform`, the others are not flagged. + ## Examples ### Flagged @@ -38,18 +45,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) From 136e8705f22c2888c6ec130463f8aeebab926dd9 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Sun, 9 Aug 2026 18:31:12 +0200 Subject: [PATCH 22/34] chore: upgrade to Biome 2.5.5 All 109 integration tests pass unchanged on 2.5.5. Migrate the repo config (rules.recommended -> preset). Test-runner configs keep the deprecated rules.recommended form, the only shape valid on both the 2.4.8 peer-dependency floor and current releases. 2.5.0+ also unlocks fixable GritQL diagnostics and per-plugin file scoping for future use. --- biome.json | 4 +-- package-lock.json | 79 ++++++++++++++++++++++++----------------------- package.json | 2 +- 3 files changed, 44 insertions(+), 41 deletions(-) diff --git a/biome.json b/biome.json index e6f6e99..08eb73c 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.5/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/package-lock.json b/package-lock.json index 1bae9a1..6bf04c3 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.5", "@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.4.8 <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.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.5.tgz", + "integrity": "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==", "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.5", + "@biomejs/cli-darwin-x64": "2.5.5", + "@biomejs/cli-linux-arm64": "2.5.5", + "@biomejs/cli-linux-arm64-musl": "2.5.5", + "@biomejs/cli-linux-x64": "2.5.5", + "@biomejs/cli-linux-x64-musl": "2.5.5", + "@biomejs/cli-win32-arm64": "2.5.5", + "@biomejs/cli-win32-x64": "2.5.5" } }, "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.5.tgz", + "integrity": "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==", "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.5.tgz", + "integrity": "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==", "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.5.tgz", + "integrity": "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==", "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.5.tgz", + "integrity": "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==", "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.5.tgz", + "integrity": "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==", "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.5.tgz", + "integrity": "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==", "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.5.tgz", + "integrity": "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==", "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.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.5.tgz", + "integrity": "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 6004a07..1c83da1 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@biomejs/biome": ">=2.4.8 <3" }, "devDependencies": { - "@biomejs/biome": "^2.0.0", + "@biomejs/biome": "^2.5.5", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.6", From befed80f69a20d885665ada188f009e4e60ca975 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 23/34] fix(rules): match every class and member shape with native AST nodes Snippet patterns only matched the exact shape they spelled out: a decorated class with an implements clause, a readonly member, or a method with an override modifier was invisible. On a CLI-generated Angular app every component implements OnInit, so the suffix rules, contextual-lifecycle and prefer-inject-function missed most real code. Rules now match through Biome's native AST nodes (JsClassDeclaration, JsExport, JsPropertyClassMember, JsMethodClassMember...), which expose decorators, implements clauses, modifiers and members as fields. Effects: - implements, extends, generics and export default classes are matched - readonly properties are matched - use-lifecycle-interface and use-pipe-transform-interface check the implements clause of each class instead of the whole file - prefer-inject-function flags every constructor parameter of a decorated class, like @angular-eslint/prefer-inject, not only parameter properties - no-input-rename / no-output-rename / no-output-on-prefix cover the input(), model(), output() and outputFromObservable() functions - component-selector accepts comma-separated selector lists - every message starts with "[rule-name]" so the suppression name (lint/plugin/rule-name) is discoverable; metavariables are not interpolated in messages The it.fails sentinels for these engine limitations now pass and were flipped to regular tests. The test runner enables unsafeParameterDecoratorsEnabled, which Angular constructors need. --- biome-manifest.jsonc | 2 +- docs/rules/component-selector.md | 3 +- docs/rules/contextual-lifecycle.md | 10 +- docs/rules/no-component-suffix.md | 5 +- docs/rules/no-directive-suffix.md | 5 +- docs/rules/no-input-rename.md | 15 +- docs/rules/no-output-on-prefix.md | 15 +- docs/rules/no-output-rename.md | 13 +- docs/rules/no-service-suffix.md | 5 +- docs/rules/prefer-inject-function.md | 11 +- docs/rules/prefer-model-signal.md | 4 +- docs/rules/use-lifecycle-interface.md | 2 + docs/rules/use-pipe-transform-interface.md | 7 +- rules/conventions/component-selector.grit | 11 +- rules/conventions/no-component-suffix.grit | 22 +- rules/conventions/no-directive-suffix.grit | 22 +- rules/conventions/no-input-rename.grit | 26 +- rules/conventions/no-output-on-prefix.grit | 21 +- rules/conventions/no-output-rename.grit | 18 +- rules/conventions/no-service-suffix.grit | 22 +- rules/conventions/pipe-prefix.grit | 5 +- rules/high-priority/prefer-standalone.grit | 8 +- rules/modern/prefer-host-property.grit | 18 +- rules/modern/prefer-inject-function.grit | 58 +--- rules/modern/prefer-model-signal.grit | 18 +- rules/modern/prefer-output-function.grit | 12 +- rules/modern/prefer-signal-inputs.grit | 12 +- rules/modern/prefer-signal-queries.grit | 19 +- rules/quality/contextual-lifecycle.grit | 43 +-- rules/quality/use-lifecycle-interface.grit | 70 +++-- .../quality/use-pipe-transform-interface.grit | 21 +- tests/helpers/biome-runner.ts | 35 ++- tests/helpers/pattern-matcher.ts | 47 +-- tests/integration/component-selector.test.ts | 5 +- .../integration/contextual-lifecycle.test.ts | 5 +- tests/integration/native-shapes.test.ts | 281 ++++++++++++++++++ tests/integration/pipe-prefix.test.ts | 6 +- .../use-lifecycle-interface.test.ts | 11 +- .../use-pipe-transform-interface.test.ts | 10 +- tests/rules/prefer-inject-function.test.ts | 23 +- 40 files changed, 615 insertions(+), 331 deletions(-) create mode 100644 tests/integration/native-shapes.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index c3453e4..a4d87d8 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -1,6 +1,6 @@ // 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": [ diff --git a/docs/rules/component-selector.md b/docs/rules/component-selector.md index b107a61..d37f154 100644 --- a/docs/rules/component-selector.md +++ b/docs/rules/component-selector.md @@ -13,7 +13,8 @@ Enforces that `@Component` selectors follow the Angular Style Guide conventions: - **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 diff --git a/docs/rules/contextual-lifecycle.md b/docs/rules/contextual-lifecycle.md index 40b61f6..dbf73fa 100644 --- a/docs/rules/contextual-lifecycle.md +++ b/docs/rules/contextual-lifecycle.md @@ -57,13 +57,11 @@ export class FooDirective { } ``` -## Known limitations +## Detection -- Classes with an `implements` clause are not matched (GritQL metavariables cannot bind - heritage clauses), so a violating `@Injectable` class declared as - `class UserStore implements OnInit { ... }` is a **false negative**. -- Hooks are detected as bare identifiers inside the class body, so any signature shape - is caught (`: void`, `async`, parameters). +- 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 diff --git a/docs/rules/no-component-suffix.md b/docs/rules/no-component-suffix.md index 0b0b0a1..6d46d73 100644 --- a/docs/rules/no-component-suffix.md +++ b/docs/rules/no-component-suffix.md @@ -13,9 +13,8 @@ recommends naming classes after the concept they represent rather than their Ang The check is decorator-gated: only classes decorated with `@Component` are flagged. Plain TypeScript, NestJS, or React classes named `*Component` are **not** flagged. -**Known false negative:** decorated classes with an `implements` clause -(e.g. `class HeaderComponent implements OnInit`) cannot be matched by GritQL and 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." diff --git a/docs/rules/no-directive-suffix.md b/docs/rules/no-directive-suffix.md index 241102e..a2767fd 100644 --- a/docs/rules/no-directive-suffix.md +++ b/docs/rules/no-directive-suffix.md @@ -13,9 +13,8 @@ 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. -**Known false negative:** decorated classes with an `implements` clause -(e.g. `class HighlightDirective implements OnInit`) cannot be matched by GritQL and 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." diff --git a/docs/rules/no-input-rename.md b/docs/rules/no-input-rename.md index cd7cd0a..3bcb974 100644 --- a/docs/rules/no-input-rename.md +++ b/docs/rules/no-input-rename.md @@ -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? diff --git a/docs/rules/no-output-on-prefix.md b/docs/rules/no-output-on-prefix.md index 9cda628..351c886 100644 --- a/docs/rules/no-output-on-prefix.md +++ b/docs/rules/no-output-on-prefix.md @@ -6,7 +6,7 @@ ## 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. @@ -24,6 +24,8 @@ export class FormComponent { @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 } ``` @@ -52,12 +54,11 @@ 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. -- Initialized (`= new EventEmitter()`), typed (`: EventEmitter`), and - definite-assignment (`!: EventEmitter`) properties are all matched, with or without - a decorator argument. -- **Known false negative:** `readonly` output properties - (`@Output() readonly onSubmit = ...`) cannot be matched by GritQL and are not flagged. +- 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 diff --git a/docs/rules/no-output-rename.md b/docs/rules/no-output-rename.md index 328b7a9..04544e7 100644 --- a/docs/rules/no-output-rename.md +++ b/docs/rules/no-output-rename.md @@ -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,12 +60,11 @@ 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 diff --git a/docs/rules/no-service-suffix.md b/docs/rules/no-service-suffix.md index e54a3b2..69b702b 100644 --- a/docs/rules/no-service-suffix.md +++ b/docs/rules/no-service-suffix.md @@ -12,9 +12,8 @@ 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. -**Known false negative:** decorated classes with an `implements` clause -(e.g. `class UserService implements OnDestroy`) cannot be matched by GritQL and 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." diff --git a/docs/rules/prefer-inject-function.md b/docs/rules/prefer-inject-function.md index ec7dcb6..463a03a 100644 --- a/docs/rules/prefer-inject-function.md +++ b/docs/rules/prefer-inject-function.md @@ -1,19 +1,20 @@ # angular/prefer-inject-function **Category:** modern (signal-based APIs) -**ESLint equivalent:** _(new rule — no direct ESLint equivalent)_ +**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 — but only inside classes decorated with `@Component`, `@Directive`, `@Injectable`, or `@Pipe`. Plain TypeScript or NestJS classes using parameter properties are **not** flagged. +**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`…). -**Known false negatives:** +**Migration:** Angular ships a schematic that rewrites constructor injection to `inject()` calls, including `@Inject`, `@Optional`, `@Self` and `@Attribute` parameters: -- Decorated classes with an `implements` clause cannot be matched by GritQL and are not flagged. -- DI without an access modifier (`constructor(dep: Service) { this.dep = dep }`) is undetectable statically and is not flagged. +```bash +ng generate @angular/core:inject +``` Benefits: - Works in functional injection contexts (guards, resolvers, interceptors) diff --git a/docs/rules/prefer-model-signal.md b/docs/rules/prefer-model-signal.md index d8c5953..7938273 100644 --- a/docs/rules/prefer-model-signal.md +++ b/docs/rules/prefer-model-signal.md @@ -8,9 +8,7 @@ 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 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`), and definite-assignment (`!: T`) properties are all matched. - -**Known false negative:** `readonly` properties (e.g. `@Output() readonly valueChange = ...`) cannot be matched by GritQL and are not flagged. +**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 diff --git a/docs/rules/use-lifecycle-interface.md b/docs/rules/use-lifecycle-interface.md index 11b8d3d..a459ebf 100644 --- a/docs/rules/use-lifecycle-interface.md +++ b/docs/rules/use-lifecycle-interface.md @@ -8,6 +8,8 @@ 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` | diff --git a/docs/rules/use-pipe-transform-interface.md b/docs/rules/use-pipe-transform-interface.md index 6811d10..b2dfa69 100644 --- a/docs/rules/use-pipe-transform-interface.md +++ b/docs/rules/use-pipe-transform-interface.md @@ -8,12 +8,7 @@ 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 file-level. The rule flags every `@Pipe` decorator in a file that never references `PipeTransform` anywhere. (GritQL cannot match a decorator + `implements` clause as one unit, so the implementation cannot inspect each class's heritage clause directly.) - -**Known false negatives:** - -- A file that imports (or otherwise mentions) `PipeTransform` without actually implementing it is not flagged. -- In a file containing several pipes, if only one of them implements `PipeTransform`, the others are not flagged. +**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 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/no-component-suffix.grit b/rules/conventions/no-component-suffix.grit index c61f6d5..bb2a1ed 100644 --- a/rules/conventions/no-component-suffix.grit +++ b/rules/conventions/no-component-suffix.grit @@ -1,20 +1,18 @@ // Port of the Angular v20+ style-guide guidance: drop the "Component" suffix. // Only classes decorated with @Component are flagged (plain TS classes are not). -// Patterns include explicit `export class` variants — GritQL treats the export -// modifier as part of the matched statement. Known false negative: decorated -// classes with an `implements` clause cannot be matched by GritQL. +// +// 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 or { - `@Component($a) -class $name { $_ }`, - `@Component($a) -export class $name { $_ }`, - `@Component($a) -class $name extends $ext { $_ }`, - `@Component($a) -export class $name extends $ext { $_ }` + 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 20e8e79..8592ee7 100644 --- a/rules/conventions/no-directive-suffix.grit +++ b/rules/conventions/no-directive-suffix.grit @@ -1,20 +1,18 @@ // Port of the Angular v20+ style-guide guidance: drop the "Directive" suffix. // Only classes decorated with @Directive are flagged (plain TS classes are not). -// Patterns include explicit `export class` variants — GritQL treats the export -// modifier as part of the matched statement. Known false negative: decorated -// classes with an `implements` clause cannot be matched by GritQL. +// +// 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 or { - `@Directive($a) -class $name { $_ }`, - `@Directive($a) -export class $name { $_ }`, - `@Directive($a) -class $name extends $ext { $_ }`, - `@Directive($a) -export class $name extends $ext { $_ }` + 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-on-prefix.grit b/rules/conventions/no-output-on-prefix.grit index 9994f10..773fdfb 100644 --- a/rules/conventions/no-output-on-prefix.grit +++ b/rules/conventions/no-output-on-prefix.grit @@ -1,14 +1,17 @@ // Port of @angular-eslint/no-output-on-prefix. -// Matches initialized, typed, and definite-assignment (!) output properties, -// with or without a decorator argument. Known false negative: `readonly` -// properties cannot be matched by GritQL snippets. +// 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 -or { - `@Output($a) $name = $init`, - `@Output($a) $name: $t`, - `@Output($a) $name!: $t` -} 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 420b06a..531cc08 100644 --- a/rules/conventions/no-service-suffix.grit +++ b/rules/conventions/no-service-suffix.grit @@ -1,20 +1,18 @@ // Port of the Angular v20+ style-guide guidance: drop the "Service" suffix. // Only classes decorated with @Injectable are flagged (plain TS classes are not). -// Patterns include explicit `export class` variants — GritQL treats the export -// modifier as part of the matched statement. Known false negative: decorated -// classes with an `implements` clause cannot be matched by GritQL. +// +// 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 or { - `@Injectable($a) -class $name { $_ }`, - `@Injectable($a) -export class $name { $_ }`, - `@Injectable($a) -class $name extends $ext { $_ }`, - `@Injectable($a) -export class $name extends $ext { $_ }` + 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/high-priority/prefer-standalone.grit b/rules/high-priority/prefer-standalone.grit index 113ab05..4d03106 100644 --- a/rules/high-priority/prefer-standalone.grit +++ b/rules/high-priority/prefer-standalone.grit @@ -1,6 +1,10 @@ // 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 { @@ -8,6 +12,6 @@ or { `@Directive({ $args })`, `@Pipe({ $args })` } where { - $args <: contains `standalone: false`, - register_diagnostic(span = $args, message = "Avoid standalone: false. Standalone is the default since Angular 19 — migrate this declarable off NgModules and remove the property.") + $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 363026d..01b5307 100644 --- a/rules/modern/prefer-inject-function.grit +++ b/rules/modern/prefer-inject-function.grit @@ -1,49 +1,21 @@ -// Port of the Angular 14+ guidance: prefer inject() over constructor DI. -// Only flags constructors inside @Component/@Directive/@Injectable/@Pipe -// classes, so plain TS or NestJS classes using parameter properties are not -// flagged. Patterns include explicit `export class` variants — GritQL treats -// the export modifier as part of the matched statement. -// Known false negatives: decorated classes with an `implements` clause cannot -// be matched by GritQL, and DI without an access modifier -// (constructor(dep: Service) { this.dep = dep }) is undetectable statically. +// 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 or { - `@Component($a) -class $name { $body }`, - `@Component($a) -export class $name { $body }`, - `@Component($a) -class $name extends $ext { $body }`, - `@Component($a) -export class $name extends $ext { $body }`, - `@Directive($a) -class $name { $body }`, - `@Directive($a) -export class $name { $body }`, - `@Directive($a) -class $name extends $ext { $body }`, - `@Directive($a) -export class $name extends $ext { $body }`, - `@Injectable($a) -class $name { $body }`, - `@Injectable($a) -export class $name { $body }`, - `@Injectable($a) -class $name extends $ext { $body }`, - `@Injectable($a) -export class $name extends $ext { $body }`, - `@Pipe($a) -class $name { $body }`, - `@Pipe($a) -export class $name { $body }`, - `@Pipe($a) -class $name extends $ext { $body }`, - `@Pipe($a) -export class $name extends $ext { $body }` + JsClassDeclaration(decorators = $decorators, members = $members), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(members = $members)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(members = $members))) } where { - $body <: contains `constructor($params) { $_ }` where { - $params <: contains or { `private`, `protected`, `public`, `readonly` } + $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() over constructor-based dependency injection (Angular 14+).", severity = "warn") + 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 f493cd3..43860bc 100644 --- a/rules/modern/prefer-model-signal.grit +++ b/rules/modern/prefer-model-signal.grit @@ -3,19 +3,15 @@ // 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. -// Known false negative: `readonly` properties cannot be matched by GritQL. +// Properties are matched through Biome's native property node, so every +// shape is covered (initialized, typed, definite-assignment, `readonly`). language js -or { - `@Output($a) $name = $init`, - `@Output($a) $name: $t`, - `@Output($a) $name!: $t` -} where { +JsPropertyClassMember(modifiers = $modifiers, name = $name) where { + $modifiers <: contains `@Output($args)`, $name <: r"(.*)Change$"($base), - $program <: contains or { - `@Input($ia) $base = $_`, - `@Input($ia) $base: $_`, - `@Input($ia) $base!: $_` + $program <: contains JsPropertyClassMember(modifiers = $inputModifiers, name = $base) where { + $inputModifiers <: contains `@Input($inputArgs)` }, - register_diagnostic(span = $name, message = "Prefer model() over the @Input() + @Output() xyzChange two-way binding pattern (Angular 17.2+).", severity = "warn") + 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 b38fbbb..7ff82b1 100644 --- a/rules/quality/contextual-lifecycle.grit +++ b/rules/quality/contextual-lifecycle.grit @@ -3,41 +3,20 @@ // 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. // -// Implementation notes: -// - Patterns include explicit `export class` variants: GritQL treats the export -// modifier as part of the matched statement. -// - Classes with an `implements` clause cannot be matched (GritQL metavariables -// cannot bind heritage clauses) — known false negative. -// - Hooks are detected as bare identifiers inside the class body so any -// signature shape matches (`: void`, `async`, parameters). +// 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 }`, - `@Injectable($args) -export class $name { $body }`, - `@Injectable($args) -class $name extends $ext { $body }`, - `@Injectable($args) -export class $name extends $ext { $body }`, - `@Pipe($args) -class $name { $body }`, - `@Pipe($args) -export class $name { $body }`, - `@Pipe($args) -class $name extends $ext { $body }`, - `@Pipe($args) -export class $name extends $ext { $body }` + JsClassDeclaration(decorators = $decorators, members = $members), + JsExport(decorators = $decorators, export_clause = JsClassDeclaration(members = $members)), + JsExport(decorators = $decorators, export_clause = JsExportDefaultDeclarationClause(declaration = JsClassExportDefaultDeclaration(members = $members))) } where { - $body <: contains or { - `ngOnChanges`, - `ngOnInit`, - `ngDoCheck`, - `ngAfterContentInit`, - `ngAfterContentChecked`, - `ngAfterViewInit`, - `ngAfterViewChecked` + $decorators <: contains or { `@Injectable($args)`, `@Pipe($args)` }, + $members <: contains JsMethodClassMember(name = $hook) where { + $hook <: r"^ng(?:OnChanges|OnInit|DoCheck|AfterContentInit|AfterContentChecked|AfterViewInit|AfterViewChecked)$" }, - register_diagnostic(span = $name, message = "Lifecycle hook used in the wrong context: @Injectable and @Pipe classes only support ngOnDestroy. Other hooks are never called on them.") + 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/use-lifecycle-interface.grit b/rules/quality/use-lifecycle-interface.grit index 6eff9c7..d24710c 100644 --- a/rules/quality/use-lifecycle-interface.grit +++ b/rules/quality/use-lifecycle-interface.grit @@ -1,45 +1,51 @@ // Port of @angular-eslint/use-lifecycle-interface. -// -// Implementation note: GritQL metavariables cannot bind an `implements` clause, -// so class-shape branches like `class $n implements $i { $b }` never match. -// Instead, match each lifecycle METHOD and require the file to reference the -// corresponding interface (import + implements both appear as identifiers). -// Known false negative: a file that imports the interface without implementing -// it. Each hook needs a `: void` variant (annotated methods are distinct nodes); -// ngOnChanges also gets parameterized variants. +// 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 { - or { `ngOnInit() { $_ }`, `ngOnInit(): void { $_ }` } where { - $program <: not contains `OnInit`, - register_diagnostic(span = $match, message = "Lifecycle method ngOnInit: implement the OnInit interface.", 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") }, - or { `ngOnDestroy() { $_ }`, `ngOnDestroy(): void { $_ }` } where { - $program <: not contains `OnDestroy`, - register_diagnostic(span = $match, message = "Lifecycle method ngOnDestroy: implement the OnDestroy 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") }, - or { `ngOnChanges() { $_ }`, `ngOnChanges(): void { $_ }`, `ngOnChanges($p) { $_ }`, `ngOnChanges($p): void { $_ }` } where { - $program <: not contains `OnChanges`, - register_diagnostic(span = $match, message = "Lifecycle method ngOnChanges: implement the OnChanges 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") }, - or { `ngDoCheck() { $_ }`, `ngDoCheck(): void { $_ }` } where { - $program <: not contains `DoCheck`, - register_diagnostic(span = $match, message = "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") }, - or { `ngAfterContentInit() { $_ }`, `ngAfterContentInit(): void { $_ }` } where { - $program <: not contains `AfterContentInit`, - register_diagnostic(span = $match, message = "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") }, - or { `ngAfterContentChecked() { $_ }`, `ngAfterContentChecked(): void { $_ }` } where { - $program <: not contains `AfterContentChecked`, - register_diagnostic(span = $match, message = "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") }, - or { `ngAfterViewInit() { $_ }`, `ngAfterViewInit(): void { $_ }` } where { - $program <: not contains `AfterViewInit`, - register_diagnostic(span = $match, message = "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") }, - or { `ngAfterViewChecked() { $_ }`, `ngAfterViewChecked(): void { $_ }` } where { - $program <: not contains `AfterViewChecked`, - register_diagnostic(span = $match, message = "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 dbf27c0..24d5266 100644 --- a/rules/quality/use-pipe-transform-interface.grit +++ b/rules/quality/use-pipe-transform-interface.grit @@ -1,14 +1,15 @@ // Port of @angular-eslint/use-pipe-transform-interface. -// -// Implementation note: GritQL metavariables cannot bind an `implements` clause -// (`class $n implements $i { $b }` never matches), so instead of matching -// decorator+class shapes we match the @Pipe decorator and require the file to -// reference PipeTransform. This covers every class form (export, extends, -// implements-other). Known false negatives: a file that imports PipeTransform -// without implementing it, or a multi-pipe file where only one pipe implements it. +// 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 -`@Pipe($args)` as $dec where { - $program <: not contains `PipeTransform`, - register_diagnostic(span = $dec, message = "@Pipe classes must implement the PipeTransform interface.", severity = "warn") +or { + 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 bf6a2e8..571a50b 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { mkdirSync, mkdtempSync, 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,7 +42,7 @@ 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', 'no-service-suffix': 'rules/conventions/no-service-suffix.grit', @@ -76,14 +76,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: { @@ -158,4 +164,23 @@ export async function runBiomeCheck(rule: string, code: string): Promise { + const inputFile = join(TEMP_DIR, `fix_${fileCounter++}.ts`); + writeFileSync(inputFile, code); + const configDir = getConfigDir(rule); + try { + await execFileAsync(BIOME_BIN, ['lint', '--write', `--config-path=${configDir}`, inputFile], { + maxBuffer: 4 * 1024 * 1024, + timeout: 25_000, + }); + } catch { + // 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.ts b/tests/helpers/pattern-matcher.ts index d45e63d..22f1d05 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -1,4 +1,11 @@ -import { Block, Project, type SourceFile } from 'ts-morph'; +import { + Block, + Node, + Project, + type PropertyDeclaration, + type SourceFile, + SyntaxKind, +} from 'ts-morph'; /** * Reference-semantics layer for the plugin's Angular rules. @@ -10,11 +17,10 @@ import { Block, Project, type SourceFile } from 'ts-morph'; * integration tests are the authority on shipped behavior whenever the two * layers disagree. * - * Deliberate divergences from the shipped GritQL rules: the GritQL engine - * cannot match `readonly` properties, `implements` clauses, or get/set - * accessors. The reference implementations here do NOT mimic those engine - * limitations — they follow the intended semantics instead, so some cases - * flagged here are known false negatives of the shipped rules. + * 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). */ @@ -750,30 +756,29 @@ 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), - ); - - if (hasAccessModifier) { - const typeNode = param.getTypeNode(); - violations.push({ - className: cls.getName() ?? 'unknown', - parameterName: param.getName(), - parameterType: typeNode?.getText() ?? 'unknown', - }); - } + const typeNode = param.getTypeNode(); + violations.push({ + className: cls.getName() ?? 'unknown', + parameterName: param.getName(), + parameterType: typeNode?.getText() ?? 'unknown', + }); } } } diff --git a/tests/integration/component-selector.test.ts b/tests/integration/component-selector.test.ts index b59dc4f..4d40917 100644 --- a/tests/integration/component-selector.test.ts +++ b/tests/integration/component-selector.test.ts @@ -59,10 +59,7 @@ describe('angular/component-selector [integration]', () => { expect(diagnostics[0].message).toContain("kebab-case with the 'app-' prefix"); }); - // Known false positive: each selector in the list is valid kebab-case with - // the app- prefix, but the regex validates the whole string instead of each - // comma-separated selector. Should eventually be fixed. - it.fails('accepts multi-selector list of valid app- selectors (known FP)', async () => { + it('accepts multi-selector list of valid app- selectors', async () => { const { diagnostics, raw } = await runBiomeCheck( 'component-selector', ` diff --git a/tests/integration/contextual-lifecycle.test.ts b/tests/integration/contextual-lifecycle.test.ts index da6ce0e..d797d79 100644 --- a/tests/integration/contextual-lifecycle.test.ts +++ b/tests/integration/contextual-lifecycle.test.ts @@ -67,10 +67,7 @@ describe('angular/contextual-lifecycle [integration]', () => { expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); }); - // GritQL metavariables cannot bind an `implements` clause, so decorated - // classes WITH `implements` are invisible to the rule. Documented false - // negative — flips to failing the day Biome supports it. - it.fails('flags ngOnInit in @Injectable that has an implements clause (known FN)', async () => { + it('flags ngOnInit in @Injectable that has an implements clause', async () => { const { diagnostics, raw } = await runBiomeCheck( 'contextual-lifecycle', ` 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/pipe-prefix.test.ts b/tests/integration/pipe-prefix.test.ts index 67f40c8..e84b89d 100644 --- a/tests/integration/pipe-prefix.test.ts +++ b/tests/integration/pipe-prefix.test.ts @@ -18,7 +18,7 @@ describe('angular/pipe-prefix [integration]', () => { `, ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); - expect(diagnostics[0].message).toContain('start with the configured prefix'); + expect(diagnostics[0].message).toContain("start with the 'app' prefix"); }); it('flags pipe name with wrong prefix', async () => { @@ -33,7 +33,7 @@ describe('angular/pipe-prefix [integration]', () => { `, ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); - expect(diagnostics[0].message).toContain('start with the configured prefix'); + expect(diagnostics[0].message).toContain("start with the 'app' prefix"); }); // Actual behavior (verified): 'apple' starts with "app" but the regex @@ -51,7 +51,7 @@ describe('angular/pipe-prefix [integration]', () => { `, ); expect(diagnostics, `raw output: ${raw}`).toHaveLength(1); - expect(diagnostics[0].message).toContain('start with the configured prefix'); + expect(diagnostics[0].message).toContain("start with the 'app' prefix"); }); }); diff --git a/tests/integration/use-lifecycle-interface.test.ts b/tests/integration/use-lifecycle-interface.test.ts index 4229b7e..34e874a 100644 --- a/tests/integration/use-lifecycle-interface.test.ts +++ b/tests/integration/use-lifecycle-interface.test.ts @@ -2,11 +2,8 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/use-lifecycle-interface [integration]', () => { - // The rule matches lifecycle METHODS and requires the corresponding - // interface to be referenced somewhere in the file (GritQL cannot bind - // implements clauses, so per-class checking is impossible). Per-file - // semantics: a file importing the interface without implementing it is a - // known false negative. + // 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( @@ -115,9 +112,7 @@ describe('angular/use-lifecycle-interface [integration]', () => { expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); - // Per-file matching: importing the interface exempts every class in the - // file even without an implements clause. Documented false negative. - it.fails('flags ngOnInit when OnInit is imported but not implemented (known FN)', async () => { + it('flags ngOnInit when OnInit is imported but not implemented', async () => { const { diagnostics, raw } = await runBiomeCheck( 'use-lifecycle-interface', ` diff --git a/tests/integration/use-pipe-transform-interface.test.ts b/tests/integration/use-pipe-transform-interface.test.ts index ae5966b..879c4a5 100644 --- a/tests/integration/use-pipe-transform-interface.test.ts +++ b/tests/integration/use-pipe-transform-interface.test.ts @@ -2,9 +2,8 @@ import { describe, expect, it } from 'vitest'; import { runBiomeCheck } from '../helpers/biome-runner.js'; describe('angular/use-pipe-transform-interface [integration]', () => { - // The rule matches the @Pipe decorator and requires the file to reference - // PipeTransform (GritQL cannot bind `implements` clauses, so decorator+class - // matching is impossible for implements-classes). Per-file semantics. + // 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('flags @Pipe class with no implements clause', async () => { const { diagnostics, raw } = await runBiomeCheck( @@ -94,10 +93,7 @@ describe('angular/use-pipe-transform-interface [integration]', () => { expect(diagnostics, `raw output: ${raw}`).toHaveLength(0); }); - // Per-file matching: a file that imports PipeTransform exempts every @Pipe - // in it, even one that does not implement the interface. Documented false - // negative of the file-level approach. - it.fails('flags a non-implementing @Pipe when the file imports PipeTransform (known FN)', async () => { + it('flags a non-implementing @Pipe even when the file imports PipeTransform', async () => { const { diagnostics, raw } = await runBiomeCheck( 'use-pipe-transform-interface', ` 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) {} } From 7100714d557f88ae43a041486fa8da445c7bc073 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 24/34] feat(rules): add a safe fix to no-empty-lifecycle-method Biome 2.5 plugins can rewrite code. The empty hook is deleted with a safe fix (biome lint --write) when the class does not implement the matching interface. When it does, deleting the method alone fails compilation with TS2420, so that case only reports and asks to remove both. Biome pairs rewrites with diagnostics by order of appearance in the file, so a single pattern cannot mix fixable and non-fixable branches: the rule is split in two files with the same stem, which Biome loads under one plugin name (one suppression name for users). The rule also moved to the native method node, so hooks with modifiers (protected override, async) and comment-only bodies are matched. --- biome-manifest.jsonc | 1 + docs/rules/no-empty-lifecycle-method.md | 20 +++++ index.js | 9 +++ .../fixes/no-empty-lifecycle-method.grit | 57 +++++++++++++ .../no-empty-lifecycle-method.grit | 80 +++++++++++++------ tests/helpers/biome-runner.ts | 6 +- 6 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 rules/high-priority/fixes/no-empty-lifecycle-method.grit diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index a4d87d8..b743415 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -6,6 +6,7 @@ "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", diff --git a/docs/rules/no-empty-lifecycle-method.md b/docs/rules/no-empty-lifecycle-method.md index e50b2ce..cdf71bf 100644 --- a/docs/rules/no-empty-lifecycle-method.md +++ b/docs/rules/no-empty-lifecycle-method.md @@ -15,6 +15,26 @@ The rule matches every empty hook regardless of signature shape: with or without 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 | diff --git a/index.js b/index.js index 97c4f2b..1fc9edb 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,15 @@ export const 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'), 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 1f5ed72..60cc061 100644 --- a/rules/high-priority/no-empty-lifecycle-method.grit +++ b/rules/high-priority/no-empty-lifecycle-method.grit @@ -1,29 +1,61 @@ // Port of @angular-eslint/no-empty-lifecycle-method. -// Each hook needs an explicit `: void` variant — GritQL snippets with a return -// type only match annotated methods and vice versa. ngOnChanges also gets -// parameterized variants (it is the only hook that takes an argument). -// Modifier prefixes (async, protected, override) match implicitly. +// 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() {}`, - `ngOnInit(): void {}`, - `ngOnDestroy() {}`, - `ngOnDestroy(): void {}`, - `ngOnChanges() {}`, - `ngOnChanges(): void {}`, - `ngOnChanges($p) {}`, - `ngOnChanges($p): void {}`, - `ngDoCheck() {}`, - `ngDoCheck(): void {}`, - `ngAfterContentInit() {}`, - `ngAfterContentInit(): void {}`, - `ngAfterContentChecked() {}`, - `ngAfterContentChecked(): void {}`, - `ngAfterViewInit() {}`, - `ngAfterViewInit(): void {}`, - `ngAfterViewChecked() {}`, - `ngAfterViewChecked(): void {}` -} 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") + }, + 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") + }, + 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") + }, + 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") + }, + 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") + }, + 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") + }, + 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") + }, + 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 571a50b..38cb41e 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -44,7 +44,11 @@ let fileCounter = 0; */ 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', From a5515834c17d00267250f907433629ded968ab65 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 25/34] feat(rules): add directive-selector Port of @angular-eslint/directive-selector with the Angular CLI defaults: camelCase attribute selector with the app prefix. Comma-separated lists are accepted when every entry is valid. --- biome-manifest.jsonc | 1 + docs/rules/directive-selector.md | 70 +++++++++++++++++ index.js | 1 + rules-to-migrate.json | 7 +- rules/conventions/directive-selector.grit | 12 +++ tests/helpers/biome-runner.ts | 1 + tests/helpers/pattern-matcher.test.ts | 1 + tests/helpers/pattern-matcher.ts | 39 ++++++++++ tests/integration/directive-selector.test.ts | 81 ++++++++++++++++++++ tests/rules/directive-selector.test.ts | 43 +++++++++++ 10 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 docs/rules/directive-selector.md create mode 100644 rules/conventions/directive-selector.grit create mode 100644 tests/integration/directive-selector.test.ts create mode 100644 tests/rules/directive-selector.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index b743415..44c7a07 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -15,6 +15,7 @@ "./rules/conventions/no-output-rename.grit", "./rules/conventions/no-output-on-prefix.grit", "./rules/conventions/pipe-prefix.grit", + "./rules/conventions/directive-selector.grit", "./rules/quality/use-lifecycle-interface.grit", "./rules/quality/use-pipe-transform-interface.grit", "./rules/quality/contextual-lifecycle.grit", 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/index.js b/index.js index 1fc9edb..8a4cd80 100644 --- a/index.js +++ b/index.js @@ -46,6 +46,7 @@ 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'), + 'directive-selector': join(__dirname, 'rules', 'conventions', 'directive-selector.grit'), // quality 'use-lifecycle-interface': join(__dirname, 'rules', 'quality', 'use-lifecycle-interface.grit'), 'use-pipe-transform-interface': join( diff --git a/rules-to-migrate.json b/rules-to-migrate.json index 2d3d63d..28c89b7 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -79,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", 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 38cb41e..551e7d4 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -57,6 +57,7 @@ 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', + 'directive-selector': 'rules/conventions/directive-selector.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', diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index 06af967..3254149 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -24,6 +24,7 @@ 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'); }); it('returned functions work correctly via the factory', () => { diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index 22f1d05..6b9cb98 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -786,6 +786,43 @@ export function detectConstructorInjection(code: string): ConstructorInjectionVi 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; +} + /** Convenience factory so tests can destructure a single import. */ export function createPatternMatcher() { return { @@ -810,5 +847,7 @@ export function createPatternMatcher() { detectTwoWayBindingPairs, detectHostDecorators, detectConstructorInjection, + // Rules added with the native-AST rewrite + detectInvalidDirectiveSelector, }; } 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/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); + }); + }); +}); From d27f61af1ebf279558f633124196253a7d0ae21e Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 26/34] feat(rules): add no-output-native Port of @angular-eslint/no-output-native. Covers @Output() on every property shape (readonly included) and the output()/outputFromObservable() functions. --- biome-manifest.jsonc | 1 + docs/rules/no-output-native.md | 67 ++++++++++++++++ index.js | 1 + rules-to-migrate.json | 9 +++ rules/conventions/no-output-native.grit | 18 +++++ tests/helpers/biome-runner.ts | 1 + tests/helpers/pattern-matcher.test.ts | 1 + tests/helpers/pattern-matcher.ts | 92 ++++++++++++++++++++++ tests/integration/no-output-native.test.ts | 74 +++++++++++++++++ tests/rules/no-output-native.test.ts | 53 +++++++++++++ 10 files changed, 317 insertions(+) create mode 100644 docs/rules/no-output-native.md create mode 100644 rules/conventions/no-output-native.grit create mode 100644 tests/integration/no-output-native.test.ts create mode 100644 tests/rules/no-output-native.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index 44c7a07..ef9f2c6 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -16,6 +16,7 @@ "./rules/conventions/no-output-on-prefix.grit", "./rules/conventions/pipe-prefix.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", 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/index.js b/index.js index 8a4cd80..211643a 100644 --- a/index.js +++ b/index.js @@ -47,6 +47,7 @@ export const rules = { 'no-output-on-prefix': join(__dirname, 'rules', 'conventions', 'no-output-on-prefix.grit'), 'pipe-prefix': join(__dirname, 'rules', 'conventions', 'pipe-prefix.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( diff --git a/rules-to-migrate.json b/rules-to-migrate.json index 28c89b7..14bf042 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -110,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", 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 551e7d4..52dcb64 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -58,6 +58,7 @@ const RULE_PATHS: Record = { 'no-output-on-prefix': 'rules/conventions/no-output-on-prefix.grit', 'pipe-prefix': 'rules/conventions/pipe-prefix.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', diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index 3254149..fb9746e 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -25,6 +25,7 @@ describe('createPatternMatcher', () => { expect(typeof matcher.detectHostDecorators).toBe('function'); expect(typeof matcher.detectConstructorInjection).toBe('function'); expect(typeof matcher.detectInvalidDirectiveSelector).toBe('function'); + expect(typeof matcher.detectNativeOutputNames).toBe('function'); }); it('returned functions work correctly via the factory', () => { diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index 6b9cb98..988f18e 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -823,6 +823,97 @@ export function detectInvalidDirectiveSelector(code: string): SelectorViolation[ return violations; } +// --------------------------------------------------------------------------- +// no-output-native +// --------------------------------------------------------------------------- + +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; +} + /** Convenience factory so tests can destructure a single import. */ export function createPatternMatcher() { return { @@ -849,5 +940,6 @@ export function createPatternMatcher() { detectConstructorInjection, // Rules added with the native-AST rewrite detectInvalidDirectiveSelector, + detectNativeOutputNames, }; } 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/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); + }); + }); +}); From cad510933e2f1e860e4e416b4d2a5fea3c124b10 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 27/34] feat(rules): add no-conflicting-lifecycle Port of @angular-eslint/no-conflicting-lifecycle: ngDoCheck and ngOnChanges must not be declared on the same class. --- biome-manifest.jsonc | 1 + docs/rules/no-conflicting-lifecycle.md | 63 ++++++++++++++ index.js | 1 + rules-to-migrate.json | 9 ++ rules/quality/no-conflicting-lifecycle.grit | 14 +++ tests/helpers/biome-runner.ts | 1 + tests/helpers/pattern-matcher.test.ts | 1 + tests/helpers/pattern-matcher.ts | 24 ++++++ .../no-conflicting-lifecycle.test.ts | 86 +++++++++++++++++++ tests/rules/no-conflicting-lifecycle.test.ts | 47 ++++++++++ 10 files changed, 247 insertions(+) create mode 100644 docs/rules/no-conflicting-lifecycle.md create mode 100644 rules/quality/no-conflicting-lifecycle.grit create mode 100644 tests/integration/no-conflicting-lifecycle.test.ts create mode 100644 tests/rules/no-conflicting-lifecycle.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index ef9f2c6..488084e 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -20,6 +20,7 @@ "./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/modern/prefer-signal-inputs.grit", "./rules/modern/prefer-output-function.grit", "./rules/modern/prefer-signal-queries.grit", 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/index.js b/index.js index 211643a..467aa0c 100644 --- a/index.js +++ b/index.js @@ -57,6 +57,7 @@ export const rules = { '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'), // 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/rules-to-migrate.json b/rules-to-migrate.json index 14bf042..dc5e1f5 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -173,6 +173,15 @@ "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-inputs-metadata-property", "eslintRule": "@angular-eslint/no-inputs-metadata-property", 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 52dcb64..63f5fb9 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -62,6 +62,7 @@ const RULE_PATHS: Record = { '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', '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', diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index fb9746e..6f22551 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -26,6 +26,7 @@ describe('createPatternMatcher', () => { expect(typeof matcher.detectConstructorInjection).toBe('function'); expect(typeof matcher.detectInvalidDirectiveSelector).toBe('function'); expect(typeof matcher.detectNativeOutputNames).toBe('function'); + expect(typeof matcher.detectConflictingLifecycle).toBe('function'); }); it('returned functions work correctly via the factory', () => { diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index 988f18e..7e1f26d 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -914,6 +914,29 @@ export function detectNativeOutputNames(code: string): OutputNativeViolation[] { 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; +} + /** Convenience factory so tests can destructure a single import. */ export function createPatternMatcher() { return { @@ -941,5 +964,6 @@ export function createPatternMatcher() { // Rules added with the native-AST rewrite detectInvalidDirectiveSelector, detectNativeOutputNames, + detectConflictingLifecycle, }; } 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/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); + }); + }); +}); From e7861c34d5417c912384c1bddc09bd0e251d4fc6 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 28/34] feat(rules): add no-lifecycle-call Port of @angular-eslint/no-lifecycle-call. Explicit hook calls are flagged; super.ngXxx() chaining is allowed. --- biome-manifest.jsonc | 1 + docs/rules/no-lifecycle-call.md | 80 +++++++++++++++++++++ index.js | 1 + rules-to-migrate.json | 9 +++ rules/quality/no-lifecycle-call.grit | 12 ++++ tests/helpers/biome-runner.ts | 1 + tests/helpers/pattern-matcher.test.ts | 1 + tests/helpers/pattern-matcher.ts | 31 ++++++++ tests/integration/no-lifecycle-call.test.ts | 73 +++++++++++++++++++ tests/rules/no-lifecycle-call.test.ts | 36 ++++++++++ 10 files changed, 245 insertions(+) create mode 100644 docs/rules/no-lifecycle-call.md create mode 100644 rules/quality/no-lifecycle-call.grit create mode 100644 tests/integration/no-lifecycle-call.test.ts create mode 100644 tests/rules/no-lifecycle-call.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index 488084e..702822e 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -21,6 +21,7 @@ "./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/modern/prefer-signal-inputs.grit", "./rules/modern/prefer-output-function.grit", "./rules/modern/prefer-signal-queries.grit", 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/index.js b/index.js index 467aa0c..b156611 100644 --- a/index.js +++ b/index.js @@ -58,6 +58,7 @@ export const rules = { ), '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'), // 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/rules-to-migrate.json b/rules-to-migrate.json index dc5e1f5..b29c71c 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -182,6 +182,15 @@ "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-inputs-metadata-property", "eslintRule": "@angular-eslint/no-inputs-metadata-property", 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 63f5fb9..a24018d 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -63,6 +63,7 @@ const RULE_PATHS: Record = { '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', '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', diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index 6f22551..402fd9c 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -27,6 +27,7 @@ describe('createPatternMatcher', () => { expect(typeof matcher.detectInvalidDirectiveSelector).toBe('function'); expect(typeof matcher.detectNativeOutputNames).toBe('function'); expect(typeof matcher.detectConflictingLifecycle).toBe('function'); + expect(typeof matcher.detectLifecycleCalls).toBe('function'); }); it('returned functions work correctly via the factory', () => { diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index 7e1f26d..911b6ad 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -937,6 +937,36 @@ export function detectConflictingLifecycle(code: string): ConflictingLifecycleVi 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; +} + /** Convenience factory so tests can destructure a single import. */ export function createPatternMatcher() { return { @@ -965,5 +995,6 @@ export function createPatternMatcher() { detectInvalidDirectiveSelector, detectNativeOutputNames, detectConflictingLifecycle, + detectLifecycleCalls, }; } 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/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); + }); + }); +}); From b9174774a9698f8803b05348b5dbbc5ba53cf1dc Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 29/34] feat(rules): add no-async-lifecycle-method Port of @angular-eslint/no-async-lifecycle-method. The async keyword is detected on the method text so every modifier combination is covered. --- biome-manifest.jsonc | 1 + docs/rules/no-async-lifecycle-method.md | 72 +++++++++++++++++++ index.js | 6 ++ rules-to-migrate.json | 9 +++ rules/quality/no-async-lifecycle-method.grit | 16 +++++ tests/helpers/biome-runner.ts | 1 + tests/helpers/pattern-matcher.test.ts | 1 + tests/helpers/pattern-matcher.ts | 26 +++++++ .../no-async-lifecycle-method.test.ts | 69 ++++++++++++++++++ tests/rules/no-async-lifecycle-method.test.ts | 40 +++++++++++ 10 files changed, 241 insertions(+) create mode 100644 docs/rules/no-async-lifecycle-method.md create mode 100644 rules/quality/no-async-lifecycle-method.grit create mode 100644 tests/integration/no-async-lifecycle-method.test.ts create mode 100644 tests/rules/no-async-lifecycle-method.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index 702822e..f050694 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -22,6 +22,7 @@ "./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/modern/prefer-signal-inputs.grit", "./rules/modern/prefer-output-function.grit", "./rules/modern/prefer-signal-queries.grit", 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/index.js b/index.js index b156611..45c7313 100644 --- a/index.js +++ b/index.js @@ -59,6 +59,12 @@ export const rules = { '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', + ), // 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/rules-to-migrate.json b/rules-to-migrate.json index b29c71c..5427280 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -191,6 +191,15 @@ "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-inputs-metadata-property", "eslintRule": "@angular-eslint/no-inputs-metadata-property", 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index a24018d..14a30af 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -64,6 +64,7 @@ const RULE_PATHS: Record = { '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', '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', diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index 402fd9c..9eca798 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -28,6 +28,7 @@ describe('createPatternMatcher', () => { expect(typeof matcher.detectNativeOutputNames).toBe('function'); expect(typeof matcher.detectConflictingLifecycle).toBe('function'); expect(typeof matcher.detectLifecycleCalls).toBe('function'); + expect(typeof matcher.detectAsyncLifecycleMethods).toBe('function'); }); it('returned functions work correctly via the factory', () => { diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index 911b6ad..193b8ca 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -967,6 +967,31 @@ export function detectLifecycleCalls(code: string): LifecycleCallViolation[] { 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; +} + /** Convenience factory so tests can destructure a single import. */ export function createPatternMatcher() { return { @@ -996,5 +1021,6 @@ export function createPatternMatcher() { detectNativeOutputNames, detectConflictingLifecycle, detectLifecycleCalls, + detectAsyncLifecycleMethods, }; } 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/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); + }); + }); +}); From 8ef25421c803e83b7edc22a81944a9ff3e73b84e Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 30/34] feat(rules): add no-pipe-impure Port of @angular-eslint/no-pipe-impure: flags @Pipe({ pure: false }). --- biome-manifest.jsonc | 1 + docs/rules/no-pipe-impure.md | 63 ++++++++++++++++++++++++ index.js | 1 + rules-to-migrate.json | 9 ++++ rules/quality/no-pipe-impure.grit | 9 ++++ tests/helpers/biome-runner.ts | 1 + tests/helpers/pattern-matcher.test.ts | 1 + tests/helpers/pattern-matcher.ts | 26 ++++++++++ tests/integration/no-pipe-impure.test.ts | 52 +++++++++++++++++++ tests/rules/no-pipe-impure.test.ts | 38 ++++++++++++++ 10 files changed, 201 insertions(+) create mode 100644 docs/rules/no-pipe-impure.md create mode 100644 rules/quality/no-pipe-impure.grit create mode 100644 tests/integration/no-pipe-impure.test.ts create mode 100644 tests/rules/no-pipe-impure.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index f050694..fe6c89a 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -23,6 +23,7 @@ "./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/modern/prefer-signal-inputs.grit", "./rules/modern/prefer-output-function.grit", "./rules/modern/prefer-signal-queries.grit", 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/index.js b/index.js index 45c7313..c85c718 100644 --- a/index.js +++ b/index.js @@ -65,6 +65,7 @@ export const rules = { 'quality', 'no-async-lifecycle-method.grit', ), + 'no-pipe-impure': join(__dirname, 'rules', 'quality', 'no-pipe-impure.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/rules-to-migrate.json b/rules-to-migrate.json index 5427280..5796672 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -200,6 +200,15 @@ "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-inputs-metadata-property", "eslintRule": "@angular-eslint/no-inputs-metadata-property", 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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index 14a30af..e75cd49 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -65,6 +65,7 @@ const RULE_PATHS: Record = { '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', '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', diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index 9eca798..bf8760c 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -29,6 +29,7 @@ describe('createPatternMatcher', () => { expect(typeof matcher.detectConflictingLifecycle).toBe('function'); expect(typeof matcher.detectLifecycleCalls).toBe('function'); expect(typeof matcher.detectAsyncLifecycleMethods).toBe('function'); + expect(typeof matcher.detectImpurePipes).toBe('function'); }); it('returned functions work correctly via the factory', () => { diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index 193b8ca..78dfd96 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -992,6 +992,31 @@ export function detectAsyncLifecycleMethods(code: string): AsyncLifecycleViolati 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; +} + /** Convenience factory so tests can destructure a single import. */ export function createPatternMatcher() { return { @@ -1022,5 +1047,6 @@ export function createPatternMatcher() { detectConflictingLifecycle, detectLifecycleCalls, detectAsyncLifecycleMethods, + detectImpurePipes, }; } 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/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); + }); + }); +}); From 2060c6a27402f5bf3a98a01843516e09b4448533 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 31/34] feat(rules): add no-attribute-decorator Port of @angular-eslint/no-attribute-decorator, pointing to inject(new HostAttributeToken()) (Angular 17.4+). --- biome-manifest.jsonc | 1 + docs/rules/no-attribute-decorator.md | 58 +++++++++++++++++++ index.js | 1 + rules-to-migrate.json | 9 +++ rules/quality/no-attribute-decorator.grit | 9 +++ tests/helpers/biome-runner.ts | 1 + tests/helpers/pattern-matcher.test.ts | 1 + tests/helpers/pattern-matcher.ts | 31 ++++++++++ .../no-attribute-decorator.test.ts | 37 ++++++++++++ tests/rules/no-attribute-decorator.test.ts | 46 +++++++++++++++ 10 files changed, 194 insertions(+) create mode 100644 docs/rules/no-attribute-decorator.md create mode 100644 rules/quality/no-attribute-decorator.grit create mode 100644 tests/integration/no-attribute-decorator.test.ts create mode 100644 tests/rules/no-attribute-decorator.test.ts diff --git a/biome-manifest.jsonc b/biome-manifest.jsonc index fe6c89a..b026d6c 100644 --- a/biome-manifest.jsonc +++ b/biome-manifest.jsonc @@ -24,6 +24,7 @@ "./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/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/index.js b/index.js index c85c718..5b744cb 100644 --- a/index.js +++ b/index.js @@ -66,6 +66,7 @@ export const rules = { '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/rules-to-migrate.json b/rules-to-migrate.json index 5796672..69abde2 100644 --- a/rules-to-migrate.json +++ b/rules-to-migrate.json @@ -209,6 +209,15 @@ "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/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/tests/helpers/biome-runner.ts b/tests/helpers/biome-runner.ts index e75cd49..0a34f2d 100644 --- a/tests/helpers/biome-runner.ts +++ b/tests/helpers/biome-runner.ts @@ -66,6 +66,7 @@ const RULE_PATHS: Record = { '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', diff --git a/tests/helpers/pattern-matcher.test.ts b/tests/helpers/pattern-matcher.test.ts index bf8760c..22cdfd1 100644 --- a/tests/helpers/pattern-matcher.test.ts +++ b/tests/helpers/pattern-matcher.test.ts @@ -30,6 +30,7 @@ describe('createPatternMatcher', () => { 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', () => { diff --git a/tests/helpers/pattern-matcher.ts b/tests/helpers/pattern-matcher.ts index 78dfd96..96fa683 100644 --- a/tests/helpers/pattern-matcher.ts +++ b/tests/helpers/pattern-matcher.ts @@ -1017,6 +1017,36 @@ export function detectImpurePipes(code: string): ImpurePipeViolation[] { 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(), + }); + } + } + } + } + + return violations; +} + /** Convenience factory so tests can destructure a single import. */ export function createPatternMatcher() { return { @@ -1048,5 +1078,6 @@ export function createPatternMatcher() { detectLifecycleCalls, detectAsyncLifecycleMethods, detectImpurePipes, + detectAttributeDecorators, }; } 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/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); + }); + }); +}); From c7b86739ab7ba631e9b872d5d34649ee48c57de3 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 32/34] feat(presets): make the recommended preset Angular-safe Two default Biome settings break real Angular projects: - parameter decorators (@Inject, @Optional, @Attribute...) are a parse error unless javascript.parser.unsafeParameterDecoratorsEnabled is on, and Biome skips the whole file - the recommended style/useImportType safe fix rewrites tokens that are only used as constructor parameter types to import type, after which the Angular build fails with NG2003 (no suitable injection token) The preset now sets both, scopes the plugin to **/*.ts minus spec files, and the CLI writes/merges/removes an extends entry pointing at it instead of a bare plugins path. The consumer smoke test covers the CLI round trip, parameter decorators, the absence of import type rewrites, the safe fix through the packed package, and the spec exclusion. --- cli.js | 38 +++++++---- presets/recommended.json | 20 +++++- tests/integration/consumer-smoke.test.ts | 87 +++++++++++++++++++++++- 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/cli.js b/cli.js index 49231b4..1429f02 100755 --- a/cli.js +++ b/cli.js @@ -8,9 +8,11 @@ * remove — Remove biome-plugin-angular from an existing biome.json * rules — List all available rules and their status * - * Note: Biome resolves plugin entries as paths relative to the config file, - * and per-rule severities are set inside each .grit rule (register_diagnostic), - * not in biome.json — there is no per-rule severity configuration for plugins. + * 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'; @@ -20,7 +22,14 @@ import { fileURLToPath } from 'node:url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PLUGIN_NAME = 'biome-plugin-angular'; -const PLUGIN_PATH = `./node_modules/${PLUGIN_NAME}`; +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 @@ -65,9 +74,10 @@ function cmdInit(args) { } writeJson(targetPath, { - plugins: [PLUGIN_PATH], + $schema: BIOME_SCHEMA, + extends: [PRESET], }); - success(`Created biome.json with ${PLUGIN_NAME}.`); + success(`Created biome.json extending ${PRESET}.`); log(''); log('Next steps:'); log(' 1. npx @biomejs/biome lint .'); @@ -83,10 +93,12 @@ function cmdMerge() { const existing = readJson(targetPath); - if (!Array.isArray(existing.plugins)) existing.plugins = []; - const alreadyConfigured = existing.plugins.some((p) => p.includes(PLUGIN_NAME)); + const alreadyConfigured = + (Array.isArray(existing.plugins) && existing.plugins.some(referencesPlugin)) || + (Array.isArray(existing.extends) && existing.extends.some(referencesPlugin)); if (!alreadyConfigured) { - existing.plugins.push(PLUGIN_PATH); + if (!Array.isArray(existing.extends)) existing.extends = []; + existing.extends.push(PRESET); } writeJson(targetPath, existing); @@ -106,9 +118,11 @@ function cmdRemove() { const config = readJson(targetPath); - if (Array.isArray(config.plugins)) { - config.plugins = config.plugins.filter((p) => !p.includes(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]; + } } // Clean up the invalid linter.rules.plugins key written by pre-1.0 versions diff --git a/presets/recommended.json b/presets/recommended.json index 6545376..4336a70 100644 --- a/presets/recommended.json +++ b/presets/recommended.json @@ -1,3 +1,21 @@ { - "plugins": ["./node_modules/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": { + "style": { + "useImportType": "off" + } + } + } } diff --git a/tests/integration/consumer-smoke.test.ts b/tests/integration/consumer-smoke.test.ts index 932b5b6..45005b9 100644 --- a/tests/integration/consumer-smoke.test.ts +++ b/tests/integration/consumer-smoke.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -113,6 +113,8 @@ describe('consumer smoke test [integration]', () => { 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 @@ -124,6 +126,24 @@ describe('consumer smoke test [integration]', () => { } }); + 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'), @@ -145,6 +165,71 @@ describe('consumer smoke test [integration]', () => { } }); + 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', From 829ab22a5a5737061d375b9a6456380e50a00531 Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 33/34] chore: require Biome 2.5, test on 2.5.12 and guard the minimum in CI Native AST node patterns and plugin rewrites both need Biome 2.5.0, so the peer range moves from >=2.4.8 to >=2.5.0. Development and schemas move to 2.5.12. A CI job runs the integration suite against 2.5.0 so the peer range stays honest; the latest-Biome job remains as an early warning. --- .github/workflows/ci.yml | 27 +++++++++++++++ biome.json | 2 +- package-lock.json | 74 ++++++++++++++++++++-------------------- package.json | 9 ++--- 4 files changed, 70 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c3642..8ce251f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,33 @@ jobs: - 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/biome.json b/biome.json index 08eb73c..30d868b 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package-lock.json b/package-lock.json index 6bf04c3..d43c6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "biome-angular": "cli.js" }, "devDependencies": { - "@biomejs/biome": "^2.5.5", + "@biomejs/biome": "^2.5.12", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.6", @@ -30,7 +30,7 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@biomejs/biome": ">=2.4.8 <3" + "@biomejs/biome": ">=2.5.0 <3" } }, "node_modules/@actions/core": { @@ -179,9 +179,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.5.tgz", - "integrity": "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==", + "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": { @@ -195,20 +195,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.5", - "@biomejs/cli-darwin-x64": "2.5.5", - "@biomejs/cli-linux-arm64": "2.5.5", - "@biomejs/cli-linux-arm64-musl": "2.5.5", - "@biomejs/cli-linux-x64": "2.5.5", - "@biomejs/cli-linux-x64-musl": "2.5.5", - "@biomejs/cli-win32-arm64": "2.5.5", - "@biomejs/cli-win32-x64": "2.5.5" + "@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.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.5.tgz", - "integrity": "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==", + "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" ], @@ -223,9 +223,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.5.tgz", - "integrity": "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==", + "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" ], @@ -240,9 +240,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.5.tgz", - "integrity": "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==", + "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" ], @@ -257,9 +257,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.5.tgz", - "integrity": "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==", + "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" ], @@ -274,9 +274,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.5.tgz", - "integrity": "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==", + "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" ], @@ -291,9 +291,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.5.tgz", - "integrity": "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==", + "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" ], @@ -308,9 +308,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.5.tgz", - "integrity": "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==", + "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" ], @@ -325,9 +325,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.5.tgz", - "integrity": "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==", + "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 1c83da1..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,7 +25,8 @@ "gritql", "plugin", "angular-eslint", - "typescript" + "typescript", + "signals" ], "author": "Virgile Vivier ", "license": "MIT", @@ -42,10 +43,10 @@ "LICENSE" ], "peerDependencies": { - "@biomejs/biome": ">=2.4.8 <3" + "@biomejs/biome": ">=2.5.0 <3" }, "devDependencies": { - "@biomejs/biome": "^2.5.5", + "@biomejs/biome": "^2.5.12", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.6", From 85f64e90948c524d4b94be8d1356d9eb81093dae Mon Sep 17 00:00:00 2001 From: Virgile Vivier Date: Fri, 4 Sep 2026 18:09:51 +0200 Subject: [PATCH 34/34] docs: describe the Angular-safe preset, suppressions, fixes and pattern rules README: preset rationale (parameter decorators, useImportType), the [rule-name] message prefix and where a biome-ignore comment must sit for a decorated class, the fix policy and the official schematics for the larger migrations, Angular 17-22, the seven new rules, and the Biome caveat on per-file rewrites. CONTRIBUTING and CLAUDE.md: native AST node patterns are the way to match classes and members; verified engine rules (full-match regex, static messages, unreliable pattern definitions, rewrite ordering, same-stem split for conditional fixes). --- CLAUDE.md | 64 ++++++++++++------ CONTRIBUTING.md | 59 +++++++++++++--- README.md | 175 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 220 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b13e70a..ff01b89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # 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 @@ -23,28 +23,54 @@ Port des règles @angular-eslint + nouvelles règles signal-based (Angular moder - rules/modern/ — APIs signal-based Angular 17+ (prefer-signal-inputs, prefer-inject-function, etc.) ## État actuel -- 19 règles dans biome-manifest.jsonc, toutes fonctionnelles et décorateur-aware +- 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) -- Consommation : "plugins": ["./node_modules/biome-plugin-angular"] (chemin relatif au config) - ou extends de biome-plugin-angular/presets/recommended.json + 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:…) -## GritQL (Biome 2.4.8+) — règles d'écriture de patterns (vérifiées empiriquement) -- `@Decorator($args)\nclass $name { $body }` matche — MAIS il faut une variante explicite - `export class` pour matcher les classes exportées (l'ancien "GritQL ne matche pas - décorateur+classe" était un mauvais diagnostic : il manquait juste `export`) -- Variantes nécessaires par forme de classe : {class, export class} × {simple, extends $ext} -- IMPOSSIBLE à matcher (limitations moteur, false negatives documentés par tests it.fails) : - - clause `implements` avec métavariable (`class $n implements $i { $b }` ne matche JAMAIS ; - littéral `implements Bar` OK) — ni `within`, ni `contains`, ni `$...` ne contournent - - propriétés `readonly` (`@Output() readonly $n = $i` ne matche jamais, même en littéral avec nom) -- Les snippets méthode avec type de retour sont distincts : `ngOnInit() {}` ≠ `ngOnInit(): void {}` - (les deux variantes sont nécessaires) ; les modificateurs (async, protected) matchent implicitement -- Identifiant nu en `contains` (ex: `ngOnInit`) matche toute occurrence, signature incluse -- Capture regex disponible : `$name <: r"(.*)Change$"($base)` puis réutiliser $base -- `$program <: contains …` permet des vérifications au niveau fichier -- Les diagnostics plugin ont category "plugin" dans le JSON (`--reporter=json`) +## 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 2df02a4..cd3faa4 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,22 @@ [![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 -- 19 GritQL rules for the Biome 2.x plugin system -- Ports the most-used `@angular-eslint` rules -- 6 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` - 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 | --- @@ -54,35 +60,77 @@ npx biome-angular merge ### Option B — Manual configuration -Add the plugin to your `biome.json`. Biome resolves plugin entries as **paths -relative to the config file** (not npm package names), so reference the -installed package directory: +Extend the shipped preset from your `biome.json`: ```jsonc { - "plugins": ["./node_modules/biome-plugin-angular"] + "extends": ["biome-plugin-angular/presets/recommended.json"] } ``` -Or extend the shipped preset (which contains exactly that plugins entry): +The preset is the recommended path because it also applies three settings that any +Angular project needs from Biome itself: ```jsonc { - "extends": ["biome-plugin-angular/presets/recommended.json"] + "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 = ''; } ``` -Biome loads `biome-manifest.jsonc` from the plugin directory and activates all -19 rules. +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 -(`no-input-rename`, `no-output-rename`, `no-output-on-prefix`, -`prefer-standalone`, `contextual-lifecycle`) 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: +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 { @@ -99,10 +147,10 @@ copy the `.grit` files you want from the package and list them individually: ### High Priority -| Rule | ESLint equivalent | Severity | Notes | -| ---- | ----------------- | :------: | ----- | -| [`prefer-standalone`](docs/rules/prefer-standalone.md) | `@angular-eslint/prefer-standalone` | error | flags only explicit `standalone: false` | -| [`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 @@ -111,48 +159,72 @@ copy the `.grit` files you want from the package and list them individually: | [`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* | -| [`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 | | +| [`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 | per-file check | +| [`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-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 | decorator-gated | +| [`prefer-inject-function`](docs/rules/prefer-inject-function.md) | `@angular-eslint/prefer-inject` | warn | any constructor parameter, `ng generate @angular/core:inject` | -\* 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 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. -### Known engine limitations +### Auto-fix and migrations -Biome's GritQL engine cannot match certain TypeScript constructs, which causes -**false negatives** (never false positives) in specific shapes: +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. + +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 +``` -- **`implements` clauses**: decorated classes with an `implements` clause are - invisible to decorator+class patterns. Affects the suffix rules, - `contextual-lifecycle`, and `prefer-inject-function`. -- **`readonly` properties**: `@Output() readonly x = …` cannot be matched. - Affects `no-output-on-prefix` and `prefer-model-signal`. +### Skipped rules -Each limitation is pinned by an `it.fails` integration test, so the suite will -flag the day a Biome release lifts it. +`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. --- @@ -160,14 +232,14 @@ flag the day a Biome release lifts it. ``` biome-angular init [--force] - Create a new biome.json preconfigured with this plugin. + Create a new biome.json that extends biome-plugin-angular/presets/recommended.json. --force Overwrite existing biome.json biome-angular merge - Add this plugin to your existing biome.json without overwriting. + Add the preset to the "extends" array of your existing biome.json. biome-angular remove - Remove the plugin reference from biome.json (also cleans config + Remove the plugin from biome.json ("extends" and "plugins" forms, plus config written by pre-1.0 versions of this CLI). biome-angular rules @@ -186,7 +258,8 @@ npm uninstall eslint @angular-eslint/eslint-plugin @angular-eslint/template-pars ``` Note: this plugin covers TypeScript rules only. `@angular-eslint/template-*` -(HTML template) rules have no Biome equivalent yet. +(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.