@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.
- package/CHANGELOG.md +23 -0
- package/package.json +4 -5
- package/src/configs/recommended.js +23 -10
- package/src/index.js +6 -1
- package/src/rules/angular-decorator-property-order.js +20 -20
- package/src/rules/class-constant-property.js +35 -14
- package/src/rules/class-member-order.js +77 -46
- package/src/rules/guard-return-newline.js +5 -4
- package/src/rules/inject-member-accessibility.js +22 -24
- package/src/rules/internals/angular-io.js +31 -14
- package/src/rules/internals/angular-member-visibility.js +16 -12
- package/src/rules/internals/angular-metadata-fix.js +22 -0
- package/src/rules/internals/import-resolution.js +191 -0
- package/src/rules/internals/member-accessibility-fix.js +26 -0
- package/src/rules/no-angular-router-api.js +23 -29
- package/src/rules/no-angular-seo-services.js +21 -16
- package/src/rules/no-cdk-import.js +7 -14
- package/src/rules/no-direct-dom-manipulation.js +17 -0
- package/src/rules/no-document-cookie.js +3 -0
- package/src/rules/no-effect-cleanup-return.js +15 -9
- package/src/rules/no-empty-angular-metadata-arrays.js +6 -36
- package/src/rules/no-enum.js +115 -0
- package/src/rules/no-inject-chain.js +3 -1
- package/src/rules/no-leading-underscore-class-member.js +33 -3
- package/src/rules/no-legacy-angular-decorators.js +17 -21
- package/src/rules/no-legacy-prepare-without-injector.js +42 -26
- package/src/rules/no-legacy-query-import.js +7 -16
- package/src/rules/no-locale-id.js +6 -18
- package/src/rules/no-member-alias.js +21 -0
- package/src/rules/no-native-html-input-name.js +1 -1
- package/src/rules/no-pipe-logic.js +11 -17
- package/src/rules/no-readonly-signal.js +13 -5
- package/src/rules/no-redundant-internal.js +21 -2
- package/src/rules/no-redundant-on-push-change-detection.js +32 -56
- package/src/rules/no-rxjs-in-effect.js +12 -8
- package/src/rules/no-screaming-case-local.js +1 -0
- package/src/rules/no-standalone-flag.js +7 -36
- package/src/rules/no-subscribe-in-pipe.js +3 -2
- package/src/rules/no-template-literal-before-inline-template.js +3 -14
- package/src/rules/no-trivial-return-type.js +2 -0
- package/src/rules/no-trivial-wrapper-method.js +13 -16
- package/src/rules/no-type-only-import.js +7 -2
- package/src/rules/no-typed-injected-element-ref.js +23 -19
- package/src/rules/no-unused-class-member.js +34 -14
- package/src/rules/prefer-clone-equal.js +8 -6
- package/src/rules/prefer-concise-angular-host-directives.js +23 -29
- package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
- package/src/rules/prefer-element-dimensions.js +9 -10
- package/src/rules/prefer-linked-signal.js +7 -4
- package/src/rules/prefer-match-media.js +12 -16
- package/src/rules/prefer-present-tense-output.js +1 -1
- package/src/rules/prefer-rxjs-timer.js +3 -1
- package/src/rules/prefer-scroll-state.js +3 -67
- package/src/rules/prefer-static-boolean-properties.js +30 -0
- package/src/rules/prefer-viewport-size.js +1 -7
- package/src/rules/require-dollar-suffix.js +7 -4
- package/src/rules/require-form-submit.js +2 -28
- package/src/rules/require-on-push-change-detection.js +48 -36
- package/src/rules/require-view-encapsulation-none.js +48 -34
- package/src/rules/take-until-destroyed-last.js +64 -0
- 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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
)
|
|
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
|
-
|
|
137
|
-
|
|
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,
|
|
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 =
|
|
168
|
-
const properties = metadata.properties
|
|
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
|
-
|
|
225
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
)
|
|
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
|
-
|
|
94
|
-
|
|
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,
|
|
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 =
|
|
122
|
-
const properties = metadata.properties
|
|
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
|
-
|
|
178
|
-
|
|
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 (
|
|
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
|
|
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;
|