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

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 (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/migrations.json +12 -0
  3. package/package.json +13 -2
  4. package/src/configs/recommended.js +37 -27
  5. package/src/generators/generators.json +10 -0
  6. package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
  7. package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
  8. package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
  9. package/src/index.js +8 -2
  10. package/src/rules/angular-decorator-property-order.js +24 -7
  11. package/src/rules/class-constant-property.js +1 -1
  12. package/src/rules/class-member-order.js +60 -8
  13. package/src/rules/consistent-type-definitions.js +60 -0
  14. package/src/rules/enforce-routing-view-naming.js +34 -23
  15. package/src/rules/guard-return-newline.js +4 -7
  16. package/src/rules/internals/angular-member-visibility.js +24 -2
  17. package/src/rules/internals/angular-metadata-fix.js +231 -1
  18. package/src/rules/internals/angular-version.js +50 -0
  19. package/src/rules/internals/implemented-contract-members.js +26 -11
  20. package/src/rules/no-angular-router-api.js +86 -33
  21. package/src/rules/no-angular-seo-services.js +1 -0
  22. package/src/rules/no-async-await.js +5 -1
  23. package/src/rules/no-cdk-import.js +32 -7
  24. package/src/rules/no-csp-unsafe.js +362 -0
  25. package/src/rules/no-direct-dom-manipulation.js +50 -28
  26. package/src/rules/no-document-cookie.js +51 -8
  27. package/src/rules/no-dom-query.js +5 -2
  28. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  29. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  30. package/src/rules/no-impure-top-level-provider.js +42 -7
  31. package/src/rules/no-leading-underscore-class-member.js +27 -2
  32. package/src/rules/no-legacy-angular-decorators.js +11 -6
  33. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  34. package/src/rules/no-legacy-query-import.js +61 -16
  35. package/src/rules/no-member-alias.js +0 -5
  36. package/src/rules/no-native-observers.js +89 -24
  37. package/src/rules/no-pipe-logic.js +1 -2
  38. package/src/rules/no-readonly-signal.js +1 -5
  39. package/src/rules/no-redundant-internal.js +5 -2
  40. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  41. package/src/rules/no-rxjs-in-effect.js +7 -5
  42. package/src/rules/no-screaming-case-local.js +3 -5
  43. package/src/rules/no-standalone-flag.js +1 -0
  44. package/src/rules/no-subscribe-with-body.js +30 -1
  45. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  46. package/src/rules/no-trivial-return-type.js +18 -1
  47. package/src/rules/no-unused-class-member.js +1 -1
  48. package/src/rules/no-window-location.js +60 -22
  49. package/src/rules/prefer-clone-equal.js +61 -8
  50. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  51. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  52. package/src/rules/prefer-element-dimensions.js +264 -11
  53. package/src/rules/prefer-linked-signal.js +37 -39
  54. package/src/rules/prefer-match-media.js +17 -3
  55. package/src/rules/prefer-rxjs-timer.js +26 -4
  56. package/src/rules/prefer-scroll-state.js +0 -4
  57. package/src/rules/prefer-static-boolean-properties.js +1 -0
  58. package/src/rules/prefer-viewport-size.js +153 -16
  59. package/src/rules/require-dollar-suffix.js +11 -3
  60. package/src/rules/require-form-submit.js +4 -1
  61. package/src/rules/require-on-push-change-detection.js +7 -259
  62. package/src/rules/require-view-encapsulation-none.js +6 -214
  63. package/src/rules/template-member-accessibility.js +12 -4
@@ -17,11 +17,12 @@
17
17
  * element.classList.add('active') → renderer.addClass(element, 'active')
18
18
  * element.classList.remove('active') → renderer.removeClass(element, 'active')
19
19
  * element.style.color = 'red' → renderer.setStyle(element, 'color', 'red')
20
+ * Object.assign(element.style, { … }) → renderer.setStyle(element, prop, value) per property
20
21
  *
21
22
  * GOOD:
22
23
  * private renderer = injectRenderer(); // from @ethlete/core
23
24
  *
24
- * NOTE: Calls where the receiver name contains 'renderer' (e.g. this.renderer.createElement)
25
+ * NOTE: Calls where the receiver name ends in 'renderer' (e.g. this.renderer.createElement)
25
26
  * are excluded — that is the correct injectRenderer() usage.
26
27
  */
27
28
 
@@ -62,16 +63,39 @@ const CLASSLIST_METHODS = new Map([
62
63
  ['replace', 'renderer.removeClass(el, old) + renderer.addClass(el, new)'],
63
64
  ]);
64
65
 
66
+ /**
67
+ * @param {any} node
68
+ * @returns {string | null}
69
+ */
70
+ const getMemberName = (node) => {
71
+ if (node.type !== 'MemberExpression') return null;
72
+ if (!node.computed && node.property.type === 'Identifier') return node.property.name;
73
+ if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') {
74
+ return node.property.value;
75
+ }
76
+
77
+ return null;
78
+ };
79
+
80
+ /**
81
+ * @param {any} node
82
+ * @returns {string | null}
83
+ */
84
+ const getTerminalName = (node) => {
85
+ if (node.type === 'Identifier') return node.name;
86
+ if (node.type === 'MemberExpression') return getMemberName(node);
87
+ if (node.type === 'CallExpression') return getTerminalName(node.callee);
88
+ if (node.type === 'ChainExpression' || node.type === 'TSNonNullExpression') return getTerminalName(node.expression);
89
+
90
+ return null;
91
+ };
92
+
65
93
  /**
66
94
  * Returns true when the receiver of a MemberExpression looks like a Renderer2 instance,
67
95
  * which means the call is already using the correct API.
68
- * @param {import('eslint').Rule.Node} objectNode
69
- * @param {import('eslint').Rule.RuleContext} context
96
+ * @param {any} objectNode
70
97
  */
