@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +2 -1
  3. package/src/configs/recommended.js +7 -16
  4. package/src/rules/angular-decorator-property-order.js +24 -7
  5. package/src/rules/class-constant-property.js +1 -1
  6. package/src/rules/class-member-order.js +60 -8
  7. package/src/rules/enforce-routing-view-naming.js +34 -23
  8. package/src/rules/guard-return-newline.js +4 -7
  9. package/src/rules/internals/angular-member-visibility.js +24 -2
  10. package/src/rules/internals/angular-metadata-fix.js +231 -1
  11. package/src/rules/internals/angular-version.js +50 -0
  12. package/src/rules/internals/implemented-contract-members.js +26 -11
  13. package/src/rules/no-angular-router-api.js +86 -33
  14. package/src/rules/no-angular-seo-services.js +1 -0
  15. package/src/rules/no-async-await.js +5 -1
  16. package/src/rules/no-cdk-import.js +32 -7
  17. package/src/rules/no-direct-dom-manipulation.js +50 -28
  18. package/src/rules/no-document-cookie.js +51 -8
  19. package/src/rules/no-dom-query.js +5 -2
  20. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  21. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  22. package/src/rules/no-impure-top-level-provider.js +42 -7
  23. package/src/rules/no-leading-underscore-class-member.js +27 -2
  24. package/src/rules/no-legacy-angular-decorators.js +11 -6
  25. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  26. package/src/rules/no-legacy-query-import.js +61 -16
  27. package/src/rules/no-member-alias.js +0 -5
  28. package/src/rules/no-native-observers.js +89 -24
  29. package/src/rules/no-pipe-logic.js +1 -2
  30. package/src/rules/no-readonly-signal.js +1 -5
  31. package/src/rules/no-redundant-internal.js +5 -2
  32. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  33. package/src/rules/no-rxjs-in-effect.js +7 -5
  34. package/src/rules/no-screaming-case-local.js +3 -5
  35. package/src/rules/no-standalone-flag.js +1 -0
  36. package/src/rules/no-subscribe-with-body.js +30 -1
  37. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  38. package/src/rules/no-trivial-return-type.js +18 -1
  39. package/src/rules/no-unused-class-member.js +1 -1
  40. package/src/rules/no-window-location.js +60 -22
  41. package/src/rules/prefer-clone-equal.js +61 -8
  42. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  43. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  44. package/src/rules/prefer-element-dimensions.js +264 -11
  45. package/src/rules/prefer-match-media.js +17 -3
  46. package/src/rules/prefer-rxjs-timer.js +26 -4
  47. package/src/rules/prefer-scroll-state.js +0 -4
  48. package/src/rules/prefer-static-boolean-properties.js +1 -0
  49. package/src/rules/prefer-viewport-size.js +153 -16
  50. package/src/rules/require-dollar-suffix.js +11 -3
  51. package/src/rules/require-form-submit.js +4 -1
  52. package/src/rules/require-on-push-change-detection.js +7 -259
  53. package/src/rules/require-view-encapsulation-none.js +6 -214
  54. package/src/rules/template-member-accessibility.js +12 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.26
4
+
5
+ ### Minor Changes
6
+
7
+ - Drop the last module-scope pins from the components import floor (2,870 B gz) and add a `forbidImpureReads` option to `no-impure-top-level-provider`.
8
+
9
+ ### Patch Changes
10
+
11
+ - The `class-member-order` fix keeps members without semicolons apart, so the reordered class still parses.
12
+ - `recommendedTemplate` no longer reports an empty `<a>` or `<button>` carrying `et-match-card`, `et-match-participant` or `aria-labelledby` under `@angular-eslint/template/elements-content`.
13
+ - Close rule gaps: `no-document-cookie`, `no-window-location`, `prefer-rxjs-timer`, `no-subscribe-with-body`, `prefer-match-media`, `prefer-clone-equal` and `no-dom-query` catch more escapes, and the `no-async-await` `play` exemption only applies in story files.
14
+ - The `prefer-concise-angular-host-directives` fix no longer lets a trailing line comment swallow the closing brace.
15
+ - Lint-scan fixes: observer, DOM, `accessor`/`abstract`/`static` member and on-push version detection gaps are closed, and importing `constructor` or `toString` from `@ethlete/cdk` or `@ethlete/query` is no longer misread as a mapped symbol.
16
+ - Several rule fixes: `guard-return-newline` no longer counts a comment as the blank line, fixers keep multi-line imports, `no-legacy-prepare-without-injector` accepts `untracked` and IIFEs, `no-cdk-import` re-reads a changed migration map, `router.snapshot` is no longer reported, and `no-rxjs-in-effect` covers `afterRenderEffect` and `linkedSignal`.
17
+ - `no-angular-router-api`, `prefer-element-dimensions`, `prefer-viewport-size` and the legacy query rules report fewer false positives and catch more escapes, and member-visibility rules see files changed during an editor session.
18
+ - Declare `@typescript-eslint/parser` as a peer dependency.
19
+ - Fix several rule gaps: decorator property order keeps trailing comments, `require-dollar-suffix` handles `toObservable` and `asObservable` but not `partition`, and legacy decorator, template literal type, recursion and routing view checks catch more cases.
20
+
3
21
  ## 1.0.0-next.25
4
22
 
5
23
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.25",
3
+ "version": "1.0.0-next.26",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -9,6 +9,7 @@
9
9
  "@angular/core": ">=21.0.0",
10
10
  "@nx/devkit": ">=21.0.0",
11
11
  "@typescript-eslint/eslint-plugin": ">=8.0.0",
12
+ "@typescript-eslint/parser": ">=8.0.0",
12
13
  "eslint": ">=9.0.0",
13
14
  "typescript": ">=5.9.0"
14
15
  },
@@ -9,12 +9,7 @@
9
9
  */
