@ethlete/eslint-plugin 1.0.0-next.22 → 1.0.0-next.24

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 (61) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +4 -5
  3. package/src/configs/recommended.js +23 -10
  4. package/src/index.js +6 -1
  5. package/src/rules/angular-decorator-property-order.js +20 -20
  6. package/src/rules/class-constant-property.js +35 -14
  7. package/src/rules/class-member-order.js +77 -46
  8. package/src/rules/guard-return-newline.js +5 -4
  9. package/src/rules/inject-member-accessibility.js +22 -24
  10. package/src/rules/internals/angular-io.js +31 -14
  11. package/src/rules/internals/angular-member-visibility.js +16 -12
  12. package/src/rules/internals/angular-metadata-fix.js +22 -0
  13. package/src/rules/internals/import-resolution.js +191 -0
  14. package/src/rules/internals/member-accessibility-fix.js +26 -0
  15. package/src/rules/no-angular-router-api.js +23 -29
  16. package/src/rules/no-angular-seo-services.js +21 -16
  17. package/src/rules/no-cdk-import.js +7 -14
  18. package/src/rules/no-direct-dom-manipulation.js +17 -0
  19. package/src/rules/no-document-cookie.js +3 -0
  20. package/src/rules/no-effect-cleanup-return.js +15 -9
  21. package/src/rules/no-empty-angular-metadata-arrays.js +6 -36
  22. package/src/rules/no-enum.js +115 -0
  23. package/src/rules/no-inject-chain.js +3 -1
  24. package/src/rules/no-leading-underscore-class-member.js +33 -3
  25. package/src/rules/no-legacy-angular-decorators.js +17 -21
  26. package/src/rules/no-legacy-prepare-without-injector.js +42 -26
  27. package/src/rules/no-legacy-query-import.js +7 -16
  28. package/src/rules/no-locale-id.js +6 -18
  29. package/src/rules/no-member-alias.js +21 -0
  30. package/src/rules/no-native-html-input-name.js +1 -1
  31. package/src/rules/no-pipe-logic.js +11 -17
  32. package/src/rules/no-readonly-signal.js +13 -5
  33. package/src/rules/no-redundant-internal.js +21 -2
  34. package/src/rules/no-redundant-on-push-change-detection.js +32 -56
  35. package/src/rules/no-rxjs-in-effect.js +12 -8
  36. package/src/rules/no-screaming-case-local.js +1 -0
  37. package/src/rules/no-standalone-flag.js +7 -36
  38. package/src/rules/no-subscribe-in-pipe.js +3 -2
  39. package/src/rules/no-template-literal-before-inline-template.js +3 -14
  40. package/src/rules/no-trivial-return-type.js +2 -0
  41. package/src/rules/no-trivial-wrapper-method.js +13 -16
  42. package/src/rules/no-type-only-import.js +7 -2
  43. package/src/rules/no-typed-injected-element-ref.js +23 -19
  44. package/src/rules/no-unused-class-member.js +34 -14
  45. package/src/rules/prefer-clone-equal.js +8 -6
  46. package/src/rules/prefer-concise-angular-host-directives.js +23 -29
  47. package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
  48. package/src/rules/prefer-element-dimensions.js +9 -10
  49. package/src/rules/prefer-linked-signal.js +7 -4
  50. package/src/rules/prefer-match-media.js +12 -16
  51. package/src/rules/prefer-present-tense-output.js +1 -1
  52. package/src/rules/prefer-rxjs-timer.js +3 -1
  53. package/src/rules/prefer-scroll-state.js +3 -67
  54. package/src/rules/prefer-static-boolean-properties.js +30 -0
  55. package/src/rules/prefer-viewport-size.js +1 -7
  56. package/src/rules/require-dollar-suffix.js +7 -4
  57. package/src/rules/require-form-submit.js +2 -28
  58. package/src/rules/require-on-push-change-detection.js +48 -36
  59. package/src/rules/require-view-encapsulation-none.js +48 -34
  60. package/src/rules/take-until-destroyed-last.js +64 -0
  61. package/src/rules/template-member-accessibility.js +12 -27
@@ -4,6 +4,7 @@
4
4
  const { createRequire } = require('node:module');
5
5
  const path = require('node:path');
6
6
  const fs = require('node:fs');
