@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
@@ -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 trivial wrapper methods that do nothing but forward all their
6
8
  * arguments to another call.
@@ -34,22 +36,14 @@ const CONTRACT_METHOD_NAMES = new Set(['focus', 'blur', 'reset']);
34
36
  const isSimpleParam = (param) => param.type === 'Identifier';
35
37
 
36
38
  /**
37
- * Whether the class holding this method is an Angular component or directive.
38
- * @param {import('eslint').Rule.Node} methodNode MethodDefinition node
39
+ * @param {import('eslint').SourceCode} sourceCode
40
+ * @param {any} methodNode
39
41
  */
40
- const isInAngularClass = (methodNode) => {
41
- const classNode = methodNode.parent?.parent;
42
- const decorators = classNode && 'decorators' in classNode ? classNode.decorators : null;
43
-
44
- if (!decorators) return false;
45
-
46
- return decorators.some((decorator) => {
47
- const expression = decorator.expression;
48
- const callee = expression.type === 'CallExpression' ? expression.callee : expression;
49
-
50
- return callee.type === 'Identifier' && (callee.name === 'Component' || callee.name === 'Directive');
51
- });
52
- };
42
+ const isInAngularClass = (sourceCode, methodNode) =>
43
+ methodNode.parent?.parent?.decorators?.some((/** @type {any} */ decorator) => {
44
+ const name = getAngularDecoratorName(sourceCode, decorator);
45
+ return name === 'Component' || name === 'Directive';
46
+ }) ?? false;
53
47
 
