@ethlete/eslint-plugin 1.0.0-next.24 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/migrations.json +12 -0
  3. package/package.json +13 -2
  4. package/src/configs/recommended.js +37 -27
  5. package/src/generators/generators.json +10 -0
  6. package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
  7. package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
  8. package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
  9. package/src/index.js +8 -2
  10. package/src/rules/angular-decorator-property-order.js +24 -7
  11. package/src/rules/class-constant-property.js +1 -1
  12. package/src/rules/class-member-order.js +60 -8
  13. package/src/rules/consistent-type-definitions.js +60 -0
  14. package/src/rules/enforce-routing-view-naming.js +34 -23
  15. package/src/rules/guard-return-newline.js +4 -7
  16. package/src/rules/internals/angular-member-visibility.js +24 -2
  17. package/src/rules/internals/angular-metadata-fix.js +231 -1
  18. package/src/rules/internals/angular-version.js +50 -0
  19. package/src/rules/internals/implemented-contract-members.js +26 -11
  20. package/src/rules/no-angular-router-api.js +86 -33
  21. package/src/rules/no-angular-seo-services.js +1 -0
  22. package/src/rules/no-async-await.js +5 -1
  23. package/src/rules/no-cdk-import.js +32 -7
  24. package/src/rules/no-csp-unsafe.js +362 -0
  25. package/src/rules/no-direct-dom-manipulation.js +50 -28
  26. package/src/rules/no-document-cookie.js +51 -8
  27. package/src/rules/no-dom-query.js +5 -2
  28. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  29. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  30. package/src/rules/no-impure-top-level-provider.js +42 -7
  31. package/src/rules/no-leading-underscore-class-member.js +27 -2
  32. package/src/rules/no-legacy-angular-decorators.js +11 -6
  33. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  34. package/src/rules/no-legacy-query-import.js +61 -16
  35. package/src/rules/no-member-alias.js +0 -5
  36. package/src/rules/no-native-observers.js +89 -24
  37. package/src/rules/no-pipe-logic.js +1 -2
  38. package/src/rules/no-readonly-signal.js +1 -5
  39. package/src/rules/no-redundant-internal.js +5 -2
  40. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  41. package/src/rules/no-rxjs-in-effect.js +7 -5
  42. package/src/rules/no-screaming-case-local.js +3 -5
  43. package/src/rules/no-standalone-flag.js +1 -0
  44. package/src/rules/no-subscribe-with-body.js +30 -1
  45. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  46. package/src/rules/no-trivial-return-type.js +18 -1
  47. package/src/rules/no-unused-class-member.js +1 -1
  48. package/src/rules/no-window-location.js +60 -22
  49. package/src/rules/prefer-clone-equal.js +61 -8
  50. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  51. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  52. package/src/rules/prefer-element-dimensions.js +264 -11
  53. package/src/rules/prefer-linked-signal.js +37 -39
  54. package/src/rules/prefer-match-media.js +17 -3
  55. package/src/rules/prefer-rxjs-timer.js +26 -4
  56. package/src/rules/prefer-scroll-state.js +0 -4
  57. package/src/rules/prefer-static-boolean-properties.js +1 -0
  58. package/src/rules/prefer-viewport-size.js +153 -16
  59. package/src/rules/require-dollar-suffix.js +11 -3
  60. package/src/rules/require-form-submit.js +4 -1
  61. package/src/rules/require-on-push-change-detection.js +7 -259
  62. package/src/rules/require-view-encapsulation-none.js +6 -214
  63. 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
- // ── new URLSearchParams(window.location.search) ─────────────────────────
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.type === 'MemberExpression' &&
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 { MODULE_REFERENCE_SELECTOR, getModuleReference, isGlobalReference } = require('./internals/import-resolution');
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
- // ── JSON.parse(JSON.stringify(expr)) ─────────────────────────────────
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
- // import cloneDeep from 'lodash/cloneDeep'
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
- if (property.value.type !== 'ArrayExpression') return null;
56
+ const value = unwrapTypeAssertions(property.value);
57
+ if (value.type !== 'ArrayExpression') return null;
39
58
 
40
- return /** @type {TArrayExpressionNode} */ (property.value);
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
- (entry, index) =>
125
- `${entry.bodyText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}${entry.trailingText}`,
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
- if (!element || element.type !== 'ObjectExpression') continue;
211
+ const unwrapped = unwrapTypeAssertions(element);
212
+ if (!unwrapped || unwrapped.type !== 'ObjectExpression') continue;
185
213
 
186
- const config = /** @type {TObjectExpressionNode} */ (element);
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(config).length > 0;
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(config, sourceCode.getText(directiveValue)),
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
- if (property.value.type !== 'ArrayExpression' || property.value.elements.length !== 1) return null;
23
+ /** @type {any} */
24
+ let value = property.value;
24
25
 
25
- const firstElement = /** @type {TArrayElement} */ (property.value.elements[0]);
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
- let crossedFunctionBoundary = false;
70
+ /** @type {any} */
57
71
  let current = node.parent;
58
72
 
59
73
  while (current) {
60
- if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
61
- crossedFunctionBoundary = true;
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
- if (crossedFunctionBoundary && current.type === 'CallExpression') {
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;