@ethlete/eslint-plugin 1.0.0-next.25 → 1.0.0-next.27

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 (60) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -0
  3. package/migrations.json +1 -0
  4. package/package.json +2 -1
  5. package/src/configs/recommended.js +16 -18
  6. package/src/rules/angular-decorator-property-order.js +24 -7
  7. package/src/rules/class-constant-property.js +1 -1
  8. package/src/rules/class-member-order.js +66 -8
  9. package/src/rules/enforce-routing-view-naming.js +47 -24
  10. package/src/rules/guard-return-newline.js +4 -7
  11. package/src/rules/internals/angular-member-visibility.js +24 -2
  12. package/src/rules/internals/angular-metadata-fix.js +231 -1
  13. package/src/rules/internals/angular-version.js +50 -0
  14. package/src/rules/internals/implemented-contract-members.js +26 -11
  15. package/src/rules/no-angular-router-api.js +86 -33
  16. package/src/rules/no-angular-seo-services.js +1 -0
  17. package/src/rules/no-async-await.js +5 -1
  18. package/src/rules/no-cdk-import.js +32 -7
  19. package/src/rules/no-direct-dom-manipulation.js +50 -28
  20. package/src/rules/no-document-cookie.js +51 -8
  21. package/src/rules/no-dom-query.js +5 -2
  22. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  23. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  24. package/src/rules/no-impure-top-level-provider.js +42 -7
  25. package/src/rules/no-inject-chain.js +5 -1
  26. package/src/rules/no-leading-underscore-class-member.js +27 -2
  27. package/src/rules/no-legacy-angular-decorators.js +19 -16
  28. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  29. package/src/rules/no-legacy-query-import.js +61 -16
  30. package/src/rules/no-member-alias.js +0 -5
  31. package/src/rules/no-native-observers.js +89 -24
  32. package/src/rules/no-pipe-logic.js +1 -2
  33. package/src/rules/no-readonly-signal.js +1 -5
  34. package/src/rules/no-redundant-internal.js +5 -2
  35. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  36. package/src/rules/no-rxjs-in-effect.js +7 -5
  37. package/src/rules/no-screaming-case-local.js +3 -5
  38. package/src/rules/no-standalone-flag.js +1 -0
  39. package/src/rules/no-subscribe-in-pipe.js +5 -1
  40. package/src/rules/no-subscribe-with-body.js +30 -1
  41. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  42. package/src/rules/no-trivial-return-type.js +18 -1
  43. package/src/rules/no-trivial-wrapper-method.js +39 -1
  44. package/src/rules/no-type-only-import.js +23 -19
  45. package/src/rules/no-unused-class-member.js +14 -6
  46. package/src/rules/no-window-location.js +60 -22
  47. package/src/rules/prefer-clone-equal.js +61 -8
  48. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  49. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  50. package/src/rules/prefer-element-dimensions.js +264 -11
  51. package/src/rules/prefer-match-media.js +17 -3
  52. package/src/rules/prefer-rxjs-timer.js +26 -4
  53. package/src/rules/prefer-scroll-state.js +0 -4
  54. package/src/rules/prefer-static-boolean-properties.js +1 -0
  55. package/src/rules/prefer-viewport-size.js +153 -16
  56. package/src/rules/require-dollar-suffix.js +11 -3
  57. package/src/rules/require-form-submit.js +4 -1
  58. package/src/rules/require-on-push-change-detection.js +7 -259
  59. package/src/rules/require-view-encapsulation-none.js +6 -214
  60. package/src/rules/template-member-accessibility.js +13 -20
@@ -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;
@@ -37,7 +37,12 @@
37
37
  * // … etc. All from @ethlete/core.
38
38
  */
39
39
 
40
- const { MODULE_REFERENCE_SELECTOR, getModuleReference, isImportedAs } = require('./internals/import-resolution');
40
+ const {
41
+ MODULE_REFERENCE_SELECTOR,
42
+ getModuleReference,
43
+ isGlobalReference,
44
+ isImportedAs,
45
+ } = require('./internals/import-resolution');
41
46
 
42
47
  const CDK_LAYOUT = '@angular/cdk/layout';
