@ethlete/eslint-plugin 1.0.0-next.23 → 1.0.0-next.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/migrations.json +12 -0
- package/package.json +13 -3
- package/src/configs/recommended.js +33 -11
- package/src/generators/generators.json +10 -0
- package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
- package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
- package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
- package/src/index.js +10 -2
- 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/consistent-type-definitions.js +60 -0
- 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-csp-unsafe.js +362 -0
- 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 +42 -41
- 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,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { isImportedAs } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
7
|
const noTypedInjectedElementRef = {
|
|
6
8
|
meta: {
|
|
@@ -20,30 +22,25 @@ const noTypedInjectedElementRef = {
|
|
|
20
22
|
create(context) {
|
|
21
23
|
return {
|
|
22
24
|
CallExpression(node) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
25
|
+
const src = context.sourceCode;
|
|
26
|
+
if (!isImportedAs(src, node.callee, 'inject')) return;
|
|
26
27
|
|
|
27
28
|
const firstArg = node.arguments[0];
|
|
28
29
|
if (!firstArg) return;
|
|
29
30
|
|
|
30
|
-
const isPlainElementRef =
|
|
31
|
+
const isPlainElementRef = isImportedAs(src, firstArg, 'ElementRef');
|
|
31
32
|
const isInstantiatedElementRef =
|
|
32
|
-
firstArg.type === 'TSInstantiationExpression' &&
|
|
33
|
-
firstArg.expression &&
|
|
34
|
-
firstArg.expression.type === 'Identifier' &&
|
|
35
|
-
firstArg.expression.name === 'ElementRef';
|
|
33
|
+
firstArg.type === 'TSInstantiationExpression' && isImportedAs(src, firstArg.expression, 'ElementRef');
|
|
36
34
|
|
|
37
35
|
if (!isPlainElementRef && !isInstantiatedElementRef) return;
|
|
38
36
|
|
|
39
|
-
const
|
|
37
|
+
const elementRef = src.getText(isPlainElementRef ? firstArg : firstArg.expression);
|
|
40
38
|
const existingTypeParams = node.typeParameters || node.typeArguments;
|
|
41
39
|
const injectType = existingTypeParams?.params?.[0];
|
|
42
40
|
const hasTypedElementRef =
|
|
43
41
|
existingTypeParams?.params?.length === 1 &&
|
|
44
42
|
injectType?.type === 'TSTypeReference' &&
|
|
45
|
-
injectType.typeName
|
|
46
|
-
injectType.typeName.name === 'ElementRef' &&
|
|
43
|
+
src.getText(injectType.typeName) === elementRef &&
|
|
47
44
|
(injectType.typeArguments || injectType.typeParameters)?.params?.length > 0;
|
|
48
45
|
|
|
49
46
|
if (isPlainElementRef && !hasTypedElementRef) {
|
|
@@ -53,8 +50,8 @@ const noTypedInjectedElementRef = {
|
|
|
53
50
|
messageId: 'missingGeneric',
|
|
54
51
|
fix(fixer) {
|
|
55
52
|
return existingTypeParams
|
|
56
|
-
? fixer.replaceText(existingTypeParams,
|
|
57
|
-
: fixer.insertTextAfter(node.callee,
|
|
53
|
+
? fixer.replaceText(existingTypeParams, `<${elementRef}<HTMLElement>>`)
|
|
54
|
+
: fixer.insertTextAfter(node.callee, `<${elementRef}<HTMLElement>>`);
|
|
58
55
|
},
|
|
59
56
|
});
|
|
60
57
|
return;
|
|
@@ -71,9 +68,9 @@ const noTypedInjectedElementRef = {
|
|
|
71
68
|
messageId: 'missingGeneric',
|
|
72
69
|
fix(fixer) {
|
|
73
70
|
const typeFix = existingTypeParams
|
|
74
|
-
? fixer.replaceText(existingTypeParams,
|
|
75
|
-
: fixer.insertTextAfter(node.callee,
|
|
76
|
-
return [typeFix, fixer.replaceText(firstArg,
|
|
71
|
+
? fixer.replaceText(existingTypeParams, `<${elementRef}${tokenTypeParamText}>`)
|
|
72
|
+
: fixer.insertTextAfter(node.callee, `<${elementRef}${tokenTypeParamText}>`);
|
|
73
|
+
return [typeFix, fixer.replaceText(firstArg, elementRef)];
|
|
77
74
|
},
|
|
78
75
|
});
|
|
79
76
|
} else {
|
|
@@ -82,7 +79,7 @@ const noTypedInjectedElementRef = {
|
|
|
82
79
|
node,
|
|
83
80
|
messageId: 'missingGeneric',
|
|
84
81
|
fix(fixer) {
|
|
85
|
-
return fixer.replaceText(firstArg,
|
|
82
|
+
return fixer.replaceText(firstArg, elementRef);
|
|
86
83
|
},
|
|
87
84
|
});
|
|
88
85
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Disallows class members that are declared but never read within the class body.
|
|
6
8
|
*
|
|
@@ -42,22 +44,17 @@ const extractIdentifiers = (str) => str.match(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g) ?
|
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Returns info about the Angular decorators applied to a class node.
|
|
47
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
45
48
|
* @param {any} classNode
|
|
46
49
|
* @returns {{ isComponent: boolean, isAngularClass: boolean }}
|
|
47
50
|
*/
|
|
48
|
-
const getDecoratorInfo = (classNode) => {
|
|
51
|
+
const getDecoratorInfo = (sourceCode, classNode) => {
|
|
49
52
|
const decorators = classNode.decorators ?? [];
|
|
50
53
|
let isComponent = false;
|
|
51
54
|
let isAngularClass = false;
|
|
52
55
|
|
|
53
56
|
for (const dec of decorators) {
|
|
54
|
-
const
|
|
55
|
-
const name =
|
|
56
|
-
expr?.type === 'CallExpression' && expr.callee?.type === 'Identifier'
|
|
57
|
-
? expr.callee.name
|
|
58
|
-
: expr?.type === 'Identifier'
|
|
59
|
-
? expr.name
|
|
60
|
-
: null;
|
|
57
|
+
const name = getAngularDecoratorName(sourceCode, dec);
|
|
61
58
|
|
|
62
59
|
if (name && ANGULAR_DECORATORS.has(name)) {
|
|
63
60
|
isAngularClass = true;
|
|
@@ -167,7 +164,7 @@ const noUnusedClassMember = {
|
|
|
167
164
|
return {
|
|
168
165
|
ClassBody(node) {
|
|
169
166
|
const classNode = node.parent;
|
|
170
|
-
const { isComponent, isAngularClass } = getDecoratorInfo(classNode);
|
|
167
|
+
const { isComponent, isAngularClass } = getDecoratorInfo(context.sourceCode, classNode);
|
|
171
168
|
const hostBound = getHostBoundMembers(classNode);
|
|
172
169
|
classStack.push({
|
|
173
170
|
classBody: node,
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* import { clone, equal } from '@ethlete/core';
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
const { MODULE_REFERENCE_SELECTOR, getModuleReference, isGlobalReference } = require('./internals/import-resolution');
|
|
29
|
+
|
|
28
30
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
29
31
|
const preferCloneEqual = {
|
|
30
32
|
meta: {
|
|
@@ -70,21 +72,21 @@ const preferCloneEqual = {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// ── structuredClone(expr) ─────────────────────────────────────────────
|
|
73
|
-
if (
|
|
75
|
+
if (isGlobalReference(context.sourceCode, callee) && callee.name === 'structuredClone') {
|
|
74
76
|
context.report({ node, messageId: 'preferClone', data: { method: 'structuredClone()' } });
|
|
75
77
|
}
|
|
76
78
|
},
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
+
[MODULE_REFERENCE_SELECTOR](node) {
|
|
81
|
+
const reference = getModuleReference(node);
|
|
82
|
+
if (!reference) return;
|
|
83
|
+
const src = reference.source;
|
|
80
84
|
|
|
81
85
|
// ── lodash cloneDeep / isEqual ────────────────────────────────────────
|
|
82
86
|
// import { cloneDeep } from 'lodash' / 'lodash-es'
|
|
83
87
|
// import { isEqual } from 'lodash' / 'lodash-es'
|
|
84
88
|
if (src === 'lodash' || src === 'lodash-es') {
|
|
85
|
-
for (const
|
|
86
|
-
if (specifier.type !== 'ImportSpecifier') continue;
|
|
87
|
-
const name = specifier.imported.name;
|
|
89
|
+
for (const { name } of reference.named) {
|
|
88
90
|
if (name === 'cloneDeep') {
|
|
89
91
|
context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
|
|
90
92
|
} else if (name === 'isEqual') {
|
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/** @typedef {'Component' | 'Directive'} TDecoratorName */
|
|
5
7
|
/** @typedef {import('estree').Property & { range: [number, number] }} TPropertyNode */
|
|
6
8
|
/** @typedef {import('estree').ArrayExpression & { elements: Array<import('estree').Expression | import('estree').SpreadElement | null> }} TArrayExpressionNode */
|
|
7
9
|
/** @typedef {import('estree').ObjectExpression & { properties: Array<TPropertyNode | import('estree').SpreadElement> }} TObjectExpressionNode */
|
|
8
|
-
/** @typedef {{ hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode;
|
|
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
|
*
|
|
@@ -22,31 +24,48 @@
|
|
|
22
24
|
* GOOD:
|
|
23
25
|
* selectedItem = linkedSignal(() => this.items()[0] ?? null);
|
|
24
26
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
+
* Also flagged when the write sits behind an `if`, as long as every branch only
|
|
28
|
+
* writes signals: `linkedSignal` keeps the previous value for the untaken branch.
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
32
|
+
* @param {any} node
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
const isSetCall = (node) =>
|
|
36
|
+
node.type === 'CallExpression' &&
|
|
37
|
+
node.callee.type === 'MemberExpression' &&
|
|
38
|
+
node.callee.property.type === 'Identifier' &&
|
|
39
|
+
node.callee.property.name === 'set' &&
|
|
40
|
+
node.arguments.length === 1;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A statement that does nothing but write signals: a lone `.set()`, a block holding one, or an
|
|
44
|
+
* `if`/`else` whose every branch is one.
|
|
42
45
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
+
* @param {any} statement
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
const isSetOnlyStatement = (statement) => {
|
|
50
|
+
if (!statement) return false;
|
|
51
|
+
if (statement.type === 'ExpressionStatement') return isSetCall(statement.expression);
|
|
52
|
+
if (statement.type === 'BlockStatement') return statement.body.length === 1 && isSetOnlyStatement(statement.body[0]);
|
|
53
|
+
if (statement.type === 'IfStatement') {
|
|
54
|
+
return (
|
|
55
|
+
isSetOnlyStatement(statement.consequent) && (!statement.alternate || isSetOnlyStatement(statement.alternate))
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True when the `.set()` call sits directly in an `effect()` callback (not in a nested function such as
|
|
63
|
+
* `untracked(() => …)`) and that callback does nothing but write signals, optionally behind an `if`.
|
|
46
64
|
*
|
|
65
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
47
66
|
* @param {import('eslint').Rule.Node} node The `.set()` CallExpression node
|
|
48
67
|
*/
|
|
49
|
-
const isPureSetInDirectEffectCallback = (node) => {
|
|
68
|
+
const isPureSetInDirectEffectCallback = (sourceCode, node) => {
|
|
50
69
|
// Find the immediate enclosing function (cross exactly one boundary).
|
|
51
70
|
let current = node.parent;
|
|
52
71
|
let immediateFunction = null;
|
|
@@ -66,27 +85,16 @@ const isPureSetInDirectEffectCallback = (node) => {
|
|
|
66
85
|
if (
|
|
67
86
|
!funcParent ||
|
|
68
87
|
funcParent.type !== 'CallExpression' ||
|
|
69
|
-
funcParent.callee
|
|
70
|
-
funcParent.callee.name !== 'effect' ||
|
|
88
|
+
!isImportedAs(sourceCode, funcParent.callee, 'effect') ||
|
|
71
89
|
funcParent.arguments[0] !== immediateFunction
|
|
72
90
|
) {
|
|
73
91
|
return false;
|
|
74
92
|
}
|
|
75
93
|
|
|
76
|
-
// The .set() must be the sole expression — no surrounding statements.
|
|
77
|
-
// Case A: arrow with expression body — the body IS the .set() call.
|
|
78
|
-
if (immediateFunction.type === 'ArrowFunctionExpression' && immediateFunction.body === node) {
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Case B: block body with exactly one ExpressionStatement wrapping the .set() call.
|
|
83
94
|
const body = immediateFunction.body;
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
body.body[0].type === 'ExpressionStatement' &&
|
|
88
|
-
body.body[0].expression === node
|
|
89
|
-
);
|
|
95
|
+
if (body.type !== 'BlockStatement') return body === node;
|
|
96
|
+
|
|
97
|
+
return node.parent.type === 'ExpressionStatement' && body.body.length === 1 && isSetOnlyStatement(body.body[0]);
|
|
90
98
|
};
|
|
91
99
|
|
|
92
100
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
@@ -106,14 +114,7 @@ const preferLinkedSignal = {
|
|
|
106
114
|
create(context) {
|
|
107
115
|
return {
|
|
108
116
|
CallExpression(node) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
callee.type === 'MemberExpression' &&
|
|
113
|
-
callee.property.type === 'Identifier' &&
|
|
114
|
-
callee.property.name === 'set' &&
|
|
115
|
-
isPureSetInDirectEffectCallback(node)
|
|
116
|
-
) {
|
|
117
|
+
if (isSetCall(node) && isPureSetInDirectEffectCallback(context.sourceCode, node)) {
|
|
117
118
|
context.report({ node, messageId: 'preferLinkedSignal' });
|
|
118
119
|
}
|
|
119
120
|
},
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
* // … etc. All from @ethlete/core.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
const { MODULE_REFERENCE_SELECTOR, getModuleReference, isImportedAs } = require('./internals/import-resolution');
|
|
41
|
+
|
|
42
|
+
const CDK_LAYOUT = '@angular/cdk/layout';
|
|
43
|
+
|
|
40
44
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
41
45
|
const preferMatchMedia = {
|
|
42
46
|
meta: {
|
|
@@ -72,25 +76,17 @@ const preferMatchMedia = {
|
|
|
72
76
|
|
|
73
77
|
// ── inject(BreakpointObserver) ────────────────────────────────────────
|
|
74
78
|
if (
|
|
75
|
-
|
|
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({
|