71
- const isRendererReceiver = (objectNode, context) => {
72
- const receiverText = context.sourceCode.getText(objectNode).toLowerCase();
73
- return receiverText.includes('renderer');
74
- };
98
+ const isRendererReceiver = (objectNode) => /renderer2?$/i.test(getTerminalName(objectNode) ?? '');
75
99
 
76
100
  /** @type {import('eslint').Rule.RuleModule} */
77
101
  const noDirectDomManipulation = {
@@ -96,16 +120,25 @@ const noDirectDomManipulation = {
96
120
  CallExpression(node) {
97
121
  const { callee } = node;
98
122
  if (callee.type !== 'MemberExpression') return;
99
- if (callee.property.type !== 'Identifier') return;
100
123
 
101
- const methodName = callee.property.name;
124
+ const [target] = node.arguments;
102
125
 
103
126
  if (
104
- methodName === 'setProperty' &&
105
- callee.object.type === 'MemberExpression' &&
106
- callee.object.property.type === 'Identifier' &&
107
- callee.object.property.name === 'style'
127
+ callee.object.type === 'Identifier' &&
128
+ callee.object.name === 'Object' &&
129
+ getMemberName(callee) === 'assign' &&
130
+ target &&
131
+ getMemberName(target) === 'style' &&
132
+ !isRendererReceiver(target.object)
108
133
  ) {
134
+ context.report({ node, messageId: 'domStyle' });
135
+ return;
136
+ }
137
+
138
+ const methodName = getMemberName(callee);
139
+ if (!methodName) return;
140
+
141
+ if (methodName === 'setProperty' && getMemberName(callee.object) === 'style') {
109
142
  context.report({
110
143
  node,
111
144
  messageId: 'domMutation',
@@ -117,12 +150,7 @@ const noDirectDomManipulation = {
117
150
  return;
118
151
  }
119
152
 
120
- // ── classList.add / .remove / .toggle / .replace ─────────────────────
121
- if (
122
- callee.object.type === 'MemberExpression' &&
123
- callee.object.property.type === 'Identifier' &&
124
- callee.object.property.name === 'classList'
125
- ) {
153
+ if (getMemberName(callee.object) === 'classList') {
126
154
  const alternative = CLASSLIST_METHODS.get(methodName);
127
155
  if (alternative) {
128
156
  context.report({
@@ -134,10 +162,8 @@ const noDirectDomManipulation = {
134
162
  return;
135
163
  }
136
164
 
137
- // Skip calls on a Renderer2 instance — those are the correct pattern.
138
- if (isRendererReceiver(callee.object, context)) return;
165
+ if (isRendererReceiver(callee.object)) return;
139
166
 
140
- // ── DOM creation ──────────────────────────────────────────────────────
141
167
  if (DOM_CREATE_METHODS.has(methodName)) {
142
168
  context.report({
143
169
  node,
@@ -147,7 +173,6 @@ const noDirectDomManipulation = {
147
173
  return;
148
174
  }
149
175
 
150
- // ── DOM mutation ──────────────────────────────────────────────────────
151
176
  if (DOM_MUTATION_METHODS.has(methodName)) {
152
177
  context.report({
153
178
  node,
@@ -157,15 +182,12 @@ const noDirectDomManipulation = {
157
182
  }
158
183
  },
159
184
 
160
- // ── el.style.prop = value ─────────────────────────────────────────────
161
185
  AssignmentExpression(node) {
162
186
  const { left } = node;
163
187
  if (
164
188
  left.type === 'MemberExpression' &&
165
- left.object.type === 'MemberExpression' &&
166
- left.object.property.type === 'Identifier' &&
167
- left.object.property.name === 'style' &&
168
- !isRendererReceiver(left.object.object, context)
189
+ getMemberName(left.object) === 'style' &&
190
+ !isRendererReceiver(left.object.object)
169
191
  ) {
170
192
  context.report({ node, messageId: 'domStyle' });
171
193
  }
@@ -39,16 +39,59 @@ const noDocumentCookie = {
39
39
  schema: [],
40
40
  },
41
41
  create(context) {
42
+ const documentAliases = new Set();
43
+ /** @type {any[]} */
44
+ const candidates = [];
45
+
46
+ const propertyName = (node) => {
47
+ if (!node.computed && node.property.type === 'Identifier') return node.property.name;
48
+ if (node.computed && node.property.type === 'Literal') return String(node.property.value);
49
+
50
+ return null;
51
+ };
52
+
53
+ const isGlobalObject = (node) =>
54
+ node.type === 'Identifier' &&
55
+ (node.name === 'window' || node.name === 'globalThis') &&
56
+ isGlobalReference(context.sourceCode, node);
57
+
58
+ const isInjectDocument = (node) =>
59
+ node?.type === 'CallExpression' &&
60
+ node.callee.type === 'Identifier' &&
61
+ node.callee.name === 'inject' &&
62
+ node.arguments[0]?.type === 'Identifier' &&
63
+ node.arguments[0].name === 'DOCUMENT';
64
+
65
+ const isDocument = (node) => {
66
+ if (node.type === 'Identifier') {
67
+ return node.name === 'document' && isGlobalReference(context.sourceCode, node);
68
+ }
69
+ if (node.type !== 'MemberExpression') return false;
70
+ if (propertyName(node) === 'document' && isGlobalObject(node.object)) return true;
71
+
72
+ return node.object.type === 'ThisExpression' && documentAliases.has(propertyName(node));
73
+ };
74
+
42
75
  return {
76
+ VariableDeclarator(node) {
77
+ if (node.id.type === 'Identifier' && isInjectDocument(node.init)) documentAliases.add(node.id.name);
78
+ },
79
+ PropertyDefinition(node) {
80
+ if (!node.computed && node.key.type === 'Identifier' && isInjectDocument(node.value)) {
81
+ documentAliases.add(node.key.name);
82
+ }
83
+ },
43
84
  MemberExpression(node) {
44
- if (
45
- node.object.type === 'Identifier' &&
46
- node.object.name === 'document' &&
47
- isGlobalReference(context.sourceCode, node.object) &&
48
- node.property.type === 'Identifier' &&
49
- node.property.name === 'cookie'
50
- ) {
51
- context.report({ node, messageId: 'noDocumentCookie' });
85
+ if (propertyName(node) === 'cookie') candidates.push(node);
86
+ },
87
+ 'Program:exit'() {
88
+ for (const node of candidates) {
89
+ const object = node.object;
90
+ const isLocalAlias = object.type === 'Identifier' && documentAliases.has(object.name);
91
+
92
+ if (isLocalAlias || isDocument(object)) {
93
+ context.report({ node, messageId: 'noDocumentCookie' });
94
+ }
52
95
  }
53
96
  },
54
97
  };
@@ -62,9 +62,12 @@ const noDomQuery = {
62
62
  CallExpression(node) {
63
63
  const { callee } = node;
64
64
  if (callee.type !== 'MemberExpression') return;
65
- if (callee.property.type !== 'Identifier') return;
66
65
 
67
- const methodName = callee.property.name;
66
+ let methodName;
67
+ if (!callee.computed && callee.property.type === 'Identifier') methodName = callee.property.name;
68
+ else if (callee.computed && callee.property.type === 'Literal') methodName = String(callee.property.value);
69
+ else return;
70
+
68
71
  const alternative = DOM_QUERY_METHODS.get(methodName);
69
72
  if (!alternative) return;
70
73
 
@@ -27,6 +27,7 @@ const noEmptyAngularMetadataArrays = {
27
27
  type: 'suggestion',
28
28
  docs: {
29
29
  description: 'Disallow empty Angular metadata arrays such as imports: [] and hostDirectives: [].',
30
+ recommended: true,
30
31
  },
31
32
  fixable: 'code',
32
33
  schema: [],
@@ -56,6 +57,7 @@ const noEmptyAngularMetadataArrays = {
56
57
 
57
58
  const propertyName = getPropertyName(property.key);
58
59
  if (propertyName !== 'imports' && propertyName !== 'hostDirectives') continue;
60
+ if (propertyName === 'imports' && decoratorName !== 'Component') continue;
59
61
  if (property.value.type !== 'ArrayExpression' || property.value.elements.length > 0) continue;
60
62
 
61
63
  context.report({
@@ -7,6 +7,7 @@ const noEmptyNewlinesBetweenImports = {
7
7
  type: 'layout',
8
8
  docs: {
9
9
  description: 'Disallow empty blank lines between consecutive import declarations.',
10
+ recommended: true,
10
11
  },
11
12
  fixable: 'code',
12
13
  schema: [],
@@ -33,7 +34,9 @@ const noEmptyNewlinesBetweenImports = {
33
34
  node: currentImport,
34
35
  messageId: 'noEmptyLine',
35
36
  fix(fixer) {
36
- return fixer.replaceTextRange([previousImport.range[1], currentImport.range[0]], '\n');
37
+ const lineBreak = textBetween.includes('\r\n') ? '\r\n' : '\n';
38
+
39
+ return fixer.replaceTextRange([previousImport.range[1], currentImport.range[0]], lineBreak);
37
40
  },
38
41
  });
39
42
  }
@@ -34,14 +34,36 @@ const calleeName = (node) => {
34
34
  return null;
35
35
  };
36
36
 
37
- /** Every call/`new` in an initializer that really is evaluated when the module is imported. */
38
- const collectImportTimeCalls = (root) => {
39
- const found = [];
37
+ const TRANSPARENT_WRAPPERS = new Set([
38
+ 'TSAsExpression',
39
+ 'TSSatisfiesExpression',
40
+ 'TSNonNullExpression',
41
+ 'TSTypeAssertion',
42
+ ]);
43
+
44
+ const unwrap = (node) => (TRANSPARENT_WRAPPERS.has(node.type) ? unwrap(node.expression) : node);
45
+
46
+ const isConstantExpression = (node) =>
47
+ node.type === 'Literal' ||
48
+ (node.type === 'UnaryExpression' && isConstantExpression(node.argument)) ||
49
+ (node.type === 'BinaryExpression' && isConstantExpression(node.left) && isConstantExpression(node.right));
50
+
51
+ /** Everything in an initializer that really is evaluated when the module is imported and that a bundler cannot drop. */
52
+ const collectImportTimeNodes = (root) => {
53
+ const calls = [];
54
+ const reads = [];
40
55
 
41
56
  const visit = (node) => {
42
57
  if (!node || typeof node.type !== 'string' || NOT_EVALUATED_ON_IMPORT.has(node.type)) return;
43
58
 
44
- if (node.type === 'CallExpression' || node.type === 'NewExpression') found.push(node);
59
+ if (node.type === 'CallExpression' || node.type === 'NewExpression') calls.push(node);
60
+ else if (node.type === 'SpreadElement') reads.push(node);
61
+ else if (node.type === 'BinaryExpression' && !isConstantExpression(node)) reads.push(node);
62
+ else if (
63
+ node.type === 'MemberExpression' &&
64
+ !(node.parent.type === 'CallExpression' && node.parent.callee === node)
65
+ )
66
+ reads.push(node);
45
67
 
46
68
  for (const key of Object.keys(node)) {
47
69
  if (key === 'parent') continue;
@@ -55,7 +77,7 @@ const collectImportTimeCalls = (root) => {
55
77
 
56
78
  visit(root);
57
79
 
58
- return found;
80
+ return { calls, reads };
59
81
  };
60
82
 
61
83
  /** @type {import('eslint').Rule.RuleModule} */
@@ -65,6 +87,7 @@ const noImpureTopLevelProvider = {
65
87
  docs: {
66
88
  description:
67
89
  'Disallow module-scope destructuring of a factory call, and require a pure annotation on module-scope calls in library source.',
90
+ recommended: true,
68
91
  },
69
92
  fixable: 'code',
70
93
  schema: [
@@ -76,6 +99,11 @@ const noImpureTopLevelProvider = {
76
99
  description:
77
100
  'Also require `@__PURE__` on every call evaluated at module scope. Turn this on in publishable library source; it is pointless in application code, where every statement is retained anyway.',
78
101
  },
102
+ forbidImpureReads: {
103
+ type: 'boolean',
104
+ description:
105
+ 'Also forbid member reads, spreads and arithmetic on identifiers in module-scope initializers, which bundlers cannot prove side-effect free.',
106
+ },
79
107
  },
80
108
  additionalProperties: false,
81
109
  },
@@ -83,6 +111,8 @@ const noImpureTopLevelProvider = {
83
111
  messages: {
84
112
  noDestructuring:
85
113
  'Do not destructure a factory call at module scope. Destructuring invokes the iterator protocol, so no bundler can drop the statement — everything the factory closes over ships to every consumer. Assign the result to one `const` and name each binding with its own annotated extractor (`toProvideFn` / `toInjectFn` / `toToken`).',
114
+ impureRead:
115
+ 'This module-scope member read, spread or arithmetic on an identifier cannot be proven side-effect free, so the whole declaration is retained in every consumer bundle. Write the literal key or number instead, or move the expression inside a function.',
86
116
  missingPure:
87
117
  'This module-scope call to `{{name}}` needs a `/* @__PURE__ */` annotation, or the whole declaration is retained in every consumer bundle — one unannotated call anywhere in an initializer (including inside an argument or an object literal) is enough. If the call is genuinely not side-effect free at import time, move it inside a function instead of annotating it: library code must not do work on import.',
88
118
  },
@@ -90,12 +120,13 @@ const noImpureTopLevelProvider = {
90
120
  create(context) {
91
121
  const sourceCode = context.sourceCode;
92
122
  const requirePureAnnotation = context.options[0]?.requirePureAnnotation === true;
123
+ const forbidImpureReads = context.options[0]?.forbidImpureReads === true;
93
124
 
94
125
  return {
95
126
  VariableDeclarator(node) {
96
127
  if (!isModuleScope(node) || !node.init) return;
97
128
 
98
- const initializer = node.init.type === 'TSAsExpression' ? node.init.expression : node.init;
129
+ const initializer = unwrap(node.init);
99
130
 
100
131
  if (
101
132
  initializer.type === 'CallExpression' &&
@@ -108,7 +139,11 @@ const noImpureTopLevelProvider = {
108
139
 
109
140
  if (!requirePureAnnotation || node.id.type !== 'Identifier') return;
110
141
 
111
- for (const call of collectImportTimeCalls(initializer)) {
142
+ const { calls, reads } = collectImportTimeNodes(initializer);
143
+
144
+ if (forbidImpureReads) for (const read of reads) context.report({ node: read, messageId: 'impureRead' });
145
+
146
+ for (const call of calls) {
112
147
  const name = calleeName(call);
113
148
 
114
149
  if (name === null || hasPureAnnotation(sourceCode, call)) continue;
@@ -11,7 +11,7 @@ const getFixedName = (name) => name.replace(/^_+/, '');
11
11
  */
12
12
  const getDirectMemberName = (node) => {
13
13
  if (node.parent?.type !== 'ClassBody') return null;
14
- if (node.static || node.computed) return null;
14
+ if (node.computed) return null;
15
15
  if (node.type === 'MethodDefinition' && node.kind === 'constructor') return null;
16
16
  return node.key?.type === 'Identifier' ? node.key.name : null;
17
17
  };
@@ -32,6 +32,7 @@ const noLeadingUnderscoreClassMember = {
32
32
  type: 'suggestion',
33
33
  docs: {
34
34
  description: 'Disallow leading underscores on class members and rename private members automatically when safe.',
35
+ recommended: true,
35
36
  },
36
37
  fixable: 'code',
37
38
  schema: [],
@@ -98,7 +99,13 @@ const noLeadingUnderscoreClassMember = {
98
99
  !frame.declaredNames.has(newName) &&
99
100
  targetNameCounts.get(newName) === 1 &&
100
101
  !frame.untrackedNames.has(oldName) &&
101
- memberNodes.every((memberNode) => memberNode.accessibility === 'private');
102
+ memberNodes.every(
103
+ (memberNode) =>
104
+ memberNode.accessibility === 'private' &&
105
+ !memberNode.static &&
106
+ !memberNode.abstract &&
107
+ !memberNode.parameterProperty,
108
+ );
102
109
 
103
110
  for (const memberNode of memberNodes) {
104
111
  context.report({
@@ -124,6 +131,24 @@ const noLeadingUnderscoreClassMember = {
124
131
 
125
132
  PropertyDefinition: registerMember,
126
133
  MethodDefinition: registerMember,
134
+ AccessorProperty: registerMember,
135
+ TSAbstractPropertyDefinition: registerMember,
136
+ TSAbstractMethodDefinition: registerMember,
137
+ TSAbstractAccessorProperty: registerMember,
138
+
139
+ TSParameterProperty(node) {
140
+ const frame = classStack[classStack.length - 1];
141
+ const parameter = node.parameter.type === 'AssignmentPattern' ? node.parameter.left : node.parameter;
142
+ if (!frame || parameter.type !== 'Identifier') return;
143
+
144
+ frame.declaredNames.add(parameter.name);
145
+
146
+ if (!parameter.name.startsWith('_')) return;
147
+
148
+ const members = frame.membersByName.get(parameter.name) ?? [];
149
+ members.push({ key: parameter, accessibility: node.accessibility, parameterProperty: true });
150
+ frame.membersByName.set(parameter.name, members);
151
+ },
127
152
 
128
153
  MemberExpression(node) {
129
154
  const frame = classStack[classStack.length - 1];
@@ -27,6 +27,15 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
27
27
  * → ✅ host: { '[class.active]': 'isActive', '(click)': 'onClick()' }
28
28
  */
29
29
 
30
+ const CLASS_MEMBER_TYPES = new Set([
31
+ 'PropertyDefinition',
32
+ 'MethodDefinition',
33
+ 'AccessorProperty',
34
+ 'TSAbstractPropertyDefinition',
35
+ 'TSAbstractMethodDefinition',
36
+ 'TSAbstractAccessorProperty',
37
+ ]);
38
+
30
39
  /**
31
40
  * Returns the static string key of a class member, or null for computed keys.
32
41
  *
@@ -219,6 +228,7 @@ const rule = {
219
228
  docs: {
220
229
  description:
221
230
  'Disallow legacy Angular decorators (@Input, @Output, @ViewChild, etc.) in favour of signal-based APIs and host: {} bindings.',
231
+ recommended: true,
222
232
  },
223
233
  fixable: 'code',
224
234
  schema: [],
@@ -256,7 +266,6 @@ const rule = {
256
266
  const reportedInputNames = new Set();
257
267
  const reportedOutputNames = new Set();
258
268
 
259
- // Detect @Input() x + @Output() xChange pairs → suggest model()
260
269
  for (const [outputName, outputDecoratorNode] of ctx.outputs) {
261
270
  if (!outputName.endsWith('Change')) continue;
262
271
  const inputName = outputName.slice(0, -'Change'.length);
@@ -277,14 +286,12 @@ const rule = {
277
286
  });
278
287
  }
279
288
 
280
- // Report remaining @Input() decorators that are not part of a pair
281
289
  for (const [name, decoratorNode] of ctx.inputs) {
282
290
  if (!reportedInputNames.has(name)) {
283
291
  context.report({ node: decoratorNode, messageId: 'useInput' });
284
292
  }
285
293
  }
286
294
 
287
- // Report remaining @Output() decorators that are not part of a pair
288
295
  for (const [name, decoratorNode] of ctx.outputs) {
289
296
  if (!reportedOutputNames.has(name)) {
290
297
  context.report({ node: decoratorNode, messageId: 'useOutput' });
@@ -303,10 +310,9 @@ const rule = {
303
310
  const decoratorName = getAngularDecoratorName(context.sourceCode, anyNode);
304
311
  if (!decoratorName) return;
305
312
 
306
- // Only act on decorators attached to class members, not to the class itself
307
313
  const parent = anyNode.parent;
308
314
  if (!parent) return;
309
- if (parent.type !== 'PropertyDefinition' && parent.type !== 'MethodDefinition') return;
315
+ if (!CLASS_MEMBER_TYPES.has(parent.type)) return;
310
316
 
311
317
  const ctx = currentClass();
312
318
 
@@ -320,7 +326,6 @@ const rule = {
320
326
  if (memberName !== null) {
321
327
  ctx.inputs.set(memberName, node);
322
328
  } else {
323
- // Computed key — cannot be part of a two-way binding pair, report immediately
324
329
  context.report({ node, messageId: 'useInput' });
325
330
  }
326
331
  break;
@@ -101,9 +101,16 @@ const isContextProvidingHost = (host) => {
101
101
  */
102
102
  const isTransparentHost = (host) =>
103
103
  !!host &&
104
- host.callee.type === 'MemberExpression' &&
105
- host.callee.property.type === 'Identifier' &&
106
- TRANSPARENT_ARRAY_METHODS.has(host.callee.property.name);
104
+ ((host.callee.type === 'Identifier' && host.callee.name === 'untracked') ||
105
+ (host.callee.type === 'MemberExpression' &&
106
+ host.callee.property.type === 'Identifier' &&
107
+ TRANSPARENT_ARRAY_METHODS.has(host.callee.property.name)));
108
+
109
+ /**
110
+ * @param {any} fn
111
+ * @returns {boolean}
112
+ */
113
+ const isImmediatelyInvoked = (fn) => fn.parent?.type === 'CallExpression' && fn.parent.callee === fn;
107
114
 
108
115
  /**
109
116
  * Whether the function's own body calls `inject()`. Such a function can only be called from an injection
@@ -175,7 +182,7 @@ const classifyCallSite = (node) => {
175
182
  return { needsInjector: false, boundary: 'injection context' };
176
183
  }
177
184
 
178
- if (!isTransparentHost(host)) {
185
+ if (!isTransparentHost(host) && !isImmediatelyInvoked(current)) {
179
186
  if (!host && callsInject(current)) {
180
187
  return { needsInjector: false, boundary: 'function that injects' };
181
188
  }
@@ -281,6 +288,31 @@ const noLegacyPrepareWithoutInjector = {
281
288
  }
282
289
  /** Names known to be legacy creators: imported under the naming convention, or declared locally. */
283
290
  const creatorNames = new Set();
291
+ /** @type {Set<string>} */
292
+ const namespaceNames = new Set();
293
+
294
+ /**
295
+ * @param {any} object
296
+ * @returns {string | null}
297
+ */
298
+ const getCreatorName = (object) => {
299
+ if (object.type === 'Identifier') {
300
+ return creatorNames.has(object.name) ? object.name : null;
301
+ }
302
+
303
+ if (
304
+ object.type === 'MemberExpression' &&
305
+ !object.computed &&
306
+ object.object.type === 'Identifier' &&
307
+ namespaceNames.has(object.object.name) &&
308
+ object.property.type === 'Identifier' &&
309
+ creatorPattern.test(object.property.name)
310
+ ) {
311
+ return `${object.object.name}.${object.property.name}`;
312
+ }
313
+
314
+ return null;
315
+ };
284
316
 
285
317
  /**
286
318
  * @param {any} classBody
@@ -288,11 +320,12 @@ const noLegacyPrepareWithoutInjector = {
288
320
  */
289
321
  const buildInjectorMemberFix = (classBody, injectorName) => {
290
322
  const firstMember = classBody.body[0];
291
- const indentation = ' '.repeat(firstMember ? firstMember.loc.start.column : 2);
323
+ const insertBefore = firstMember ? (sourceCode.getCommentsBefore(firstMember)[0] ?? firstMember) : null;
324
+ const indentation = ' '.repeat(insertBefore ? insertBefore.loc.start.column : 2);
292
325
  const declaration = `private ${injectorName} = inject(Injector);`;
293
326
 
294
- return firstMember
295
- ? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(firstMember, `${declaration}\n\n${indentation}`)
327
+ return insertBefore
328
+ ? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(insertBefore, `${declaration}\n\n${indentation}`)
296
329
  : /** @param {any} fixer */ (fixer) => fixer.replaceText(classBody, `{\n${indentation}${declaration}\n}`);
297
330
  };
298
331
 
@@ -393,7 +426,15 @@ const noLegacyPrepareWithoutInjector = {
393
426
  if (node.type === 'ImportDeclaration') {
394
427
  node.specifiers.forEach(
395
428
  /** @param {any} specifier */ (specifier) => {
396
- if (specifier.type === 'ImportSpecifier' && creatorPattern.test(specifier.local.name)) {
429
+ if (specifier.type === 'ImportNamespaceSpecifier') {
430
+ namespaceNames.add(specifier.local.name);
431
+ }
432
+
433
+ if (specifier.type !== 'ImportSpecifier') return;
434
+
435
+ const importedName = specifier.imported.name ?? specifier.imported.value;
436
+
437
+ if (creatorPattern.test(specifier.local.name) || creatorPattern.test(importedName)) {
397
438
  creatorNames.add(specifier.local.name);
398
439
  }
399
440
  },
@@ -433,13 +474,17 @@ const noLegacyPrepareWithoutInjector = {
433
474
  if (
434
475
  callee.type !== 'MemberExpression' ||
435
476
  callee.property.type !== 'Identifier' ||
436
- callee.property.name !== 'prepare' ||
437
- callee.object.type !== 'Identifier' ||
438
- !creatorNames.has(callee.object.name)
477
+ callee.property.name !== 'prepare'
439
478
  ) {
440
479
  return;
441
480
  }
442
481
 
482
+ const creatorName = getCreatorName(callee.object);
483
+
484
+ if (!creatorName) {
485
+ return;
486
+ }
487
+
443
488
  const firstArgument = /** @type {any} */ (node.arguments[0]);
444
489
  const hasInjector =
445
490
  firstArgument?.type === 'ObjectExpression' &&
@@ -479,7 +524,7 @@ const noLegacyPrepareWithoutInjector = {
479
524
  context.report({
480
525
  node,
481
526
  messageId: 'missingInjector',
482
- data: { creator: callee.object.name, boundary },
527
+ data: { creator: creatorName, boundary },
483
528
  // Only the in-class shape is auto-fixed: a standalone function has no obvious injector to reach
484
529
  // for, and inventing an `inject()` call there would move the failure rather than fix it.
485
530
  fix: