@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
@@ -3,23 +3,13 @@
3
3
 
4
4
  /**
5
5
  * Prefers signalElementScrollState / signalHostElementScrollState from @ethlete/core
6
- * over imperatively reading scroll position properties inside reactive contexts,
7
- * and disallows adding raw scroll event listeners in favour of the same signal utilities.
6
+ * instead of raw scroll event listeners.
8
7
  *
9
8
  * The signal utilities:
10
9
  * - Set up a scroll event listener that keeps a reactive signal in sync
11
10
  * - Clean up automatically when the component is destroyed
12
11
  * - Provide debouncing and direction tracking built in
13
12
  *
14
- * NOTE: The property-read check is a WARNING — imperative one-shot reads of
15
- * scrollTop/scrollLeft are fine in event handlers and animations; only flag
16
- * inside effect() / computed().
17
- *
18
- * BAD (reactive context):
19
- * effect(() => {
20
- * const top = this.el.nativeElement.scrollTop; // stale snapshot ❌
21
- * });
22
- *
23
13
  * BAD (scroll listeners — always wrong):
24
14
  * el.addEventListener('scroll', fn); // ❌
25
15
  * fromEvent(el, 'scroll').subscribe(fn); // ❌
@@ -31,53 +21,17 @@
31
21
  * // Or use <et-scrollable> / ScrollableComponent for list scrolling
32
22
  */
33
23
 
34
- /** Scroll position properties read from DOM elements. */
35
- const SCROLL_PROPS = new Set(['scrollTop', 'scrollLeft', 'scrollY', 'scrollX']);
36
-
37
- /**
38
- * Returns the name of the wrapping reactive context ('effect' | 'computed') if
39
- * the node is inside one, crossing at least one function boundary.
40
- * @param {import('eslint').Rule.Node} node
41
- * @returns {string | null}
42
- */
43
- const getReactiveContext = (node) => {
44
- let crossedFunctionBoundary = false;
45
- let current = node.parent;
46
-
47
- while (current) {
48
- if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
49
- crossedFunctionBoundary = true;
50
- }
51
-
52
- if (
53
- crossedFunctionBoundary &&
54
- current.type === 'CallExpression' &&
55
- current.callee.type === 'Identifier' &&
56
- (current.callee.name === 'effect' || current.callee.name === 'computed')
57
- ) {
58
- return current.callee.name;
59
- }
60
-
61
- current = current.parent;
62
- }
63
-
64
- return null;
65
- };
66
-
67
24
  /** @type {import('eslint').Rule.RuleModule} */
68
25
  const preferScrollState = {
69
26
  meta: {
70
27
  type: 'suggestion',
71
28
  docs: {
72
- description:
73
- "Prefer 'signalElementScrollState()' / 'signalHostElementScrollState()' from '@ethlete/core' over reading scroll position properties inside reactive contexts.",
29
+ description: "Prefer the scroll signal utilities from '@ethlete/core' over raw scroll event listeners.",
74
30
  recommended: true,
75
31
  },
76
32
  messages: {
77
- preferScrollState:
78
- "Avoid reading '{{prop}}' inside {{context}}() — it creates a stale snapshot. Use 'signalElementScrollState(elementRef)' or 'signalHostElementScrollState()' from '@ethlete/core' instead. For scroll direction, use 'signalElementLastScrollDirection()' / 'signalHostElementLastScrollDirection()'.",
79
33
  noScrollListener:
80
- "Do not add 'scroll' event listeners directly. Use 'signalElementScrollState(elementRef)' / 'signalHostElementScrollState()' for scroll position or state, 'signalElementLastScrollDirection(elementRef)' / 'signalHostElementLastScrollDirection()' for scroll direction, or the 'ScrollableComponent' (et-scrollable) from '@ethlete/cdk' for scrollable lists. All are imported from '@ethlete/core' or '@ethlete/cdk'.",
34
+ "Do not add 'scroll' event listeners directly. Use 'signalElementScrollState(elementRef)' / 'signalHostElementScrollState()' for scroll state, 'signalElementLastScrollDirection(elementRef)' / 'signalHostElementLastScrollDirection()' for direction, or 'ScrollableComponent' from '@ethlete/components'.",
81
35
  },
82
36
  schema: [],
83
37
  },
@@ -133,24 +87,6 @@ const preferScrollState = {
133
87
  context.report({ node, messageId: 'noScrollListener' });
134
88
  }
135
89
  },
