@ethlete/eslint-plugin 1.0.0-next.21 → 1.0.0-next.23

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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +3 -4
  3. package/src/configs/recommended.js +20 -10
  4. package/src/index.js +4 -1
  5. package/src/rules/class-constant-property.js +27 -0
  6. package/src/rules/guard-return-newline.js +5 -4
  7. package/src/rules/inject-member-accessibility.js +17 -21
  8. package/src/rules/internals/angular-io.js +19 -1
  9. package/src/rules/internals/angular-member-visibility.js +5 -5
  10. package/src/rules/internals/angular-metadata-fix.js +22 -0
  11. package/src/rules/internals/member-accessibility-fix.js +26 -0
  12. package/src/rules/no-angular-seo-services.js +7 -7
  13. package/src/rules/no-direct-dom-manipulation.js +17 -0
  14. package/src/rules/no-effect-cleanup-return.js +8 -6
  15. package/src/rules/no-empty-angular-metadata-arrays.js +3 -19
  16. package/src/rules/no-enum.js +115 -0
  17. package/src/rules/no-leading-underscore-class-member.js +7 -0
  18. package/src/rules/no-legacy-angular-decorators.js +8 -0
  19. package/src/rules/no-legacy-prepare-without-injector.js +42 -26
  20. package/src/rules/no-legacy-query-import.js +1 -1
  21. package/src/rules/no-locale-id.js +2 -14
  22. package/src/rules/no-member-alias.js +17 -0
  23. package/src/rules/no-native-observers.js +4 -1
  24. package/src/rules/no-pipe-logic.js +1 -0
  25. package/src/rules/no-readonly-signal.js +13 -5
  26. package/src/rules/no-redundant-internal.js +21 -2
  27. package/src/rules/no-redundant-on-push-change-detection.js +28 -38
  28. package/src/rules/no-standalone-flag.js +4 -19
  29. package/src/rules/no-trivial-return-type.js +1 -0
  30. package/src/rules/no-trivial-wrapper-method.js +3 -0
  31. package/src/rules/no-type-only-import.js +7 -2
  32. package/src/rules/no-typed-injected-element-ref.js +16 -9
  33. package/src/rules/no-unused-class-member.js +28 -5
  34. package/src/rules/prefer-match-media.js +2 -2
  35. package/src/rules/prefer-scroll-state.js +3 -67
  36. package/src/rules/prefer-static-boolean-properties.js +30 -0
  37. package/src/rules/prefer-viewport-size.js +1 -7
  38. package/src/rules/require-form-submit.js +2 -28
  39. package/src/rules/require-on-push-change-detection.js +31 -11
  40. package/src/rules/require-view-encapsulation-none.js +31 -9
  41. package/src/rules/template-member-accessibility.js +4 -21
@@ -379,6 +379,14 @@ const rule = {
379
379
  const expression = anyNode.expression;
380
380
  if (expression.type !== 'CallExpression' && expression.type !== 'Identifier') return null;
381
381
 
382
+ if (
383
+ expression.type === 'CallExpression' &&
384
+ expression.arguments.length > 0 &&
385
+ (expression.arguments[0]?.type !== 'Literal' || typeof expression.arguments[0].value !== 'string')
386
+ ) {
387
+ return null;
388
+ }
389
+
382
390
  const bindingName =
383
391
  expression.type === 'CallExpression'
384
392
  ? expression.arguments[0]?.type === 'Literal' && typeof expression.arguments[0].value === 'string'
@@ -273,7 +273,12 @@ const noLegacyPrepareWithoutInjector = {
273
273
  },
274
274
  create(context) {
275
275
  const sourceCode = context.sourceCode;
276
- const creatorPattern = new RegExp(context.options[0]?.creatorPattern ?? '^legacy');
276
+ let creatorPattern;
277
+ try {
278
+ creatorPattern = new RegExp(context.options[0]?.creatorPattern ?? '^legacy');
279
+ } catch {
280
+ creatorPattern = /^legacy/u;
281
+ }
277
282
  /** Names known to be legacy creators: imported under the naming convention, or declared locally. */
278
283
  const creatorNames = new Set();
279
284
 
@@ -297,38 +302,42 @@ const noLegacyPrepareWithoutInjector = {
297
302
  * @param {string[]} needed
298
303
  */
299
304
  const buildCoreImportFix = (needed) => {
300
- const coreImport = /** @type {any} */ (
301
- sourceCode.ast.body.find(
305
+ const coreImports = /** @type {any[]} */ (
306
+ sourceCode.ast.body.filter(
302
307
  (node) =>
303
308
  node.type === 'ImportDeclaration' &&
304
309
  node.source.type === 'Literal' &&
305
310
  node.source.value === '@angular/core',
306
311
  )
307
312
  );
313
+ const existing = new Set(
314
+ coreImports.flatMap((coreImport) =>
315
+ coreImport.specifiers
316
+ .filter((specifier) => specifier.type === 'ImportSpecifier' && coreImport.importKind !== 'type')
317
+ .map((specifier) => specifier.imported.name),
318
+ ),
319
+ );
320
+ const missing = needed.filter((name) => !existing.has(name));
321
+ if (missing.length === 0) return null;
322
+
323
+ const editableImport = coreImports.find(
324
+ (coreImport) =>
325
+ coreImport.importKind !== 'type' &&
326
+ coreImport.specifiers.length > 0 &&
327
+ coreImport.specifiers.every((specifier) => specifier.type === 'ImportSpecifier'),
328
+ );
308
329
 
309
- if (!coreImport) {
330
+ if (!editableImport) {
310
331
  const imports = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
311
- const lastImport = imports[imports.length - 1];
312
- const text = `import { ${needed.join(', ')} } from '@angular/core';`;
332
+ const lastImport = coreImports.at(-1) ?? imports.at(-1);
333
+ const text = `import { ${missing.join(', ')} } from '@angular/core';`;
313
334
 
314
335
  return lastImport
315
336
  ? /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastImport, `\n${text}`)
316
337
  : /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(sourceCode.ast.body[0], `${text}\n\n`);
317
338
  }
318
339
 
319
- const specifiers = coreImport.specifiers ?? [];
320
-
321
- if (specifiers.some(/** @param {any} specifier */ (specifier) => specifier.type !== 'ImportSpecifier')) {
322
- return null;
323
- }
324
-
325
- const existing = new Set(specifiers.map(/** @param {any} specifier */ (specifier) => specifier.imported.name));
326
- const missing = needed.filter((name) => !existing.has(name));
327
- const lastSpecifier = specifiers[specifiers.length - 1];
328
-
329
- if (missing.length === 0 || !lastSpecifier) {
330
- return null;
331
- }
340
+ const lastSpecifier = editableImport.specifiers.at(-1);
332
341
 
333
342
  return /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastSpecifier, `, ${missing.join(', ')}`);
334
343
  };
@@ -368,12 +377,6 @@ const noLegacyPrepareWithoutInjector = {
368
377
  );
369
378
  }
370
379
 
371
- // `prepare(args)` - spreading is the only way to keep whatever the variable holds.
372
- if (argument.type === 'Identifier' || argument.type === 'MemberExpression') {
373
- return /** @param {any} fixer */ (fixer) =>
374
- fixer.replaceText(argument, `{ ...${sourceCode.getText(argument)}, ${injectorProperty} }`);
375
- }
376
-
377
380
  return null;
378
381
  };
379
382
 
@@ -457,7 +460,20 @@ const noLegacyPrepareWithoutInjector = {
457
460
 
458
461
  const classBody = findEnclosingClassBody(node);
459
462
  const existingInjector = classBody ? findInjectorMemberName(classBody) : null;
460
- const injectorName = existingInjector ?? 'injector';
463
+ const declaredNames = new Set(
464
+ classBody?.body
465
+ .map((member) => (member.key?.type === 'Identifier' ? member.key.name : null))
466
+ .filter(Boolean) ?? [],
467
+ );
468
+ let injectorName = existingInjector ?? 'injector';
469
+ if (!existingInjector && declaredNames.has(injectorName)) {
470
+ injectorName = 'queryInjector';
471
+ let suffix = 2;
472
+ while (declaredNames.has(injectorName)) {
473
+ injectorName = `queryInjector${suffix}`;
474
+ suffix += 1;
475
+ }
476
+ }
461
477
  const argumentFix = classBody ? buildArgumentFix(node, `this.${injectorName}`) : null;
462
478
 
463
479
  context.report({
@@ -48,7 +48,7 @@ const LEGACY_SYMBOLS = {
48
48
  queryStateErrorSignal: { to: '`query.error()`', docs: '/query/queries#the-query-object' },
49
49
  queryStateLoadingSignal: { to: '`query.loading()`', docs: '/query/queries#the-query-object' },
50
50
  validateWithV2Query: { to: '`validateWithQuery`', docs: '/query/errors#validating-against-the-server-as-the-user-types' }, // prettier-ignore
51
- provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query-devtools`, which registers every client at once', docs: '/query-devtools/' }, // prettier-ignore
51
+ provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query`, which registers every client at once', docs: '/query-devtools/' }, // prettier-ignore
52
52
  };
53
53
 
54
54
  /** The current-system counterparts of the names the legacy system had to give up its half of. */
@@ -4,12 +4,10 @@
4
4
  /**
5
5
  * Disallows injecting Angular's LOCALE_ID token directly.
6
6
  *
7
- * @ethlete/core provides injectLocale() which wraps LOCALE_ID in a signal
8
- * and exposes helper utilities for locale-aware formatting.
7
+ * @ethlete/core provides a signal-based locale store for application code.
9
8
  *
10
9
  * BAD:
11
10
  * inject(LOCALE_ID)
12
- * import { LOCALE_ID } from '@angular/core';
13
11
  *
14
12
  * GOOD:
15
13
  * import { injectLocale } from '@ethlete/core';
@@ -26,7 +24,7 @@ const noLocaleId = {
26
24
  },
27
25
  messages: {
28
26
  noLocaleId:
29
- "Do not inject 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core' instead — it wraps LOCALE_ID and provides a clean, signal-compatible API.",
27
+ "Do not inject 'LOCALE_ID' directly. Use the signal-based 'injectLocale()' API from '@ethlete/core' for application locale state.",
30
28
  },
31
29
  schema: [],
32
30
  },
@@ -40,16 +38,6 @@ const noLocaleId = {
40
38
  context.report({ node, messageId: 'noLocaleId' });
41
39
  }
42
40
  },
43
-
44
- ImportDeclaration(node) {
45
- if (node.source.value !== '@angular/core') return;
46
- for (const specifier of node.specifiers) {
47
- if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'LOCALE_ID') {
48
- context.report({ node, messageId: 'noLocaleId' });
49
- return;
50
- }
51
- }
52
- },
53
41
  };
54
42
  },
55
43
  };
@@ -79,6 +79,16 @@ const getMethodAliasInfo = (node) => {
79
79
  return { sourceName: obj.property.name, propName: callee.property.name };
80
80
  };
81
81
 
82
+ const CONTRACT_METHOD_NAMES = new Set(['blur', 'focus', 'reset']);
83
+
84
+ /** @param {any} methodNode */
85
+ const isInAngularClass = (methodNode) =>
86
+ methodNode.parent?.parent?.decorators?.some((decorator) => {
87
+ const expression = decorator.expression;
88
+ const callee = expression.type === 'CallExpression' ? expression.callee : expression;
89
+ return callee.type === 'Identifier' && (callee.name === 'Component' || callee.name === 'Directive');
90
+ }) ?? false;
91
+
82
92
  /** @type {import('eslint').Rule.RuleModule} */
83
93
  const noMemberAlias = {
84
94
  meta: {
@@ -119,6 +129,10 @@ const noMemberAlias = {
119
129
 
120
130
  const sourceName = obj.property.name;
121
131
  const propName = value.property.name;
132
+ const sourceMember = node.parent?.body?.find(
133
+ (member) => !member.computed && member.key?.type === 'Identifier' && member.key.name === sourceName,
134
+ );
135
+ if (node.accessibility === 'private' && sourceMember?.accessibility === 'private') return;
122
136
 
123
137
  context.report({
124
138
  node,
@@ -133,6 +147,9 @@ const noMemberAlias = {
133
147
 
134
148
  const info = getMethodAliasInfo(node);
135
149
  if (!info) return;
150
+ const classNode = node.parent?.parent;
151
+ if (node.override || classNode?.superClass || classNode?.implements?.length) return;
152
+ if (isInAngularClass(node) && CONTRACT_METHOD_NAMES.has(aliasName)) return;
136
153
 
137
154
  // Only flag when names match — different names indicate an intentional
138
155
  // API rename, which is not a pure alias.
@@ -73,7 +73,10 @@ const noNativeObservers = {
73
73
  if (node.callee.type !== 'Identifier') return;
74
74
 
75
75
  const name = node.callee.name;
76
- if (!(name in OBSERVER_ALTERNATIVES)) return;
76
+
77
+ // hasOwn, not `in`: `new constructor()` / `new toString()` would otherwise match an
78
+ // inherited Object.prototype key and read a function where an entry is expected
79
+ if (!Object.hasOwn(OBSERVER_ALTERNATIVES, name)) return;
77
80
 
78
81
  const info = OBSERVER_ALTERNATIVES[name];
79
82
 
@@ -39,6 +39,7 @@ const noPipeLogic = {
39
39
  MethodDefinition(node) {
40
40
  const key = /** @type {any} */ (node).key;
41
41
  if (key.type !== 'Identifier' || key.name !== 'transform') return;
42
+ if (!node.value?.body || node.value.body.type === 'TSEmptyBodyFunctionExpression') return;
42
43
 
43
44
  const classBody = node.parent;
44
45
  if (!classBody || classBody.type !== 'ClassBody') return;
@@ -50,6 +50,10 @@ const REACTIVE_APIS = new Set([
50
50
  'effect',
51
51
  'afterNextRender',
52
52
  'afterRender',
53
+ 'afterRenderEffect',
54
+ 'resource',
55
+ 'rxResource',
56
+ 'httpResource',
53
57
  // RxJS interop
54
58
  'toSignal',
55
59
  'toObservable',
@@ -83,7 +87,7 @@ const noReadonlySignal = {
83
87
  let apiName = null;
84
88
 
85
89
  // Simple call: signal(), input(), computed(), inject(), etc.
86
- if (callee.type === 'Identifier' && REACTIVE_APIS.has(callee.name)) {
90
+ if (callee.type === 'Identifier' && (REACTIVE_APIS.has(callee.name) || callee.name.startsWith('inject'))) {
87
91
  apiName = callee.name;
88
92
  }
89
93
  // Member call: input.required(), outputFromObservable.something(), etc.
@@ -101,13 +105,17 @@ const noReadonlySignal = {
101
105
  messageId: 'noReadonlySignal',
102
106
  data: { name: apiName },
103
107
  fix(fixer) {
104
- // Find and remove the "readonly" token
105
108
  const sourceCode = context.sourceCode;
106
109
  const tokens = sourceCode.getTokens(node);
107
- const readonlyToken = tokens.find((t) => t.value === 'readonly');
110
+ const decoratorEnd = node.decorators?.at(-1)?.range[1] ?? node.range[0];
111
+ const readonlyToken = tokens.find(
112
+ (token) =>
113
+ token.value === 'readonly' && token.range[0] >= decoratorEnd && token.range[1] <= node.key.range[0],
114
+ );
108
115
  if (!readonlyToken) return null;
109
- // Remove "readonly " (including the trailing space)
110
- return fixer.removeRange([readonlyToken.range[0], readonlyToken.range[1] + 1]);
116
+ const nextToken = sourceCode.getTokenAfter(readonlyToken);
117
+ const rangeEnd = nextToken ? nextToken.range[0] : readonlyToken.range[1];
118
+ return fixer.removeRange([readonlyToken.range[0], rangeEnd]);
111
119
  },
112
120
  });
113
121
  }
@@ -40,6 +40,25 @@ const getMemberKind = (node) => {
40
40
  return 'property';
41
41
  };
42
42
 
43
+ /**
44
+ * @param {import('eslint').SourceCode} sourceCode
45
+ * @param {import('eslint').Rule.Node} node
46
+ * @param {import('eslint').AST.Token} comment
47
+ */
48
+ const buildInternalTagFix = (sourceCode, node, comment) => {
49
+ const commentText = sourceCode.getText(comment);
50
+ const remainingText = commentText
51
+ .replace(/^\s*\/\*\*?\s*@internal\s*\*\/\s*$/u, '')
52
+ .replace(/^\s*\*\s*@internal\s*\r?\n/mu, '')
53
+ .replace(/@internal\b\s*/u, '');
54
+
55
+ if (remainingText.trim() === '') {
56
+ return (fixer) => fixer.removeRange([comment.range[0], node.range[0]]);
57
+ }
58
+
59
+ return (fixer) => fixer.replaceText(comment, remainingText);
60
+ };
61
+
43
62
  /** @type {import('eslint').Rule.RuleModule} */
44
63
  const noRedundantInternal = {
45
64
  meta: {
@@ -68,7 +87,7 @@ const noRedundantInternal = {
68
87
  return;
69
88
  }
70
89
 
71
- if (node.accessibility !== 'private' && node.accessibility !== 'protected') {
90
+ if (node.accessibility !== 'private') {
72
91
  return;
73
92
  }
74
93
 
@@ -80,7 +99,7 @@ const noRedundantInternal = {
80
99
  kind: getMemberKind(node),
81
100
  name: getMemberName(node),
82
101
  },
83
- fix: (fixer) => fixer.removeRange([internalComment.range[0], node.range[0]]),
102
+ fix: buildInternalTagFix(sourceCode, node, internalComment),
84
103
  });
85
104
  };
86
105
 
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
5
+
4
6
  /**
5
7
  * Disallow explicit `changeDetection: ChangeDetectionStrategy.OnPush` on
6
8
  * `@Component` decorators. Since Angular 22, OnPush is the default change
@@ -85,34 +87,13 @@ const isComponentDecorator = (node) => {
85
87
  /**
86
88
  * @param {any} value
87
89
  */
88
- const isOnPushValue = (value) =>
90
+ const isOnPushValue = (value, localName) =>
89
91
  value.type === 'MemberExpression' &&
90
92
  value.object.type === 'Identifier' &&
91
- value.object.name === 'ChangeDetectionStrategy' &&
93
+ value.object.name === localName &&
92
94
  value.property.type === 'Identifier' &&
93
95
  value.property.name === 'OnPush';
94
96
 
95
- /**
96
- * Range that removes a property together with the comma that joins it to its
97
- * siblings (leading comma when it is the last property, otherwise trailing).
98
- *
99
- * @param {import('eslint').SourceCode} sourceCode
100
- * @param {any} metadata
101
- * @param {any} property
102
- */
103
- const getPropertyRemovalRange = (sourceCode, metadata, property) => {
104
- const properties = metadata.properties.filter((entry) => entry.type === 'Property');
105
- const index = properties.indexOf(property);
106
- const openingBrace = sourceCode.getFirstToken(metadata);
107
- const closingBrace = sourceCode.getLastToken(metadata);
108
-
109
- if (!openingBrace || !closingBrace) return property.range;
110
- if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
111
- if (index < properties.length - 1) return [property.range[0], properties[index + 1].range[0]];
112
-
113
- return [properties[index - 1].range[1], property.range[1]];
114
- };
115
-
116
97
  /**
117
98
  * Range that removes a single named import specifier together with its comma.
118
99
  *
@@ -126,6 +107,15 @@ const getSpecifierRemovalRange = (sourceCode, importNode, specifier) => {
126
107
 
127
108
  // Sole specifier of the whole import — drop the entire declaration.
128
109
  if (importNode.specifiers.length === 1) return importNode.range;
110
+ if (named.length === 1) {
111
+ const openingBrace = sourceCode.getTokenBefore(specifier, (token) => token.value === '{');
112
+ const closingBrace = sourceCode.getTokenAfter(specifier, (token) => token.value === '}');
113
+ const precedingComma = openingBrace ? sourceCode.getTokenBefore(openingBrace) : null;
114
+
115
+ if (openingBrace && closingBrace && precedingComma?.value === ',') {
116
+ return [precedingComma.range[0], closingBrace.range[1]];
117
+ }
118
+ }
129
119
  if (index < named.length - 1) return [specifier.range[0], named[index + 1].range[0]];
130
120
 
131
121
  return [named[index - 1].range[1], specifier.range[1]];
@@ -137,18 +127,10 @@ const getSpecifierRemovalRange = (sourceCode, importNode, specifier) => {
137
127
  * removing those properties leaves the import unused and safe to drop.
138
128
  *
139
129
  * @param {import('eslint').Scope.Reference} reference
130
+ * @param {Set<any>} removableIdentifiers
140
131
  */
141
- const isOnPushChangeDetectionReference = (reference) => {
142
- const identifier = /** @type {any} */ (reference.identifier);
143
- const member = identifier.parent;
144
- if (!member || member.type !== 'MemberExpression' || member.object !== identifier) return false;
145
- if (member.property.type !== 'Identifier' || member.property.name !== 'OnPush') return false;
146
-
147
- const property = member.parent;
148
- if (!property || property.type !== 'Property' || property.value !== member) return false;
149
-
150
- return getPropertyName(property.key) === 'changeDetection';
151
- };
132
+ const isOnPushChangeDetectionReference = (reference, removableIdentifiers) =>
133
+ removableIdentifiers.has(reference.identifier);
152
134
 
153
135
  /** @type {import('eslint').Rule.RuleModule} */
154
136
  const noRedundantOnPushChangeDetection = {
@@ -177,6 +159,8 @@ const noRedundantOnPushChangeDetection = {
177
159
  const sourceCode = context.sourceCode;
178
160
  /** @type {any} */
179
161
  let changeDetectionSpecifier = null;
162
+ let changeDetectionLocalName = 'ChangeDetectionStrategy';
163
+ const removableIdentifiers = new Set();
180
164
 
181
165
  return {
182
166
  Decorator(node) {
@@ -191,12 +175,13 @@ const noRedundantOnPushChangeDetection = {
191
175
  const property = metadata.properties.find(
192
176
  (entry) => entry.type === 'Property' && getPropertyName(entry.key) === 'changeDetection',
193
177
  );
194
- if (!property || !isOnPushValue(property.value)) return;
178
+ if (!property || !isOnPushValue(property.value, changeDetectionLocalName)) return;
179
+ removableIdentifiers.add(property.value.object);
195
180
 
196
181
  context.report({
197
182
  node: property,
198
183
  messageId: 'redundant',
199
- fix: (fixer) => fixer.replaceTextRange(getPropertyRemovalRange(sourceCode, metadata, property), ''),
184
+ fix: (fixer) => fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), ''),
200
185
  });
201
186
  },
202
187
 
@@ -210,6 +195,7 @@ const noRedundantOnPushChangeDetection = {
210
195
  specifier.imported.name === 'ChangeDetectionStrategy'
211
196
  ) {
212
197
  changeDetectionSpecifier = specifier;
198
+ changeDetectionLocalName = specifier.local.name;
213
199
  return;
214
200
  }
215
201
  }
@@ -219,8 +205,12 @@ const noRedundantOnPushChangeDetection = {
219
205
  if (!changeDetectionSpecifier) return;
220
206
 
221
207
  const [variable] = sourceCode.getDeclaredVariables(changeDetectionSpecifier);
222
- if (!variable || variable.references.length === 0) return;
223
- if (!variable.references.every(isOnPushChangeDetectionReference)) return;
208
+ if (!variable) return;
209
+ if (
210
+ !variable.references.every((reference) => isOnPushChangeDetectionReference(reference, removableIdentifiers))
211
+ ) {
212
+ return;
213
+ }
224
214
 
225
215
  const importNode = changeDetectionSpecifier.parent;
226
216
  context.report({
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
5
+
4
6
  /** @typedef {'Component' | 'Directive' | 'Pipe'} TDecoratorName */
5
7
 
6
8
  /**
@@ -28,24 +30,6 @@ const getDecoratorName = (node) => {
28
30
  return expression.type === 'Identifier' ? expression.name : null;
29
31
  };
30
32
 
31
- /**
32
- * @param {import('eslint').SourceCode} sourceCode
33
- * @param {any} metadata
34
- * @param {any} property
35
- */
36
- const getRemovalRange = (sourceCode, metadata, property) => {
37
- const properties = metadata.properties.filter((entry) => entry.type === 'Property');
38
- const propertyIndex = properties.indexOf(property);
39
- const openingBrace = sourceCode.getFirstToken(metadata);
40
- const closingBrace = sourceCode.getLastToken(metadata);
41
-
42
- if (!openingBrace || !closingBrace) return property.range;
43
- if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
44
- if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
45
-
46
- return [properties[propertyIndex - 1].range[1], property.range[1]];
47
- };
48
-
49
33
  /** @type {import('eslint').Rule.RuleModule} */
50
34
  const noStandaloneFlag = {
51
35
  meta: {
@@ -78,12 +62,13 @@ const noStandaloneFlag = {
78
62
  for (const property of metadata.properties) {
79
63
  if (property.type !== 'Property') continue;
80
64
  if (getPropertyName(property.key) !== 'standalone') continue;
65
+ if (property.value.type !== 'Literal' || property.value.value !== true) continue;
81
66
 
82
67
  context.report({
83
68
  node: property,
84
69
  messageId: 'noStandalone',
85
70
  fix(fixer) {
86
- return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
71
+ return fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), '');
87
72
  },
88
73
  });
89
74
  }
@@ -173,6 +173,7 @@ const noTrivialReturnType = {
173
173
 
174
174
  const annotation = fn.returnType.typeAnnotation;
175
175
  if (!TRIVIAL_TYPES.has(annotation.type)) return;
176
+ if (fn.body.type !== 'BlockStatement') return;
176
177
  if (isSelfReferencing(fn)) return;
177
178
 
178
179
  const label = TRIVIAL_LABELS[annotation.type] ?? annotation.type;
@@ -124,6 +124,9 @@ const noTrivialWrapperMethod = {
124
124
  const methodName = node.key.type === 'Identifier' ? node.key.name : null;
125
125
  if (!methodName) return;
126
126
 
127
+ const classNode = node.parent?.parent;
128
+ if (node.override || classNode?.superClass || classNode?.implements?.length) return;
129
+ if (node.static && methodName === 'ngTemplateContextGuard') return;
127
130
  if (CONTRACT_METHOD_NAMES.has(methodName) && isInAngularClass(node)) return;
128
131
 
129
132
  const callee = callExpr.callee;
@@ -31,8 +31,13 @@ const noTypeOnlyImport = {
31
31
  messageId: 'noTypeImportDeclaration',
32
32
  data: { specifiers, source },
33
33
  fix(fixer) {
34
- const specifierText = node.specifiers.map((s) => s.local.name).join(', ');
35
- return fixer.replaceText(node, `import { ${specifierText} } from '${source}';`);
34
+ const sourceCode = context.sourceCode;
35
+ const importToken = sourceCode.getFirstToken(node);
36
+ const typeToken = importToken ? sourceCode.getTokenAfter(importToken) : null;
37
+ const nextToken = typeToken ? sourceCode.getTokenAfter(typeToken) : null;
38
+
39
+ if (typeToken?.value !== 'type' || !nextToken) return null;
40
+ return fixer.removeRange([typeToken.range[0], nextToken.range[0]]);
36
41
  },
37
42
  });
38
43
 
@@ -38,16 +38,23 @@ const noTypedInjectedElementRef = {
38
38
 
39
39
  const src = context.sourceCode;
40
40
  const existingTypeParams = node.typeParameters || node.typeArguments;
41
- const hasInjectTypeParam =
42
- existingTypeParams && existingTypeParams.params && existingTypeParams.params.length > 0;
41
+ const injectType = existingTypeParams?.params?.[0];
42
+ const hasTypedElementRef =
43
+ existingTypeParams?.params?.length === 1 &&
44
+ injectType?.type === 'TSTypeReference' &&
45
+ injectType.typeName?.type === 'Identifier' &&
46
+ injectType.typeName.name === 'ElementRef' &&
47
+ (injectType.typeArguments || injectType.typeParameters)?.params?.length > 0;
43
48
 
44
- if (isPlainElementRef && !hasInjectTypeParam) {
49
+ if (isPlainElementRef && !hasTypedElementRef) {
45
50
  // inject(ElementRef) → inject<ElementRef<HTMLElement>>(ElementRef)
46
51
  context.report({
47
52
  node,
48
53
  messageId: 'missingGeneric',
49
54
  fix(fixer) {
50
- return fixer.insertTextAfter(node.callee, '<ElementRef<HTMLElement>>');
55
+ return existingTypeParams
56
+ ? fixer.replaceText(existingTypeParams, '<ElementRef<HTMLElement>>')
57
+ : fixer.insertTextAfter(node.callee, '<ElementRef<HTMLElement>>');
51
58
  },
52
59
  });
53
60
  return;
@@ -57,16 +64,16 @@ const noTypedInjectedElementRef = {
57
64
  // Capture the type args text from the token, e.g. "<HTMLElement>"
58
65
  const tokenTypeParamText = src.getText(firstArg.typeParameters || firstArg.typeArguments);
59
66
 
60
- if (!hasInjectTypeParam) {
67
+ if (!hasTypedElementRef) {
61
68
  // inject(ElementRef<HTMLElement>) → inject<ElementRef<HTMLElement>>(ElementRef)
62
69
  context.report({
63
70
  node,
64
71
  messageId: 'missingGeneric',
65
72
  fix(fixer) {
66
- return [
67
- fixer.insertTextAfter(node.callee, `<ElementRef${tokenTypeParamText}>`),
68
- fixer.replaceText(firstArg, 'ElementRef'),
69
- ];
73
+ const typeFix = existingTypeParams
74
+ ? fixer.replaceText(existingTypeParams, `<ElementRef${tokenTypeParamText}>`)
75
+ : fixer.insertTextAfter(node.callee, `<ElementRef${tokenTypeParamText}>`);
76
+ return [typeFix, fixer.replaceText(firstArg, 'ElementRef')];
70
77
  },
71
78
  });
72
79
  } else {
@@ -126,6 +126,7 @@ const noUnusedClassMember = {
126
126
  *
127
127
  * @type {Array<{
128
128
  * classBody: import('eslint').Rule.Node,
129
+ * isAbstract: boolean,
129
130
  * isComponent: boolean,
130
131
  * isAngularClass: boolean,
131
132
  * hostBound: Set<string>,
@@ -145,6 +146,7 @@ const noUnusedClassMember = {
145
146
  if (node.parent !== frame.classBody) return;
146
147
 
147
148
  if (node.static) return;
149
+ if (node.override || node.abstract || node.decorators?.length) return;
148
150
 
149
151
  const key = node.key;
150
152
  if (!key || key.type !== 'Identifier') return;
@@ -153,9 +155,10 @@ const noUnusedClassMember = {
153
155
  const name = key.name;
154
156
  const accessibility = node.accessibility;
155
157
  const { isComponent, isAngularClass, hostBound } = frame;
158
+ if (frame.isAbstract && accessibility === 'protected') return;
156
159
 
157
160
  if (accessibility === 'private') {
158
- frame.candidates.set(name, node);
161
+ if (!hostBound.has(name)) frame.candidates.set(name, node);
159
162
  } else if (accessibility === 'protected' && isAngularClass && !isComponent) {
160
163
  if (!hostBound.has(name)) frame.candidates.set(name, node);
161
164
  }
@@ -168,6 +171,7 @@ const noUnusedClassMember = {
168
171
  const hostBound = getHostBoundMembers(classNode);
169
172
  classStack.push({
170
173
  classBody: node,
174
+ isAbstract: classNode.abstract === true,
171
175
  isComponent,
172
176
  isAngularClass,
173
177
  hostBound,
@@ -190,17 +194,36 @@ const noUnusedClassMember = {
190
194
  MethodDefinition: registerMember,
191
195
 
192
196
  MemberExpression(node) {
193
- // Only care about `this.name` (non-computed direct property access)
194
- if (node.computed) return;
195
197
  if (node.object?.type !== 'ThisExpression') return;
196
- if (node.property?.type !== 'Identifier') return;
198
+ const name =
199
+ !node.computed && node.property?.type === 'Identifier'
200
+ ? node.property.name
201
+ : node.computed && node.property?.type === 'Literal' && typeof node.property.value === 'string'
202
+ ? node.property.value
203
+ : null;
204
+ if (!name) return;
197
205
 
198
206
  // Mark used in the innermost class frame. Arrow functions don't push a
199
207
  // new frame so they correctly share the enclosing class's `this`.
200
208
  // Regular nested classes push a new frame so their `this.x` won't
201
209
  // accidentally mark an outer class member as used.
202
210
  const frame = classStack[classStack.length - 1];
203
- if (frame) frame.usedNames.add(node.property.name);
211
+ if (frame) frame.usedNames.add(name);
212
+ },
213
+
214
+ VariableDeclarator(node) {
215
+ if (node.init?.type !== 'ThisExpression' || node.id?.type !== 'ObjectPattern') return;
216
+
217
+ const frame = classStack[classStack.length - 1];
218
+ if (!frame) return;
219
+
220
+ for (const property of node.id.properties) {
221
+ if (property.type !== 'Property') continue;
222
+ if (property.key.type === 'Identifier') frame.usedNames.add(property.key.name);
223
+ if (property.key.type === 'Literal' && typeof property.key.value === 'string') {
224
+ frame.usedNames.add(property.key.value);
225
+ }
226
+ }
204
227
  },
205
228
  };
206
229
  },