@ethlete/eslint-plugin 1.0.0-next.23 → 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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -2
  3. package/src/configs/recommended.js +3 -0
  4. package/src/index.js +2 -0
  5. package/src/rules/angular-decorator-property-order.js +20 -20
  6. package/src/rules/class-constant-property.js +8 -14
  7. package/src/rules/class-member-order.js +77 -46
  8. package/src/rules/inject-member-accessibility.js +5 -3
  9. package/src/rules/internals/angular-io.js +12 -13
  10. package/src/rules/internals/angular-member-visibility.js +11 -7
  11. package/src/rules/internals/import-resolution.js +191 -0
  12. package/src/rules/no-angular-router-api.js +23 -29
  13. package/src/rules/no-angular-seo-services.js +14 -9
  14. package/src/rules/no-cdk-import.js +7 -14
  15. package/src/rules/no-document-cookie.js +3 -0
  16. package/src/rules/no-effect-cleanup-return.js +7 -3
  17. package/src/rules/no-empty-angular-metadata-arrays.js +3 -17
  18. package/src/rules/no-inject-chain.js +3 -1
  19. package/src/rules/no-leading-underscore-class-member.js +26 -3
  20. package/src/rules/no-legacy-angular-decorators.js +9 -21
  21. package/src/rules/no-legacy-query-import.js +6 -15
  22. package/src/rules/no-locale-id.js +4 -4
  23. package/src/rules/no-member-alias.js +11 -7
  24. package/src/rules/no-native-html-input-name.js +1 -1
  25. package/src/rules/no-pipe-logic.js +10 -17
  26. package/src/rules/no-redundant-on-push-change-detection.js +9 -23
  27. package/src/rules/no-rxjs-in-effect.js +12 -8
  28. package/src/rules/no-screaming-case-local.js +1 -0
  29. package/src/rules/no-standalone-flag.js +3 -17
  30. package/src/rules/no-subscribe-in-pipe.js +3 -2
  31. package/src/rules/no-template-literal-before-inline-template.js +3 -14
  32. package/src/rules/no-trivial-return-type.js +1 -0
  33. package/src/rules/no-trivial-wrapper-method.js +10 -16
  34. package/src/rules/no-typed-injected-element-ref.js +14 -17
  35. package/src/rules/no-unused-class-member.js +6 -9
  36. package/src/rules/prefer-clone-equal.js +8 -6
  37. package/src/rules/prefer-concise-angular-host-directives.js +23 -29
  38. package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
  39. package/src/rules/prefer-element-dimensions.js +9 -10
  40. package/src/rules/prefer-linked-signal.js +7 -4
  41. package/src/rules/prefer-match-media.js +10 -14
  42. package/src/rules/prefer-present-tense-output.js +1 -1
  43. package/src/rules/prefer-rxjs-timer.js +3 -1
  44. package/src/rules/require-dollar-suffix.js +7 -4
  45. package/src/rules/require-on-push-change-detection.js +17 -25
  46. package/src/rules/require-view-encapsulation-none.js +17 -25
  47. package/src/rules/take-until-destroyed-last.js +64 -0
  48. package/src/rules/template-member-accessibility.js +8 -6
@@ -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
  }