54
48
  /**
55
49
  * Returns the single CallExpression in the method body when the method is a
@@ -124,7 +118,10 @@ const noTrivialWrapperMethod = {
124
118
  const methodName = node.key.type === 'Identifier' ? node.key.name : null;
125
119
  if (!methodName) return;
126
120
 
127
- if (CONTRACT_METHOD_NAMES.has(methodName) && isInAngularClass(node)) return;
121
+ const classNode = node.parent?.parent;
122
+ if (node.override || classNode?.superClass || classNode?.implements?.length) return;
123
+ if (node.static && methodName === 'ngTemplateContextGuard') return;
124
+ if (CONTRACT_METHOD_NAMES.has(methodName) && isInAngularClass(context.sourceCode, node)) return;
128
125
 
129
126
  const callee = callExpr.callee;
130
127
  const targetName =
@@ -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
 
@@ -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 noTypedInjectedElementRef = {
6
8
  meta: {
@@ -20,34 +22,36 @@ const noTypedInjectedElementRef = {
20
22
  create(context) {
21
23
  return {
22
24
  CallExpression(node) {
23
- if (node.callee.type !== 'Identifier' || node.callee.name !== 'inject') {
24
- return;
25
- }
25
+ const src = context.sourceCode;
26
+ if (!isImportedAs(src, node.callee, 'inject')) return;
26
27
 
27
28
  const firstArg = node.arguments[0];
28
29
  if (!firstArg) return;
29
30
 
30
- const isPlainElementRef = firstArg.type === 'Identifier' && firstArg.name === 'ElementRef';
31
+ const isPlainElementRef = isImportedAs(src, firstArg, 'ElementRef');
31
32
  const isInstantiatedElementRef =
32
- firstArg.type === 'TSInstantiationExpression' &&
33
- firstArg.expression &&
34
- firstArg.expression.type === 'Identifier' &&
35
- firstArg.expression.name === 'ElementRef';
33
+ firstArg.type === 'TSInstantiationExpression' && isImportedAs(src, firstArg.expression, 'ElementRef');
36
34
 
37
35
  if (!isPlainElementRef && !isInstantiatedElementRef) return;
38
36
 
39
- const src = context.sourceCode;
37
+ const elementRef = src.getText(isPlainElementRef ? firstArg : firstArg.expression);
40
38
  const existingTypeParams = node.typeParameters || node.typeArguments;
41
- const hasInjectTypeParam =
42
- existingTypeParams && existingTypeParams.params && existingTypeParams.params.length > 0;
39
+ const injectType = existingTypeParams?.params?.[0];
40
+ const hasTypedElementRef =
41
+ existingTypeParams?.params?.length === 1 &&
42
+ injectType?.type === 'TSTypeReference' &&
43
+ src.getText(injectType.typeName) === elementRef &&
44
+ (injectType.typeArguments || injectType.typeParameters)?.params?.length > 0;
43
45
 
44
- if (isPlainElementRef && !hasInjectTypeParam) {
46
+ if (isPlainElementRef && !hasTypedElementRef) {
45
47
  // inject(ElementRef) → inject<ElementRef<HTMLElement>>(ElementRef)
46
48
  context.report({
47
49
  node,
48
50
  messageId: 'missingGeneric',
49
51
  fix(fixer) {
50
- return fixer.insertTextAfter(node.callee, '<ElementRef<HTMLElement>>');
52
+ return existingTypeParams
53
+ ? fixer.replaceText(existingTypeParams, `<${elementRef}<HTMLElement>>`)
54
+ : fixer.insertTextAfter(node.callee, `<${elementRef}<HTMLElement>>`);
51
55
  },
52
56
  });
53
57
  return;
@@ -57,16 +61,16 @@ const noTypedInjectedElementRef = {
57
61
  // Capture the type args text from the token, e.g. "<HTMLElement>"
58
62
  const tokenTypeParamText = src.getText(firstArg.typeParameters || firstArg.typeArguments);
59
63
 
60
- if (!hasInjectTypeParam) {
64
+ if (!hasTypedElementRef) {
61
65
  // inject(ElementRef<HTMLElement>) → inject<ElementRef<HTMLElement>>(ElementRef)
62
66
  context.report({
63
67
  node,
64
68
  messageId: 'missingGeneric',
65
69
  fix(fixer) {
66
- return [
67
- fixer.insertTextAfter(node.callee, `<ElementRef${tokenTypeParamText}>`),
68
- fixer.replaceText(firstArg, 'ElementRef'),
69
- ];
70
+ const typeFix = existingTypeParams
71
+ ? fixer.replaceText(existingTypeParams, `<${elementRef}${tokenTypeParamText}>`)
72
+ : fixer.insertTextAfter(node.callee, `<${elementRef}${tokenTypeParamText}>`);
73
+ return [typeFix, fixer.replaceText(firstArg, elementRef)];
70
74
  },
71
75
  });
72
76
  } else {
@@ -75,7 +79,7 @@ const noTypedInjectedElementRef = {
75
79
  node,
76
80
  messageId: 'missingGeneric',
77
81
  fix(fixer) {
78
- return fixer.replaceText(firstArg, 'ElementRef');
82
+ return fixer.replaceText(firstArg, elementRef);
79
83
  },
80
84
  });
81
85
  }
@@ -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 class members that are declared but never read within the class body.
6
8
  *
@@ -42,22 +44,17 @@ const extractIdentifiers = (str) => str.match(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g) ?
42
44
 
43
45
  /**
44
46
  * Returns info about the Angular decorators applied to a class node.
47
+ * @param {import('eslint').SourceCode} sourceCode
45
48
  * @param {any} classNode
46
49
  * @returns {{ isComponent: boolean, isAngularClass: boolean }}
47
50
  */
