@ethlete/eslint-plugin 0.1.0-next.0 → 0.1.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/package.json +6 -5
  3. package/src/configs/recommended.js +414 -0
  4. package/src/index.js +138 -0
  5. package/src/rules/angular-decorator-property-order.js +205 -0
  6. package/src/rules/class-constant-property.js +365 -0
  7. package/src/rules/class-member-order.js +456 -0
  8. package/src/rules/enforce-routing-view-naming.js +84 -0
  9. package/src/rules/guard-return-newline.js +81 -0
  10. package/src/rules/inject-member-accessibility.js +93 -0
  11. package/src/rules/internals/angular-member-visibility.js +233 -0
  12. package/src/rules/internals/implemented-contract-members.js +294 -0
  13. package/src/rules/no-angular-router-api.js +158 -0
  14. package/src/rules/no-angular-seo-services.js +82 -0
  15. package/src/rules/no-direct-dom-manipulation.js +160 -0
  16. package/src/rules/no-document-cookie.js +55 -0
  17. package/src/rules/no-dom-query.js +81 -0
  18. package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
  19. package/src/rules/no-empty-newlines-between-imports.js +45 -0
  20. package/src/rules/no-inject-chain.js +44 -0
  21. package/src/rules/no-leading-underscore-class-member.js +120 -0
  22. package/src/rules/no-legacy-angular-decorators.js +405 -0
  23. package/src/rules/no-locale-id.js +57 -0
  24. package/src/rules/no-member-alias.js +151 -0
  25. package/src/rules/no-native-observers.js +102 -0
  26. package/src/rules/no-pipe-logic.js +76 -0
  27. package/src/rules/no-public-property.js +85 -0
  28. package/src/rules/no-readonly-signal.js +119 -0
  29. package/src/rules/no-redundant-internal.js +94 -0
  30. package/src/rules/no-redundant-on-push-change-detection.js +237 -0
  31. package/src/rules/no-rxjs-in-effect.js +88 -0
  32. package/src/rules/no-screaming-case-local.js +113 -0
  33. package/src/rules/no-standalone-flag.js +95 -0
  34. package/src/rules/no-subscribe-in-pipe.js +89 -0
  35. package/src/rules/no-subscribe-with-body.js +95 -0
  36. package/src/rules/no-trivial-return-type.js +104 -0
  37. package/src/rules/no-trivial-wrapper-method.js +120 -0
  38. package/src/rules/no-type-only-import.js +79 -0
  39. package/src/rules/no-typed-injected-element-ref.js +88 -0
  40. package/src/rules/no-unused-class-member.js +209 -0
  41. package/src/rules/no-window-location.js +109 -0
  42. package/src/rules/prefer-clone-equal.js +112 -0
  43. package/src/rules/prefer-concise-angular-host-directives.js +225 -0
  44. package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
  45. package/src/rules/prefer-element-dimensions.js +128 -0
  46. package/src/rules/prefer-linked-signal.js +124 -0
  47. package/src/rules/prefer-match-media.js +101 -0
  48. package/src/rules/prefer-rxjs-timer.js +82 -0
  49. package/src/rules/prefer-scroll-state.js +158 -0
  50. package/src/rules/prefer-viewport-size.js +75 -0
  51. package/src/rules/require-dollar-suffix.js +123 -0
  52. package/src/rules/require-on-push-change-detection.js +320 -0
  53. package/src/rules/require-view-encapsulation-none.js +264 -0
  54. package/src/rules/template-member-accessibility.js +204 -0
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows non-empty function bodies inside `.subscribe()` calls.
6
+ * Side effects must be moved into a `tap()` operator inside the pipe instead.
7
+ *
8
+ * ❌ someObs$.subscribe((res) => console.log(res));
9
+ * ❌ someObs$.subscribe((res) => { doSomething(res); });
10
+ * ❌ someObs$.subscribe({ next: (res) => doSomething(res) });
11
+ *
12
+ * ✅ someObs$.pipe(tap((res) => console.log(res))).subscribe();
13
+ * ✅ someObs$.subscribe();
14
+ * ✅ someObs$.subscribe(() => {}); // empty body is fine
15
+ *
16
+ * @type {import('eslint').Rule.RuleModule}
17
+ */
18
+ const rule = {
19
+ meta: {
20
+ type: 'suggestion',
21
+ docs: {
22
+ description: 'Require subscribe() to be called with an empty body. Move side effects into tap() inside the pipe.',
23
+ },
24
+ schema: [],
25
+ messages: {
26
+ noSubscribeBody: 'Keep subscribe() empty. Move side effects into a tap() operator inside the pipe.',
27
+ },
28
+ },
29
+ create(context) {
30
+ /**
31
+ * Returns true if the function node has a non-empty body.
32
+ * @param {import('eslint').Rule.Node} fn
33
+ */
34
+ const hasBody = (fn) => {
35
+ const node = /** @type {any} */ (fn);
36
+ if (node.type === 'ArrowFunctionExpression') {
37
+ // Concise arrow: `(x) => x.foo` — always has a value expression
38
+ if (node.body.type !== 'BlockStatement') return true;
39
+ return node.body.body.length > 0;
40
+ }
41
+ if (node.type === 'FunctionExpression') {
42
+ return node.body.body.length > 0;
43
+ }
44
+ return false;
45
+ };
46
+
47
+ return {
48
+ CallExpression(node) {
49
+ const callee = /** @type {any} */ (node.callee);
50
+
51
+ if (
52
+ callee.type !== 'MemberExpression' ||
53
+ callee.property.type !== 'Identifier' ||
54
+ callee.property.name !== 'subscribe'
55
+ ) {
56
+ return;
57
+ }
58
+
59
+ const args = /** @type {any[]} */ (node.arguments);
60
+
61
+ // Skip non-RxJS subscribe patterns where the first argument is a string
62
+ // literal (e.g. Facebook SDK: player.subscribe('eventName', callback)).
63
+ // RxJS .subscribe() never takes a string as its first argument.
64
+ if (args.length > 0 && args[0].type === 'Literal' && typeof args[0].value === 'string') {
65
+ return;
66
+ }
67
+
68
+ for (const arg of args) {
69
+ // Direct function argument: .subscribe((res) => ...)
70
+ if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
71
+ if (hasBody(arg)) {
72
+ context.report({ node, messageId: 'noSubscribeBody' });
73
+ return;
74
+ }
75
+ continue;
76
+ }
77
+
78
+ // Observer object: .subscribe({ next: (res) => ..., error: ..., complete: ... })
79
+ if (arg.type === 'ObjectExpression') {
80
+ for (const prop of arg.properties) {
81
+ if (prop.type !== 'Property') continue;
82
+ const val = /** @type {any} */ (prop.value);
83
+ if ((val.type === 'ArrowFunctionExpression' || val.type === 'FunctionExpression') && hasBody(val)) {
84
+ context.report({ node, messageId: 'noSubscribeBody' });
85
+ return;
86
+ }
87
+ }
88
+ }
89
+ }
90
+ },
91
+ };
92
+ },
93
+ };
94
+
95
+ module.exports = rule;
@@ -0,0 +1,104 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows trivially-inferable explicit return type annotations on function
6
+ * implementations. TypeScript infers `void`, `boolean`, `string`, `number`,
7
+ * `undefined`, and `null` without help; writing them out adds noise.
8
+ *
9
+ * BAD: const go = (): void => { ... }
10
+ * GOOD: const go = () => { ... }
11
+ *
12
+ * ALSO GOOD (non-trivial, keep it):
13
+ * const parse = (): Date => new Date(value)
14
+ */
15
+
16
+ const TRIVIAL_TYPES = new Set([
17
+ 'TSVoidKeyword',
18
+ 'TSBooleanKeyword',
19
+ 'TSStringKeyword',
20
+ 'TSNumberKeyword',
21
+ 'TSUndefinedKeyword',
22
+ 'TSNullKeyword',
23
+ ]);
24
+
25
+ const TRIVIAL_LABELS = {
26
+ TSVoidKeyword: 'void',
27
+ TSBooleanKeyword: 'boolean',
28
+ TSStringKeyword: 'string',
29
+ TSNumberKeyword: 'number',
30
+ TSUndefinedKeyword: 'undefined',
31
+ TSNullKeyword: 'null',
32
+ };
33
+
34
+ /** @type {import('eslint').Rule.RuleModule} */
35
+ const noTrivialReturnType = {
36
+ meta: {
37
+ type: 'suggestion',
38
+ fixable: 'code',
39
+ docs: {
40
+ description:
41
+ 'Disallow explicit return type annotations that TypeScript can infer (void, boolean, string, number, undefined, null).',
42
+ recommended: true,
43
+ },
44
+ messages: {
45
+ trivialReturnType: "Omit the explicit ': {{type}}' return type — TypeScript infers it.",
46
+ },
47
+ schema: [],
48
+ },
49
+ create(context) {
50
+ /**
51
+ * Walk up the AST to detect type-level contexts (type aliases, interface bodies,
52
+ * abstract method signatures) where return type annotations are required / expected.
53
+ * @param {import('eslint').Rule.Node} node
54
+ */
55
+ const isInsideTypeContext = (node) => {
56
+ let current = node.parent;
57
+ while (current) {
58
+ const t = current.type;
59
+ // Type-level signatures — not implementations
60
+ if (t === 'TSTypeLiteral' || t === 'TSInterfaceBody' || t === 'TSTypeAliasDeclaration') {
61
+ return true;
62
+ }
63
+ // Abstract method declarations (separate AST node via @typescript-eslint/parser)
64
+ if (t === 'TSAbstractMethodDefinition' || t === 'TSMethodSignature') {
65
+ return true;
66
+ }
67
+ current = current.parent;
68
+ }
69
+ return false;
70
+ };
71
+
72
+ /**
73
+ * @param {import('eslint').Rule.Node} node
74
+ */
75
+ const checkReturnType = (node) => {
76
+ // @typescript-eslint/parser adds `returnType` to function nodes at runtime
77
+ // even though the base ESTree type doesn't declare it.
78
+ const fn = /** @type {any} */ (node);
79
+ if (!fn.returnType) return;
80
+ if (isInsideTypeContext(node)) return;
81
+
82
+ const annotation = fn.returnType.typeAnnotation;
83
+ if (!TRIVIAL_TYPES.has(annotation.type)) return;
84
+
85
+ const label = TRIVIAL_LABELS[annotation.type] ?? annotation.type;
86
+
87
+ context.report({
88
+ node: fn.returnType,
89
+ messageId: 'trivialReturnType',
90
+ data: { type: label },
91
+ fix(fixer) {
92
+ return fixer.remove(fn.returnType);
93
+ },
94
+ });
95
+ };
96
+
97
+ return {
98
+ ArrowFunctionExpression: checkReturnType,
99
+ FunctionExpression: checkReturnType,
100
+ };
101
+ },
102
+ };
103
+
104
+ module.exports = noTrivialReturnType;
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows trivial wrapper methods that do nothing but forward all their
6
+ * arguments to another call.
7
+ *
8
+ * These methods add an extra layer of indirection without adding any value.
9
+ * Call sites should invoke the target directly.
10
+ *
11
+ * BAD:
12
+ * setValue(val: string) { this.value.set(val); } // just delegates
13
+ * getItem(key: string) { return this.store.get(key); } // just delegates
14
+ * doThing(a: string, b: number) { this.impl.doThing(a, b); }
15
+ *
16
+ * GOOD:
17
+ * // Call this.value.set(val) directly at the call sites.
18
+ * // If the method wraps something non-trivially (transforms args, adds logic), it is fine.
19
+ */
20
+
21
+ /**
22
+ * Returns true when the param is a plain identifier (no default, no destructuring, no rest).
23
+ * @param {import('@typescript-eslint/types').TSESTree.Parameter} param
24
+ */
25
+ const isSimpleParam = (param) => param.type === 'Identifier';
26
+
27
+ /**
28
+ * Returns the single CallExpression in the method body when the method is a
29
+ * trivial all-args-forwarding wrapper, or null otherwise.
30
+ *
31
+ * Matches both void wrappers:
32
+ * setValue(val) { this.value.set(val); }
33
+ * and return wrappers:
34
+ * get(key) { return this.store.get(key); }
35
+ *
36
+ * @param {import('eslint').Rule.Node} methodNode MethodDefinition node
37
+ * @returns {import('eslint').Rule.Node | null}
38
+ */
39
+ const getTrivialCallExpr = (methodNode) => {
40
+ const fnValue = methodNode.value;
41
+ if (!fnValue) return null;
42
+
43
+ const params = fnValue.params;
44
+ // Require at least one parameter — zero-param delegate methods are deliberate API surface.
45
+ if (params.length === 0) return null;
46
+ // All params must be simple identifiers (no destructuring, defaults, or rest).
47
+ if (!params.every(isSimpleParam)) return null;
48
+
49
+ const body = fnValue.body;
50
+ if (!body || body.type !== 'BlockStatement') return null;
51
+ if (body.body.length !== 1) return null;
52
+
53
+ const stmt = body.body[0];
54
+
55
+ let callExpr = null;
56
+ if (stmt.type === 'ExpressionStatement') {
57
+ callExpr = stmt.expression;
58
+ } else if (stmt.type === 'ReturnStatement' && stmt.argument) {
59
+ callExpr = stmt.argument;
60
+ }
61
+
62
+ if (!callExpr || callExpr.type !== 'CallExpression') return null;
63
+ if (callExpr.arguments.length !== params.length) return null;
64
+
65
+ // All arguments must be the method's own param names in the same order.
66
+ const allMatch = params.every((param, i) => {
67
+ const arg = callExpr.arguments[i];
68
+ return arg.type === 'Identifier' && arg.name === param.name;
69
+ });
70
+
71
+ return allMatch ? callExpr : null;
72
+ };
73
+
74
+ /** @type {import('eslint').Rule.RuleModule} */
75
+ const noTrivialWrapperMethod = {
76
+ meta: {
77
+ type: 'suggestion',
78
+ docs: {
79
+ description: 'Disallow trivial wrapper methods that only forward all arguments to another call.',
80
+ recommended: true,
81
+ },
82
+ messages: {
83
+ noTrivialWrapperMethod:
84
+ "'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}'. Remove it and call '{{target}}' directly at each call site.",
85
+ },
86
+ schema: [],
87
+ },
88
+ create(context) {
89
+ return {
90
+ MethodDefinition(node) {
91
+ // Skip constructors, getters, and setters — they commonly delegate for good reasons.
92
+ if (node.kind === 'constructor' || node.kind === 'get' || node.kind === 'set') return;
93
+
94
+ const callExpr = getTrivialCallExpr(node);
95
+ if (!callExpr) return;
96
+
97
+ const methodName = node.key.type === 'Identifier' ? node.key.name : null;
98
+ if (!methodName) return;
99
+
100
+ const callee = callExpr.callee;
101
+ const targetName =
102
+ callee.type === 'MemberExpression'
103
+ ? context.sourceCode.getText(callee)
104
+ : callee.type === 'Identifier'
105
+ ? callee.name
106
+ : null;
107
+
108
+ if (!targetName) return;
109
+
110
+ context.report({
111
+ node,
112
+ messageId: 'noTrivialWrapperMethod',
113
+ data: { name: methodName, target: targetName },
114
+ });
115
+ },
116
+ };
117
+ },
118
+ };
119
+
120
+ module.exports = noTrivialWrapperMethod;
@@ -0,0 +1,79 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @type {import('eslint').Rule.RuleModule} */
5
+ const noTypeOnlyImport = {
6
+ meta: {
7
+ type: 'suggestion',
8
+ docs: {
9
+ description: 'Disallow `import type { Foo }` and `import { type Foo }` — use a regular value import instead.',
10
+ recommended: true,
11
+ },
12
+ fixable: 'code',
13
+ messages: {
14
+ noTypeImportDeclaration:
15
+ "Do not use `import type`. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
16
+ noInlineTypeSpecifier:
17
+ "Do not use inline `type` on import specifiers. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
18
+ },
19
+ schema: [],
20
+ },
21
+ create(context) {
22
+ return {
23
+ ImportDeclaration(node) {
24
+ const decl = /** @type {any} */ (node);
25
+ if (decl.importKind === 'type') {
26
+ const source = /** @type {string} */ (node.source.value);
27
+ const specifiers = node.specifiers.map((s) => s.local.name).join(', ');
28
+
29
+ context.report({
30
+ node,
31
+ messageId: 'noTypeImportDeclaration',
32
+ data: { specifiers, source },
33
+ fix(fixer) {
34
+ const specifierText = node.specifiers.map((s) => s.local.name).join(', ');
35
+ return fixer.replaceText(node, `import { ${specifierText} } from '${source}';`);
36
+ },
37
+ });
38
+
39
+ return;
40
+ }
41
+
42
+ for (const specifier of node.specifiers) {
43
+ const spec = /** @type {any} */ (specifier);
44
+ if (specifier.type === 'ImportSpecifier' && spec.importKind === 'type') {
45
+ const source = /** @type {string} */ (node.source.value);
46
+ const allSpecifiers = node.specifiers
47
+ .map((s) => {
48
+ if (s.type === 'ImportSpecifier') {
49
+ const imported = s.imported.type === 'Identifier' ? s.imported.name : s.imported.value;
50
+ return s.local.name === imported ? imported : `${imported} as ${s.local.name}`;
51
+ }
52
+ return s.local.name;
53
+ })
54
+ .join(', ');
55
+
56
+ context.report({
57
+ node: specifier,
58
+ messageId: 'noInlineTypeSpecifier',
59
+ data: { specifiers: allSpecifiers, source },
60
+ fix(fixer) {
61
+ const sourceCode = context.sourceCode;
62
+ const tokenBefore = sourceCode.getTokenBefore(specifier.imported ?? specifier.local);
63
+ if (tokenBefore && tokenBefore.value === 'type') {
64
+ const tokenAfter = sourceCode.getTokenAfter(tokenBefore);
65
+ if (tokenAfter) {
66
+ return fixer.removeRange([tokenBefore.range[0], tokenAfter.range[0]]);
67
+ }
68
+ }
69
+ return null;
70
+ },
71
+ });
72
+ }
73
+ }
74
+ },
75
+ };
76
+ },
77
+ };
78
+
79
+ module.exports = noTypeOnlyImport;
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @type {import('eslint').Rule.RuleModule} */
5
+ const noTypedInjectedElementRef = {
6
+ meta: {
7
+ type: 'suggestion',
8
+ docs: {
9
+ description:
10
+ 'Require that inject(ElementRef) uses a generic type parameter on inject() itself, not on ElementRef.',
11
+ recommended: true,
12
+ },
13
+ messages: {
14
+ missingGeneric:
15
+ 'Provide a generic type on inject() itself: `inject<ElementRef<HTMLElement>>(ElementRef)`. Passing a type argument on ElementRef directly is also disallowed.',
16
+ },
17
+ schema: [],
18
+ fixable: 'code',
19
+ },
20
+ create(context) {
21
+ return {
22
+ CallExpression(node) {
23
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'inject') {
24
+ return;
25
+ }
26
+
27
+ const firstArg = node.arguments[0];
28
+ if (!firstArg) return;
29
+
30
+ const isPlainElementRef = firstArg.type === 'Identifier' && firstArg.name === 'ElementRef';
31
+ const isInstantiatedElementRef =
32
+ firstArg.type === 'TSInstantiationExpression' &&
33
+ firstArg.expression &&
34
+ firstArg.expression.type === 'Identifier' &&
35
+ firstArg.expression.name === 'ElementRef';
36
+
37
+ if (!isPlainElementRef && !isInstantiatedElementRef) return;
38
+
39
+ const src = context.sourceCode;
40
+ const existingTypeParams = node.typeParameters || node.typeArguments;
41
+ const hasInjectTypeParam =
42
+ existingTypeParams && existingTypeParams.params && existingTypeParams.params.length > 0;
43
+
44
+ if (isPlainElementRef && !hasInjectTypeParam) {
45
+ // inject(ElementRef) → inject<ElementRef<HTMLElement>>(ElementRef)
46
+ context.report({
47
+ node,
48
+ messageId: 'missingGeneric',
49
+ fix(fixer) {
50
+ return fixer.insertTextAfter(node.callee, '<ElementRef<HTMLElement>>');
51
+ },
52
+ });
53
+ return;
54
+ }
55
+
56
+ if (isInstantiatedElementRef) {
57
+ // Capture the type args text from the token, e.g. "<HTMLElement>"
58
+ const tokenTypeParamText = src.getText(firstArg.typeParameters || firstArg.typeArguments);
59
+
60
+ if (!hasInjectTypeParam) {
61
+ // inject(ElementRef<HTMLElement>) → inject<ElementRef<HTMLElement>>(ElementRef)
62
+ context.report({
63
+ node,
64
+ messageId: 'missingGeneric',
65
+ fix(fixer) {
66
+ return [
67
+ fixer.insertTextAfter(node.callee, `<ElementRef${tokenTypeParamText}>`),
68
+ fixer.replaceText(firstArg, 'ElementRef'),
69
+ ];
70
+ },
71
+ });
72
+ } else {
73
+ // inject<X>(ElementRef<Y>) — token must not carry type args; keep inject's type param
74
+ context.report({
75
+ node,
76
+ messageId: 'missingGeneric',
77
+ fix(fixer) {
78
+ return fixer.replaceText(firstArg, 'ElementRef');
79
+ },
80
+ });
81
+ }
82
+ }
83
+ },
84
+ };
85
+ },
86
+ };
87
+
88
+ module.exports = noTypedInjectedElementRef;
@@ -0,0 +1,209 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows class members that are declared but never read within the class body.
6
+ *
7
+ * Only checks members that CANNOT be accessed from outside the class at compile
8
+ * time — so false positives (e.g. template usage) are avoided:
9
+ *
10
+ * - `private` members — always checked. Private members are only accessible
11
+ * within the class body, so if they are never read there they are dead code.
12
+ *
13
+ * - `protected` members — checked only when the class has an Angular decorator
14
+ * other than `@Component` (i.e. `@Directive`, `@Pipe`, `@Injectable`).
15
+ * For `@Component`, protected members may be read in the HTML template file
16
+ * which is invisible to static analysis, so protected is skipped there.
17
+ * For plain (non-Angular) classes, protected members may be used in a
18
+ * subclass in another file, so they are also skipped.
19
+ *
20
+ * Members referenced in `host:` binding expressions in the class decorator are
21
+ * excluded from the check for `protected` (since they are accessed by Angular
22
+ * at runtime via the host binding mechanism).
23
+ *
24
+ * BAD:
25
+ * private document = inject(DOCUMENT); // fetched but never used
26
+ * private unusedFlag = false;
27
+ *
28
+ * GOOD:
29
+ * private document = inject(DOCUMENT);
30
+ * createElement() { return this.document.createElement('div'); }
31
+ */
32
+
33
+ const ANGULAR_DECORATORS = new Set(['Component', 'Directive', 'Pipe', 'Injectable']);
34
+
35
+ /**
36
+ * Extract all identifier-like tokens from a template expression string.
37
+ * Used for `host:` binding values like `'myMember ? "on" : "off"'`.
38
+ * @param {string} str
39
+ * @returns {string[]}
40
+ */
41
+ const extractIdentifiers = (str) => str.match(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g) ?? [];
42
+
43
+ /**
44
+ * Returns info about the Angular decorators applied to a class node.
45
+ * @param {any} classNode
46
+ * @returns {{ isComponent: boolean, isAngularClass: boolean }}
47
+ */
48
+ const getDecoratorInfo = (classNode) => {
49
+ const decorators = classNode.decorators ?? [];
50
+ let isComponent = false;
51
+ let isAngularClass = false;
52
+
53
+ for (const dec of decorators) {
54
+ const expr = dec.expression;
55
+ const name =
56
+ expr?.type === 'CallExpression' && expr.callee?.type === 'Identifier'
57
+ ? expr.callee.name
58
+ : expr?.type === 'Identifier'
59
+ ? expr.name
60
+ : null;
61
+
62
+ if (name && ANGULAR_DECORATORS.has(name)) {
63
+ isAngularClass = true;
64
+ if (name === 'Component') isComponent = true;
65
+ }
66
+ }
67
+
68
+ return { isComponent, isAngularClass };
69
+ };
70
+
71
+ /**
72
+ * Returns names of class members referenced in `host:` binding value strings
73
+ * found in any decorator argument on the class.
74
+ * @param {any} classNode
75
+ * @returns {Set<string>}
76
+ */
77
+ const getHostBoundMembers = (classNode) => {
78
+ /** @type {Set<string>} */
79
+ const result = new Set();
80
+ const decorators = classNode.decorators ?? [];
81
+
82
+ for (const dec of decorators) {
83
+ const expr = dec.expression;
84
+ if (expr?.type !== 'CallExpression') continue;
85
+
86
+ const arg = expr.arguments?.[0];
87
+ if (!arg || arg.type !== 'ObjectExpression') continue;
88
+
89
+ const hostProp = arg.properties.find(
90
+ /** @param {any} p */
91
+ (p) => p.type === 'Property' && (p.key?.name === 'host' || p.key?.value === 'host'),
92
+ );
93
+ if (!hostProp || hostProp.value?.type !== 'ObjectExpression') continue;
94
+
95
+ for (const binding of hostProp.value.properties) {
96
+ if (binding.type !== 'Property') continue;
97
+ /** @type {any} */
98
+ const val = binding.value;
99
+ if (val?.type !== 'Literal' || typeof val.value !== 'string') continue;
100
+ for (const id of extractIdentifiers(val.value)) result.add(id);
101
+ }
102
+ }
103
+
104
+ return result;
105
+ };
106
+
107
+ /** @type {import('eslint').Rule.RuleModule} */
108
+ const noUnusedClassMember = {
109
+ meta: {
110
+ type: 'suggestion',
111
+ docs: {
112
+ description: 'Disallow class members that are declared but never read within the class body.',
113
+ recommended: true,
114
+ },
115
+ messages: {
116
+ noUnused:
117
+ "'{{name}}' is declared but its value is never read within this class. " +
118
+ 'Remove it, or widen its accessibility if it is intentionally used externally.',
119
+ },
120
+ schema: [],
121
+ },
122
+ create(context) {
123
+ /**
124
+ * Stack of class frames. Each frame tracks which members are candidates and
125
+ * which have been seen in a `this.x` expression.
126
+ *
127
+ * @type {Array<{
128
+ * classBody: import('eslint').Rule.Node,
129
+ * isComponent: boolean,
130
+ * isAngularClass: boolean,
131
+ * hostBound: Set<string>,
132
+ * candidates: Map<string, import('eslint').Rule.Node>,
133
+ * usedNames: Set<string>,
134
+ * }>}
135
+ */
136
+ const classStack = [];
137
+
138
+ /**
139
+ * @param {any} node
140
+ */
141
+ const registerMember = (node) => {
142
+ const frame = classStack[classStack.length - 1];
143
+ if (!frame) return;
144
+ // Only register members that belong directly to this class body
145
+ if (node.parent !== frame.classBody) return;
146
+
147
+ if (node.static) return;
148
+
149
+ const key = node.key;
150
+ if (!key || key.type !== 'Identifier') return;
151
+ if (node.type === 'MethodDefinition' && node.kind === 'constructor') return;
152
+
153
+ const name = key.name;
154
+ const accessibility = node.accessibility;
155
+ const { isComponent, isAngularClass, hostBound } = frame;
156
+
157
+ if (accessibility === 'private') {
158
+ frame.candidates.set(name, node);
159
+ } else if (accessibility === 'protected' && isAngularClass && !isComponent) {
160
+ if (!hostBound.has(name)) frame.candidates.set(name, node);
161
+ }
162
+ };
163
+
164
+ return {
165
+ ClassBody(node) {
166
+ const classNode = node.parent;
167
+ const { isComponent, isAngularClass } = getDecoratorInfo(classNode);
168
+ const hostBound = getHostBoundMembers(classNode);
169
+ classStack.push({
170
+ classBody: node,
171
+ isComponent,
172
+ isAngularClass,
173
+ hostBound,
174
+ candidates: new Map(),
175
+ usedNames: new Set(),
176
+ });
177
+ },
178
+
179
+ 'ClassBody:exit'() {
180
+ const frame = classStack.pop();
181
+ if (!frame) return;
182
+ for (const [name, memberNode] of frame.candidates) {
183
+ if (!frame.usedNames.has(name)) {
184
+ context.report({ node: memberNode, messageId: 'noUnused', data: { name } });
185
+ }
186
+ }
187
+ },
188
+
189
+ PropertyDefinition: registerMember,
190
+ MethodDefinition: registerMember,
191
+
192
+ MemberExpression(node) {
193
+ // Only care about `this.name` (non-computed direct property access)
194
+ if (node.computed) return;
195
+ if (node.object?.type !== 'ThisExpression') return;
196
+ if (node.property?.type !== 'Identifier') return;
197
+
198
+ // Mark used in the innermost class frame. Arrow functions don't push a
199
+ // new frame so they correctly share the enclosing class's `this`.
200
+ // Regular nested classes push a new frame so their `this.x` won't
201
+ // accidentally mark an outer class member as used.
202
+ const frame = classStack[classStack.length - 1];
203
+ if (frame) frame.usedNames.add(node.property.name);
204
+ },
205
+ };
206
+ },
207
+ };
208
+
209
+ module.exports = noUnusedClassMember;