10
10
  const recommendedTs = {
11
11
  files: ['**/*.ts'],
12
- plugins: {
13
- // The plugin itself is injected by the caller (see index.js)
14
- },
15
12
  rules: {
16
- // ── TypeScript ──────────────────────────────────────────────────────────
17
-
18
13
  // No interface — use type (owned by ethlete/consistent-type-definitions)
19
14
  '@typescript-eslint/consistent-type-definitions': 'off',
20
15
 
@@ -36,8 +31,6 @@ const recommendedTs = {
36
31
  },
37
32
  ],
38
33
 
39
- // ── Naming & formatting ─────────────────────────────────────────────────
40
-
41
34
  // No var
42
35
  'no-var': 'error',
43
36
 
@@ -53,7 +46,7 @@ const recommendedTs = {
53
46
  // Max two function parameters
54
47
  'max-params': ['error', 2],
55
48
 
56
- // No _ or # prefixes; enforce camelCase / PascalCase / UPPER_CASE
49
+ // No # or trailing _ (leading _ only on variables, parameters, properties, methods); enforce camelCase / PascalCase / UPPER_CASE
57
50
  '@typescript-eslint/naming-convention': [
58
51
  'error',
59
52
  {
@@ -103,8 +96,6 @@ const recommendedTs = {
103
96
  },
104
97
  ],
105
98
 
106
- // ── Banned syntax ───────────────────────────────────────────────────────
107
-
108
99
  'no-restricted-syntax': [
109
100
  'error',
110
101
  // enum / const enum is owned by ethlete/no-enum, so a lib can keep it on while it works
@@ -188,8 +179,6 @@ const recommendedTs = {
188
179
  },
189
180
  ],
190
181
 
191
- // ── Native DOM globals ──────────────────────────────────────────────────
192
-
193
182
  // No direct document/window access — use inject(DOCUMENT) / inject(WINDOW) instead
194
183
  'no-restricted-globals': [
195
184
  'error',
@@ -203,8 +192,6 @@ const recommendedTs = {
203
192
  },
204
193
  ],
205
194
 
206
- // ── Custom plugin rules ─────────────────────────────────────────────────
207
-
208
195
  // No inject(X).member chaining — assign to a const first
209
196
  'ethlete/no-inject-chain': 'error',
210
197
 
@@ -357,8 +344,6 @@ const recommendedTs = {
357
344
  // Angular language service completions for the rest of the file
358
345
  'ethlete/no-template-literal-before-inline-template': 'error',
359
346
 
360
- // ── Angular components ──────────────────────────────────────────────────
361
-
362
347
  // Require ChangeDetectionStrategy.OnPush on Angular <= 21, where it is opt-in
363
348
  // (version-aware: inert on Angular 22+, where OnPush is the default).
364
349
  'ethlete/require-on-push-change-detection': 'error',
@@ -429,6 +414,12 @@ const recommendedTemplate = {
429
414
  '@angular-eslint/template/no-any': 'error',
430
415
  // Prefer plain attribute over unnecessary property binding for static strings: etIcon="foo" not [etIcon]="'foo'"
431
416
  '@angular-eslint/template/prefer-static-string-properties': 'error',
417
+ // Merged with the rule's own allowList (aria-label, title, innerText, ...), not replacing it.
418
+ // The et-* hosts render their own content and accessible name.
419
+ '@angular-eslint/template/elements-content': [
420
+ 'error',
421
+ { allowList: ['aria-labelledby', 'et-match-card', 'et-match-participant'] },
422
+ ],
432
423
  // Prefer plain attribute over unnecessary property binding for static booleans: isReadonly not [isReadonly]="true"
433
424
  // (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
434
425
  // which a template rule cannot verify)
@@ -9,7 +9,7 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
9
9
  /** @typedef {import('estree').Property & TNodeWithRange} TPropertyNode */
10
10
  /** @typedef {import('estree').SpreadElement & TNodeWithRange} TSpreadElementNode */
11
11
  /** @typedef {import('estree').ObjectExpression & TNodeWithRange & { properties: Array<TPropertyNode | TSpreadElementNode> }} TObjectExpressionNode */
12
- /** @typedef {{ hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; segmentText: string }} TPropertyEntry */
12
+ /** @typedef {{ endsWithLineComment: boolean; hasComma: boolean; trailingText: string; originalIndex: number; orderIndex: number; property: TPropertyNode; segmentText: string }} TPropertyEntry */
13
13
 
14
14
  /** @type {Record<TDecoratorName, TDecoratorConfig>} */
15
15
  const DECORATOR_CONFIG = {
@@ -88,6 +88,7 @@ const angularDecoratorPropertyOrder = {
88
88
  type: 'layout',
89
89
  docs: {
90
90
  description: 'Require a consistent property order in Angular @Component and @Directive metadata objects.',
91
+ recommended: true,
91
92
  },
92
93
  fixable: 'code',
93
94
  messages: {
@@ -142,14 +143,25 @@ const angularDecoratorPropertyOrder = {
142
143
  const segmentEnd = hasComma && tokenAfter ? tokenAfter.range[0] : propertyNode.range[1];
143
144
  const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
144
145
 
146
+ let trailingText = '';
147
+ let endsWithLineComment = false;
148
+
145
149
  if (hasComma && tokenAfter) {
146
- segmentStart = tokenAfter.range[1];
150
+ const sameLineComments = sourceCode
151
+ .getCommentsAfter(tokenAfter)
152
+ .filter((comment) => comment.loc?.start.line === tokenAfter.loc.end.line);
153
+ const lastComment = sameLineComments[sameLineComments.length - 1];
154
+ segmentStart = lastComment?.range ? lastComment.range[1] : tokenAfter.range[1];
155
+ trailingText = sourceCode.text.slice(tokenAfter.range[1], segmentStart);
156
+ endsWithLineComment = lastComment?.type === 'Line';
147
157
  } else {
148
158
  segmentStart = propertyNode.range[1];
149
159
  }
150
160
 
151
161
  return {
162
+ endsWithLineComment,
152
163
  hasComma,
164
+ trailingText,
153
165
  originalIndex,
154
166
  orderIndex,
155
167
  property: propertyNode,
@@ -188,11 +200,16 @@ const angularDecoratorPropertyOrder = {
188
200
  fix(fixer) {
189
201
  const reorderedBody =
190
202
  sortedEntries
191
- .map(
192
- (entry, index) =>
193
- `${entry.segmentText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}`,
194
- )
195
- .join('') + suffix;
203
+ .map((entry, index) => {
204
+ const needsComma = index < sortedEntries.length - 1 || trailingComma || entry.trailingText !== '';
205
+ const previous = sortedEntries[index - 1];
206
+ const lead = previous?.endsWithLineComment && !entry.segmentText.startsWith('\n') ? '\n' : '';
207
+
208
+ return `${lead}${entry.segmentText}${needsComma ? ',' : ''}${entry.trailingText}`;
209
+ })
210
+ .join('') +
211
+ (sortedEntries.at(-1)?.endsWithLineComment && !suffix.startsWith('\n') ? '\n' : '') +
212
+ suffix;
196
213
 
197
214
  return fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
198
215
  },
@@ -63,7 +63,7 @@ const getCallRootName = (node) => {
63
63
  return callee.name;
64
64
  }
65
65
 
66
- if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') {
66
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.object.type === 'Identifier') {
67
67
  return callee.object.name;
68
68
  }
69
69
 
@@ -30,6 +30,9 @@ const MEMBER_GROUP_INDEX = new Map(
30
30
  MEMBER_GROUP_ORDER.map(/** @param {TMemberGroup} group @param {number} index */ (group, index) => [group, index]),
31
31
  );
32
32
 
33
+ /** @param {any} node */
34
+ const isProperty = (node) => node.type === 'PropertyDefinition' || node.type === 'AccessorProperty';
35
+
33
36
  /**
34
37
  * @param {any} key
35
38
  */
@@ -95,7 +98,7 @@ const getAngularCallName = (sourceCode, value) => {
95
98
  * @param {Set<string>} apiNames
96
99
  */
97
100
  const isPropertyInitializedWith = (sourceCode, node, apiNames) => {
98
- if (node.type !== 'PropertyDefinition') return false;
101
+ if (!isProperty(node)) return false;
99
102
 
100
103
  const apiName = getAngularCallName(sourceCode, node.value);
101
104
  return apiName !== null && apiNames.has(apiName);
@@ -108,7 +111,7 @@ const INJECT_HELPER_NAME_PATTERN = /^inject[A-Z]/;
108
111
  * @param {any} node
109
112
  */
110
113
  const isPropertyInitializedWithInject = (sourceCode, node) => {
111
- if (node.type !== 'PropertyDefinition') return false;
114
+ if (!isProperty(node)) return false;
112
115
  if (getAngularCallName(sourceCode, node.value) === 'inject') return true;
113
116
 
114
117
  const rootName = getCallRootName(node.value);
@@ -123,7 +126,7 @@ const isPropertyInitializedWithInject = (sourceCode, node) => {
123
126
  const getMemberGroup = (sourceCode, node) => {
124
127
  if (node.static) return null;
125
128
 
126
- if (node.type === 'PropertyDefinition') {
129
+ if (isProperty(node)) {
127
130
  if (isPropertyInitializedWithInject(sourceCode, node)) {
128
131
  return 'inject';
129
132
  }
@@ -194,7 +197,7 @@ const collectThisReferences = (node, references) => {
194
197
  * @param {any} node
195
198
  */
196
199
  const getInitializerDependencies = (node) => {
197
- if (node.type !== 'PropertyDefinition' || !node.value) {
200
+ if (!isProperty(node) || !node.value) {
198
201
  return new Set();
199
202
  }
200
203
 
@@ -345,6 +348,42 @@ const getSegments = (sourceCode, classBodyNode) => {
345
348
  };
346
349
  };
347
350
 
351
+ const ASI_HAZARD_TOKENS = new Set(['[', '(', '*', '`', 'in', 'instanceof']);
352
+
353
+ /**
354
+ * @param {any} node
355
+ */
356
+ const endsWithOwnBlock = (node) => node.type === 'StaticBlock' || (node.type === 'MethodDefinition' && node.value.body);
357
+
358
+ /**
359
+ * @param {import('eslint').SourceCode} sourceCode
360
+ * @param {any} node
361
+ * @param {string} segmentText
362
+ * @param {any} nextNode
363
+ * @param {string} nextText
364
+ */
365
+ const joinMovedSegment = (sourceCode, node, segmentText, nextNode, nextText) => {
366
+ const nextLeadingText = nextNode
367
+ ? nextText.slice(0, nextText.length - (getMemberEnd(sourceCode, nextNode) - nextNode.range[0]))
368
+ : nextText;
369
+ const nextStartsOnNewLine = nextLeadingText.includes('\n');
370
+ const lastToken = sourceCode.getLastToken(node);
371
+ const needsSemicolon =
372
+ lastToken?.value !== ';' &&
373
+ !endsWithOwnBlock(node) &&
374
+ nextNode &&
375
+ (!nextStartsOnNewLine || ASI_HAZARD_TOKENS.has(sourceCode.getFirstToken(nextNode)?.value ?? ''));
376
+
377
+ const memberEnd = getMemberEnd(sourceCode, node);
378
+ const nodeEnd = segmentText.length - (memberEnd - node.range[1]);
379
+ const withSemicolon = needsSemicolon ? `${segmentText.slice(0, nodeEnd)};${segmentText.slice(nodeEnd)}` : segmentText;
380
+ const endsWithLineComment = sourceCode
381
+ .getCommentsAfter(node)
382
+ .some((comment) => comment.type === 'Line' && comment.range?.[1] === memberEnd);
383
+
384
+ return endsWithLineComment && !nextStartsOnNewLine ? `${withSemicolon}\n` : withSemicolon;
385
+ };
386
+
348
387
  /**
349
388
  * @param {import('eslint').SourceCode} sourceCode
350
389
  * @param {any} classBodyNode
@@ -373,11 +412,24 @@ const buildClassOrderFix = (sourceCode, classBodyNode, members) => {
373
412
  const rankedIndexes = new Set(members.map((member) => member.originalIndex));
374
413
  let nextSortedMember = 0;
375
414
 
415
+ const order = segmentTexts.map((_, index) =>
416
+ rankedIndexes.has(index) ? sortedMembers[nextSortedMember++].originalIndex : index,
417
+ );
418
+
376
419
  const reorderedBody =
377
- segmentTexts
378
- .map((segmentText, index) =>
379
- rankedIndexes.has(index) ? segmentTexts[sortedMembers[nextSortedMember++].originalIndex] : segmentText,
380
- )
420
+ order
421
+ .map((originalIndex, position) => {
422
+ const nextIndex = order[position + 1] ?? segmentTexts.length;
423
+ if (nextIndex === originalIndex + 1) return segmentTexts[originalIndex];
424
+
425
+ return joinMovedSegment(
426
+ sourceCode,
427
+ classBodyNode.body[originalIndex],
428
+ segmentTexts[originalIndex],
429
+ classBodyNode.body[nextIndex] ?? null,
430
+ segmentTexts[nextIndex] ?? suffix,
431
+ );
432
+ })
381
433
  .join('') + suffix;
382
434
 
383
435
  return (/** @type {import('eslint').Rule.RuleFixer} */ fixer) =>
@@ -10,6 +10,8 @@
10
10
  * loadComponent: () => import('./items-list-view/items-list-view.component').then((m) => m.ItemsListViewComponent)
11
11
  */
12
12
 
13
+ const VIEW_PATH = /-view(?![\w-])/;
14
+
13
15
  /** @type {import('eslint').Rule.RuleModule} */
14
16
  const enforceRoutingViewNaming = {
15
17
  meta: {
@@ -17,6 +19,7 @@ const enforceRoutingViewNaming = {
17
19
  docs: {
18
20
  description:
19
21
  'Routing components must import from a path containing "-view" and use a class name ending in "ViewComponent".',
22
+ recommended: true,
20
23
  },
21
24
  messages: {
22
25
  pathMustContainView: 'Routing import path must contain "-view". Got: "{{path}}".',
@@ -25,48 +28,56 @@ const enforceRoutingViewNaming = {
25
28
  schema: [],
26
29
  },
27
30
  create(context) {
31
+ /** @param {any} importExpr */
32
+ const checkImportPath = (importExpr) => {
33
+ if (!importExpr || importExpr.type !== 'ImportExpression') return;
34
+
35
+ const source = importExpr.source;
36
+ if (source && source.type === 'Literal' && typeof source.value === 'string' && !VIEW_PATH.test(source.value)) {
37
+ context.report({
38
+ node: source,
39
+ messageId: 'pathMustContainView',
40
+ data: { path: source.value },
41
+ });
42
+ }
43
+ };
44
+
28
45
  return {
29
46
  Property(node) {
30
47
  const key = /** @type {any} */ (node).key;
31
48
  if (key.type !== 'Identifier' || key.name !== 'loadComponent') return;
32
49
 
33
- // Must be an arrow function: () => import(...).then(m => m.XxxViewComponent)
34
50
  const arrow = /** @type {any} */ (node).value;
35
51
  if (!arrow || arrow.type !== 'ArrowFunctionExpression') return;
36
52
 
37
53
  const body = arrow.body;
38
- if (!body || body.type !== 'CallExpression') return;
54
+ if (!body) return;
55
+
56
+ if (body.type === 'ImportExpression') {
57
+ checkImportPath(body);
58
+ return;
59
+ }
60
+
61
+ if (body.type !== 'CallExpression') return;
39
62
 
40
63
  const callee = body.callee;
41
64
  if (!callee || callee.type !== 'MemberExpression') return;
42
65
  if (callee.property.type !== 'Identifier' || callee.property.name !== 'then') return;
43
66
 
44
- // Check the import path contains "-view"
45
- const importExpr = callee.object;
46
- if (importExpr && importExpr.type === 'ImportExpression') {
47
- const source = importExpr.source;
48
- if (source && source.type === 'Literal' && typeof source.value === 'string') {
49
- if (!source.value.includes('-view')) {
50
- context.report({
51
- node: source,
52
- messageId: 'pathMustContainView',
53
- data: { path: source.value },
54
- });
55
- }
56
- }
57
- }
58
-
59
- // Check the class name in .then(m => m.XxxViewComponent)
60
- const args = body.arguments;
61
- if (!args || args.length === 0) return;
67
+ checkImportPath(callee.object);
62
68
 
63
- const callback = args[0];
69
+ const callback = body.arguments?.[0];
64
70
  if (!callback || callback.type !== 'ArrowFunctionExpression') return;
65
71
 
66
72
  const cbBody = callback.body;
67
- if (!cbBody || cbBody.type !== 'MemberExpression') return;
73
+ const returned =
74
+ cbBody?.type === 'BlockStatement'
75
+ ? cbBody.body.find(/** @param {any} statement */ (statement) => statement.type === 'ReturnStatement')
76
+ ?.argument
77
+ : cbBody;
78
+ if (!returned || returned.type !== 'MemberExpression') return;
68
79
 
69
- const prop = cbBody.property;
80
+ const prop = returned.property;
70
81
  if (!prop || prop.type !== 'Identifier') return;
71
82
 
72
83
  if (!prop.name.endsWith('ViewComponent')) {
@@ -25,6 +25,7 @@ const rule = {
25
25
  type: 'layout',
26
26
  docs: {
27
27
  description: 'Require an empty line before a return in a multi-statement if-block (guard clause).',
28
+ recommended: true,
28
29
  },
29
30
  fixable: 'whitespace',
30
31
  schema: [],
@@ -37,18 +38,14 @@ const rule = {
37
38
  ReturnStatement(node) {
38
39
  const blockParent = node.parent;
39
40
 
40
- // Must be directly inside a BlockStatement
41
41
  if (!blockParent || blockParent.type !== 'BlockStatement') return;
42
42
 
43
- // The block's parent must be an IfStatement's consequent
44
43
  const ifStatement = blockParent.parent;
45
44
  if (!ifStatement || ifStatement.type !== 'IfStatement') return;
46
45
  if (ifStatement.consequent !== blockParent) return;
47
46
 
48
- // Block must have more than one statement (multi-statement guard)
49
47
  if (blockParent.body.length <= 1) return;
50
48
 
51
- // The return must be the last statement in the block
52
49
  if (blockParent.body[blockParent.body.length - 1] !== node) return;
53
50
 
54
51
  const prevStatement = blockParent.body[blockParent.body.length - 2];
@@ -56,11 +53,11 @@ const rule = {
56
53
  if (!node.loc || !prevStatement.loc) return;
57
54
 
58
55
  const prevEndLine = prevStatement.loc.end.line;
59
- const returnStartLine = node.loc.start.line;
56
+ const sourceCode = context.sourceCode;
60
57
 
61
- if (returnStartLine - prevEndLine >= 2) return;
58
+ const textBetween = sourceCode.text.slice(prevStatement.range[1], node.range[0]);
62
59
 
63
- const sourceCode = context.sourceCode;
60
+ if (/\n[ \t]*\r?\n/.test(textBetween)) return;
64
61
 
65
62
  context.report({
66
63
  node,
@@ -92,6 +92,28 @@ const readFileIfExists = (filePath) => {
92
92
  return null;
93
93
  };
94
94
 
95
+ /** @type {WeakMap<import('eslint').SourceCode, Map<string, string | null>>} */
96
+ const TEMPLATE_TEXT_CACHE = new WeakMap();
97
+
98
+ /**
99
+ * @param {import('eslint').SourceCode} sourceCode
100
+ * @param {string} templatePath
101
+ */
102
+ const readTemplateText = (sourceCode, templatePath) => {
103
+ let templates = TEMPLATE_TEXT_CACHE.get(sourceCode);
104
+
105
+ if (!templates) {
106
+ templates = new Map();
107
+ TEMPLATE_TEXT_CACHE.set(sourceCode, templates);
108
+ }
109
+
110
+ if (!templates.has(templatePath)) {
111
+ templates.set(templatePath, readFileIfExists(templatePath));
112
+ }
113
+
114
+ return templates.get(templatePath) ?? null;
115
+ };
116
+
95
117
  /**
96
118
  * @param {import('eslint').Rule.RuleContext} context
97
119
  */
@@ -183,7 +205,7 @@ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) =
183
205
 
184
206
  if (templateUrl && filename) {
185
207
  const templatePath = path.resolve(path.dirname(filename), templateUrl);
186
- const templateText = readFileIfExists(templatePath);
208
+ const templateText = readTemplateText(context.sourceCode, templatePath);
187
209
  if (templateReferencesMember(memberName, templateText)) {
188
210
  return true;
189
211
  }
@@ -209,7 +231,7 @@ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) =
209
231
  * @param {any} node
210
232
  */
211
233
  const getMemberName = (node) => {
212
- if (node.key?.type === 'Identifier') return node.key.name;
234
+ if (node.key?.type === 'Identifier' && !node.computed) return node.key.name;
213
235
  if (node.key?.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
214
236
 
215
237
  return null;