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

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 (55) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/migrations.json +12 -0
  3. package/package.json +13 -3
  4. package/src/configs/recommended.js +33 -11
  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 +10 -2
  10. package/src/rules/angular-decorator-property-order.js +20 -20
  11. package/src/rules/class-constant-property.js +8 -14
  12. package/src/rules/class-member-order.js +77 -46
  13. package/src/rules/consistent-type-definitions.js +60 -0
  14. package/src/rules/inject-member-accessibility.js +5 -3
  15. package/src/rules/internals/angular-io.js +12 -13
  16. package/src/rules/internals/angular-member-visibility.js +11 -7
  17. package/src/rules/internals/import-resolution.js +191 -0
  18. package/src/rules/no-angular-router-api.js +23 -29
  19. package/src/rules/no-angular-seo-services.js +14 -9
  20. package/src/rules/no-cdk-import.js +7 -14
  21. package/src/rules/no-csp-unsafe.js +362 -0
  22. package/src/rules/no-document-cookie.js +3 -0
  23. package/src/rules/no-effect-cleanup-return.js +7 -3
  24. package/src/rules/no-empty-angular-metadata-arrays.js +3 -17
  25. package/src/rules/no-inject-chain.js +3 -1
  26. package/src/rules/no-leading-underscore-class-member.js +26 -3
  27. package/src/rules/no-legacy-angular-decorators.js +9 -21
  28. package/src/rules/no-legacy-query-import.js +6 -15
  29. package/src/rules/no-locale-id.js +4 -4
  30. package/src/rules/no-member-alias.js +11 -7
  31. package/src/rules/no-native-html-input-name.js +1 -1
  32. package/src/rules/no-pipe-logic.js +10 -17
  33. package/src/rules/no-redundant-on-push-change-detection.js +9 -23
  34. package/src/rules/no-rxjs-in-effect.js +12 -8
  35. package/src/rules/no-screaming-case-local.js +1 -0
  36. package/src/rules/no-standalone-flag.js +3 -17
  37. package/src/rules/no-subscribe-in-pipe.js +3 -2
  38. package/src/rules/no-template-literal-before-inline-template.js +3 -14
  39. package/src/rules/no-trivial-return-type.js +1 -0
  40. package/src/rules/no-trivial-wrapper-method.js +10 -16
  41. package/src/rules/no-typed-injected-element-ref.js +14 -17
  42. package/src/rules/no-unused-class-member.js +6 -9
  43. package/src/rules/prefer-clone-equal.js +8 -6
  44. package/src/rules/prefer-concise-angular-host-directives.js +23 -29
  45. package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
  46. package/src/rules/prefer-element-dimensions.js +9 -10
  47. package/src/rules/prefer-linked-signal.js +42 -41
  48. package/src/rules/prefer-match-media.js +10 -14
  49. package/src/rules/prefer-present-tense-output.js +1 -1
  50. package/src/rules/prefer-rxjs-timer.js +3 -1
  51. package/src/rules/require-dollar-suffix.js +7 -4
  52. package/src/rules/require-on-push-change-detection.js +17 -25
  53. package/src/rules/require-view-encapsulation-none.js +17 -25
  54. package/src/rules/take-until-destroyed-last.js +64 -0
  55. package/src/rules/template-member-accessibility.js +8 -6
@@ -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,30 +22,25 @@ 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
39
  const injectType = existingTypeParams?.params?.[0];
42
40
  const hasTypedElementRef =
43
41
  existingTypeParams?.params?.length === 1 &&
44
42
  injectType?.type === 'TSTypeReference' &&
45
- injectType.typeName?.type === 'Identifier' &&
46
- injectType.typeName.name === 'ElementRef' &&
43
+ src.getText(injectType.typeName) === elementRef &&
47
44
  (injectType.typeArguments || injectType.typeParameters)?.params?.length > 0;
48
45
 
49
46
  if (isPlainElementRef && !hasTypedElementRef) {
@@ -53,8 +50,8 @@ const noTypedInjectedElementRef = {
53
50
  messageId: 'missingGeneric',
54
51
  fix(fixer) {
55
52
  return existingTypeParams
56
- ? fixer.replaceText(existingTypeParams, '<ElementRef<HTMLElement>>')
57
- : fixer.insertTextAfter(node.callee, '<ElementRef<HTMLElement>>');
53
+ ? fixer.replaceText(existingTypeParams, `<${elementRef}<HTMLElement>>`)
54
+ : fixer.insertTextAfter(node.callee, `<${elementRef}<HTMLElement>>`);
58
55
  },
59
56
  });
60
57
  return;