@@ -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({
@@ -1,6 +1,14 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const {
5
+ ANGULAR_CORE,
6
+ getAngularDecoratorName,
7
+ getImportLocalName,
8
+ isImportedAs,
9
+ resolveIdentifier,
10
+ } = require('./internals/import-resolution');
11
+
4
12
  const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
5
13
 
6
14
  // OnPush became the default change detection strategy in Angular 22, so from
@@ -187,7 +195,7 @@ const getChangeDetectionInsertionTarget = (metadata) => {
187
195
  * @param {any} metadata
188
196
  */
189
197
  const buildMetadataFix = (sourceCode, metadata) => {
190
- const changeDetectionText = 'changeDetection: ChangeDetectionStrategy.OnPush';
198
+ const changeDetectionText = `changeDetection: ${getImportLocalName(sourceCode, ANGULAR_CORE, 'ChangeDetectionStrategy') ?? 'ChangeDetectionStrategy'}.OnPush`;
191
199
  const properties = metadata.properties;
192
200
  const insertionTarget = getChangeDetectionInsertionTarget(metadata);
193
201
  const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
@@ -236,6 +244,10 @@ const buildMetadataFix = (sourceCode, metadata) => {
236
244
  * @param {any} valueNode
237
245
  */
238
246
  const buildFix = (sourceCode, metadata, valueNode) => {
247
+ const valueFix = valueNode && ((fixer) => fixer.replaceText(valueNode.property, 'OnPush'));
248
+ if (valueNode && valueNode.object.type !== 'Identifier') return valueFix;
249
+ if (valueNode && resolveIdentifier(sourceCode, valueNode.object)?.source) return valueFix;
250
+
239
251
  const fixes = [];
240
252
  const angularCoreImport = findAngularCoreImport(sourceCode);
241
253
  const importFix = angularCoreImport
@@ -244,12 +256,8 @@ const buildFix = (sourceCode, metadata, valueNode) => {
244
256
 
245
257
  if (importFix) fixes.push(importFix);
246
258
 
247
- if (valueNode) {
248
- fixes.push((fixer) => fixer.replaceText(valueNode, 'ChangeDetectionStrategy.OnPush'));
249
- } else {
250
- const metadataFix = buildMetadataFix(sourceCode, metadata);
251
- if (metadataFix) fixes.push(metadataFix);
252
- }
259
+ const metadataFix = valueFix ?? buildMetadataFix(sourceCode, metadata);
260
+ if (metadataFix) fixes.push(metadataFix);
253
261
 
254
262
  return (fixer) => fixes.map((applyFix) => applyFix(fixer));
255
263
  };
@@ -278,24 +286,9 @@ const requireOnPushChangeDetection = {
278
286
 
279
287
  const sourceCode = context.sourceCode;
280
288
 
281
- /**
282
- * @param {import('eslint').Rule.Node} node
283
- */
284
- const isComponentDecorator = (node) => {
285
- const decorator = /** @type {any} */ (node);
286
- if (decorator.type !== 'Decorator') return false;
287
-
288
- const expression = decorator.expression;
289
- if (expression.type === 'CallExpression') {
290
- return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
291
- }
292
-
293
- return expression.type === 'Identifier' && expression.name === 'Component';
294
- };
295
-
296
289
  return {
297
290
  Decorator(node) {
298
- if (!isComponentDecorator(node)) return;
291
+ if (getAngularDecoratorName(sourceCode, node) !== 'Component') return;
299
292
 
300
293
  const decorator = /** @type {any} */ (node);
301
294
  const expression = decorator.expression;
@@ -320,8 +313,7 @@ const requireOnPushChangeDetection = {
320
313
  const value = changeDetectionProp.value;
321
314
  if (
322
315
  value.type === 'MemberExpression' &&
323
- value.object.type === 'Identifier' &&
324
- value.object.name === 'ChangeDetectionStrategy' &&
316
+ isImportedAs(sourceCode, value.object, 'ChangeDetectionStrategy') &&
325
317
  value.property.type === 'Identifier' &&
326
318
  value.property.name !== 'OnPush'
327
319
  ) {
@@ -1,6 +1,14 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const {
5
+ ANGULAR_CORE,
6
+ getAngularDecoratorName,
7
+ getImportLocalName,
8
+ isImportedAs,
9
+ resolveIdentifier,
10
+ } = require('./internals/import-resolution');
11
+
4
12
  const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
5
13
 
6
14
  /**
@@ -141,7 +149,7 @@ const getEncapsulationInsertionTarget = (metadata) => {
141
149
  * @param {any} metadata
142
150
  */
143
151
  const buildMetadataFix = (sourceCode, metadata) => {
144
- const encText = 'encapsulation: ViewEncapsulation.None';
152
+ const encText = `encapsulation: ${getImportLocalName(sourceCode, ANGULAR_CORE, 'ViewEncapsulation') ?? 'ViewEncapsulation'}.None`;
145
153
  const properties = metadata.properties;
146
154
  const insertionTarget = getEncapsulationInsertionTarget(metadata);
147
155
  const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
@@ -189,6 +197,10 @@ const buildMetadataFix = (sourceCode, metadata) => {
189
197
  * @param {any} valueNode
190
198
  */
191
199
  const buildFix = (sourceCode, metadata, valueNode) => {
200
+ const valueFix = valueNode && ((fixer) => fixer.replaceText(valueNode.property, 'None'));
201
+ if (valueNode && valueNode.object.type !== 'Identifier') return valueFix;
202
+ if (valueNode && resolveIdentifier(sourceCode, valueNode.object)?.source) return valueFix;
203
+
192
204
  const fixes = [];
193
205
  const angularCoreImport = findAngularCoreImport(sourceCode);
194
206
  const importFix = angularCoreImport
@@ -197,12 +209,8 @@ const buildFix = (sourceCode, metadata, valueNode) => {
197
209
 
198
210
  if (importFix) fixes.push(importFix);
199
211
 
200
- if (valueNode) {
201
- fixes.push((fixer) => fixer.replaceText(valueNode, 'ViewEncapsulation.None'));
202
- } else {
203
- const metadataFix = buildMetadataFix(sourceCode, metadata);
204
- if (metadataFix) fixes.push(metadataFix);
205
- }
212
+ const metadataFix = valueFix ?? buildMetadataFix(sourceCode, metadata);
213
+ if (metadataFix) fixes.push(metadataFix);
206
214
 
207
215
  return (fixer) => fixes.map((applyFix) => applyFix(fixer));
208
216
  };
@@ -224,24 +232,9 @@ const requireViewEncapsulationNone = {
224
232
  create(context) {
225
233
  const sourceCode = context.sourceCode;
226
234
 
227
- /**
228
- * @param {import('eslint').Rule.Node} node
229
- */
230
- const isComponentDecorator = (node) => {
231
- const decorator = /** @type {any} */ (node);
232
- if (decorator.type !== 'Decorator') return false;
233
-
234
- const expression = decorator.expression;
235
- if (expression.type === 'CallExpression') {
236
- return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
237
- }
238
-
239
- return expression.type === 'Identifier' && expression.name === 'Component';
240
- };
241
-
242
235
  return {
243
236
  Decorator(node) {
244
- if (!isComponentDecorator(node)) return;
237
+ if (getAngularDecoratorName(sourceCode, node) !== 'Component') return;
245
238
 
246
239
  const decorator = /** @type {any} */ (node);
247
240
  const expression = decorator.expression;
@@ -266,8 +259,7 @@ const requireViewEncapsulationNone = {
266
259
  const value = encProp.value;
267
260
  if (
268
261
  value.type === 'MemberExpression' &&
269
- value.object.type === 'Identifier' &&
270
- value.object.name === 'ViewEncapsulation' &&
262
+ isImportedAs(sourceCode, value.object, 'ViewEncapsulation') &&
271
263
  value.property.type === 'Identifier' &&
272
264
  value.property.name !== 'None'
273
265
  ) {
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { isImportedAs } = require('./internals/import-resolution');
5
+
6
+ /**
7
+ * Requires takeUntilDestroyed() to be the last operator in a .pipe().
8
+ *
9
+ * BAD:
10
+ * obs$.pipe(
11
+ * takeUntilDestroyed(),
12
+ * switchMap(() => fromEvent(el, 'scroll')), // ❌ the inner subscription outlives the component
13
+ * ).subscribe();
14
+ *
15
+ * GOOD:
16
+ * obs$.pipe(
17
+ * switchMap(() => fromEvent(el, 'scroll')),
18
+ * takeUntilDestroyed(), // ✅ tears down everything above it
19
+ * ).subscribe();
20
+ */
21
+
22
+ /** @type {import('eslint').Rule.RuleModule} */
23
+ const takeUntilDestroyedLast = {
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description: 'Require takeUntilDestroyed() to be the last operator in a .pipe().',
28
+ recommended: true,
29
+ },
30
+ messages: {
31
+ takeUntilDestroyedLast:
32
+ 'takeUntilDestroyed() must be the last operator in the pipe. An operator after it (switchMap, shareReplay, …) can keep the subscription alive after destroy.',
33
+ },
34
+ schema: [],
35
+ },
36
+ create(context) {
37
+ /** @param {import('estree').Node} node */
38
+ const isTakeUntilDestroyedCall = (node) =>
39
+ node.type === 'CallExpression' &&
40
+ isImportedAs(context.sourceCode, node.callee, 'takeUntilDestroyed', '@angular/core/rxjs-interop');
41
+
42
+ return {
43
+ CallExpression(node) {
44
+ const { callee } = node;
45
+ if (
46
+ callee.type !== 'MemberExpression' ||
47
+ callee.property.type !== 'Identifier' ||
48
+ callee.property.name !== 'pipe'
49
+ ) {
50
+ return;
51
+ }
52
+
53
+ const lastIndex = node.arguments.length - 1;
54
+ node.arguments.forEach((arg, index) => {
55
+ if (index < lastIndex && isTakeUntilDestroyedCall(arg)) {
56
+ context.report({ node: arg, messageId: 'takeUntilDestroyedLast' });
57
+ }
58
+ });
59
+ },
60
+ };
61
+ },
62
+ };
63
+
64
+ module.exports = takeUntilDestroyedLast;
@@ -7,15 +7,17 @@ const {
7
7
  isReferencedFromTemplateOrHost,
8
8
  } = require('./internals/angular-member-visibility');
9
9
  const { getImplementedContractMemberNames } = require('./internals/implemented-contract-members');
10
+ const { isImportedAs } = require('./internals/import-resolution');
10
11
  const { buildAccessibilityFix } = require('./internals/member-accessibility-fix');
11
12
 
12
13
  /**
14
+ * @param {import('eslint').SourceCode} sourceCode
13
15
  * @param {any} node
14
16
  */
15
- const isInjectCall = (node) => {
17
+ const isInjectCall = (sourceCode, node) => {
16
18
  if (!node || node.type !== 'PropertyDefinition' || !node.value) return false;
17
19
  if (node.value.type !== 'CallExpression') return false;
18
- return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
20
+ return isImportedAs(sourceCode, node.value.callee, 'inject');
19
21
  };
20
22
 
21
23
  /**
@@ -91,11 +93,11 @@ const templateMemberAccessibility = {
91
93
  */
92
94
  const checkMember = (node) => {
93
95
  if (!isSupportedMember(node)) return;
94
- if (node.type === 'PropertyDefinition' && isInjectCall(node)) return;
96
+ if (node.type === 'PropertyDefinition' && isInjectCall(context.sourceCode, node)) return;
95
97
 
96
98
  const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
97
99
  if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
98
- if (!getAngularMetadata(classNode)) return;
100
+ if (!getAngularMetadata(context.sourceCode, classNode)) return;
99
101
 
100
102
  const memberName = getMemberName(node);
101
103
  if (!memberName) return;
@@ -148,11 +150,11 @@ const templateMemberAccessibility = {
148
150
  */
149
151
  const checkImplicitPublicMember = (node) => {
150
152
  if (!isSupportedMember(node)) return;
151
- if (node.type === 'PropertyDefinition' && isInjectCall(node)) return;
153
+ if (node.type === 'PropertyDefinition' && isInjectCall(context.sourceCode, node)) return;
152
154
 
153
155
  const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
154
156
  if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
155
- if (!getAngularMetadata(classNode)) return;
157
+ if (!getAngularMetadata(context.sourceCode, classNode)) return;
156
158
 
157
159
  const memberName = getMemberName(node);
158
160
  if (!memberName) return;