136
-
137
- MemberExpression(node) {
138
- if (node.property.type !== 'Identifier') return;
139
- if (!SCROLL_PROPS.has(node.property.name)) return;
140
-
141
- // Only flag reads (not assignments like el.scrollTop = 0).
142
- const parent = node.parent;
143
- if (parent && parent.type === 'AssignmentExpression' && parent.left === node) return;
144
-
145
- const reactiveCtx = getReactiveContext(node);
146
- if (!reactiveCtx) return;
147
-
148
- context.report({
149
- node,
150
- messageId: 'preferScrollState',
151
- data: { prop: node.property.name, context: reactiveCtx },
152
- });
153
- },
154
90
  };
155
91
  },
156
92
  };
@@ -23,6 +23,33 @@
23
23
  * which covers the string-literal case (where the rewrite is always safe).
24
24
  */
25
25
 
26
+ const NATIVE_BOOLEAN_PROPERTIES = new Set([
27
+ 'allowFullscreen',
28
+ 'async',
29
+ 'autofocus',
30
+ 'autoplay',
31
+ 'checked',
32
+ 'controls',
33
+ 'default',
34
+ 'defer',
35
+ 'disabled',
36
+ 'formNoValidate',
37
+ 'hidden',
38
+ 'inert',
39
+ 'isMap',
40
+ 'loop',
41
+ 'multiple',
42
+ 'muted',
43
+ 'noModule',
44
+ 'noValidate',
45
+ 'open',
46
+ 'playsInline',
47
+ 'readOnly',
48
+ 'required',
49
+ 'reversed',
50
+ 'selected',
51
+ ]);
52
+
26
53
  /** @type {import('eslint').Rule.RuleModule} */
