From 1f2bf7e5b9129ddd162afbdc76114a0eb0c63389 Mon Sep 17 00:00:00 2001 From: reduckted Date: Sun, 23 Feb 2025 21:35:22 +1000 Subject: [PATCH] feat(eslint-plugin): add rule require-lifecycle-on-prototype --- packages/angular-eslint/src/configs/ts-all.ts | 1 + packages/eslint-plugin/README.md | 1 + .../rules/require-lifecycle-on-prototype.md | 1533 +++++++++++++++++ packages/eslint-plugin/src/configs/all.json | 1 + packages/eslint-plugin/src/index.ts | 4 + .../rules/require-lifecycle-on-prototype.ts | 82 + .../require-lifecycle-on-prototype/cases.ts | 476 +++++ .../require-lifecycle-on-prototype/spec.ts | 12 + 8 files changed, 2110 insertions(+) create mode 100644 packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md create mode 100644 packages/eslint-plugin/src/rules/require-lifecycle-on-prototype.ts create mode 100644 packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/cases.ts create mode 100644 packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/spec.ts diff --git a/packages/angular-eslint/src/configs/ts-all.ts b/packages/angular-eslint/src/configs/ts-all.ts index 7c2659c69..f8afc489b 100644 --- a/packages/angular-eslint/src/configs/ts-all.ts +++ b/packages/angular-eslint/src/configs/ts-all.ts @@ -46,6 +46,7 @@ export default ( '@angular-eslint/prefer-signals': 'error', '@angular-eslint/prefer-standalone': 'error', '@angular-eslint/relative-url-prefix': 'error', + '@angular-eslint/require-lifecycle-on-prototype': 'error', '@angular-eslint/require-localize-metadata': 'error', '@angular-eslint/runtime-localize': 'error', '@angular-eslint/sort-lifecycle-methods': 'error', diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index bc2ffa126..67d6134bf 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -24,6 +24,7 @@ Please see https://github.com/angular-eslint/angular-eslint for full usage instr | [`contextual-lifecycle`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/contextual-lifecycle.md) | Ensures that lifecycle methods are used in a correct context | :white_check_mark: | | | | [`no-async-lifecycle-method`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-async-lifecycle-method.md) | Angular Lifecycle methods should not be async. Angular does not wait for async lifecycle but the code incorrectly suggests it does. | | | | | [`no-attribute-decorator`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/no-attribute-decorator.md) | The @Attribute decorator is used to obtain a single value for an attribute. This is a much less common use-case than getting a stream of values (using @Input), so often the @Attribute decorator is mistakenly used when @Input was what was intended. This rule disallows usage of @Attribute decorator altogether in order to prevent these mistakes. | | | | +| [`require-lifecycle-on-prototype`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md) | Ensures that lifecycle methods are defined on the object's prototype instead of on an instance. | | | | | [`sort-lifecycle-methods`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/sort-lifecycle-methods.md) | Ensures that lifecycle methods are declared in order of execution | | | | diff --git a/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md b/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md new file mode 100644 index 000000000..e6d1d71a5 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/require-lifecycle-on-prototype.md @@ -0,0 +1,1533 @@ + + +
+ +# `@angular-eslint/require-lifecycle-on-prototype` + +Ensures that lifecycle methods are defined on the object's prototype instead of on an instance. + +- Type: problem + +
+ +## Rule Options + +The rule does not have any configuration options. + +
+ +## Usage Examples + +> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit. + +
+ +
+❌ - Toggle examples of incorrect code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngOnChanges = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngOnInit = func; + ~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngDoCheck = func; + ~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngAfterContentInit = func; + ~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngAfterContentChecked = func; + ~~~~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngAfterViewInit = func; + ~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngAfterViewChecked = func; + ~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Component({}) +class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngOnChanges = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngOnInit = func; + ~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngDoCheck = func; + ~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngAfterContentInit = func; + ~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngAfterContentChecked = func; + ~~~~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngAfterViewInit = func; + ~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngAfterViewChecked = func; + ~~~~~~~~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Directive({}) +class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Injectable({}) +class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +@Pipe({}) +class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +class Test { + constructor() { + this.ngOnDestroy = func; + ~~~~~~~~~~~ + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +class Test { + constructor() { + this['ngOnDestroy'] = func; + ~~~~~~~~~~~~~ + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +class Test { + run() { + this.ngOnDestroy = func; + ~~~~~~~~~~~ + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +function hook(component) { + component.ngOnDestroy = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +function hook(component) { + (component as any).ngOnDestroy = func; + ~~~~~~~~~~~ +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```ts +class Test { + ['ngOnDestroy'] = func; + ~~~~~~~~~~~~~ +} +``` + +
+ +
+ +--- + +
+ +
+✅ - Toggle examples of correct code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngAfterContentChecked() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngAfterContentInit() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngAfterViewChecked() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngAfterViewInit() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngOnChanges() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngOnDestroy() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngOnInit() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + ngDoCheck() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngAfterContentChecked() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngAfterContentInit() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngAfterViewChecked() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngAfterViewInit() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngOnChanges() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngOnDestroy() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngOnInit() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Directive({}) +class Test { + ngDoCheck() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Injectable({}) +class Test { + ngOnDestroy() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Pipe({}) +class Test { + ngOnDestroy() {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test {} +function hook(type) { + type.prototype.ngOnDestroy = () => {}; +} +hook(Test); +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test {} +function hook(type) { + (type.prototype as any).ngOnDestroy = () => {}; +} +hook(Test); +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test {} +function hook(type) { + type['prototype'].ngOnDestroy = () => {}; +} +hook(Test); +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test {} +function hook(type) { + (type.prototype as unknown as { ngOnDestroy: () => void}).ngOnDestroy = () => {}; +} +hook(Test); +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + onDestroy = () => {} +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + constructor() { + this.onDestroy = () => {} + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + constructor() { + this['onDestroy'] = () => {} + } +} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/require-lifecycle-on-prototype": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```ts +@Component({}) +class Test { + constructor() { + let ngOnDestroy; + ngOnDestroy = () => {}; + } +} +``` + +
+ +
diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index f1b94a661..4cd036f49 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -32,6 +32,7 @@ "@angular-eslint/prefer-signals": "error", "@angular-eslint/prefer-standalone": "error", "@angular-eslint/relative-url-prefix": "error", + "@angular-eslint/require-lifecycle-on-prototype": "error", "@angular-eslint/require-localize-metadata": "error", "@angular-eslint/runtime-localize": "error", "@angular-eslint/sort-lifecycle-methods": "error", diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 1577000ec..0c0fbe8e7 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -88,6 +88,9 @@ import preferStandalone, { import relativeUrlPrefix, { RULE_NAME as relativeUrlPrefixRuleName, } from './rules/relative-url-prefix'; +import requireLifecycleOnPrototype, { + RULE_NAME as requireLifecycleOnPrototypeRuleName, +} from './rules/require-lifecycle-on-prototype'; import requireLocalizeMetadata, { RULE_NAME as requireLocalizeMetadataRuleName, } from './rules/require-localize-metadata'; @@ -153,6 +156,7 @@ export = { [preferStandaloneRuleName]: preferStandalone, [preferOutputReadonlyRuleName]: preferOutputReadonly, [relativeUrlPrefixRuleName]: relativeUrlPrefix, + [requireLifecycleOnPrototypeRuleName]: requireLifecycleOnPrototype, [requireLocalizeMetadataRuleName]: requireLocalizeMetadata, [runtimeLocalizeRuleName]: runtimeLocalize, [sortLifecycleMethodsRuleName]: sortLifecycleMethods, diff --git a/packages/eslint-plugin/src/rules/require-lifecycle-on-prototype.ts b/packages/eslint-plugin/src/rules/require-lifecycle-on-prototype.ts new file mode 100644 index 000000000..f0a2d29b9 --- /dev/null +++ b/packages/eslint-plugin/src/rules/require-lifecycle-on-prototype.ts @@ -0,0 +1,82 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { createESLintRule } from '../utils/create-eslint-rule'; +import { ASTUtils, toPattern } from '@angular-eslint/utils'; + +export type Options = []; + +const ISSUE_LINK = 'https://github.com/angular/angular/issues/38241'; + +export type MessageIds = 'defineOnPrototype'; +export const RULE_NAME = 'require-lifecycle-on-prototype'; + +const angularLifecycleMethodsPattern = toPattern([ + ...ASTUtils.ANGULAR_LIFECYCLE_METHODS, +]); +const propertyDefinitionSelector = `PropertyDefinition > ${createAngularLifecycleMethodsPattern('key')}`; +const assignmentSelector = `AssignmentExpression[operator="="] > MemberExpression.left > ${createAngularLifecycleMethodsPattern('property')}`; + +function createAngularLifecycleMethodsPattern(parentProperty: string): string { + return `:matches(Literal.${parentProperty}[value=${angularLifecycleMethodsPattern}], Identifier.${parentProperty}[name=${angularLifecycleMethodsPattern}])`; +} + +export default createESLintRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + "Ensures that lifecycle methods are defined on the object's prototype instead of on an instance.", + }, + schema: [], + messages: { + defineOnPrototype: `The {{ method }} lifecycle method should be defined on the object's prototype. See more at ${ISSUE_LINK}`, + }, + }, + defaultOptions: [], + create(context) { + return { + [propertyDefinitionSelector]( + node: TSESTree.Literal | TSESTree.Identifier, + ) { + context.report({ + node, + messageId: 'defineOnPrototype', + data: { + method: + node.type === AST_NODE_TYPES.Literal ? node.value : node.name, + }, + }); + }, + [assignmentSelector](node: TSESTree.Literal | TSESTree.Identifier) { + // Assigning to the prototype is OK. + if (!isPrototype((node.parent as TSESTree.MemberExpression).object)) { + context.report({ + node, + messageId: 'defineOnPrototype', + data: { + method: + node.type === AST_NODE_TYPES.Literal ? node.value : node.name, + }, + }); + } + }, + }; + }, +}); + +function isPrototype(node: TSESTree.Node): boolean { + while (node.type === AST_NODE_TYPES.TSAsExpression) { + node = node.expression; + } + if (node.type === AST_NODE_TYPES.MemberExpression) { + switch (node.property.type) { + case AST_NODE_TYPES.Identifier: + return node.property.name === 'prototype'; + + case AST_NODE_TYPES.Literal: + return node.property.value === 'prototype'; + } + } + return false; +} diff --git a/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/cases.ts b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/cases.ts new file mode 100644 index 000000000..3c7d29bbf --- /dev/null +++ b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/cases.ts @@ -0,0 +1,476 @@ +import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/test-utils'; +import type { + InvalidTestCase, + ValidTestCase, +} from '@typescript-eslint/rule-tester'; +import type { + MessageIds, + Options, +} from '../../../src/rules/require-lifecycle-on-prototype'; + +const messageId: MessageIds = 'defineOnPrototype'; + +export const valid: readonly (string | ValidTestCase)[] = [ + ` + @Component({}) + class Test { + ngAfterContentChecked() {} + } + `, + ` + @Component({}) + class Test { + ngAfterContentInit() {} + } + `, + ` + @Component({}) + class Test { + ngAfterViewChecked() {} + } + `, + ` + @Component({}) + class Test { + ngAfterViewInit() {} + } + `, + ` + @Component({}) + class Test { + ngOnChanges() {} + } + `, + ` + @Component({}) + class Test { + ngOnDestroy() {} + } + `, + ` + @Component({}) + class Test { + ngOnInit() {} + } + `, + ` + @Component({}) + class Test { + ngDoCheck() {} + } + `, + ` + @Directive({}) + class Test { + ngAfterContentChecked() {} + } + `, + ` + @Directive({}) + class Test { + ngAfterContentInit() {} + } + `, + ` + @Directive({}) + class Test { + ngAfterViewChecked() {} + } + `, + ` + @Directive({}) + class Test { + ngAfterViewInit() {} + } + `, + ` + @Directive({}) + class Test { + ngOnChanges() {} + } + `, + ` + @Directive({}) + class Test { + ngOnDestroy() {} + } + `, + ` + @Directive({}) + class Test { + ngOnInit() {} + } + `, + ` + @Directive({}) + class Test { + ngDoCheck() {} + } + `, + ` + @Injectable({}) + class Test { + ngOnDestroy() {} + } + `, + ` + @Pipe({}) + class Test { + ngOnDestroy() {} + } + `, + ` + @Component({}) + class Test {} + function hook(type) { + type.prototype.ngOnDestroy = () => {}; + } + hook(Test); + `, + ` + @Component({}) + class Test {} + function hook(type) { + (type.prototype as any).ngOnDestroy = () => {}; + } + hook(Test); + `, + ` + @Component({}) + class Test {} + function hook(type) { + type['prototype'].ngOnDestroy = () => {}; + } + hook(Test); + `, + ` + @Component({}) + class Test {} + function hook(type) { + (type.prototype as unknown as { ngOnDestroy: () => void}).ngOnDestroy = () => {}; + } + hook(Test); + `, + ` + @Component({}) + class Test { + onDestroy = () => {} + } + `, + ` + @Component({}) + class Test { + constructor() { + this.onDestroy = () => {} + } + } + `, + ` + @Component({}) + class Test { + constructor() { + this['onDestroy'] = () => {} + } + } + `, + ` + @Component({}) + class Test { + constructor() { + let ngOnDestroy; + ngOnDestroy = () => {}; + } + } + `, +]; + +export const invalid: readonly InvalidTestCase[] = [ + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngOnChanges property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngOnChanges = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnChanges' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngOnInit property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngOnInit = func; + ~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnInit' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngDoCheck property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngDoCheck = func; + ~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngDoCheck' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngAfterContentInit property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngAfterContentInit = func; + ~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterContentInit' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngAfterContentChecked property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngAfterContentChecked = func; + ~~~~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterContentChecked' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngAfterViewInit property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngAfterViewInit = func; + ~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterViewInit' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngAfterViewChecked property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngAfterViewChecked = func; + ~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterViewChecked' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when component has ngOnDestroy property initialized to value.`, + annotatedSource: ` + @Component({}) + class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngOnChanges property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngOnChanges = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnChanges' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngOnInit property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngOnInit = func; + ~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnInit' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngDoCheck property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngDoCheck = func; + ~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngDoCheck' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngAfterContentInit property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngAfterContentInit = func; + ~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterContentInit' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngAfterContentChecked property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngAfterContentChecked = func; + ~~~~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterContentChecked' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngAfterViewInit property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngAfterViewInit = func; + ~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterViewInit' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngAfterViewChecked property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngAfterViewChecked = func; + ~~~~~~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngAfterViewChecked' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when directive has ngOnDestroy property initialized to value.`, + annotatedSource: ` + @Directive({}) + class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when injectable has ngOnDestroy property initialized to value.`, + annotatedSource: ` + @Injectable({}) + class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when pipe has ngOnDestroy property initialized to value.`, + annotatedSource: ` + @Pipe({}) + class Test { + ngOnDestroy = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when lifecycle property is initialized in constructor.`, + annotatedSource: ` + class Test { + constructor() { + this.ngOnDestroy = func; + ~~~~~~~~~~~ + } + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when lifecycle property is initialized in constructor using literal notation.`, + annotatedSource: ` + class Test { + constructor() { + this['ngOnDestroy'] = func; + ~~~~~~~~~~~~~ + } + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when lifecycle property is initialized in method.`, + annotatedSource: ` + class Test { + run() { + this.ngOnDestroy = func; + ~~~~~~~~~~~ + } + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when lifecycle property is set outside of class.`, + annotatedSource: ` + function hook(component) { + component.ngOnDestroy = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when cast is used to set lifecycle property.`, + annotatedSource: ` + function hook(component) { + (component as any).ngOnDestroy = func; + ~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), + convertAnnotatedSourceToFailureCase({ + description: `should fail when literal notation is used for property name.`, + annotatedSource: ` + class Test { + ['ngOnDestroy'] = func; + ~~~~~~~~~~~~~ + } + `, + messageId, + data: { method: 'ngOnDestroy' }, + }), +]; diff --git a/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/spec.ts b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/spec.ts new file mode 100644 index 000000000..47f8cef9e --- /dev/null +++ b/packages/eslint-plugin/tests/rules/require-lifecycle-on-prototype/spec.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '@angular-eslint/test-utils'; +import rule, { + RULE_NAME, +} from '../../../src/rules/require-lifecycle-on-prototype'; +import { invalid, valid } from './cases'; + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid, + invalid, +});