@ethlete/eslint-plugin 1.0.0-next.25 → 1.0.0-next.26
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 +18 -0
- package/package.json +2 -1
- package/src/configs/recommended.js +7 -16
- package/src/rules/angular-decorator-property-order.js +24 -7
- package/src/rules/class-constant-property.js +1 -1
- package/src/rules/class-member-order.js +60 -8
- package/src/rules/enforce-routing-view-naming.js +34 -23
- package/src/rules/guard-return-newline.js +4 -7
- package/src/rules/internals/angular-member-visibility.js +24 -2
- package/src/rules/internals/angular-metadata-fix.js +231 -1
- package/src/rules/internals/angular-version.js +50 -0
- package/src/rules/internals/implemented-contract-members.js +26 -11
- package/src/rules/no-angular-router-api.js +86 -33
- package/src/rules/no-angular-seo-services.js +1 -0
- package/src/rules/no-async-await.js +5 -1
- package/src/rules/no-cdk-import.js +32 -7
- package/src/rules/no-direct-dom-manipulation.js +50 -28
- package/src/rules/no-document-cookie.js +51 -8
- package/src/rules/no-dom-query.js +5 -2
- package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
- package/src/rules/no-empty-newlines-between-imports.js +4 -1
- package/src/rules/no-impure-top-level-provider.js +42 -7
- package/src/rules/no-leading-underscore-class-member.js +27 -2
- package/src/rules/no-legacy-angular-decorators.js +11 -6
- package/src/rules/no-legacy-prepare-without-injector.js +57 -12
- package/src/rules/no-legacy-query-import.js +61 -16
- package/src/rules/no-member-alias.js +0 -5
- package/src/rules/no-native-observers.js +89 -24
- package/src/rules/no-pipe-logic.js +1 -2
- package/src/rules/no-readonly-signal.js +1 -5
- package/src/rules/no-redundant-internal.js +5 -2
- package/src/rules/no-redundant-on-push-change-detection.js +3 -48
- package/src/rules/no-rxjs-in-effect.js +7 -5
- package/src/rules/no-screaming-case-local.js +3 -5
- package/src/rules/no-standalone-flag.js +1 -0
- package/src/rules/no-subscribe-with-body.js +30 -1
- package/src/rules/no-template-literal-before-inline-template.js +6 -0
- package/src/rules/no-trivial-return-type.js +18 -1
- package/src/rules/no-unused-class-member.js +1 -1
- package/src/rules/no-window-location.js +60 -22
- package/src/rules/prefer-clone-equal.js +61 -8
- package/src/rules/prefer-concise-angular-host-directives.js +39 -11
- package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
- package/src/rules/prefer-element-dimensions.js +264 -11
- package/src/rules/prefer-match-media.js +17 -3
- package/src/rules/prefer-rxjs-timer.js +26 -4
- package/src/rules/prefer-scroll-state.js +0 -4
- package/src/rules/prefer-static-boolean-properties.js +1 -0
- package/src/rules/prefer-viewport-size.js +153 -16
- package/src/rules/require-dollar-suffix.js +11 -3
- package/src/rules/require-form-submit.js +4 -1
- package/src/rules/require-on-push-change-detection.js +7 -259
- package/src/rules/require-view-encapsulation-none.js +6 -214
- package/src/rules/template-member-accessibility.js +12 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { isGlobalReference } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Disallows reading URL state from window.location properties.
|
|
6
8
|
*
|
|
@@ -52,29 +54,55 @@ const noWindowLocation = {
|
|
|
52
54
|
schema: [],
|
|
53
55
|
},
|
|
54
56
|
create(context) {
|
|
57
|
+
const isGlobalObject = (node) =>
|
|
58
|
+
node.type === 'Identifier' &&
|
|
59
|
+
(node.name === 'window' || node.name === 'globalThis') &&
|
|
60
|
+
isGlobalReference(context.sourceCode, node);
|
|
61
|
+
|
|
62
|
+
const findVariable = (identifier) => {
|
|
63
|
+
for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
64
|
+
const variable = scope.set.get(identifier.name);
|
|
65
|
+
if (variable) return variable;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isLocation = (node) => {
|
|
72
|
+
if (node.type === 'Identifier') {
|
|
73
|
+
if (node.name === 'location' && isGlobalReference(context.sourceCode, node)) return true;
|
|
74
|
+
|
|
75
|
+
const definitions = findVariable(node)?.defs ?? [];
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
definitions.length === 1 &&
|
|
79
|
+
definitions[0].type === 'Variable' &&
|
|
80
|
+
definitions[0].node.id.type === 'Identifier' &&
|
|
81
|
+
definitions[0].parent.kind === 'const' &&
|
|
82
|
+
!!definitions[0].node.init &&
|
|
83
|
+
isLocation(definitions[0].node.init)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
node.type === 'MemberExpression' &&
|
|
89
|
+
!node.computed &&
|
|
90
|
+
isGlobalObject(node.object) &&
|
|
91
|
+
node.property.type === 'Identifier' &&
|
|
92
|
+
node.property.name === 'location'
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
55
96
|
return {
|
|
56
|
-
// ── window.location.{prop} reads ────────────────────────────────────────
|
|
57
97
|
MemberExpression(node) {
|
|
58
|
-
if (node.property.type !== 'Identifier') return;
|
|
98
|
+
if (node.property.type !== 'Identifier' || node.computed) return;
|
|
59
99
|
const prop = node.property.name;
|
|
60
100
|
|
|
61
101
|
if (!LOCATION_STATE_PROPS.has(prop)) return;
|
|
102
|
+
if (!isLocation(node.object)) return;
|
|
62
103
|
|
|
63
|
-
// Must be window.location.{prop} — check the object is window.location
|
|
64
|
-
const parent = node.object;
|
|
65
|
-
if (
|
|
66
|
-
parent.type !== 'MemberExpression' ||
|
|
67
|
-
parent.object.type !== 'Identifier' ||
|
|
68
|
-
parent.object.name !== 'window' ||
|
|
69
|
-
parent.property.type !== 'Identifier' ||
|
|
70
|
-
parent.property.name !== 'location'
|
|
71
|
-
) {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Skip assignments TO window.location.href (navigation redirect)
|
|
76
104
|
if (prop === 'href' && node.parent.type === 'AssignmentExpression' && node.parent.left === node) {
|
|
77
|
-
return;
|
|
105
|
+
if (node.parent.operator === '=') return;
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
context.report({
|
|
@@ -84,18 +112,28 @@ const noWindowLocation = {
|
|
|
84
112
|
});
|
|
85
113
|
},
|
|
86
114
|
|
|
87
|
-
|
|
115
|
+
VariableDeclarator(node) {
|
|
116
|
+
if (node.id.type !== 'ObjectPattern' || !node.init || !isLocation(node.init)) return;
|
|
117
|
+
|
|
118
|
+
for (const property of node.id.properties) {
|
|
119
|
+
if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier') continue;
|
|
120
|
+
if (!LOCATION_STATE_PROPS.has(property.key.name)) continue;
|
|
121
|
+
|
|
122
|
+
context.report({
|
|
123
|
+
node: property,
|
|
124
|
+
messageId: 'noWindowLocation',
|
|
125
|
+
data: { prop: property.key.name, replacement: LOCATION_STATE_PROPS.get(property.key.name) },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
88
130
|
NewExpression(node) {
|
|
89
131
|
if (node.callee.type !== 'Identifier' || node.callee.name !== 'URLSearchParams') return;
|
|
90
132
|
const arg = node.arguments[0];
|
|
91
133
|
if (!arg) return;
|
|
92
134
|
if (
|
|
93
135
|
arg.type === 'MemberExpression' &&
|
|
94
|
-
arg.object
|
|
95
|
-
arg.object.object.type === 'Identifier' &&
|
|
96
|
-
arg.object.object.name === 'window' &&
|
|
97
|
-
arg.object.property.type === 'Identifier' &&
|
|
98
|
-
arg.object.property.name === 'location' &&
|
|
136
|
+
isLocation(arg.object) &&
|
|
99
137
|
arg.property.type === 'Identifier' &&
|
|
100
138
|
arg.property.name === 'search'
|
|
101
139
|
) {
|
|
@@ -25,7 +25,20 @@
|
|
|
25
25
|
* import { clone, equal } from '@ethlete/core';
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
const {
|
|
28
|
+
const {
|
|
29
|
+
MODULE_REFERENCE_SELECTOR,
|
|
30
|
+
getModuleReference,
|
|
31
|
+
isGlobalReference,
|
|
32
|
+
resolveIdentifier,
|
|
33
|
+
} = require('./internals/import-resolution');
|
|
34
|
+
|
|
35
|
+
const LODASH_SOURCES = new Set(['lodash', 'lodash-es']);
|
|
36
|
+
|
|
37
|
+
/** @type {Record<string, { messageId: 'preferClone' | 'preferEqual'; method: string }>} */
|
|
38
|
+
const LODASH_METHODS = {
|
|
39
|
+
cloneDeep: { messageId: 'preferClone', method: 'lodash cloneDeep' },
|
|
40
|
+
isEqual: { messageId: 'preferEqual', method: 'isEqual' },
|
|
41
|
+
};
|
|
29
42
|
|
|
30
43
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
31
44
|
const preferCloneEqual = {
|
|
@@ -49,7 +62,43 @@ const preferCloneEqual = {
|
|
|
49
62
|
CallExpression(node) {
|
|
50
63
|
const { callee } = node;
|
|
51
64
|
|
|
52
|
-
|
|
65
|
+
if (
|
|
66
|
+
callee.type === 'MemberExpression' &&
|
|
67
|
+
!callee.computed &&
|
|
68
|
+
callee.object.type === 'Identifier' &&
|
|
69
|
+
callee.property.type === 'Identifier' &&
|
|
70
|
+
Object.hasOwn(LODASH_METHODS, callee.property.name)
|
|
71
|
+
) {
|
|
72
|
+
const resolved = resolveIdentifier(context.sourceCode, callee.object);
|
|
73
|
+
if (
|
|
74
|
+
resolved?.source &&
|
|
75
|
+
LODASH_SOURCES.has(resolved.source) &&
|
|
76
|
+
(resolved.name === '*' || resolved.name === 'default')
|
|
77
|
+
) {
|
|
78
|
+
const { messageId, method } = LODASH_METHODS[callee.property.name];
|
|
79
|
+
context.report({ node, messageId, data: { method } });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
isGlobalReference(context.sourceCode, callee) &&
|
|
86
|
+
callee.name === 'require' &&
|
|
87
|
+
node.arguments[0]?.type === 'Literal' &&
|
|
88
|
+
LODASH_SOURCES.has(String(node.arguments[0].value))
|
|
89
|
+
) {
|
|
90
|
+
const { parent } = node;
|
|
91
|
+
const method =
|
|
92
|
+
parent.type === 'MemberExpression' && parent.object === node && parent.property.type === 'Identifier'
|
|
93
|
+
? parent.property.name
|
|
94
|
+
: null;
|
|
95
|
+
if (method && Object.hasOwn(LODASH_METHODS, method)) {
|
|
96
|
+
const { messageId, method: label } = LODASH_METHODS[method];
|
|
97
|
+
context.report({ node, messageId, data: { method: label } });
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
53
102
|
if (
|
|
54
103
|
callee.type === 'MemberExpression' &&
|
|
55
104
|
callee.object.type === 'Identifier' &&
|
|
@@ -71,7 +120,6 @@ const preferCloneEqual = {
|
|
|
71
120
|
}
|
|
72
121
|
}
|
|
73
122
|
|
|
74
|
-
// ── structuredClone(expr) ─────────────────────────────────────────────
|
|
75
123
|
if (isGlobalReference(context.sourceCode, callee) && callee.name === 'structuredClone') {
|
|
76
124
|
context.report({ node, messageId: 'preferClone', data: { method: 'structuredClone()' } });
|
|
77
125
|
}
|
|
@@ -82,9 +130,6 @@ const preferCloneEqual = {
|
|
|
82
130
|
if (!reference) return;
|
|
83
131
|
const src = reference.source;
|
|
84
132
|
|
|
85
|
-
// ── lodash cloneDeep / isEqual ────────────────────────────────────────
|
|
86
|
-
// import { cloneDeep } from 'lodash' / 'lodash-es'
|
|
87
|
-
// import { isEqual } from 'lodash' / 'lodash-es'
|
|
88
133
|
if (src === 'lodash' || src === 'lodash-es') {
|
|
89
134
|
for (const { name } of reference.named) {
|
|
90
135
|
if (name === 'cloneDeep') {
|
|
@@ -96,13 +141,21 @@ const preferCloneEqual = {
|
|
|
96
141
|
return;
|
|
97
142
|
}
|
|
98
143
|
|
|
99
|
-
|
|
144
|
+
if (src === 'lodash.clonedeep') {
|
|
145
|
+
context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (src === 'lodash.isequal') {
|
|
150
|
+
context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
100
154
|
if (src === 'lodash/cloneDeep' || src === 'lodash-es/cloneDeep') {
|
|
101
155
|
context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
|
|
102
156
|
return;
|
|
103
157
|
}
|
|
104
158
|
|
|
105
|
-
// import isEqual from 'lodash/isEqual'
|
|
106
159
|
if (src === 'lodash/isEqual' || src === 'lodash-es/isEqual') {
|
|
107
160
|
context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
|
|
108
161
|
}
|
|
@@ -7,7 +7,7 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
|
7
7
|
/** @typedef {import('estree').Property & { range: [number, number] }} TPropertyNode */
|
|
8
8
|
/** @typedef {import('estree').ArrayExpression & { elements: Array<import('estree').Expression | import('estree').SpreadElement | null> }} TArrayExpressionNode */
|
|
9
9
|
/** @typedef {import('estree').ObjectExpression & { properties: Array<TPropertyNode | import('estree').SpreadElement> }} TObjectExpressionNode */
|
|
10
|
-
/** @typedef {{ bodyText: string; hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; trailingText: string }} TPropertyEntry */
|
|
10
|
+
/** @typedef {{ bodyText: string; endsWithLineComment: boolean; hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; trailingText: string }} TPropertyEntry */
|
|
11
11
|
|
|
12
12
|
const HOST_DIRECTIVE_PROPERTY_ORDER = ['directive', 'inputs', 'outputs'];
|
|
13
13
|
|
|
@@ -20,6 +20,24 @@ const getPropertyName = (key) => {
|
|
|
20
20
|
return null;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @param {any} node
|
|
25
|
+
*/
|
|
26
|
+
const unwrapTypeAssertions = (node) => {
|
|
27
|
+
let current = node;
|
|
28
|
+
|
|
29
|
+
while (
|
|
30
|
+
current &&
|
|
31
|
+
(current.type === 'TSAsExpression' ||
|
|
32
|
+
current.type === 'TSSatisfiesExpression' ||
|
|
33
|
+
current.type === 'TSNonNullExpression')
|
|
34
|
+
) {
|
|
35
|
+
current = current.expression;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return current;
|
|
39
|
+
};
|
|
40
|
+
|
|
23
41
|
/**
|
|
24
42
|
* @param {TDecoratorName} decoratorName
|
|
25
43
|
* @param {import('estree').Node} node
|
|
@@ -35,9 +53,10 @@ const getHostDirectivesArray = (decoratorName, node) => {
|
|
|
35
53
|
for (const property of metadata.properties) {
|
|
36
54
|
if (!property || property.type !== 'Property') continue;
|
|
37
55
|
if (getPropertyName(property.key) !== 'hostDirectives') continue;
|
|
38
|
-
|
|
56
|
+
const value = unwrapTypeAssertions(property.value);
|
|
57
|
+
if (value.type !== 'ArrayExpression') return null;
|
|
39
58
|
|
|
40
|
-
return /** @type {TArrayExpressionNode} */ (
|
|
59
|
+
return /** @type {TArrayExpressionNode} */ (value);
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
return null;
|
|
@@ -66,6 +85,11 @@ const getHostDirectiveConfigProperties = (node) => {
|
|
|
66
85
|
return properties;
|
|
67
86
|
};
|
|
68
87
|
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} text
|
|
90
|
+
*/
|
|
91
|
+
const startsWithLineBreak = (text) => /^\s*/.exec(text)?.[0].includes('\n') ?? false;
|
|
92
|
+
|
|
69
93
|
/**
|
|
70
94
|
* @param {TObjectExpressionNode} node
|
|
71
95
|
* @param {import('eslint').SourceCode} sourceCode
|
|
@@ -98,6 +122,7 @@ const buildSortedObjectText = (node, sourceCode) => {
|
|
|
98
122
|
|
|
99
123
|
return {
|
|
100
124
|
bodyText,
|
|
125
|
+
endsWithLineComment: trailingComment?.type === 'Line',
|
|
101
126
|
hasComma: Boolean(comma),
|
|
102
127
|
originalIndex,
|
|
103
128
|
orderIndex: orderIndexMap.get(getPropertyName(property.key)) ?? HOST_DIRECTIVE_PROPERTY_ORDER.length,
|
|
@@ -120,10 +145,12 @@ const buildSortedObjectText = (node, sourceCode) => {
|
|
|
120
145
|
return (
|
|
121
146
|
'{' +
|
|
122
147
|
sortedEntries
|
|
123
|
-
.map(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
.map((entry, index) => {
|
|
149
|
+
const followingText = sortedEntries[index + 1]?.bodyText ?? suffix;
|
|
150
|
+
const needsLineBreak = entry.endsWithLineComment && !startsWithLineBreak(followingText);
|
|
151
|
+
|
|
152
|
+
return `${entry.bodyText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}${entry.trailingText}${needsLineBreak ? '\n' : ''}`;
|
|
153
|
+
})
|
|
127
154
|
.join('') +
|
|
128
155
|
suffix +
|
|
129
156
|
'}'
|
|
@@ -181,9 +208,10 @@ const preferConciseAngularHostDirectives = {
|
|
|
181
208
|
if (!hostDirectives) return;
|
|
182
209
|
|
|
183
210
|
for (const element of hostDirectives.elements) {
|
|
184
|
-
|
|
211
|
+
const unwrapped = unwrapTypeAssertions(element);
|
|
212
|
+
if (!unwrapped || unwrapped.type !== 'ObjectExpression') continue;
|
|
185
213
|
|
|
186
|
-
const config = /** @type {TObjectExpressionNode} */ (
|
|
214
|
+
const config = /** @type {TObjectExpressionNode} */ (unwrapped);
|
|
187
215
|
const properties = getHostDirectiveConfigProperties(config);
|
|
188
216
|
if (!properties) continue;
|
|
189
217
|
|
|
@@ -191,12 +219,12 @@ const preferConciseAngularHostDirectives = {
|
|
|
191
219
|
const directiveValue = getDirectiveValueNode(config);
|
|
192
220
|
if (!directiveValue) continue;
|
|
193
221
|
|
|
194
|
-
const hasComments = sourceCode.getCommentsInside(
|
|
222
|
+
const hasComments = sourceCode.getCommentsInside(element).length > 0;
|
|
195
223
|
|
|
196
224
|
context.report({
|
|
197
225
|
node: config,
|
|
198
226
|
messageId: 'preferShorthand',
|
|
199
|
-
fix: hasComments ? null : (fixer) => fixer.replaceText(
|
|
227
|
+
fix: hasComments ? null : (fixer) => fixer.replaceText(element, sourceCode.getText(directiveValue)),
|
|
200
228
|
});
|
|
201
229
|
|
|
202
230
|
continue;
|
|
@@ -20,9 +20,16 @@ const getPropertyName = (key) => {
|
|
|
20
20
|
* @returns {import('estree').Expression | null}
|
|
21
21
|
*/
|
|
22
22
|
const getSingleArrayElement = (property) => {
|
|
23
|
-
|
|
23
|
+
/** @type {any} */
|
|
24
|
+
let value = property.value;
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
while (value.type === 'TSAsExpression' || value.type === 'TSSatisfiesExpression') {
|
|
27
|
+
value = value.expression;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (value.type !== 'ArrayExpression' || value.elements.length !== 1) return null;
|
|
31
|
+
|
|
32
|
+
const firstElement = /** @type {TArrayElement} */ (value.elements[0]);
|
|
26
33
|
if (!firstElement || firstElement.type === 'SpreadElement') return null;
|
|
27
34
|
|
|
28
35
|
return firstElement;
|
|
@@ -35,6 +42,7 @@ const preferConciseAngularStyleMetadata = {
|
|
|
35
42
|
docs: {
|
|
36
43
|
description:
|
|
37
44
|
'Prefer `styleUrl` over single-item `styleUrls`, and avoid wrapping a single `styles` entry in an array.',
|
|
45
|
+
recommended: true,
|
|
38
46
|
},
|
|
39
47
|
fixable: 'code',
|
|
40
48
|
messages: {
|
|
@@ -29,7 +29,7 @@ const { ANGULAR_CORE, getImportedName } = require('./internals/import-resolution
|
|
|
29
29
|
* // or
|
|
30
30
|
* dimensions = signalElementDimensions(inject(ElementRef));
|
|
31
31
|
* // Then:
|
|
32
|
-
* effect(() => { const w = this.dimensions().rect.width; }); // ✅ reactive
|
|
32
|
+
* effect(() => { const w = this.dimensions().rect().width; }); // ✅ reactive
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
/** Size-related properties read from DOM elements. */
|
|
@@ -45,25 +45,48 @@ const ELEMENT_SIZE_PROPS = new Set([
|
|
|
45
45
|
/** Size-related methods called on DOM elements. */
|
|
46
46
|
const ELEMENT_SIZE_METHODS = new Set(['getBoundingClientRect', 'getClientRects']);
|
|
47
47
|
|
|
48
|
+
const SYNCHRONOUS_ARRAY_METHODS = new Set([
|
|
49
|
+
'map',
|
|
50
|
+
'flatMap',
|
|
51
|
+
'filter',
|
|
52
|
+
'forEach',
|
|
53
|
+
'find',
|
|
54
|
+
'findLast',
|
|
55
|
+
'findIndex',
|
|
56
|
+
'findLastIndex',
|
|
57
|
+
'some',
|
|
58
|
+
'every',
|
|
59
|
+
'reduce',
|
|
60
|
+
'reduceRight',
|
|
61
|
+
'sort',
|
|
62
|
+
]);
|
|
63
|
+
|
|
48
64
|
/**
|
|
49
|
-
* Returns the name of the wrapping reactive context ('effect' | 'computed') if
|
|
50
|
-
* the node is inside one, crossing exactly one function boundary.
|
|
51
65
|
* @param {import('eslint').SourceCode} sourceCode
|
|
52
66
|
* @param {import('eslint').Rule.Node} node
|
|
53
67
|
* @returns {string | null}
|
|
54
68
|
*/
|
|
55
69
|
const getReactiveContext = (sourceCode, node) => {
|
|
56
|
-
|
|
70
|
+
/** @type {any} */
|
|
57
71
|
let current = node.parent;
|
|
58
72
|
|
|
59
73
|
while (current) {
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
if (
|
|
75
|
+
current.type === 'ArrowFunctionExpression' ||
|
|
76
|
+
current.type === 'FunctionExpression' ||
|
|
77
|
+
current.type === 'FunctionDeclaration'
|
|
78
|
+
) {
|
|
79
|
+
const host = current.parent;
|
|
80
|
+
if (host?.type !== 'CallExpression' || !host.arguments.includes(current)) return null;
|
|
63
81
|
|
|
64
|
-
|
|
65
|
-
const calleeName = getImportedName(sourceCode, current.callee, ANGULAR_CORE);
|
|
82
|
+
const calleeName = getImportedName(sourceCode, host.callee, ANGULAR_CORE);
|
|
66
83
|
if (calleeName === 'effect' || calleeName === 'computed') return calleeName;
|
|
84
|
+
|
|
85
|
+
const isSynchronousArrayCallback =
|
|
86
|
+
host.callee.type === 'MemberExpression' &&
|
|
87
|
+
host.callee.property.type === 'Identifier' &&
|
|
88
|
+
SYNCHRONOUS_ARRAY_METHODS.has(host.callee.property.name);
|
|
89
|
+
if (!isSynchronousArrayCallback) return null;
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
current = current.parent;
|
|
@@ -72,6 +95,236 @@ const getReactiveContext = (sourceCode, node) => {
|
|
|
72
95
|
return null;
|
|
73
96
|
};
|
|
74
97
|
|
|
98
|
+
const DOM_QUERY_METHODS = new Set(['querySelector', 'getElementById', 'closest', 'elementFromPoint']);
|
|
99
|
+
const DOM_LIST_QUERY_METHODS = new Set([
|
|
100
|
+
'querySelectorAll',
|
|
101
|
+
'getElementsByClassName',
|
|
102
|
+
'getElementsByTagName',
|
|
103
|
+
'getElementsByName',
|
|
104
|
+
]);
|
|
105
|
+
const ELEMENT_NAVIGATION_PROPS = new Set([
|
|
106
|
+
'parentElement',
|
|
107
|
+
'offsetParent',
|
|
108
|
+
'firstElementChild',
|
|
109
|
+
'lastElementChild',
|
|
110
|
+
'nextElementSibling',
|
|
111
|
+
'previousElementSibling',
|
|
112
|
+
]);
|
|
113
|
+
const ELEMENT_INJECTORS = new Set(['injectHostElement', 'injectAngularRootElement', 'injectBoundaryElement']);
|
|
114
|
+
const ELEMENT_TYPE_NAME = /^(Element|HTMLElement|SVGElement|HTML\w*Element|SVG\w*Element)$/u;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {any} typeAnnotation
|
|
118
|
+
*/
|
|
119
|
+
const isElementType = (typeAnnotation) => {
|
|
120
|
+
const type = typeAnnotation?.type === 'TSTypeAnnotation' ? typeAnnotation.typeAnnotation : typeAnnotation;
|
|
121
|
+
|
|
122
|
+
if (type?.type === 'TSUnionType') return type.types.some(isElementType);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
type?.type === 'TSTypeReference' &&
|
|
126
|
+
type.typeName.type === 'Identifier' &&
|
|
127
|
+
ELEMENT_TYPE_NAME.test(type.typeName.name)
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {any} node
|
|
133
|
+
*/
|
|
134
|
+
const isDocumentReference = (node) =>
|
|
135
|
+
(node.type === 'Identifier' && node.name === 'document') ||
|
|
136
|
+
(node.type === 'MemberExpression' &&
|
|
137
|
+
!node.computed &&
|
|
138
|
+
node.property.type === 'Identifier' &&
|
|
139
|
+
/^(document|doc)$/u.test(node.property.name));
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {any} node
|
|
143
|
+
*/
|
|
144
|
+
const getThisClassBody = (node) => {
|
|
145
|
+
let current = node.parent;
|
|
146
|
+
|
|
147
|
+
while (current) {
|
|
148
|
+
if (current.type === 'ClassBody') return current;
|
|
149
|
+
current = current.parent;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const ELEMENT_COLLECTION_NAME = /(Children|Elements)$/u;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {any} node
|
|
159
|
+
* @returns {string | null}
|
|
160
|
+
*/
|
|
161
|
+
const getReferenceName = (node) => {
|
|
162
|
+
if (node.type === 'Identifier') return node.name;
|
|
163
|
+
if (node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier') {
|
|
164
|
+
return node.property.name;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
172
|
+
* @param {any} node
|
|
173
|
+
* @param {Set<any>} seen
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
const isElementCollection = (sourceCode, node, seen) => {
|
|
177
|
+
if (!node || seen.has(node)) return false;
|
|
178
|
+
seen.add(node);
|
|
179
|
+
|
|
180
|
+
if (node.type === 'ChainExpression' || node.type === 'TSNonNullExpression') {
|
|
181
|
+
return isElementCollection(sourceCode, node.expression, seen);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (node.type === 'ArrayExpression') {
|
|
185
|
+
return (
|
|
186
|
+
node.elements.length > 0 &&
|
|
187
|
+
node.elements.every(
|
|
188
|
+
(/** @type {any} */ element) =>
|
|
189
|
+
element?.type === 'SpreadElement' && isElementCollection(sourceCode, element.argument, seen),
|
|
190
|
+
)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (node.type === 'CallExpression') {
|
|
195
|
+
const name = getReferenceName(node.callee);
|
|
196
|
+
if (name && DOM_LIST_QUERY_METHODS.has(name)) return true;
|
|
197
|
+
if (name === 'from' && node.arguments[0]) return isElementCollection(sourceCode, node.arguments[0], seen);
|
|
198
|
+
|
|
199
|
+
return !!name && ELEMENT_COLLECTION_NAME.test(name);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const name = getReferenceName(node);
|
|
203
|
+
if (name === 'children') return true;
|
|
204
|
+
if (name && ELEMENT_COLLECTION_NAME.test(name)) return true;
|
|
205
|
+
if (node.type !== 'Identifier') return false;
|
|
206
|
+
|
|
207
|
+
/** @type {import('eslint').Scope.Scope | null} */
|
|
208
|
+
let scope = sourceCode.getScope(node);
|
|
209
|
+
|
|
210
|
+
while (scope) {
|
|
211
|
+
const variable = scope.set.get(node.name);
|
|
212
|
+
|
|
213
|
+
if (variable) {
|
|
214
|
+
const definition = /** @type {any} */ (variable.defs[0]);
|
|
215
|
+
|
|
216
|
+
return definition?.type === 'Variable' && isElementCollection(sourceCode, definition.node.init, seen);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
scope = scope.upper;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return false;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
227
|
+
* @param {any} node
|
|
228
|
+
* @param {Set<any>} seen
|
|
229
|
+
* @returns {boolean}
|
|
230
|
+
*/
|
|
231
|
+
const isElementExpression = (sourceCode, node, seen = new Set()) => {
|
|
232
|
+
if (!node || seen.has(node)) return false;
|
|
233
|
+
seen.add(node);
|
|
234
|
+
|
|
235
|
+
switch (node.type) {
|
|
236
|
+
case 'ChainExpression':
|
|
237
|
+
case 'TSNonNullExpression':
|
|
238
|
+
return isElementExpression(sourceCode, node.expression, seen);
|
|
239
|
+
case 'TSAsExpression':
|
|
240
|
+
case 'TSTypeAssertion':
|
|
241
|
+
return isElementType(node.typeAnnotation) || isElementExpression(sourceCode, node.expression, seen);
|
|
242
|
+
case 'CallExpression': {
|
|
243
|
+
const { callee } = node;
|
|
244
|
+
|
|
245
|
+
if (callee.type === 'Identifier') return ELEMENT_INJECTORS.has(callee.name);
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
callee.type === 'MemberExpression' &&
|
|
249
|
+
callee.property.type === 'Identifier' &&
|
|
250
|
+
DOM_QUERY_METHODS.has(callee.property.name)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
case 'MemberExpression': {
|
|
254
|
+
if (node.computed) {
|
|
255
|
+
const list = node.object;
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
list.type === 'CallExpression' &&
|
|
259
|
+
list.callee.type === 'MemberExpression' &&
|
|
260
|
+
list.callee.property.type === 'Identifier' &&
|
|
261
|
+
DOM_LIST_QUERY_METHODS.has(list.callee.property.name)
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (node.property.type !== 'Identifier') return false;
|
|
266
|
+
|
|
267
|
+
const prop = node.property.name;
|
|
268
|
+
if (prop === 'nativeElement' || ELEMENT_NAVIGATION_PROPS.has(prop)) return true;
|
|
269
|
+
if (prop === 'documentElement' || prop === 'body') return isDocumentReference(node.object);
|
|
270
|
+
if (node.object.type !== 'ThisExpression') return false;
|
|
271
|
+
|
|
272
|
+
const classBody = getThisClassBody(node);
|
|
273
|
+
const field = classBody?.body.find(
|
|
274
|
+
(/** @type {any} */ member) =>
|
|
275
|
+
member.type === 'PropertyDefinition' &&
|
|
276
|
+
!member.computed &&
|
|
277
|
+
member.key.type === 'Identifier' &&
|
|
278
|
+
member.key.name === prop,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return !!field && (isElementType(field.typeAnnotation) || isElementExpression(sourceCode, field.value, seen));
|
|
282
|
+
}
|
|
283
|
+
case 'Identifier': {
|
|
284
|
+
/** @type {import('eslint').Scope.Scope | null} */
|
|
285
|
+
let scope = sourceCode.getScope(node);
|
|
286
|
+
|
|
287
|
+
while (scope) {
|
|
288
|
+
const variable = scope.set.get(node.name);
|
|
289
|
+
|
|
290
|
+
if (variable) {
|
|
291
|
+
const definition = /** @type {any} */ (variable.defs[0]);
|
|
292
|
+
if (!definition) return false;
|
|
293
|
+
if (isElementType(definition.name.typeAnnotation)) return true;
|
|
294
|
+
|
|
295
|
+
if (definition.type === 'Parameter') {
|
|
296
|
+
const host = definition.node.parent;
|
|
297
|
+
const isArrayCallback =
|
|
298
|
+
host?.type === 'CallExpression' &&
|
|
299
|
+
host.arguments[0] === definition.node &&
|
|
300
|
+
host.callee.type === 'MemberExpression' &&
|
|
301
|
+
host.callee.property.type === 'Identifier' &&
|
|
302
|
+
SYNCHRONOUS_ARRAY_METHODS.has(host.callee.property.name) &&
|
|
303
|
+
definition.node.params[0] === definition.name;
|
|
304
|
+
|
|
305
|
+
return isArrayCallback && isElementCollection(sourceCode, host.callee.object, seen);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (definition.type !== 'Variable') return false;
|
|
309
|
+
|
|
310
|
+
const loop = definition.parent?.parent;
|
|
311
|
+
if (loop?.type === 'ForOfStatement' && loop.left === definition.parent) {
|
|
312
|
+
return isElementCollection(sourceCode, loop.right, seen);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return isElementExpression(sourceCode, definition.node.init, seen);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
scope = scope.upper;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
default:
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
75
328
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
76
329
|
const preferElementDimensions = {
|
|
77
330
|
meta: {
|
|
@@ -89,10 +342,10 @@ const preferElementDimensions = {
|
|
|
89
342
|
},
|
|
90
343
|
create(context) {
|
|
91
344
|
return {
|
|
92
|
-
// el.offsetWidth, el.clientWidth, etc.
|
|
93
345
|
MemberExpression(node) {
|
|
94
346
|
if (node.property.type !== 'Identifier') return;
|
|
95
347
|
if (!ELEMENT_SIZE_PROPS.has(node.property.name)) return;
|
|
348
|
+
if (!isElementExpression(context.sourceCode, node.object)) return;
|
|
96
349
|
|
|
97
350
|
const reactiveCtx = getReactiveContext(context.sourceCode, node);
|
|
98
351
|
if (!reactiveCtx) return;
|
|
@@ -104,12 +357,12 @@ const preferElementDimensions = {
|
|
|
104
357
|
});
|
|
105
358
|
},
|
|
106
359
|
|
|
107
|
-
// el.getBoundingClientRect(), el.getClientRects()
|
|
108
360
|
CallExpression(node) {
|
|
109
361
|
const { callee } = node;
|
|
110
362
|
if (callee.type !== 'MemberExpression') return;
|
|
111
363
|
if (callee.property.type !== 'Identifier') return;
|
|
112
364
|
if (!ELEMENT_SIZE_METHODS.has(callee.property.name)) return;
|
|
365
|
+
if (!isElementExpression(context.sourceCode, callee.object)) return;
|
|
113
366
|
|
|
114
367
|
const reactiveCtx = getReactiveContext(context.sourceCode, node);
|
|
115
368
|
if (!reactiveCtx) return;
|