@@ -71,9 +68,9 @@ const noTypedInjectedElementRef = {
71
68
  messageId: 'missingGeneric',
72
69
  fix(fixer) {
73
70
  const typeFix = existingTypeParams
74
- ? fixer.replaceText(existingTypeParams, `<ElementRef${tokenTypeParamText}>`)
75
- : fixer.insertTextAfter(node.callee, `<ElementRef${tokenTypeParamText}>`);
76
- return [typeFix, fixer.replaceText(firstArg, 'ElementRef')];
71
+ ? fixer.replaceText(existingTypeParams, `<${elementRef}${tokenTypeParamText}>`)
72
+ : fixer.insertTextAfter(node.callee, `<${elementRef}${tokenTypeParamText}>`);
73
+ return [typeFix, fixer.replaceText(firstArg, elementRef)];
77
74
  },
78
75
  });
79
76
  } else {
@@ -82,7 +79,7 @@ const noTypedInjectedElementRef = {
82
79
  node,
83
80
  messageId: 'missingGeneric',
84
81
  fix(fixer) {
85
- return fixer.replaceText(firstArg, 'ElementRef');
82
+ return fixer.replaceText(firstArg, elementRef);
86
83
  },
87
84
  });
88
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;
@@ -167,7 +164,7 @@ const noUnusedClassMember = {
167
164
  return {
168
165
  ClassBody(node) {
169
166
  const classNode = node.parent;
170
- const { isComponent, isAngularClass } = getDecoratorInfo(classNode);
167
+ const { isComponent, isAngularClass } = getDecoratorInfo(context.sourceCode, classNode);
171
168
  const hostBound = getHostBoundMembers(classNode);
172
169
  classStack.push({
173
170
  classBody: node,
@@ -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
  *
@@ -22,31 +24,48 @@
22
24
  * GOOD:
23
25
  * selectedItem = linkedSignal(() => this.items()[0] ?? null);
24
26
  *
25
- * NOTE: Only flag when .set() is called directly inside an effect() callback.
26
- * Setting signals outside effects (in methods, event handlers, etc.) is fine.
27
+ * Also flagged when the write sits behind an `if`, as long as every branch only
28
+ * writes signals: `linkedSignal` keeps the previous value for the untaken branch.
27
29
  */
28
30
 
29
31
  /**
30
- * Returns true only when the `.set()` call node is a *pure derivation* inside
31
- * an effect() callback — meaning:
32
- *
33
- * 1. The IMMEDIATE enclosing function (the first function boundary walking up
34
- * from the node) IS the first argument passed to `effect()`. This ensures
35
- * that `.set()` calls nested inside `untracked(() => ...)` or other inner
36
- * functions within the effect are NOT flagged — those are intentional
37
- * write-back patterns, not derivations.
38
- *
39
- * 2. The `.set()` call is the SOLE expression in that callback:
40
- * - Arrow with expression body: `effect(() => sig.set(x))`
41
- * - Block body with a single ExpressionStatement: `effect(() => { sig.set(x); })`
32
+ * @param {any} node
33
+ * @returns {boolean}
34
+ */
35
+ const isSetCall = (node) =>
36
+ node.type === 'CallExpression' &&
37
+ node.callee.type === 'MemberExpression' &&
38
+ node.callee.property.type === 'Identifier' &&
39
+ node.callee.property.name === 'set' &&
40
+ node.arguments.length === 1;
41
+
42
+ /**
43
+ * A statement that does nothing but write signals: a lone `.set()`, a block holding one, or an
44
+ * `if`/`else` whose every branch is one.
42
45
  *
43
- * Multi-statement blocks (even if they end with a `.set()`) indicate that the
44
- * effect is performing side effects alongside the signal write, so they are
45
- * not flagged either.
46
+ * @param {any} statement
47
+ * @returns {boolean}
48
+ */
49
+ const isSetOnlyStatement = (statement) => {
50
+ if (!statement) return false;
51
+ if (statement.type === 'ExpressionStatement') return isSetCall(statement.expression);
52
+ if (statement.type === 'BlockStatement') return statement.body.length === 1 && isSetOnlyStatement(statement.body[0]);
53
+ if (statement.type === 'IfStatement') {
54
+ return (
55
+ isSetOnlyStatement(statement.consequent) && (!statement.alternate || isSetOnlyStatement(statement.alternate))
56
+ );
57
+ }
58
+ return false;
59
+ };
60
+
61
+ /**
62
+ * True when the `.set()` call sits directly in an `effect()` callback (not in a nested function such as
63
+ * `untracked(() => …)`) and that callback does nothing but write signals, optionally behind an `if`.
46
64
  *
65
+ * @param {import('eslint').SourceCode} sourceCode
47
66
  * @param {import('eslint').Rule.Node} node The `.set()` CallExpression node
48
67
  */
49
- const isPureSetInDirectEffectCallback = (node) => {
68
+ const isPureSetInDirectEffectCallback = (sourceCode, node) => {
50
69
  // Find the immediate enclosing function (cross exactly one boundary).
51
70
  let current = node.parent;
52
71
  let immediateFunction = null;
@@ -66,27 +85,16 @@ const isPureSetInDirectEffectCallback = (node) => {
66
85
  if (
67
86
  !funcParent ||
68
87
  funcParent.type !== 'CallExpression' ||
69
- funcParent.callee.type !== 'Identifier' ||
70
- funcParent.callee.name !== 'effect' ||
88
+ !isImportedAs(sourceCode, funcParent.callee, 'effect') ||
71
89
  funcParent.arguments[0] !== immediateFunction
72
90
  ) {
73
91
  return false;
74
92
  }
75
93
 
76
- // The .set() must be the sole expression — no surrounding statements.
77
- // Case A: arrow with expression body — the body IS the .set() call.
78
- if (immediateFunction.type === 'ArrowFunctionExpression' && immediateFunction.body === node) {
79
- return true;
80
- }
81
-
82
- // Case B: block body with exactly one ExpressionStatement wrapping the .set() call.
83
94
  const body = immediateFunction.body;
84
- return (
85
- body.type === 'BlockStatement' &&
86
- body.body.length === 1 &&
87
- body.body[0].type === 'ExpressionStatement' &&
88
- body.body[0].expression === node
89
- );
95
+ if (body.type !== 'BlockStatement') return body === node;
96
+
97
+ return node.parent.type === 'ExpressionStatement' && body.body.length === 1 && isSetOnlyStatement(body.body[0]);
90
98
  };
91
99
 
92
100
  /** @type {import('eslint').Rule.RuleModule} */
@@ -106,14 +114,7 @@ const preferLinkedSignal = {
106
114
  create(context) {
107
115
  return {
108
116
  CallExpression(node) {
109
- const { callee } = node;
110
-
111
- if (
112
- callee.type === 'MemberExpression' &&
113
- callee.property.type === 'Identifier' &&
114
- callee.property.name === 'set' &&
115
- isPureSetInDirectEffectCallback(node)
116
- ) {
117
+ if (isSetCall(node) && isPureSetInDirectEffectCallback(context.sourceCode, node)) {
117
118
  context.report({ node, messageId: 'preferLinkedSignal' });
118
119
  }
119
120
  },
@@ -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: {
@@ -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' });
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getImportedName } = require('./internals/import-resolution');
5
+
4
6
  /**
5
7
  * Requires observable variables and class properties to be suffixed with `$`.
6
8
  *
@@ -45,9 +47,10 @@ const RXJS_SUBJECT_CTORS = new Set(['Subject', 'BehaviorSubject', 'ReplaySubject
45
47
 
46
48
  /**
47
49
  * Returns true if the expression looks like an Observable.
50
+ * @param {import('eslint').SourceCode} sourceCode
48
51
  * @param {any} node
49
52
  */
50
- const isObservableInit = (node) => {
53
+ const isObservableInit = (sourceCode, node) => {
51
54
  if (!node) return false;
52
55
 
53
56
  // someObs.pipe(...)
@@ -61,12 +64,12 @@ const isObservableInit = (node) => {
61
64
  }
62
65
 
63
66
  // from(...), of(...), interval(...), etc.
64
- if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && RXJS_CREATORS.has(node.callee.name)) {
67
+ if (node.type === 'CallExpression' && RXJS_CREATORS.has(getImportedName(sourceCode, node.callee, 'rxjs') ?? '')) {
65
68
  return true;
66
69
  }
67
70
 
68
71
  // new Subject(), new BehaviorSubject(...), etc.
69
- if (node.type === 'NewExpression' && node.callee.type === 'Identifier' && RXJS_SUBJECT_CTORS.has(node.callee.name)) {
72
+ if (node.type === 'NewExpression' && RXJS_SUBJECT_CTORS.has(getImportedName(sourceCode, node.callee, 'rxjs') ?? '')) {
70
73
  return true;
71
74
  }
72
75
 
@@ -92,7 +95,7 @@ const rule = {
92
95
  * @param {any} initNode
93
96
  */
94
97
  const checkBinding = (name, reportNode, initNode) => {
95
- if (!isObservableInit(initNode)) return;
98
+ if (!isObservableInit(context.sourceCode, initNode)) return;
96
99
  if (name.endsWith('$')) return;
97
100
 
98
101
  context.report({