27
54
  const preferStaticBooleanProperties = {
28
55
  meta: {
@@ -55,6 +82,9 @@ const preferStaticBooleanProperties = {
55
82
  // structural directives (*foo has no keySpan details)
56
83
  const isBindingProperty = keySpan?.details && !keySpan.details.includes('@') && !keySpan.details.includes('.');
57
84
  if (!isBindingProperty) return;
85
+ if (name.startsWith('ng') || NATIVE_BOOLEAN_PROPERTIES.has(name)) {
86
+ return;
87
+ }
58
88
 
59
89
  // Only literal `true` / `false` expressions. `value.ast` is a
60
90
  // LiteralPrimitive for literals; the raw-source check guards against
@@ -21,13 +21,7 @@
21
21
  */
22
22
 
23
23
  /** Properties on window that report viewport dimensions. */
24
- const VIEWPORT_PROPERTIES = new Set([
25
- 'innerWidth',
26
- 'innerHeight',
27
- 'outerWidth',
28
- 'outerHeight',
29
- 'screen', // window.screen.width / window.screen.height
30
- ]);
24
+ const VIEWPORT_PROPERTIES = new Set(['innerWidth', 'innerHeight']);
31
25
 
32
26
  /** @type {import('eslint').Rule.RuleModule} */
33
27
  const preferViewportSize = {
@@ -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({
@@ -2,17 +2,12 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * Checks the two ends of the same wire: that a `<form>` handles its own submission, and that a
6
- * submit control reaches a form at all.
5
+ * Checks that a `<form>` handles its own submission.
7
6
  *
8
7
  * <form (ngSubmit)="save()"> ✔
9
8
  * <form [etForm]="form"> ✔ signal forms - the directive submits the field tree
10
9
  * <form> ✘ pressing Enter in a field does nothing, or reloads the page
11
10
  *
12
- * <form …><button type="submit"> ✔
13
- * <button type="submit" form="edit"> ✔ associated by id, outside the form's subtree
14
- * <button type="submit"> ✘ submits nothing
15
- *
16
11
  * A form declaring native submission (`action`, `ngNoForm`, `method="dialog"`) is left alone - it is
17
12
  * handled by the platform rather than by a handler.
18
13
  */
@@ -35,23 +30,17 @@ const handlesSubmit = (node) =>
35
30
  const submitsNatively = (node) =>
36
31
  hasBinding(node, 'action') || hasBinding(node, 'ngNoForm') || attribute(node, 'method')?.value === 'dialog';
37
32
 
38
- /** @param {any} node */
39
- const isSubmitControl = (node) =>
40
- (node.name === 'button' || node.name === 'input') && attribute(node, 'type')?.value === 'submit';
41
-
42
33
  /** @type {import('eslint').Rule.RuleModule} */
43
34
  const requireFormSubmit = {
44
35
  meta: {
45
36
  type: 'problem',
46
37
  docs: {
47
- description: 'Require a `<form>` to handle its own submission, and a submit control to reach a form.',
38
+ description: 'Require a `<form>` to handle its own submission.',
48
39
  },
49
40
  schema: [],
50
41
  messages: {
51
42
  missingSubmitHandler:
52
43
  'This `<form>` handles no submission - pressing Enter in a field either does nothing or reloads the page. Bind `[etForm]` (signal forms), `(ngSubmit)` (reactive forms) or `(submit)`, or use a plain element if this is not a form.',
53
- submitOutsideForm:
54
- 'A `type="submit"` control outside a `<form>` submits nothing. Put it inside the form, or associate it with one by id: `form="the-form-id"`.',
55
44
  },
56
45
  },
57
46
  create(context) {
@@ -59,8 +48,6 @@ const requireFormSubmit = {
59
48
 
60
49
  if (!parserServices?.convertNodeSourceSpanToLoc) return {};
61
50
 
62
- let formDepth = 0;
63
-
64
51
  /** @param {any} node */
65
52
  const report = (node, messageId) =>
66
53
  context.report({
@@ -72,21 +59,8 @@ const requireFormSubmit = {
72
59
  /** @param {any} node */
73
60
  Element(node) {
74
61
  if (node.name === 'form') {
75
- formDepth++;
76
-
77
62
  if (!handlesSubmit(node) && !submitsNatively(node)) report(node, 'missingSubmitHandler');
78
-
79
- return;
80
63
  }
81
-
82
- if (formDepth > 0 || !isSubmitControl(node) || hasBinding(node, 'form')) return;
83
-
84
- report(node, 'submitOutsideForm');
85
- },
86
-
87
- /** @param {any} node */
88
- 'Element:exit'(node) {
89
- if (node.name === 'form') formDepth--;
90
64
  },
91
65
  };
92
66
  },
@@ -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
@@ -87,11 +95,25 @@ const hasTrailingComma = (sourceCode, property) => {
87
95
  /**
88
96
  * @param {import('eslint').SourceCode} sourceCode
89
97
  */
90
- const findAngularCoreImport = (sourceCode) =>
91
- sourceCode.ast.body.find(
98
+ const findAngularCoreImport = (sourceCode) => {
99
+ const imports = sourceCode.ast.body.filter(
92
100
  (node) =>
93
101
  node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
94
- ) ?? null;
102
+ );
103
+
104
+ return (
105
+ imports.find((node) =>
106
+ node.specifiers.some(
107
+ (specifier) =>
108
+ specifier.type === 'ImportSpecifier' &&
109
+ specifier.imported.type === 'Identifier' &&
110
+ specifier.imported.name === 'ChangeDetectionStrategy',
111
+ ),
112
+ ) ??
113
+ imports.find((node) => node.importKind !== 'type') ??
114
+ null
115
+ );
116
+ };
95
117
 
96
118
  /**
97
119
  * @param {import('eslint').SourceCode} sourceCode
@@ -133,11 +155,18 @@ const buildMissingAngularCoreImportFix = (sourceCode) => {
133
155
  const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
134
156
  const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
135
157
 
136
- return lastImport
137
- ? (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ChangeDetectionStrategy } from '@angular/core';`)
158
+ if (lastImport) {
159
+ return (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ChangeDetectionStrategy } from '@angular/core';`);
160
+ }
161
+
162
+ const firstNode = sourceCode.ast.body[0];
163
+ const lastComment = firstNode ? sourceCode.getCommentsBefore(firstNode).at(-1) : null;
164
+
165
+ return lastComment
166
+ ? (fixer) => fixer.insertTextAfter(lastComment, `\nimport { ChangeDetectionStrategy } from '@angular/core';\n`)
138
167
  : (fixer) =>
139
168
  fixer.replaceTextRange(
140
- [0, sourceCode.ast.range[0]],
169
+ [0, firstNode?.range[0] ?? 0],
141
170
  `import { ChangeDetectionStrategy } from '@angular/core';\n\n`,
142
171
  );
143
172
  };
@@ -146,6 +175,8 @@ const buildMissingAngularCoreImportFix = (sourceCode) => {
146
175
  * @param {any} metadata
147
176
  */
148
177
  const getChangeDetectionInsertionTarget = (metadata) => {
178
+ if (metadata.properties.some((property) => property.type === 'SpreadElement')) return null;
179
+
149
180
  const changeDetectionIndex = COMPONENT_ORDER.indexOf('changeDetection');
150
181
 
151
182
  return (
@@ -164,8 +195,8 @@ const getChangeDetectionInsertionTarget = (metadata) => {
164
195
  * @param {any} metadata
165
196
  */
166
197
  const buildMetadataFix = (sourceCode, metadata) => {
167
- const changeDetectionText = 'changeDetection: ChangeDetectionStrategy.OnPush';
168
- const properties = metadata.properties.filter((property) => property.type === 'Property');
198
+ const changeDetectionText = `changeDetection: ${getImportLocalName(sourceCode, ANGULAR_CORE, 'ChangeDetectionStrategy') ?? 'ChangeDetectionStrategy'}.OnPush`;
199
+ const properties = metadata.properties;
169
200
  const insertionTarget = getChangeDetectionInsertionTarget(metadata);
170
201
  const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
171
202
 
@@ -213,6 +244,10 @@ const buildMetadataFix = (sourceCode, metadata) => {
213
244
  * @param {any} valueNode
214
245
  */
215
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
+
216
251
  const fixes = [];
217
252
  const angularCoreImport = findAngularCoreImport(sourceCode);
218
253
  const importFix = angularCoreImport
@@ -221,12 +256,8 @@ const buildFix = (sourceCode, metadata, valueNode) => {
221
256
 
222
257
  if (importFix) fixes.push(importFix);
223
258
 
224
- if (valueNode) {
225
- fixes.push((fixer) => fixer.replaceText(valueNode, 'ChangeDetectionStrategy.OnPush'));
226
- } else {
227
- const metadataFix = buildMetadataFix(sourceCode, metadata);
228
- if (metadataFix) fixes.push(metadataFix);
229
- }
259
+ const metadataFix = valueFix ?? buildMetadataFix(sourceCode, metadata);
260
+ if (metadataFix) fixes.push(metadataFix);
230
261
 
231
262
  return (fixer) => fixes.map((applyFix) => applyFix(fixer));
232
263
  };
@@ -255,24 +286,9 @@ const requireOnPushChangeDetection = {
255
286
 
256
287
  const sourceCode = context.sourceCode;
257
288
 
258
- /**
259
- * @param {import('eslint').Rule.Node} node
260
- */
261
- const isComponentDecorator = (node) => {
262
- const decorator = /** @type {any} */ (node);
263
- if (decorator.type !== 'Decorator') return false;
264
-
265
- const expression = decorator.expression;
266
- if (expression.type === 'CallExpression') {
267
- return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
268
- }
269
-
270
- return expression.type === 'Identifier' && expression.name === 'Component';
271
- };
272
-
273
289
  return {
274
290
  Decorator(node) {
275
- if (!isComponentDecorator(node)) return;
291
+ if (getAngularDecoratorName(sourceCode, node) !== 'Component') return;
276
292
 
277
293
  const decorator = /** @type {any} */ (node);
278
294
  const expression = decorator.expression;
@@ -282,10 +298,7 @@ const requireOnPushChangeDetection = {
282
298
  if (!metadata || metadata.type !== 'ObjectExpression') return;
283
299
 
284
300
  const changeDetectionProp = metadata.properties.find(
285
- (property) =>
286
- property.type === 'Property' &&
287
- property.key.type === 'Identifier' &&
288
- property.key.name === 'changeDetection',
301
+ (property) => property.type === 'Property' && getPropertyName(property.key) === 'changeDetection',
289
302
  );
290
303
 
291
304
  if (!changeDetectionProp) {
@@ -300,8 +313,7 @@ const requireOnPushChangeDetection = {
300
313
  const value = changeDetectionProp.value;
301
314
  if (
302
315
  value.type === 'MemberExpression' &&
303
- value.object.type === 'Identifier' &&
304
- value.object.name === 'ChangeDetectionStrategy' &&
316
+ isImportedAs(sourceCode, value.object, 'ChangeDetectionStrategy') &&
305
317
  value.property.type === 'Identifier' &&
306
318
  value.property.name !== 'OnPush'
307
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
  /**
@@ -44,11 +52,25 @@ const hasTrailingComma = (sourceCode, property) => {
44
52
  /**
45
53
  * @param {import('eslint').SourceCode} sourceCode
46
54
  */
47
- const findAngularCoreImport = (sourceCode) =>
48
- sourceCode.ast.body.find(
55
+ const findAngularCoreImport = (sourceCode) => {
56
+ const imports = sourceCode.ast.body.filter(
49
57
  (node) =>
50
58
  node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
51
- ) ?? null;
59
+ );
60
+
61
+ return (
62
+ imports.find((node) =>
63
+ node.specifiers.some(
64
+ (specifier) =>
65
+ specifier.type === 'ImportSpecifier' &&
66
+ specifier.imported.type === 'Identifier' &&
67
+ specifier.imported.name === 'ViewEncapsulation',
68
+ ),
69
+ ) ??
70
+ imports.find((node) => node.importKind !== 'type') ??
71
+ null
72
+ );
73
+ };
52
74
 
53
75
  /**
54
76
  * @param {import('eslint').SourceCode} sourceCode
@@ -90,16 +112,25 @@ const buildMissingAngularCoreImportFix = (sourceCode) => {
90
112
  const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
91
113
  const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
92
114
 
93
- return lastImport
94
- ? (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ViewEncapsulation } from '@angular/core';`)
115
+ if (lastImport) {
116
+ return (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ViewEncapsulation } from '@angular/core';`);
117
+ }
118
+
119
+ const firstNode = sourceCode.ast.body[0];
120
+ const lastComment = firstNode ? sourceCode.getCommentsBefore(firstNode).at(-1) : null;
121
+
122
+ return lastComment
123
+ ? (fixer) => fixer.insertTextAfter(lastComment, `\nimport { ViewEncapsulation } from '@angular/core';\n`)
95
124
  : (fixer) =>
96
- fixer.replaceTextRange([0, sourceCode.ast.range[0]], `import { ViewEncapsulation } from '@angular/core';\n\n`);
125
+ fixer.replaceTextRange([0, firstNode?.range[0] ?? 0], `import { ViewEncapsulation } from '@angular/core';\n\n`);
97
126
  };
98
127
 
99
128
  /**
100
129
  * @param {any} metadata
101
130
  */
102
131
  const getEncapsulationInsertionTarget = (metadata) => {
132
+ if (metadata.properties.some((property) => property.type === 'SpreadElement')) return null;
133
+
103
134
  const encapsulationIndex = COMPONENT_ORDER.indexOf('encapsulation');
104
135
 
105
136
  return (
@@ -118,8 +149,8 @@ const getEncapsulationInsertionTarget = (metadata) => {
118
149
  * @param {any} metadata
119
150
  */
120
151
  const buildMetadataFix = (sourceCode, metadata) => {
121
- const encText = 'encapsulation: ViewEncapsulation.None';
122
- const properties = metadata.properties.filter((property) => property.type === 'Property');
152
+ const encText = `encapsulation: ${getImportLocalName(sourceCode, ANGULAR_CORE, 'ViewEncapsulation') ?? 'ViewEncapsulation'}.None`;
153
+ const properties = metadata.properties;
123
154
  const insertionTarget = getEncapsulationInsertionTarget(metadata);
124
155
  const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
125
156
 
@@ -166,6 +197,10 @@ const buildMetadataFix = (sourceCode, metadata) => {
166
197
  * @param {any} valueNode
167
198
  */
168
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
+
169
204
  const fixes = [];
170
205
  const angularCoreImport = findAngularCoreImport(sourceCode);
171
206
  const importFix = angularCoreImport
@@ -174,12 +209,8 @@ const buildFix = (sourceCode, metadata, valueNode) => {
174
209
 
175
210
  if (importFix) fixes.push(importFix);
176
211
 
177
- if (valueNode) {
178
- fixes.push((fixer) => fixer.replaceText(valueNode, 'ViewEncapsulation.None'));
179
- } else {
180
- const metadataFix = buildMetadataFix(sourceCode, metadata);
181
- if (metadataFix) fixes.push(metadataFix);
182
- }
212
+ const metadataFix = valueFix ?? buildMetadataFix(sourceCode, metadata);
213
+ if (metadataFix) fixes.push(metadataFix);
183
214
 
184
215
  return (fixer) => fixes.map((applyFix) => applyFix(fixer));
185
216
  };
@@ -201,24 +232,9 @@ const requireViewEncapsulationNone = {
201
232
  create(context) {
202
233
  const sourceCode = context.sourceCode;
203
234
 
204
- /**
205
- * @param {import('eslint').Rule.Node} node
206
- */
207
- const isComponentDecorator = (node) => {
208
- const decorator = /** @type {any} */ (node);
209
- if (decorator.type !== 'Decorator') return false;
210
-
211
- const expression = decorator.expression;
212
- if (expression.type === 'CallExpression') {
213
- return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
214
- }
215
-
216
- return expression.type === 'Identifier' && expression.name === 'Component';
217
- };
218
-
219
235
  return {
220
236
  Decorator(node) {
221
- if (!isComponentDecorator(node)) return;
237
+ if (getAngularDecoratorName(sourceCode, node) !== 'Component') return;
222
238
 
223
239
  const decorator = /** @type {any} */ (node);
224
240
  const expression = decorator.expression;
@@ -228,8 +244,7 @@ const requireViewEncapsulationNone = {
228
244
  if (!metadata || metadata.type !== 'ObjectExpression') return;
229
245
 
230
246
  const encProp = metadata.properties.find(
231
- (property) =>
232
- property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === 'encapsulation',
247
+ (property) => property.type === 'Property' && getPropertyName(property.key) === 'encapsulation',
233
248
  );
234
249
 
235
250
  if (!encProp) {
@@ -244,8 +259,7 @@ const requireViewEncapsulationNone = {
244
259
  const value = encProp.value;
245
260
  if (
246
261
  value.type === 'MemberExpression' &&
247
- value.object.type === 'Identifier' &&
248
- value.object.name === 'ViewEncapsulation' &&
262
+ isImportedAs(sourceCode, value.object, 'ViewEncapsulation') &&
249
263
  value.property.type === 'Identifier' &&
250
264
  value.property.name !== 'None'
251
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;