7
+ const { MODULE_REFERENCE_SELECTOR, getModuleReference } = require('./internals/import-resolution');
7
8
 
8
9
  /**
9
10
  * Disallows importing from `@ethlete/cdk`, the maintenance-mode predecessor of `@ethlete/components`,
@@ -52,13 +53,6 @@ const loadMigrationMap = (mapPath, cwd) => {
52
53
  return map;
53
54
  };
54
55
 
55
- /** @param {any} specifier */
56
- const importedName = (specifier) => {
57
- if (specifier.type !== 'ImportSpecifier') return null;
58
-
59
- return specifier.imported.name ?? specifier.imported.value ?? null;
60
- };
61
-
62
56
  /** @type {import('eslint').Rule.RuleModule} */
63
57
  const noCdkImport = {
64
58
  meta: {
@@ -95,13 +89,13 @@ const noCdkImport = {
95
89
  const entryFor = (name) => map?.[name] ?? null;
96
90
 
97
91
  return {
98
- ImportDeclaration(node) {
99
- const declaration = /** @type {any} */ (node);
100
- const source = declaration.source.value;
92
+ [MODULE_REFERENCE_SELECTOR](node) {
93
+ const reference = getModuleReference(node);
94
+ const source = reference?.source;
101
95
 
102
- if (typeof source !== 'string' || (source !== CDK_PACKAGE && !source.startsWith(`${CDK_PACKAGE}/`))) return;
96
+ if (!reference || (source !== CDK_PACKAGE && !source?.startsWith(`${CDK_PACKAGE}/`))) return;
103
97
 
104
- const named = declaration.specifiers.filter(/** @param {any} s */ (s) => importedName(s));
98
+ const { named } = reference;
105
99
 
106
100
  if (!named.length) {
107
101
  context.report({ node, messageId: 'module', data: { cdk: CDK_PACKAGE, docs: migrationDocs } });
@@ -109,8 +103,7 @@ const noCdkImport = {
109
103
  return;
110
104
  }
111
105
 
112
- for (const specifier of named) {
113
- const name = /** @type {string} */ (importedName(specifier));
106
+ for (const { name, node: specifier } of named) {
114
107
  const entry = entryFor(name);
115
108
 
116
109
  if (!entry) {
@@ -100,6 +100,23 @@ const noDirectDomManipulation = {
100
100
 
101
101
  const methodName = callee.property.name;
102
102
 
103
+ if (
104
+ methodName === 'setProperty' &&
105
+ callee.object.type === 'MemberExpression' &&
106
+ callee.object.property.type === 'Identifier' &&
107
+ callee.object.property.name === 'style'
108
+ ) {
109
+ context.report({
110
+ node,
111
+ messageId: 'domMutation',
112
+ data: {
113
+ method: methodName,
114
+ alternative: 'renderer.setStyle(el, prop, value, RendererStyleFlags2.DashCase)',
115
+ },
116
+ });
117
+ return;
118
+ }
119
+
103
120
  // ── classList.add / .remove / .toggle / .replace ─────────────────────
104
121
  if (
105
122
  callee.object.type === 'MemberExpression' &&
@@ -21,6 +21,8 @@
21
21
  * deleteCookie('name');
22
22
  */
23
23
 
24
+ const { isGlobalReference } = require('./internals/import-resolution');
25
+
24
26
  /** @type {import('eslint').Rule.RuleModule} */
25
27
  const noDocumentCookie = {
26
28
  meta: {
@@ -42,6 +44,7 @@ const noDocumentCookie = {
42
44
  if (
43
45
  node.object.type === 'Identifier' &&
44
46
  node.object.name === 'document' &&
47
+ isGlobalReference(context.sourceCode, node.object) &&
45
48
  node.property.type === 'Identifier' &&
46
49
  node.property.name === 'cookie'
47
50
  ) {
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { ANGULAR_CORE, getImportedName } = require('./internals/import-resolution');
5
+
4
6
  /**
5
7
  * Disallows returning a cleanup function from an `effect()` callback.
6
8
  *
@@ -115,7 +117,9 @@ const noEffectCleanupReturn = {
115
117
  CallExpression(node) {
116
118
  const { callee } = node;
117
119
 
118
- if (callee.type !== 'Identifier' || !EFFECT_CALLEES.has(callee.name)) {
120
+ const calleeName = getImportedName(context.sourceCode, callee, ANGULAR_CORE);
121
+
122
+ if (!calleeName || !EFFECT_CALLEES.has(calleeName)) {
119
123
  return;
120
124
  }
121
125
 
@@ -130,7 +134,7 @@ const noEffectCleanupReturn = {
130
134
  context.report({
131
135
  node: callback.body,
132
136
  messageId: 'returnedCleanup',
133
- data: { callee: callee.name },
137
+ data: { callee: calleeName },
134
138
  });
135
139
 
136
140
  return;
@@ -147,21 +151,23 @@ const noEffectCleanupReturn = {
147
151
  return;
148
152
  }
149
153
 
150
- // Only the mechanical shape is auto-fixed: a zero-parameter callback whose single `return` hands
151
- // back a function literal. With an existing parameter, an early `return`, or a returned reference,
152
- // the intent needs a human.
153
- // The parameter list's `(` — the first one before the body, so this works for `() => {}` and
154
- // `function () {}` alike (their first token differs).
155
154
  const openParen = sourceCode
156
155
  .getTokens(callback)
157
156
  .find((token) => token.value === '(' && token.range[1] <= callback.body.range[0]);
158
- const fixable = callback.params.length === 0 && returns.length === 1 && cleanupReturns.length === 1;
157
+ const callbackScope = sourceCode.getScope(callback);
158
+ const hasOnCleanupBinding = callbackScope.variables.some((variable) => variable.name === 'onCleanup');
159
+ const fixable =
160
+ callback.params.length === 0 &&
161
+ returns.length === 1 &&
162
+ cleanupReturns.length === 1 &&
163
+ callback.body.body.at(-1) === cleanupReturns[0] &&
164
+ !hasOnCleanupBinding;
159
165
 
160
166
  for (const statement of cleanupReturns) {
161
167
  context.report({
162
168
  node: statement.argument,
163
169
  messageId: 'returnedCleanup',
164
- data: { callee: callee.name },
170
+ data: { callee: calleeName },
165
171
  fix:
166
172
  fixable && openParen
167
173
  ? (fixer) => [
@@ -1,6 +1,10 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getAngularDecoratorName } = require('./internals/import-resolution');
5
+
6
+ const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
7
+
4
8
  /** @typedef {'Component' | 'Directive'} TDecoratorName */
5
9
 
6
10
  /**
@@ -12,40 +16,6 @@ const getPropertyName = (key) => {
12
16
  return null;
13
17
  };
14
18
 
15
- /**
16
- * @param {import('eslint').Rule.Node} node
17
- * @returns {TDecoratorName | null}
18
- */
19
- const getDecoratorName = (node) => {
20
- const decorator = /** @type {any} */ (node);
21
- if (decorator.type !== 'Decorator') return null;
22
-
23
- const expression = decorator.expression;
24
- if (expression.type === 'CallExpression') {
25
- return expression.callee.type === 'Identifier' ? expression.callee.name : null;
26
- }
27
-
28
- return expression.type === 'Identifier' ? expression.name : null;
29
- };
30
-
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
19
  const MESSAGE_IDS = {
50
20
  hostDirectives: 'noEmptyHostDirectives',
51
21
  imports: 'noEmptyImports',
@@ -71,7 +41,7 @@ const noEmptyAngularMetadataArrays = {
71
41
  return {
72
42
  /** @param {import('eslint').Rule.Node} node */
73
43
  Decorator(node) {
74
- const decoratorName = getDecoratorName(node);
44
+ const decoratorName = getAngularDecoratorName(context.sourceCode, node);
75
45
  if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
76
46
 
77
47
  const decorator = /** @type {any} */ (node);
@@ -92,7 +62,7 @@ const noEmptyAngularMetadataArrays = {
92
62
  node: property,
93
63
  messageId: MESSAGE_IDS[propertyName],
94
64
  fix(fixer) {
95
- return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
65
+ return fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), '');
96
66
  },
97
67
  });
98
68
  }
@@ -0,0 +1,115 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallow `enum` and `const enum` — use a const object plus a derived union type.
6
+ *
7
+ * An enum is the only TypeScript construct that emits runtime code from a type declaration, so it
8
+ * cannot be erased and it cannot be tree-shaken. Its members are nominal, so a plain string is not
9
+ * assignable to an enum-typed parameter even when the value matches. A `const enum` is worse: a
10
+ * transpile-only consumer (esbuild, SWC, Babel) has no cross-file type information, so it cannot
11
+ * inline the member and the build fails or emits a missing binding.
12
+ *
13
+ * A const object with `as const` plus a derived union gives the same dotted access, keeps the name
14
+ * usable in a type position, accepts a matching literal, and tree-shakes.
15
+ *
16
+ * BAD:
17
+ * export enum MatchState {
18
+ * Live = 'live',
19
+ * }
20
+ *
21
+ * GOOD:
22
+ * export const MatchState = {
23
+ * Live: 'live',
24
+ * } as const;
25
+ *
26
+ * export type MatchState = (typeof MatchState)[keyof typeof MatchState];
27
+ *
28
+ * The fixer runs only on an enum whose members all have a string literal initializer. It widens a
29
+ * member used in a type position (`state: MatchState.Live`), which then needs `typeof`.
30
+ */
31
+
32
+ /** typescript-eslint v8 nests members under a TSEnumBody; earlier versions put them on the declaration. */
33
+ const membersOf = (node) => node.body?.members ?? node.members ?? [];
34
+
35
+ const isStringLiteral = (node) => node?.type === 'Literal' && typeof node.value === 'string';
36
+
37
+ /** Only a plain string enum converts to a const object without changing what a member holds. */
38
+ const isFixable = (node) => {
39
+ if (node.declare) return false;
40
+
41
+ const members = membersOf(node);
42
+
43
+ if (!members.length) return false;
44
+
45
+ return members.every(
46
+ (member) => isStringLiteral(member.initializer) && (member.id.type === 'Identifier' || isStringLiteral(member.id)),
47
+ );
48
+ };
49
+
50
+ /** @type {import('eslint').Rule.RuleModule} */
51
+ const noEnum = {
52
+ meta: {
53
+ type: 'problem',
54
+ docs: {
55
+ description: 'Disallow enum and const enum. Use a const object with `as const` plus a derived union type.',
56
+ recommended: true,
57
+ },
58
+ fixable: 'code',
59
+ messages: {
60
+ noEnum:
61
+ "No enums. '{{name}}' emits runtime code that cannot be tree-shaken, and its members are nominal. Use a const object with 'as const' plus a derived union type.",
62
+ noConstEnum:
63
+ "No 'const enum'. A transpile-only consumer (esbuild, SWC, Babel) cannot inline '{{name}}' and its build fails. Use a const object with 'as const' plus a derived union type.",
64
+ },
65
+ schema: [],
66
+ },
67
+ create(context) {
68
+ const { sourceCode } = context;
69
+
70
+ const buildReplacement = (node, target) => {
71
+ const name = node.id.name;
72
+ const indent = ' '.repeat(target.loc.start.column);
73
+ const memberIndent = `${indent} `;
74
+ const exported = target.type === 'ExportNamedDeclaration' ? 'export ' : '';
75
+
76
+ const lines = membersOf(node).flatMap((member) => {
77
+ const comments = sourceCode.getCommentsBefore(member).flatMap((comment) =>
78
+ sourceCode
79
+ .getText(comment)
80
+ .split('\n')
81
+ // A block comment's continuation lines carry one more space so the leading `*` stays aligned.
82
+ .map((line, index) => `${memberIndent}${index === 0 ? '' : ' '}${line.trim()}`),
83
+ );
84
+
85
+ const key = sourceCode.getText(member.id);
86
+ const value = sourceCode.getText(member.initializer);
87
+
88
+ return [...comments, `${memberIndent}${key}: ${value},`];
89
+ });
90
+
91
+ return [
92
+ `${exported}const ${name} = {`,
93
+ ...lines,
94
+ `${indent}} as const;`,
95
+ '',
96
+ `${indent}${exported}type ${name} = (typeof ${name})[keyof typeof ${name}];`,
97
+ ].join('\n');
98
+ };
99
+
100
+ return {
101
+ TSEnumDeclaration(node) {
102
+ const target = node.parent?.type === 'ExportNamedDeclaration' ? node.parent : node;
103
+
104
+ context.report({
105
+ node: node.id,
106
+ messageId: node.const ? 'noConstEnum' : 'noEnum',
107
+ data: { name: node.id.name },
108
+ fix: isFixable(node) ? (fixer) => fixer.replaceTextRange(target.range, buildReplacement(node, target)) : null,
109
+ });
110
+ },
111
+ };
112
+ },
113
+ };
114
+
115
+ module.exports = noEnum;
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { isImportedAs } = require('./internals/import-resolution');
5
+
4
6
  /** @type {import('eslint').Rule.RuleModule} */
5
7
  const noInjectChain = {
6
8
  meta: {
@@ -19,7 +21,7 @@ const noInjectChain = {
19
21
  return {
20
22
  MemberExpression(node) {
21
23
  const obj = node.object;
22
- if (obj.type === 'CallExpression' && obj.callee.type === 'Identifier' && obj.callee.name === 'inject') {
24
+ if (obj.type === 'CallExpression' && isImportedAs(context.sourceCode, obj.callee, 'inject')) {
23
25
  // Allow inject(X).method() — immediately-invoked patterns like
24
26
  // inject(DestroyRef).onDestroy(...) are intentional Angular idioms.
25
27
  const parent = node.parent;
@@ -16,6 +16,16 @@ const getDirectMemberName = (node) => {
16
16
  return node.key?.type === 'Identifier' ? node.key.name : null;
17
17
  };
18
18
 
19
+ /**
20
+ * @param {any} key
21
+ * @param {boolean} computed
22
+ */
23
+ const getReferencedName = (key, computed) => {
24
+ if (!computed && key?.type === 'Identifier') return key.name;
25
+ if (key?.type === 'Literal' && typeof key.value === 'string') return key.value;
26
+ return null;
27
+ };
28
+
19
29
  /** @type {import('eslint').Rule.RuleModule} */
20
30
  const noLeadingUnderscoreClassMember = {
21
31
  meta: {
@@ -36,6 +46,7 @@ const noLeadingUnderscoreClassMember = {
36
46
  * declaredNames: Set<string>,
37
47
  * membersByName: Map<string, any[]>,
38
48
  * memberReads: any[],
49
+ * untrackedNames: Set<string>,
39
50
  * }>}
40
51
  */
41
52
  const classStack = [];
@@ -66,6 +77,7 @@ const noLeadingUnderscoreClassMember = {
66
77
  declaredNames: new Set(),
67
78
  membersByName: new Map(),
68
79
  memberReads: [],
80
+ untrackedNames: new Set(),
69
81
  });
70
82
  },
71
83
 
@@ -73,11 +85,19 @@ const noLeadingUnderscoreClassMember = {
73
85
  const frame = classStack.pop();
74
86
  if (!frame) return;
75
87
 
88
+ const targetNameCounts = new Map();
89
+ for (const oldName of frame.membersByName.keys()) {
90
+ const newName = getFixedName(oldName);
91
+ targetNameCounts.set(newName, (targetNameCounts.get(newName) ?? 0) + 1);
92
+ }
93
+
76
94
  for (const [oldName, memberNodes] of frame.membersByName) {
77
95
  const newName = getFixedName(oldName);
78
96
  const canFix =
79
97
  newName.length > 0 &&
80
98
  !frame.declaredNames.has(newName) &&
99
+ targetNameCounts.get(newName) === 1 &&
100
+ !frame.untrackedNames.has(oldName) &&
81
101
  memberNodes.every((memberNode) => memberNode.accessibility === 'private');
82
102
 
83
103
  for (const memberNode of memberNodes) {
@@ -108,10 +128,20 @@ const noLeadingUnderscoreClassMember = {
108
128
  MemberExpression(node) {
109
129
  const frame = classStack[classStack.length - 1];
110
130
  if (!frame) return;
111
- if (node.object?.type !== 'ThisExpression' || node.computed) return;
112
- if (node.property?.type !== 'Identifier') return;
113
131
 
114
- frame.memberReads.push(node);
132
+ if (node.object?.type === 'ThisExpression' && !node.computed && node.property?.type === 'Identifier') {
133
+ frame.memberReads.push(node);
134
+ return;
135
+ }
136
+
137
+ const name = getReferencedName(node.property, node.computed);
138
+ if (name) frame.untrackedNames.add(name);
139
+ },
140
+
141
+ 'ObjectPattern > Property'(node) {
142
+ const frame = classStack[classStack.length - 1];
143
+ const name = getReferencedName(node.key, node.computed);
144
+ if (frame && name) frame.untrackedNames.add(name);
115
145
  },
116
146
  };
117
147
  },
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getAngularDecoratorName } = require('./internals/import-resolution');
5
+
4
6
  /**
5
7
  * Disallows legacy Angular decorators in favour of their modern signal-based
6
8
  * equivalents and the `host: {}` shorthand.
@@ -25,20 +27,6 @@
25
27
  * → ✅ host: { '[class.active]': 'isActive', '(click)': 'onClick()' }
26
28
  */
27
29
 
28
- /**
29
- * Returns the identifier name of a decorator.
30
- * Works for both `@Foo` (Identifier) and `@Foo(...)` (CallExpression).
31
- *
32
- * @param {any} decorator
33
- * @returns {string | null}
34
- */
35
- const getDecoratorName = (decorator) => {
36
- const expr = decorator.expression;
37
- if (expr.type === 'Identifier') return expr.name;
38
- if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier') return expr.callee.name;
39
- return null;
40
- };
41
-
42
30
  /**
43
31
  * Returns the static string key of a class member, or null for computed keys.
44
32
  *
@@ -72,11 +60,12 @@ const getIndent = (sourceCode, node) => {
72
60
  };
73
61
 
74
62
  /**
63
+ * @param {import('eslint').SourceCode} sourceCode
75
64
  * @param {any} classNode
76
65
  */
77
- const getAngularMetadata = (classNode) => {
66
+ const getAngularMetadata = (sourceCode, classNode) => {
78
67
  for (const decorator of classNode.decorators ?? []) {
79
- const decoratorName = getDecoratorName(decorator);
68
+ const decoratorName = getAngularDecoratorName(sourceCode, decorator);
80
69
  if (decoratorName !== 'Component' && decoratorName !== 'Directive') continue;
81
70
 
82
71
  const expression = decorator.expression;
@@ -106,7 +95,7 @@ const findObjectProperty = (objectExpression, propertyName) =>
106
95
  * @param {string} entryText
107
96
  */
108
97
  const buildObjectTextWithAppendedProperty = (sourceCode, objectExpression, entryText) => {
109
- const properties = objectExpression.properties.filter((property) => property.type === 'Property');
98
+ const properties = objectExpression.properties;
110
99
  const isMultiline = Boolean(
111
100
  objectExpression.loc && objectExpression.loc.start.line !== objectExpression.loc.end.line,
112
101
  );
@@ -138,8 +127,7 @@ ${closingIndent}}`;
138
127
  * @param {any} objectExpression
139
128
  */
140
129
  const hasTrailingComma = (sourceCode, objectExpression) => {
141
- const properties = objectExpression.properties.filter((property) => property.type === 'Property');
142
- const lastProperty = properties[properties.length - 1];
130
+ const lastProperty = objectExpression.properties.at(-1);
143
131
  if (!lastProperty) return false;
144
132
 
145
133
  const tokenAfter = sourceCode.getTokenAfter(lastProperty);
@@ -158,7 +146,7 @@ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey,
158
146
  const classNode = classBody?.parent;
159
147
  if (!classNode) return null;
160
148
 
161
- const metadata = getAngularMetadata(classNode);
149
+ const metadata = getAngularMetadata(sourceCode, classNode);
162
150
  if (!metadata) return null;
163
151
 
164
152
  const hostProperty = findObjectProperty(metadata, 'host');
@@ -312,7 +300,7 @@ const rule = {
312
300
 
313
301
  Decorator(node) {
314
302
  const anyNode = /** @type {any} */ (node);
315
- const decoratorName = getDecoratorName(anyNode);
303
+ const decoratorName = getAngularDecoratorName(context.sourceCode, anyNode);
316
304
  if (!decoratorName) return;
317
305
 
318
306
  // Only act on decorators attached to class members, not to the class itself
@@ -379,6 +367,14 @@ const rule = {
379
367
  const expression = anyNode.expression;
380
368
  if (expression.type !== 'CallExpression' && expression.type !== 'Identifier') return null;
381
369
 
370
+ if (
371
+ expression.type === 'CallExpression' &&
372
+ expression.arguments.length > 0 &&
373
+ (expression.arguments[0]?.type !== 'Literal' || typeof expression.arguments[0].value !== 'string')
374
+ ) {
375
+ return null;
376
+ }
377
+
382
378
  const bindingName =
383
379
  expression.type === 'CallExpression'
384
380
  ? 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({