48
- const getDecoratorInfo = (classNode) => {
51
+ const getDecoratorInfo = (sourceCode, classNode) => {
49
52
  const decorators = classNode.decorators ?? [];
50
53
  let isComponent = false;
51
54
  let isAngularClass = false;
52
55
 
53
56
  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;
57
+ const name = getAngularDecoratorName(sourceCode, dec);
61
58
 
62
59
  if (name && ANGULAR_DECORATORS.has(name)) {
63
60
  isAngularClass = true;
@@ -126,6 +123,7 @@ const noUnusedClassMember = {
126
123
  *
127
124
  * @type {Array<{
128
125
  * classBody: import('eslint').Rule.Node,
126
+ * isAbstract: boolean,
129
127
  * isComponent: boolean,
130
128
  * isAngularClass: boolean,
131
129
  * hostBound: Set<string>,
@@ -145,6 +143,7 @@ const noUnusedClassMember = {
145
143
  if (node.parent !== frame.classBody) return;
146
144
 
147
145
  if (node.static) return;
146
+ if (node.override || node.abstract || node.decorators?.length) return;
148
147
 
149
148
  const key = node.key;
150
149
  if (!key || key.type !== 'Identifier') return;
@@ -153,9 +152,10 @@ const noUnusedClassMember = {
153
152
  const name = key.name;
154
153
  const accessibility = node.accessibility;
155
154
  const { isComponent, isAngularClass, hostBound } = frame;
155
+ if (frame.isAbstract && accessibility === 'protected') return;
156
156
 
157
157
  if (accessibility === 'private') {
158
- frame.candidates.set(name, node);
158
+ if (!hostBound.has(name)) frame.candidates.set(name, node);
159
159
  } else if (accessibility === 'protected' && isAngularClass && !isComponent) {
160
160
  if (!hostBound.has(name)) frame.candidates.set(name, node);
161
161
  }
@@ -164,10 +164,11 @@ const noUnusedClassMember = {
164
164
  return {
165
165
  ClassBody(node) {
166
166
  const classNode = node.parent;
167
- const { isComponent, isAngularClass } = getDecoratorInfo(classNode);
167
+ const { isComponent, isAngularClass } = getDecoratorInfo(context.sourceCode, classNode);
168
168
  const hostBound = getHostBoundMembers(classNode);
169
169
  classStack.push({
170
170
  classBody: node,
171
+ isAbstract: classNode.abstract === true,
171
172
  isComponent,
172
173
  isAngularClass,
173
174
  hostBound,
@@ -190,17 +191,36 @@ const noUnusedClassMember = {
190
191
  MethodDefinition: registerMember,
191
192
 
192
193
  MemberExpression(node) {
193
- // Only care about `this.name` (non-computed direct property access)
194
- if (node.computed) return;
195
194
  if (node.object?.type !== 'ThisExpression') return;
196
- if (node.property?.type !== 'Identifier') return;
195
+ const name =
196
+ !node.computed && node.property?.type === 'Identifier'
197
+ ? node.property.name
198
+ : node.computed && node.property?.type === 'Literal' && typeof node.property.value === 'string'
199
+ ? node.property.value
200
+ : null;
201
+ if (!name) return;
197
202
 
198
203
  // Mark used in the innermost class frame. Arrow functions don't push a
199
204
  // new frame so they correctly share the enclosing class's `this`.
200
205
  // Regular nested classes push a new frame so their `this.x` won't
201
206
  // accidentally mark an outer class member as used.
202
207
  const frame = classStack[classStack.length - 1];
203
- if (frame) frame.usedNames.add(node.property.name);
208
+ if (frame) frame.usedNames.add(name);
209
+ },
210
+
211
+ VariableDeclarator(node) {
212
+ if (node.init?.type !== 'ThisExpression' || node.id?.type !== 'ObjectPattern') return;
213
+
214
+ const frame = classStack[classStack.length - 1];
215
+ if (!frame) return;
216
+
217
+ for (const property of node.id.properties) {
218
+ if (property.type !== 'Property') continue;
219
+ if (property.key.type === 'Identifier') frame.usedNames.add(property.key.name);
220
+ if (property.key.type === 'Literal' && typeof property.key.value === 'string') {
221
+ frame.usedNames.add(property.key.value);
222
+ }
223
+ }
204
224
  },
205
225
  };
206
226
  },
@@ -25,6 +25,8 @@
25
25
  * import { clone, equal } from '@ethlete/core';
26
26
  */
27
27
 
28
+ const { MODULE_REFERENCE_SELECTOR, getModuleReference, isGlobalReference } = require('./internals/import-resolution');
29
+
28
30
  /** @type {import('eslint').Rule.RuleModule} */
29
31
  const preferCloneEqual = {
30
32
  meta: {
@@ -70,21 +72,21 @@ const preferCloneEqual = {
70
72
  }
71
73
 
72
74
  // ── structuredClone(expr) ─────────────────────────────────────────────
73
- if (callee.type === 'Identifier' && callee.name === 'structuredClone') {
75
+ if (isGlobalReference(context.sourceCode, callee) && callee.name === 'structuredClone') {
74
76
  context.report({ node, messageId: 'preferClone', data: { method: 'structuredClone()' } });
75
77
  }
76
78
  },
77
79
 
78
- ImportDeclaration(node) {
79
- const src = node.source.value;
80
+ [MODULE_REFERENCE_SELECTOR](node) {
81
+ const reference = getModuleReference(node);
82
+ if (!reference) return;
83
+ const src = reference.source;
80
84
 
81
85
  // ── lodash cloneDeep / isEqual ────────────────────────────────────────
82
86
  // import { cloneDeep } from 'lodash' / 'lodash-es'
83
87
  // import { isEqual } from 'lodash' / 'lodash-es'
84
88
  if (src === 'lodash' || src === 'lodash-es') {
85
- for (const specifier of node.specifiers) {
86
- if (specifier.type !== 'ImportSpecifier') continue;
87
- const name = specifier.imported.name;
89
+ for (const { name } of reference.named) {
88
90
  if (name === 'cloneDeep') {
89
91
  context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
90
92
  } else if (name === 'isEqual') {
@@ -1,29 +1,16 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getAngularDecoratorName } = require('./internals/import-resolution');
5
+
4
6
  /** @typedef {'Component' | 'Directive'} TDecoratorName */
5
7
  /** @typedef {import('estree').Property & { range: [number, number] }} TPropertyNode */
6
8
  /** @typedef {import('estree').ArrayExpression & { elements: Array<import('estree').Expression | import('estree').SpreadElement | null> }} TArrayExpressionNode */
7
9
  /** @typedef {import('estree').ObjectExpression & { properties: Array<TPropertyNode | import('estree').SpreadElement> }} TObjectExpressionNode */
8
- /** @typedef {{ hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; segmentText: string }} TPropertyEntry */
10
+ /** @typedef {{ bodyText: string; hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; trailingText: string }} TPropertyEntry */
9
11
 
10
12
  const HOST_DIRECTIVE_PROPERTY_ORDER = ['directive', 'inputs', 'outputs'];
11
13
 
12
- /**
13
- * @param {import('eslint').Rule.Node} node
14
- */
15
- const getDecoratorName = (node) => {
16
- const decorator = /** @type {any} */ (node);
17
- if (decorator.type !== 'Decorator') return null;
18
-
19
- const expression = decorator.expression;
20
- if (expression.type === 'CallExpression') {
21
- return expression.callee.type === 'Identifier' ? expression.callee.name : null;
22
- }
23
-
24
- return expression.type === 'Identifier' ? expression.name : null;
25
- };
26
-
27
14
  /**
28
15
  * @param {import('estree').Property['key']} key
29
16
  */
@@ -98,22 +85,24 @@ const buildSortedObjectText = (node, sourceCode) => {
98
85
  /** @param {TPropertyNode} property @param {number} originalIndex @returns {TPropertyEntry} */
99
86
  (property, originalIndex) => {
100
87
  const tokenAfter = sourceCode.getTokenAfter(property);
101
- const hasComma = Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
102
- const segmentEnd = hasComma && tokenAfter ? tokenAfter.range[0] : property.range[1];
103
- const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
88
+ const comma = tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',' ? tokenAfter : null;
89
+ const bodyText = sourceCode.text.slice(segmentStart, property.range[1]);
90
+ const afterProperty = comma ?? property;
91
+ const trailingComment = sourceCode
92
+ .getCommentsAfter(afterProperty)
93
+ .filter((comment) => comment.loc?.start.line === property.loc?.end.line)
94
+ .at(-1);
95
+ const trailingEnd = trailingComment?.range ? trailingComment.range[1] : afterProperty.range[1];
104
96
 
105
- if (hasComma && tokenAfter) {
106
- segmentStart = tokenAfter.range[1];
107
- } else {
108
- segmentStart = property.range[1];
109
- }
97
+ segmentStart = trailingEnd;
110
98
 
111
99
  return {
112
- hasComma,
100
+ bodyText,
101
+ hasComma: Boolean(comma),
113
102
  originalIndex,
114
103
  orderIndex: orderIndexMap.get(getPropertyName(property.key)) ?? HOST_DIRECTIVE_PROPERTY_ORDER.length,
115
104
  property,
116
- segmentText,
105
+ trailingText: trailingComment ? sourceCode.text.slice(afterProperty.range[1], trailingEnd) : '',
117
106
  };
118
107
  },
119
108
  );
@@ -131,7 +120,10 @@ const buildSortedObjectText = (node, sourceCode) => {
131
120
  return (
132
121
  '{' +
133
122
  sortedEntries
134
- .map((entry, index) => `${entry.segmentText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}`)
123
+ .map(
124
+ (entry, index) =>
125
+ `${entry.bodyText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}${entry.trailingText}`,
126
+ )
135
127
  .join('') +
136
128
  suffix +
137
129
  '}'
@@ -182,7 +174,7 @@ const preferConciseAngularHostDirectives = {
182
174
 
183
175
  return {
184
176
  Decorator(node) {
185
- const decoratorName = getDecoratorName(node);
177
+ const decoratorName = getAngularDecoratorName(context.sourceCode, node);
186
178
  if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
187
179
 
188
180
  const hostDirectives = getHostDirectivesArray(decoratorName, node);
@@ -199,10 +191,12 @@ const preferConciseAngularHostDirectives = {
199
191
  const directiveValue = getDirectiveValueNode(config);
200
192
  if (!directiveValue) continue;
201
193
 
194
+ const hasComments = sourceCode.getCommentsInside(config).length > 0;
195
+
202
196
  context.report({
203
197
  node: config,
204
198
  messageId: 'preferShorthand',
205
- fix: (fixer) => fixer.replaceText(config, sourceCode.getText(directiveValue)),
199
+ fix: hasComments ? null : (fixer) => fixer.replaceText(config, sourceCode.getText(directiveValue)),
206
200
  });
207
201
 
208
202
  continue;
@@ -1,24 +1,11 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getAngularDecoratorName } = require('./internals/import-resolution');
5
+
4
6
  /** @typedef {import('estree').Property} TPropertyNode */
5
7
  /** @typedef {import('estree').Expression | import('estree').SpreadElement | null} TArrayElement */
6
8
 
7
- /**
8
- * @param {import('eslint').Rule.Node} node
9
- */
10
- const isComponentDecorator = (node) => {
11
- const decorator = /** @type {any} */ (node);
12
- if (decorator.type !== 'Decorator') return false;
13
-
14
- const expression = decorator.expression;
15
- if (expression.type === 'CallExpression') {
16
- return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
17
- }
18
-
19
- return expression.type === 'Identifier' && expression.name === 'Component';
20
- };
21
-
22
9
  /**
23
10
  * @param {import('estree').Property['key']} key
24
11
  */
@@ -62,7 +49,7 @@ const preferConciseAngularStyleMetadata = {
62
49
  return {
63
50
  /** @param {import('eslint').Rule.Node} node */
64
51
  Decorator(node) {
65
- if (!isComponentDecorator(node)) return;
52
+ if (getAngularDecoratorName(context.sourceCode, node) !== 'Component') return;
66
53
 
67
54
  const decorator = /** @type {any} */ (node);
68
55
  const expression = decorator.expression;
@@ -71,11 +58,16 @@ const preferConciseAngularStyleMetadata = {
71
58
  const metadata = expression.arguments[0];
72
59
  if (!metadata || metadata.type !== 'ObjectExpression') return;
73
60
 
61
+ const hasStyleUrl = metadata.properties.some(
62
+ (/** @type {any} */ property) => property.type === 'Property' && getPropertyName(property.key) === 'styleUrl',
63
+ );
64
+
74
65
  for (const property of metadata.properties) {
75
66
  if (property.type !== 'Property') continue;
76
67
 
77
68
  const propertyName = getPropertyName(property.key);
78
69
  if (propertyName !== 'styleUrls' && propertyName !== 'styles') continue;
70
+ if (propertyName === 'styleUrls' && hasStyleUrl) continue;
79
71
 
80
72
  const singleElement = getSingleArrayElement(property);
81
73
  if (!singleElement) continue;
@@ -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
  * Prefers signalElementDimensions / signalHostElementDimensions from @ethlete/core
6
8
  * over imperatively reading element size properties inside reactive contexts
@@ -46,10 +48,11 @@ const ELEMENT_SIZE_METHODS = new Set(['getBoundingClientRect', 'getClientRects']
46
48
  /**
47
49
  * Returns the name of the wrapping reactive context ('effect' | 'computed') if
48
50
  * the node is inside one, crossing exactly one function boundary.
51
+ * @param {import('eslint').SourceCode} sourceCode
49
52
  * @param {import('eslint').Rule.Node} node
50
53
  * @returns {string | null}
51
54
  */
52
- const getReactiveContext = (node) => {
55
+ const getReactiveContext = (sourceCode, node) => {
53
56
  let crossedFunctionBoundary = false;
54
57
  let current = node.parent;
55
58
 
@@ -58,13 +61,9 @@ const getReactiveContext = (node) => {
58
61
  crossedFunctionBoundary = true;
59
62
  }
60
63
 
61
- if (
62
- crossedFunctionBoundary &&
63
- current.type === 'CallExpression' &&
64
- current.callee.type === 'Identifier' &&
65
- (current.callee.name === 'effect' || current.callee.name === 'computed')
66
- ) {
67
- return current.callee.name;
64
+ if (crossedFunctionBoundary && current.type === 'CallExpression') {
65
+ const calleeName = getImportedName(sourceCode, current.callee, ANGULAR_CORE);
66
+ if (calleeName === 'effect' || calleeName === 'computed') return calleeName;
68
67
  }
69
68
 
70
69
  current = current.parent;
@@ -95,7 +94,7 @@ const preferElementDimensions = {
95
94
  if (node.property.type !== 'Identifier') return;
96
95
  if (!ELEMENT_SIZE_PROPS.has(node.property.name)) return;
97
96
 
98
- const reactiveCtx = getReactiveContext(node);
97
+ const reactiveCtx = getReactiveContext(context.sourceCode, node);
99
98
  if (!reactiveCtx) return;
100
99
 
101
100
  context.report({
@@ -112,7 +111,7 @@ const preferElementDimensions = {
112
111
  if (callee.property.type !== 'Identifier') return;
113
112
  if (!ELEMENT_SIZE_METHODS.has(callee.property.name)) return;
114
113
 
115
- const reactiveCtx = getReactiveContext(node);
114
+ const reactiveCtx = getReactiveContext(context.sourceCode, node);
116
115
  if (!reactiveCtx) return;
117
116
 
118
117
  context.report({
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { isImportedAs } = require('./internals/import-resolution');
5
+
4
6
  /**
5
7
  * Prefer linkedSignal over setting a signal inside an effect().
6
8
  *
@@ -44,9 +46,10 @@
44
46
  * effect is performing side effects alongside the signal write, so they are
45
47
  * not flagged either.
46
48
  *
49
+ * @param {import('eslint').SourceCode} sourceCode
47
50
  * @param {import('eslint').Rule.Node} node The `.set()` CallExpression node
48
51
  */
49
- const isPureSetInDirectEffectCallback = (node) => {
52
+ const isPureSetInDirectEffectCallback = (sourceCode, node) => {
50
53
  // Find the immediate enclosing function (cross exactly one boundary).
51
54
  let current = node.parent;
52
55
  let immediateFunction = null;
@@ -66,8 +69,7 @@ const isPureSetInDirectEffectCallback = (node) => {
66
69
  if (
67
70
  !funcParent ||
68
71
  funcParent.type !== 'CallExpression' ||
69
- funcParent.callee.type !== 'Identifier' ||
70
- funcParent.callee.name !== 'effect' ||
72
+ !isImportedAs(sourceCode, funcParent.callee, 'effect') ||
71
73
  funcParent.arguments[0] !== immediateFunction
72
74
  ) {
73
75
  return false;
@@ -112,7 +114,8 @@ const preferLinkedSignal = {
112
114
  callee.type === 'MemberExpression' &&
113
115
  callee.property.type === 'Identifier' &&
114
116
  callee.property.name === 'set' &&
115
- isPureSetInDirectEffectCallback(node)
117
+ node.arguments.length === 1 &&
118
+ isPureSetInDirectEffectCallback(context.sourceCode, node)
116
119
  ) {
117
120
  context.report({ node, messageId: 'preferLinkedSignal' });
118
121
  }
@@ -6,7 +6,7 @@
6
6
  * calling window.matchMedia() directly or using Angular CDK's BreakpointObserver.
7
7
  *
8
8
  * The utilities:
9
- * - Return reactive signals that update when the media query result changes
9
+ * - Offer both one-shot boolean reads and reactive signals
10
10
  * - Are SSR-safe (no window access during server rendering)
11
11
  * - Are automatically cleaned up when the component is destroyed
12
12
  *
@@ -37,6 +37,10 @@
37
37
  * // … etc. All from @ethlete/core.
38
38
  */
39
39
 
40
+ const { MODULE_REFERENCE_SELECTOR, getModuleReference, isImportedAs } = require('./internals/import-resolution');
41
+
42
+ const CDK_LAYOUT = '@angular/cdk/layout';
43
+
40
44
  /** @type {import('eslint').Rule.RuleModule} */
41
45
  const preferMatchMedia = {
42
46
  meta: {
@@ -47,7 +51,7 @@ const preferMatchMedia = {
47
51
  },
48
52
  messages: {
49
53
  preferMatchMedia:
50
- "Avoid 'window.matchMedia()' directly. Use 'injectMediaQueryIsMatched(query)' or a pre-built helper (injectCanHover, injectHasTouchInput, injectIsPortrait, injectBreakpointIsMatched, …) from '@ethlete/core' — they return reactive signals and are SSR-safe.",
54
+ "Avoid 'window.matchMedia()' directly. Use 'injectMediaQueryIsMatched(query)' for a one-shot SSR-safe boolean, or 'injectObserveMediaQuery(query)' when the result must update reactively.",
51
55
  noBreakpointObserver:
52
56
  "Do not use Angular CDK's 'BreakpointObserver' directly. Use 'injectBreakpointObserver()' from '@ethlete/core' instead, which wraps it with a reactive signal API and proper DI scoping.",
53
57
  },
@@ -72,25 +76,17 @@ const preferMatchMedia = {
72
76
 
73
77
  // ── inject(BreakpointObserver) ────────────────────────────────────────
74
78
  if (
75
- callee.type === 'Identifier' &&
76
- callee.name === 'inject' &&
77
- node.arguments[0]?.type === 'Identifier' &&
78
- node.arguments[0].name === 'BreakpointObserver'
79
+ isImportedAs(context.sourceCode, callee, 'inject') &&
80
+ isImportedAs(context.sourceCode, node.arguments[0], 'BreakpointObserver', CDK_LAYOUT)
79
81
  ) {
80
82
  context.report({ node, messageId: 'noBreakpointObserver' });
81
83
  }
82
84
  },
83
85
 
84
- ImportDeclaration(node) {
85
- // ── import { BreakpointObserver } from '@angular/cdk/layout' ─────────
86
- if (node.source.value !== '@angular/cdk/layout') return;
87
- const hasBreakpointObserver = node.specifiers.some(
88
- (s) =>
89
- s.type === 'ImportSpecifier' &&
90
- s.imported.type === 'Identifier' &&
91
- s.imported.name === 'BreakpointObserver',
92
- );
93
- if (hasBreakpointObserver) {
86
+ [MODULE_REFERENCE_SELECTOR](node) {
87
+ const reference = getModuleReference(node);
88
+ if (reference?.source !== CDK_LAYOUT) return;
89
+ if (reference.named.some((entry) => entry.name === 'BreakpointObserver')) {
94
90
  context.report({ node, messageId: 'noBreakpointObserver' });
95
91
  }
96
92
  },
@@ -72,7 +72,7 @@ const rule = {
72
72
  create(context) {
73
73
  return {
74
74
  PropertyDefinition(node) {
75
- const io = getReactiveIo(node);
75
+ const io = getReactiveIo(context.sourceCode, node);
76
76
  if (!io || io.kind !== 'output') return;
77
77
 
78
78
  const word = lastWord(io.name);
@@ -19,6 +19,8 @@
19
19
  * fromEvent(element, 'click').pipe(takeUntilDestroyed()).subscribe(handler);
20
20
  */
21
21
 
22
+ const { isGlobalReference } = require('./internals/import-resolution');
23
+
22
24
  /** @type {import('eslint').Rule.RuleModule} */
23
25
  const preferRxjsTimer = {
24
26
  meta: {
@@ -47,7 +49,7 @@ const preferRxjsTimer = {
47
49
  const { callee } = node;
48
50
 
49
51
  // ── Global timer calls ────────────────────────────────────────────────
50
- if (callee.type === 'Identifier') {
52
+ if (isGlobalReference(context.sourceCode, callee)) {
51
53
  const { name } = callee;
52
54
  if (name === 'setTimeout') {
53
55
  context.report({ node, messageId: 'preferTimer' });