@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.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -2
- package/src/configs/recommended.js +3 -0
- package/src/index.js +2 -0
- package/src/rules/angular-decorator-property-order.js +20 -20
- package/src/rules/class-constant-property.js +8 -14
- package/src/rules/class-member-order.js +77 -46
- package/src/rules/inject-member-accessibility.js +5 -3
- package/src/rules/internals/angular-io.js +12 -13
- package/src/rules/internals/angular-member-visibility.js +11 -7
- package/src/rules/internals/import-resolution.js +191 -0
- package/src/rules/no-angular-router-api.js +23 -29
- package/src/rules/no-angular-seo-services.js +14 -9
- package/src/rules/no-cdk-import.js +7 -14
- package/src/rules/no-document-cookie.js +3 -0
- package/src/rules/no-effect-cleanup-return.js +7 -3
- package/src/rules/no-empty-angular-metadata-arrays.js +3 -17
- package/src/rules/no-inject-chain.js +3 -1
- package/src/rules/no-leading-underscore-class-member.js +26 -3
- package/src/rules/no-legacy-angular-decorators.js +9 -21
- package/src/rules/no-legacy-query-import.js +6 -15
- package/src/rules/no-locale-id.js +4 -4
- package/src/rules/no-member-alias.js +11 -7
- package/src/rules/no-native-html-input-name.js +1 -1
- package/src/rules/no-pipe-logic.js +10 -17
- package/src/rules/no-redundant-on-push-change-detection.js +9 -23
- 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 +3 -17
- 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 +1 -0
- package/src/rules/no-trivial-wrapper-method.js +10 -16
- package/src/rules/no-typed-injected-element-ref.js +14 -17
- package/src/rules/no-unused-class-member.js +6 -9
- 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 +10 -14
- package/src/rules/prefer-present-tense-output.js +1 -1
- package/src/rules/prefer-rxjs-timer.js +3 -1
- package/src/rules/require-dollar-suffix.js +7 -4
- package/src/rules/require-on-push-change-detection.js +17 -25
- package/src/rules/require-view-encapsulation-none.js +17 -25
- package/src/rules/take-until-destroyed-last.js +64 -0
- 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;
|
|
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
|
|
102
|
-
const
|
|
103
|
-
const
|
|
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
|
-
|
|
106
|
-
segmentStart = tokenAfter.range[1];
|
|
107
|
-
} else {
|
|
108
|
-
segmentStart = property.range[1];
|
|
109
|
-
}
|
|
97
|
+
segmentStart = trailingEnd;
|
|
110
98
|
|
|
111
99
|
return {
|
|
112
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
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
|
},
|
|
@@ -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 (
|
|
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
|
|
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({
|
|
@@ -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 =
|
|
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
|
-
|
|
248
|
-
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
201
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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;
|