@ethlete/eslint-plugin 1.0.0-next.22 → 1.0.0-next.24

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 (61) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +4 -5
  3. package/src/configs/recommended.js +23 -10
  4. package/src/index.js +6 -1
  5. package/src/rules/angular-decorator-property-order.js +20 -20
  6. package/src/rules/class-constant-property.js +35 -14
  7. package/src/rules/class-member-order.js +77 -46
  8. package/src/rules/guard-return-newline.js +5 -4
  9. package/src/rules/inject-member-accessibility.js +22 -24
  10. package/src/rules/internals/angular-io.js +31 -14
  11. package/src/rules/internals/angular-member-visibility.js +16 -12
  12. package/src/rules/internals/angular-metadata-fix.js +22 -0
  13. package/src/rules/internals/import-resolution.js +191 -0
  14. package/src/rules/internals/member-accessibility-fix.js +26 -0
  15. package/src/rules/no-angular-router-api.js +23 -29
  16. package/src/rules/no-angular-seo-services.js +21 -16
  17. package/src/rules/no-cdk-import.js +7 -14
  18. package/src/rules/no-direct-dom-manipulation.js +17 -0
  19. package/src/rules/no-document-cookie.js +3 -0
  20. package/src/rules/no-effect-cleanup-return.js +15 -9
  21. package/src/rules/no-empty-angular-metadata-arrays.js +6 -36
  22. package/src/rules/no-enum.js +115 -0
  23. package/src/rules/no-inject-chain.js +3 -1
  24. package/src/rules/no-leading-underscore-class-member.js +33 -3
  25. package/src/rules/no-legacy-angular-decorators.js +17 -21
  26. package/src/rules/no-legacy-prepare-without-injector.js +42 -26
  27. package/src/rules/no-legacy-query-import.js +7 -16
  28. package/src/rules/no-locale-id.js +6 -18
  29. package/src/rules/no-member-alias.js +21 -0
  30. package/src/rules/no-native-html-input-name.js +1 -1
  31. package/src/rules/no-pipe-logic.js +11 -17
  32. package/src/rules/no-readonly-signal.js +13 -5
  33. package/src/rules/no-redundant-internal.js +21 -2
  34. package/src/rules/no-redundant-on-push-change-detection.js +32 -56
  35. package/src/rules/no-rxjs-in-effect.js +12 -8
  36. package/src/rules/no-screaming-case-local.js +1 -0
  37. package/src/rules/no-standalone-flag.js +7 -36
  38. package/src/rules/no-subscribe-in-pipe.js +3 -2
  39. package/src/rules/no-template-literal-before-inline-template.js +3 -14
  40. package/src/rules/no-trivial-return-type.js +2 -0
  41. package/src/rules/no-trivial-wrapper-method.js +13 -16
  42. package/src/rules/no-type-only-import.js +7 -2
  43. package/src/rules/no-typed-injected-element-ref.js +23 -19
  44. package/src/rules/no-unused-class-member.js +34 -14
  45. package/src/rules/prefer-clone-equal.js +8 -6
  46. package/src/rules/prefer-concise-angular-host-directives.js +23 -29
  47. package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
  48. package/src/rules/prefer-element-dimensions.js +9 -10
  49. package/src/rules/prefer-linked-signal.js +7 -4
  50. package/src/rules/prefer-match-media.js +12 -16
  51. package/src/rules/prefer-present-tense-output.js +1 -1
  52. package/src/rules/prefer-rxjs-timer.js +3 -1
  53. package/src/rules/prefer-scroll-state.js +3 -67
  54. package/src/rules/prefer-static-boolean-properties.js +30 -0
  55. package/src/rules/prefer-viewport-size.js +1 -7
  56. package/src/rules/require-dollar-suffix.js +7 -4
  57. package/src/rules/require-form-submit.js +2 -28
  58. package/src/rules/require-on-push-change-detection.js +48 -36
  59. package/src/rules/require-view-encapsulation-none.js +48 -34
  60. package/src/rules/take-until-destroyed-last.js +64 -0
  61. package/src/rules/template-member-accessibility.js +12 -27
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.24
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `take-until-destroyed-last` to the recommended config: `takeUntilDestroyed()` must be the last operator in a `.pipe()`, since an operator after it can keep the subscription alive.
8
+
9
+ ### Patch Changes
10
+
11
+ - `class-member-order` now fixes classes with `static` members or index signatures and keeps a trailing comment with its member, and `prefer-concise-angular-host-directives` no longer drops or misplaces comments.
12
+ - Angular and RxJS rules now resolve `Component`, `inject`, `input`, `effect` and friends through their imports, `document` and `setTimeout` match only the globals, and import bans also report re-exports and dynamic imports.
13
+ - Several fixers no longer emit broken code (a dangling `other._value`, a duplicate `styleUrl`, a dropped `host` spread), `no-trivial-return-type` checks function declarations, `no-screaming-case-local` reports a function once, and `standalone` sorts after `selector`.
14
+
15
+ ## 1.0.0-next.23
16
+
17
+ ### Minor Changes
18
+
19
+ - [#3069](https://github.com/ethlete-io/ethdk/pull/3069) [`a9113cb`](https://github.com/ethlete-io/ethdk/commit/a9113cb6ce63942ffce0e26ca7df7f0556a14838) Thanks [@github-actions](https://github.com/apps/github-actions)! - New rule `ethlete/no-enum`. It bans `enum` and `const enum`, and auto-fixes a string enum into a
20
+ const object plus a derived union type.
21
+
22
+ ### Patch Changes
23
+
24
+ - [#3069](https://github.com/ethlete-io/ethdk/pull/3069) [`a9113cb`](https://github.com/ethlete-io/ethdk/commit/a9113cb6ce63942ffce0e26ca7df7f0556a14838) Thanks [@github-actions](https://github.com/apps/github-actions)! - Harden unsafe fixers, reduce contract and test false positives, and correct replacement guidance across the recommended rules.
25
+
3
26
  ## 1.0.0-next.22
4
27
 
5
28
  ### Patch Changes
package/package.json CHANGED
@@ -1,15 +1,14 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.22",
3
+ "version": "1.0.0-next.24",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
7
7
  "peerDependencies": {
8
8
  "@angular-eslint/eslint-plugin-template": ">=21.0.0",
9
- "@angular/core": "22.0.7",
9
+ "@angular/core": ">=21.0.0",
10
10
  "@typescript-eslint/eslint-plugin": ">=8.0.0",
11
11
  "eslint": ">=9.0.0",
12
- "tslib": "2.8.1",
13
- "typescript": "6.0.3"
12
+ "typescript": ">=5.9.0"
14
13
  }
15
- }
14
+ }
@@ -107,11 +107,8 @@ const recommendedTs = {
107
107
 
108
108
  'no-restricted-syntax': [
109
109
  'error',
110
- // No enum — use const object + derived union type
111
- {
112
- selector: 'TSEnumDeclaration',
113
- message: 'No enums. Use a const object + derived union type instead.',
114
- },
110
+ // enum / const enum is owned by ethlete/no-enum, so a lib can keep it on while it works
111
+ // through the rest of this rule's backlog.
115
112
  // No standalone function declarations / expressions — use arrow functions
116
113
  {
117
114
  selector: 'FunctionDeclaration',
@@ -242,9 +239,15 @@ const recommendedTs = {
242
239
  // No .subscribe() inside a .pipe() callback
243
240
  'ethlete/no-subscribe-in-pipe': 'error',
244
241
 
242
+ // takeUntilDestroyed() is the last operator in a .pipe()
243
+ 'ethlete/take-until-destroyed-last': 'error',
244
+
245
245
  // No async/await — asynchronous work is modelled as cold Observables
246
246
  'ethlete/no-async-await': 'error',
247
247
 
248
+ // No enum / const enum — use a const object with `as const` plus a derived union type
249
+ 'ethlete/no-enum': 'error',
250
+
248
251
  // No .subscribe() inside effect() or computed()
249
252
  'ethlete/no-rxjs-in-effect': 'error',
250
253
 
@@ -272,7 +275,7 @@ const recommendedTs = {
272
275
  // No raw IntersectionObserver / MutationObserver / ResizeObserver — use @ethlete/core signal utils
273
276
  'ethlete/no-native-observers': 'error',
274
277
 
275
- // Prefer injectViewportSize() over window.innerWidth / innerHeight / outerWidth / outerHeight
278
+ // Prefer injectViewportSize() over window.innerWidth / innerHeight
276
279
  'ethlete/prefer-viewport-size': 'warn',
277
280
 
278
281
  // Prefer injectMediaQueryIsMatched() / injectBreakpointIsMatched() etc. over .matchMedia()
@@ -297,7 +300,7 @@ const recommendedTs = {
297
300
  'ethlete/no-legacy-angular-decorators': 'error',
298
301
 
299
302
  // No Angular Title/Meta services — use @ethlete/core SEO utilities instead
300
- // (injectTitleBinding, injectMetaBinding, injectLinkBinding, etc.)
303
+ // (applyHeadTitleBinding, applyMetaBinding, applyLinkBinding, etc.)
301
304
  'ethlete/no-angular-seo-services': 'error',
302
305
 
303
306
  // No JSON.parse(JSON.stringify()), structuredClone, or lodash cloneDeep / isEqual
@@ -414,20 +417,30 @@ const recommendedTemplate = {
414
417
  // (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
415
418
  // which a template rule cannot verify)
416
419
  'ethlete/prefer-static-boolean-properties': 'warn',
417
- // A form must handle its own submission, and a type="submit" control must reach a form
420
+ // A form must handle its own submission
418
421
  'ethlete/require-form-submit': 'error',
419
422
  },
420
423
  };
421
424
 
422
425
  /**
423
- * Relaxed rules for test/spec files.
424
- * Non-null assertions are common and intentional in tests.
426
+ * Relaxed rules for test/spec files, including common DOM assertions and async test orchestration.
425
427
  * @type {import('eslint').Linter.Config}
426
428
  */
427
429
  const recommendedSpec = {
428
430
  files: ['**/*.spec.ts'],
429
431
  rules: {
430
432
  '@typescript-eslint/no-non-null-assertion': 'off',
433
+ 'no-restricted-globals': 'off',
434
+ 'ethlete/no-async-await': 'off',
435
+ 'ethlete/no-direct-dom-manipulation': 'off',
436
+ 'ethlete/no-document-cookie': 'off',
437
+ 'ethlete/no-dom-query': 'off',
438
+ 'ethlete/no-native-observers': 'off',
439
+ 'ethlete/no-window-location': 'off',
440
+ 'ethlete/prefer-element-dimensions': 'off',
441
+ 'ethlete/prefer-match-media': 'off',
442
+ 'ethlete/prefer-scroll-state': 'off',
443
+ 'ethlete/prefer-viewport-size': 'off',
431
444
  },
432
445
  };
433
446
 
package/src/index.js CHANGED
@@ -59,19 +59,23 @@ const requireFormSubmit = require('./rules/require-form-submit');
59
59
  const noCdkImport = require('./rules/no-cdk-import');
60
60
  const noLegacyQueryImport = require('./rules/no-legacy-query-import');
61
61
  const noAsyncAwait = require('./rules/no-async-await');
62
+ const noEnum = require('./rules/no-enum');
63
+ const takeUntilDestroyedLast = require('./rules/take-until-destroyed-last');
62
64
  const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
65
+ const { version } = require('../package.json');
63
66
 
64
67
  /** @type {import('eslint').ESLint.Plugin} */
65
68
  const plugin = {
66
69
  meta: {
67
70
  name: '@ethlete/eslint-plugin',
68
- version: '0.0.1',
71
+ version,
69
72
  },
70
73
  rules: {
71
74
  'no-inject-chain': noInjectChain,
72
75
  'no-legacy-prepare-without-injector': noLegacyPrepareWithoutInjector,
73
76
  'no-trivial-return-type': noTrivialReturnType,
74
77
  'no-subscribe-in-pipe': noSubscribeInPipe,
78
+ 'take-until-destroyed-last': takeUntilDestroyedLast,
75
79
  'no-rxjs-in-effect': noRxjsInEffect,
76
80
  'no-effect-cleanup-return': noEffectCleanupReturn,
77
81
  'no-readonly-signal': noReadonlySignal,
@@ -126,6 +130,7 @@ const plugin = {
126
130
  'no-cdk-import': noCdkImport,
127
131
  'no-legacy-query-import': noLegacyQueryImport,
128
132
  'no-async-await': noAsyncAwait,
133
+ 'no-enum': noEnum,
129
134
  },
130
135
  };
131
136
 
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getAngularDecoratorName } = require('./internals/import-resolution');
5
+
4
6
  /** @typedef {'Component' | 'Directive'} TDecoratorName */
5
7
  /** @typedef {{ order: string[]; orderText: string }} TDecoratorConfig */
6
8
  /** @typedef {{ range: [number, number] }} TNodeWithRange */
@@ -14,11 +16,13 @@ const DECORATOR_CONFIG = {
14
16
  Component: {
15
17
  order: [
16
18
  'selector',
19
+ 'standalone',
17
20
  'template',
18
21
  'styleUrl',
19
22
  'encapsulation',
20
23
  'changeDetection',
21
24
  'imports',
25
+ 'deferredImports',
22
26
  'providers',
23
27
  'viewProviders',
24
28
  'animations',
@@ -29,16 +33,28 @@ const DECORATOR_CONFIG = {
29
33
  'outputs',
30
34
  'queries',
31
35
  'preserveWhitespaces',
36
+ 'interpolation',
32
37
  'schemas',
33
38
  'jit',
34
39
  'exportAs',
35
40
  ],
36
41
  orderText:
37
- 'selector, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces, schemas, jit, exportAs',
42
+ 'selector, standalone, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, deferredImports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces, interpolation, schemas, jit, exportAs',
38
43
  },
39
44
  Directive: {
40
- order: ['selector', 'exportAs', 'providers', 'inputs', 'outputs', 'queries', 'hostDirectives', 'host', 'jit'],
41
- orderText: 'selector, exportAs, providers, inputs, outputs, queries, hostDirectives, host, jit',
45
+ order: [
46
+ 'selector',
47
+ 'standalone',
48
+ 'exportAs',
49
+ 'providers',
50
+ 'inputs',
51
+ 'outputs',
52
+ 'queries',
53
+ 'hostDirectives',
54
+ 'host',
55
+ 'jit',
56
+ ],
57
+ orderText: 'selector, standalone, exportAs, providers, inputs, outputs, queries, hostDirectives, host, jit',
42
58
  },
43
59
  };
44
60
 
@@ -66,22 +82,6 @@ const getOrderKey = (decoratorName, propertyName) => {
66
82
  return propertyName;
67
83
  };
68
84
 
69
- /**
70
- * @param {import('eslint').Rule.Node} node
71
- * @returns {TDecoratorName | null}
72
- */
73
- const getDecoratorName = (node) => {
74
- const decorator = /** @type {any} */ (node);
75
- if (decorator.type !== 'Decorator') return null;
76
-
77
- const expression = decorator.expression;
78
- if (expression.type === 'CallExpression') {
79
- return expression.callee.type === 'Identifier' ? expression.callee.name : null;
80
- }
81
-
82
- return expression.type === 'Identifier' ? expression.name : null;
83
- };
84
-
85
85
  /** @type {import('eslint').Rule.RuleModule} */
86
86
  const angularDecoratorPropertyOrder = {
87
87
  meta: {
@@ -101,7 +101,7 @@ const angularDecoratorPropertyOrder = {
101
101
  return {
102
102
  /** @param {import('eslint').Rule.Node} node */
103
103
  Decorator(node) {
104
- const decoratorName = getDecoratorName(node);
104
+ const decoratorName = getAngularDecoratorName(context.sourceCode, node);
105
105
  if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
106
106
 
107
107
  const decorator = /** @type {any} */ (node);
@@ -1,24 +1,18 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getAngularDecoratorName } = require('./internals/import-resolution');
5
+
4
6
  const SCREAMING_CASE_RE = /^[A-Z][A-Z0-9_]*$/;
5
7
 
6
8
  /**
9
+ * @param {import('eslint').SourceCode} sourceCode
7
10
  * @param {any} classNode
8
11
  */
9
- const hasPipeDecorator = (classNode) => {
10
- const decorators = classNode.decorators ?? [];
11
- return decorators.some((/** @type {any} */ dec) => {
12
- const expr = dec.expression;
13
- if (expr.type === 'CallExpression') {
14
- return expr.callee.type === 'Identifier' && expr.callee.name === 'Pipe';
15
- }
16
- if (expr.type === 'Identifier') {
17
- return expr.name === 'Pipe';
18
- }
19
- return false;
20
- });
21
- };
12
+ const hasPipeDecorator = (sourceCode, classNode) =>
13
+ (classNode.decorators ?? []).some(
14
+ (/** @type {any} */ decorator) => getAngularDecoratorName(sourceCode, decorator) === 'Pipe',
15
+ );
22
16
 
23
17
  const REACTIVE_APIS = new Set([
24
18
  'signal',
@@ -249,6 +243,11 @@ const collectWrittenMembers = (node, writtenMembers) => {
249
243
 
250
244
  if (node.type === 'AssignmentExpression') {
251
245
  collectAssignedMemberNames(node.left, writtenMembers);
246
+
247
+ if (node.left.type === 'MemberExpression' && node.left.object.type === 'MemberExpression') {
248
+ const memberName = getWrittenMemberName(node.left.object);
249
+ if (memberName !== null) writtenMembers.add(memberName);
250
+ }
252
251
  }
253
252
 
254
253
  if (node.type === 'UpdateExpression') {
@@ -259,6 +258,20 @@ const collectWrittenMembers = (node, writtenMembers) => {
259
258
  }
260
259
  }
261
260
 
261
+ if (
262
+ node.type === 'CallExpression' &&
263
+ node.callee.type === 'MemberExpression' &&
264
+ node.callee.object.type === 'MemberExpression' &&
265
+ !node.callee.computed &&
266
+ node.callee.property.type === 'Identifier' &&
267
+ ['add', 'clear', 'delete', 'pop', 'push', 'reverse', 'set', 'shift', 'sort', 'splice', 'unshift'].includes(
268
+ node.callee.property.name,
269
+ )
270
+ ) {
271
+ const memberName = getWrittenMemberName(node.callee.object);
272
+ if (memberName !== null) writtenMembers.add(memberName);
273
+ }
274
+
262
275
  for (const [key, value] of Object.entries(node)) {
263
276
  if (key === 'parent' || key === 'range' || key === 'loc') continue;
264
277
  if (!value || typeof value !== 'object') continue;
@@ -326,7 +339,15 @@ const classConstantProperty = {
326
339
  continue;
327
340
  }
328
341
 
329
- if (memberName === 'transform' && hasPipeDecorator(node.parent)) {
342
+ if (
343
+ !member.readonly &&
344
+ ((member.value.type === 'Literal' && member.value.value === null) ||
345
+ (member.value.type === 'Identifier' && member.value.name === 'undefined'))
346
+ ) {
347
+ continue;
348
+ }
349
+
350
+ if (memberName === 'transform' && hasPipeDecorator(context.sourceCode, node.parent)) {
330
351
  continue;
331
352
  }
332
353
 
@@ -1,13 +1,15 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { ANGULAR_CORE, getImportedName } = require('./internals/import-resolution');
5
+
4
6
  /** @typedef {'inject' | 'input' | 'output' | 'query' | 'property' | 'constructor' | 'method' | 'private-method'} TMemberGroup */
5
7
  /** @typedef {{ dependencies: Set<string>; group: TMemberGroup; groupIndex: number; name: string; node: any; originalIndex: number }} TMemberEntry */
6
- /** @typedef {TMemberEntry & { segmentText: string }} TSegmentEntry */
7
8
 
8
9
  const INPUT_APIS = new Set(['input', 'model']);
9
10
  const OUTPUT_APIS = new Set(['output', 'outputFromObservable']);
10
11
  const QUERY_APIS = new Set(['viewChild', 'viewChildren', 'contentChild', 'contentChildren']);
12
+ const ANGULAR_API_SOURCES = [ANGULAR_CORE, '@angular/core/rxjs-interop'];
11
13
 
12
14
  /** @type {TMemberGroup[]} */
13
15
  const MEMBER_GROUP_ORDER = [
@@ -71,49 +73,70 @@ const getCallRootName = (value) => {
71
73
  };
72
74
 
73
75
  /**
76
+ * @param {import('eslint').SourceCode} sourceCode
77
+ * @param {any} value
78
+ */
79
+ const getAngularCallName = (sourceCode, value) => {
80
+ if (!value || value.type !== 'CallExpression') return null;
81
+
82
+ const { callee } = value;
83
+ const isRequired =
84
+ callee.type === 'MemberExpression' &&
85
+ !callee.computed &&
86
+ callee.property.type === 'Identifier' &&
87
+ callee.property.name === 'required';
88
+
89
+ return getImportedName(sourceCode, isRequired ? callee.object : callee, ANGULAR_API_SOURCES);
90
+ };
91
+
92
+ /**
93
+ * @param {import('eslint').SourceCode} sourceCode
74
94
  * @param {any} node
75
95
  * @param {Set<string>} apiNames
76
96
  */
77
- const isPropertyInitializedWith = (node, apiNames) => {
97
+ const isPropertyInitializedWith = (sourceCode, node, apiNames) => {
78
98
  if (node.type !== 'PropertyDefinition') return false;
79
99
 
80
- const apiName = getCallRootName(node.value);
100
+ const apiName = getAngularCallName(sourceCode, node.value);
81
101
  return apiName !== null && apiNames.has(apiName);
82
102
  };
83
103
 
84
104
  const INJECT_HELPER_NAME_PATTERN = /^inject[A-Z]/;
85
105
 
86
106
  /**
107
+ * @param {import('eslint').SourceCode} sourceCode
87
108
  * @param {any} node
88
109
  */
89
- const isPropertyInitializedWithInject = (node) => {
110
+ const isPropertyInitializedWithInject = (sourceCode, node) => {
90
111
  if (node.type !== 'PropertyDefinition') return false;
112
+ if (getAngularCallName(sourceCode, node.value) === 'inject') return true;
91
113
 
92
- const apiName = getCallRootName(node.value);
93
- return apiName !== null && (apiName === 'inject' || INJECT_HELPER_NAME_PATTERN.test(apiName));
114
+ const rootName = getCallRootName(node.value);
115
+ return rootName !== null && INJECT_HELPER_NAME_PATTERN.test(rootName);
94
116
  };
95
117
 
96
118
  /**
119
+ * @param {import('eslint').SourceCode} sourceCode
97
120
  * @param {any} node
98
121
  * @returns {TMemberGroup | null}
99
122
  */
100
- const getMemberGroup = (node) => {
123
+ const getMemberGroup = (sourceCode, node) => {
101
124
  if (node.static) return null;
102
125
 
103
126
  if (node.type === 'PropertyDefinition') {
104
- if (isPropertyInitializedWithInject(node)) {
127
+ if (isPropertyInitializedWithInject(sourceCode, node)) {
105
128
  return 'inject';
106
129
  }
107
130
 
108
- if (isPropertyInitializedWith(node, INPUT_APIS)) {
131
+ if (isPropertyInitializedWith(sourceCode, node, INPUT_APIS)) {
109
132
  return 'input';
110
133
  }
111
134
 
112
- if (isPropertyInitializedWith(node, OUTPUT_APIS)) {
135
+ if (isPropertyInitializedWith(sourceCode, node, OUTPUT_APIS)) {
113
136
  return 'output';
114
137
  }
115
138
 
116
- if (isPropertyInitializedWith(node, QUERY_APIS)) {
139
+ if (isPropertyInitializedWith(sourceCode, node, QUERY_APIS)) {
117
140
  return 'query';
118
141
  }
119
142
 
@@ -188,12 +211,13 @@ const getInitializerDependencies = (node) => {
188
211
  };
189
212
 
190
213
  /**
214
+ * @param {import('eslint').SourceCode} sourceCode
191
215
  * @param {any} node
192
216
  * @param {number} originalIndex
193
217
  * @returns {TMemberEntry | null}
194
218
  */
195
- const toMemberEntry = (node, originalIndex) => {
196
- const group = getMemberGroup(node);
219
+ const toMemberEntry = (sourceCode, node, originalIndex) => {
220
+ const group = getMemberGroup(sourceCode, node);
197
221
  const name = getMemberName(node);
198
222
 
199
223
  if (group === null || name === null) {
@@ -273,12 +297,24 @@ const sortMembers = (members) => {
273
297
  return sortedMembers;
274
298
  };
275
299
 
300
+ /**
301
+ * @param {import('eslint').SourceCode} sourceCode
302
+ * @param {any} node
303
+ */
304
+ const getMemberEnd = (sourceCode, node) => {
305
+ const trailingComments = sourceCode
306
+ .getCommentsAfter(node)
307
+ .filter((comment) => comment.loc?.start.line === node.loc.end.line);
308
+ const lastComment = trailingComments.at(-1);
309
+
310
+ return lastComment?.range ? lastComment.range[1] : node.range[1];
311
+ };
312
+
276
313
  /**
277
314
  * @param {import('eslint').SourceCode} sourceCode
278
315
  * @param {any} classBodyNode
279
- * @param {TMemberEntry[]} members
280
316
  */
281
- const getSegmentEntries = (sourceCode, classBodyNode, members) => {
317
+ const getSegments = (sourceCode, classBodyNode) => {
282
318
  const openingBrace = sourceCode.getFirstToken(classBodyNode);
283
319
  const closingBrace = sourceCode.getLastToken(classBodyNode);
284
320
 
@@ -288,22 +324,23 @@ const getSegmentEntries = (sourceCode, classBodyNode, members) => {
288
324
 
289
325
  let segmentStart = openingBrace.range[1];
290
326
 
291
- const segmentEntries = members.map((member) => {
292
- const segmentText = sourceCode.text.slice(segmentStart, member.node.range[1]);
293
- segmentStart = member.node.range[1];
327
+ /** @type {string[]} */
328
+ const segmentTexts = classBodyNode.body.map(
329
+ /** @param {any} node */ (node) => {
330
+ const segmentEnd = getMemberEnd(sourceCode, node);
331
+ const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
332
+ segmentStart = segmentEnd;
294
333
 
295
- return {
296
- ...member,
297
- segmentText,
298
- };
299
- });
334
+ return segmentText;
335
+ },
336
+ );
300
337
 
301
338
  const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
302
339
 
303
340
  return {
304
341
  closingBrace,
305
342
  openingBrace,
306
- segmentEntries,
343
+ segmentTexts,
307
344
  suffix,
308
345
  };
309
346
  };
@@ -314,43 +351,37 @@ const getSegmentEntries = (sourceCode, classBodyNode, members) => {
314
351
  * @param {TMemberEntry[]} members
315
352
  */
316
353
  const buildClassOrderFix = (sourceCode, classBodyNode, members) => {
317
- if (classBodyNode.body.length !== members.length) {
318
- return null;
319
- }
320
-
321
354
  const sortedMembers = sortMembers(members);
322
355
 
323
356
  if (!sortedMembers) {
324
357
  return null;
325
358
  }
326
359
 
327
- const segments = getSegmentEntries(sourceCode, classBodyNode, members);
360
+ const isAlreadySorted = sortedMembers.every((member, index) => member === members[index]);
328
361
 
329
- if (!segments) {
362
+ if (isAlreadySorted) {
330
363
  return null;
331
364
  }
332
365
 
333
- const { closingBrace, openingBrace, segmentEntries, suffix } = segments;
334
- const segmentEntriesByOriginalIndex = new Map(
335
- segmentEntries.map(
336
- /** @param {TSegmentEntry} segmentEntry */ (segmentEntry) => [segmentEntry.originalIndex, segmentEntry],
337
- ),
338
- );
339
- const sortedSegmentEntries = sortedMembers.map((member) => segmentEntriesByOriginalIndex.get(member.originalIndex));
366
+ const segments = getSegments(sourceCode, classBodyNode);
340
367
 
341
- if (sortedSegmentEntries.some((entry) => entry === undefined)) {
368
+ if (!segments) {
342
369
  return null;
343
370
  }
344
371
 
345
- const isAlreadySorted = sortedSegmentEntries.every((entry, index) => entry === segmentEntries[index]);
346
-
347
- if (isAlreadySorted) {
348
- return null;
349
- }
372
+ const { closingBrace, openingBrace, segmentTexts, suffix } = segments;
373
+ const rankedIndexes = new Set(members.map((member) => member.originalIndex));
374
+ let nextSortedMember = 0;
350
375
 
351
- const reorderedBody = sortedSegmentEntries.map((entry) => entry.segmentText).join('') + suffix;
376
+ const reorderedBody =
377
+ segmentTexts
378
+ .map((segmentText, index) =>
379
+ rankedIndexes.has(index) ? segmentTexts[sortedMembers[nextSortedMember++].originalIndex] : segmentText,
380
+ )
381
+ .join('') + suffix;
352
382
 
353
- return (fixer) => fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
383
+ return (/** @type {import('eslint').Rule.RuleFixer} */ fixer) =>
384
+ fixer.replaceTextRange([openingBrace.range[1], closingBrace.range[0]], reorderedBody);
354
385
  };
355
386
 
356
387
  /** @type {import('eslint').Rule.RuleModule} */
@@ -379,7 +410,7 @@ const classMemberOrder = {
379
410
  const members = node.body
380
411
  .map(
381
412
  /** @param {any} member @param {number} originalIndex */ (member, originalIndex) =>
382
- toMemberEntry(member, originalIndex),
413
+ toMemberEntry(sourceCode, member, originalIndex),
383
414
  )
384
415
  .filter(Boolean);
385
416
 
@@ -58,7 +58,6 @@ const rule = {
58
58
  const prevEndLine = prevStatement.loc.end.line;
59
59
  const returnStartLine = node.loc.start.line;
60
60
 
61
- // Need at least one empty line (gap of 2+ lines) between prev statement and return
62
61
  if (returnStartLine - prevEndLine >= 2) return;
63
62
 
64
63
  const sourceCode = context.sourceCode;
@@ -67,10 +66,12 @@ const rule = {
67
66
  node,
68
67
  messageId: 'missingEmptyLine',
69
68
  fix(fixer) {
70
- const prevToken = sourceCode.getLastToken(/** @type {import('eslint').Rule.Node} */ (prevStatement));
71
- if (!prevToken) return null;
69
+ const trailingComment = sourceCode
70
+ .getCommentsAfter(/** @type {import('eslint').Rule.Node} */ (prevStatement))
71
+ .find((comment) => comment.loc.start.line === prevEndLine && comment.range[1] < node.range[0]);
72
+ const insertionNode = trailingComment ?? prevStatement;
72
73
 
73
- return fixer.insertTextAfter(prevToken, '\n');
74
+ return fixer.insertTextAfter(insertionNode, '\n');
74
75
  },
75
76
  });
76
77
  },