43
48
 
@@ -62,7 +67,15 @@ const preferMatchMedia = {
62
67
  CallExpression(node) {
63
68
  const { callee } = node;
64
69
 
65
- // ── window.matchMedia() ───────────────────────────────────────────────
70
+ if (
71
+ callee.type === 'Identifier' &&
72
+ callee.name === 'matchMedia' &&
73
+ isGlobalReference(context.sourceCode, callee)
74
+ ) {
75
+ context.report({ node, messageId: 'preferMatchMedia' });
76
+ return;
77
+ }
78
+
66
79
  if (
67
80
  callee.type === 'MemberExpression' &&
68
81
  callee.property.type === 'Identifier' &&
@@ -74,9 +87,10 @@ const preferMatchMedia = {
74
87
  return;
75
88
  }
76
89
 
77
- // ── inject(BreakpointObserver) ────────────────────────────────────────
78
90
  if (
79
91
  isImportedAs(context.sourceCode, callee, 'inject') &&
92
+ node.arguments[0]?.type === 'Identifier' &&
93
+ isGlobalReference(context.sourceCode, node.arguments[0]) &&
80
94
  isImportedAs(context.sourceCode, node.arguments[0], 'BreakpointObserver', CDK_LAYOUT)
81
95
  ) {
82
96
  context.report({ node, messageId: 'noBreakpointObserver' });
@@ -21,6 +21,9 @@
21
21
 
22
22
  const { isGlobalReference } = require('./internals/import-resolution');
23
23
 
24
+ const GLOBAL_OBJECTS = new Set(['window', 'globalThis', 'self']);
25
+ const WINDOW_MEMBERS = new Set(['win', 'window', '_win', '_window']);
26
+
24
27
  /** @type {import('eslint').Rule.RuleModule} */
25
28
  const preferRxjsTimer = {
26
29
  meta: {
@@ -44,13 +47,33 @@ const preferRxjsTimer = {
44
47
  schema: [],
45
48
  },
46
49
  create(context) {
50
+ const getTimerName = (callee) => {
51
+ if (callee.type === 'Identifier') {
52
+ return isGlobalReference(context.sourceCode, callee) ? callee.name : null;
53
+ }
54
+ if (callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') return null;
55
+
56
+ const { object } = callee;
57
+ const isWindow =
58
+ (object.type === 'Identifier' &&
59
+ GLOBAL_OBJECTS.has(object.name) &&
60
+ isGlobalReference(context.sourceCode, object)) ||
61
+ (object.type === 'MemberExpression' &&
62
+ object.object.type === 'ThisExpression' &&
63
+ !object.computed &&
64
+ object.property.type === 'Identifier' &&
65
+ WINDOW_MEMBERS.has(object.property.name));
66
+
67
+ return isWindow ? callee.property.name : null;
68
+ };
69
+
47
70
  return {
48
71
  CallExpression(node) {
49
72
  const { callee } = node;
50
73
 
51
- // ── Global timer calls ────────────────────────────────────────────────
52
- if (isGlobalReference(context.sourceCode, callee)) {
53
- const { name } = callee;
74
+ const timerName = getTimerName(callee);
75
+ if (timerName) {
76
+ const name = timerName;
54
77
  if (name === 'setTimeout') {
55
78
  context.report({ node, messageId: 'preferTimer' });
56
79
  return;
@@ -65,7 +88,6 @@ const preferRxjsTimer = {
65
88
  }
66
89
  }
67
90
 
68
- // ── Member calls: .addEventListener / .removeEventListener ────────────
69
91
  if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
70
92
  const methodName = callee.property.name;
71
93
  if (methodName === 'addEventListener') {
@@ -41,7 +41,6 @@ const preferScrollState = {
41
41
  const { callee } = node;
42
42
  const args = node.arguments;
43
43
 
44
- // ── el.addEventListener('scroll', fn) / window.addEventListener('scroll', fn) ──
45
44
  if (
46
45
  callee.type === 'MemberExpression' &&
47
46
  callee.property.type === 'Identifier' &&
@@ -53,7 +52,6 @@ const preferScrollState = {
53
52
  return;
54
53
  }
55
54
 
56
- // ── fromEvent(el, 'scroll') ──────────────────────────────────────────
57
55
  if (
58
56
  callee.type === 'Identifier' &&
59
57
  callee.name === 'fromEvent' &&
@@ -64,7 +62,6 @@ const preferScrollState = {
64
62
  return;
65
63
  }
66
64
 
67
- // ── renderer.listen(el, 'scroll', fn) ───────────────────────────────
68
65
  if (
69
66
  callee.type === 'MemberExpression' &&
70
67
  callee.property.type === 'Identifier' &&
@@ -77,7 +74,6 @@ const preferScrollState = {
77
74
  },
78
75
 
79
76
  AssignmentExpression(node) {
80
- // ── el.onscroll = fn ────────────────────────────────────────────────
81
77
  const { left } = node;
82
78
  if (
83
79
  left.type === 'MemberExpression' &&
@@ -57,6 +57,7 @@ const preferStaticBooleanProperties = {
57
57
  docs: {
58
58
  description:
59
59
  'Prefer a static attribute over a property binding for static boolean values when the input uses a booleanAttribute transform.',
60
+ recommended: true,
60
61
  },
61
62
  hasSuggestions: true,
62
63
  schema: [],
@@ -20,8 +20,37 @@
20
20
  * const h = viewport().height;
21
21
  */
22
22
 
23
- /** Properties on window that report viewport dimensions. */
23
+ const { isGlobalReference } = require('./internals/import-resolution');
24
+
24
25
  const VIEWPORT_PROPERTIES = new Set(['innerWidth', 'innerHeight']);
26
+ const GLOBAL_OBJECTS = new Set(['window', 'globalThis', 'self']);
27
+
28
+ /**
29
+ * @param {any} node
30
+ * @returns {string | null}
31
+ */
32
+ const getPropertyName = (node) => {
33
+ if (!node.computed && node.property.type === 'Identifier') return node.property.name;
34
+ if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') {
35
+ return node.property.value;
36
+ }
37
+
38
+ return null;
39
+ };
40
+
41
+ /**
42
+ * @param {any} node
43
+ */
44
+ const getThisClassBody = (node) => {
45
+ let current = node.parent;
46
+
47
+ while (current) {
48
+ if (current.type === 'ClassBody') return current;
49
+ current = current.parent;
50
+ }
51
+
52
+ return null;
53
+ };
25
54
 
26
55
  /** @type {import('eslint').Rule.RuleModule} */
27
56
  const preferViewportSize = {
@@ -38,30 +67,138 @@ const preferViewportSize = {
38
67
  schema: [],
39
68
  },
40
69
  create(context) {
41
- return {
42
- MemberExpression(node) {
43
- if (node.property.type !== 'Identifier') return;
44
- if (!VIEWPORT_PROPERTIES.has(node.property.name)) return;
70
+ const { sourceCode } = context;
71
+ /** @type {WeakMap<any, Set<string>>} */
72
+ const classWindowMembers = new WeakMap();
73
+
74
+ /**
75
+ * @param {any} classBody
76
+ * @returns {Set<string>}
77
+ */
78
+ const getClassWindowMembers = (classBody) => {
79
+ const cached = classWindowMembers.get(classBody);
80
+ if (cached) return cached;
81
+
82
+ /** @type {Set<string>} */
83
+ const members = new Set();
84
+ classWindowMembers.set(classBody, members);
85
+
86
+ /** @param {any} node */
87
+ const visit = (node) => {
88
+ if (!node || typeof node.type !== 'string') return;
89
+ if (node !== classBody && node.type === 'ClassBody') return;
45
90
 
46
- // Only flag when the object is clearly a window-like reference.
47
- const obj = node.object;
48
- const objText = context.sourceCode.getText(obj).toLowerCase();
91
+ if (
92
+ node.type === 'PropertyDefinition' &&
93
+ !node.computed &&
94
+ node.key.type === 'Identifier' &&
95
+ isWindowLike(node.value)
96
+ ) {
97
+ members.add(node.key.name);
98
+ }
49
99
 
50
- const isWindowRef =
51
- objText === 'window' ||
52
- objText.endsWith('.defaultview') ||
53
- objText.endsWith('?.defaultview') ||
54
- // document.defaultView without optional chain
55
- /defaultview$/.test(objText);
100
+ if (
101
+ node.type === 'AssignmentExpression' &&
102
+ node.left.type === 'MemberExpression' &&
103
+ node.left.object.type === 'ThisExpression' &&
104
+ !node.left.computed &&
105
+ node.left.property.type === 'Identifier' &&
106
+ isWindowLike(node.right)
107
+ ) {
108
+ members.add(node.left.property.name);
109
+ }
110
+
111
+ for (const key of Object.keys(node)) {
112
+ if (key === 'parent') continue;
113
+
114
+ const value = node[key];
115
+ if (Array.isArray(value)) value.forEach(visit);
116
+ else if (value && typeof value.type === 'string') visit(value);
117
+ }
118
+ };
119
+
120
+ visit(classBody);
121
+
122
+ return members;
123
+ };
56
124
 
57
- if (!isWindowRef) return;
125
+ /**
126
+ * @param {any} node
127
+ * @param {Set<any>} seen
128
+ * @returns {boolean}
129
+ */
130
+ const isWindowLike = (node, seen = new Set()) => {
131
+ if (!node || seen.has(node)) return false;
132
+ seen.add(node);
133
+
134
+ if (node.type === 'ChainExpression' || node.type === 'TSNonNullExpression' || node.type === 'TSAsExpression') {
135
+ return isWindowLike(node.expression, seen);
136
+ }
137
+
138
+ if (node.type === 'Identifier') {
139
+ if (isGlobalReference(sourceCode, node)) return GLOBAL_OBJECTS.has(node.name);
140
+
141
+ /** @type {import('eslint').Scope.Scope | null} */
142
+ let scope = sourceCode.getScope(node);
143
+
144
+ while (scope) {
145
+ const variable = scope.set.get(node.name);
146
+
147
+ if (variable) {
148
+ const definition = /** @type {any} */ (variable.defs[0]);
149
+
150
+ return definition?.type === 'Variable' && isWindowLike(definition.node.init, seen);
151
+ }
152
+
153
+ scope = scope.upper;
154
+ }
155
+
156
+ return false;
157
+ }
158
+
159
+ if (node.type !== 'MemberExpression') return false;
160
+
161
+ const name = getPropertyName(node);
162
+ if (name === 'defaultView') return true;
163
+ if (name && GLOBAL_OBJECTS.has(name) && isWindowLike(node.object, seen)) return true;
164
+ if (node.object.type !== 'ThisExpression' || !name) return false;
165
+
166
+ const classBody = getThisClassBody(node);
167
+
168
+ return !!classBody && getClassWindowMembers(classBody).has(name);
169
+ };
170
+
171
+ return {
172
+ MemberExpression(node) {
173
+ const prop = getPropertyName(node);
174
+ if (!prop || !VIEWPORT_PROPERTIES.has(prop)) return;
175
+ if (!isWindowLike(node.object)) return;
58
176
 
59
177
  context.report({
60
178
  node,
61
179
  messageId: 'preferViewportSize',
62
- data: { prop: node.property.name },
180
+ data: { prop },
63
181
  });
64
182
  },
183
+ 'Program:exit'(program) {
184
+ const globalScope = sourceCode.getScope(program);
185
+ const declaredGlobalReferences = [...VIEWPORT_PROPERTIES].flatMap((name) => {
186
+ const variable = globalScope.set.get(name);
187
+
188
+ return variable && variable.defs.length === 0 ? variable.references : [];
189
+ });
190
+
191
+ for (const reference of [...globalScope.through, ...declaredGlobalReferences]) {
192
+ const { identifier } = reference;
193
+ if (!VIEWPORT_PROPERTIES.has(identifier.name)) continue;
194
+
195
+ context.report({
196
+ node: identifier,
197
+ messageId: 'preferViewportSize',
198
+ data: { prop: identifier.name },
199
+ });
200
+ }
201
+ },
65
202
  };
66
203
  },
67
204
  };