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

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 (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +3 -4
  3. package/src/configs/recommended.js +20 -10
  4. package/src/index.js +4 -1
  5. package/src/rules/class-constant-property.js +27 -0
  6. package/src/rules/guard-return-newline.js +5 -4
  7. package/src/rules/inject-member-accessibility.js +17 -21
  8. package/src/rules/internals/angular-io.js +19 -1
  9. package/src/rules/internals/angular-member-visibility.js +5 -5
  10. package/src/rules/internals/angular-metadata-fix.js +22 -0
  11. package/src/rules/internals/member-accessibility-fix.js +26 -0
  12. package/src/rules/no-angular-seo-services.js +7 -7
  13. package/src/rules/no-direct-dom-manipulation.js +17 -0
  14. package/src/rules/no-effect-cleanup-return.js +8 -6
  15. package/src/rules/no-empty-angular-metadata-arrays.js +3 -19
  16. package/src/rules/no-enum.js +115 -0
  17. package/src/rules/no-leading-underscore-class-member.js +7 -0
  18. package/src/rules/no-legacy-angular-decorators.js +8 -0
  19. package/src/rules/no-legacy-prepare-without-injector.js +42 -26
  20. package/src/rules/no-legacy-query-import.js +1 -1
  21. package/src/rules/no-locale-id.js +2 -14
  22. package/src/rules/no-member-alias.js +17 -0
  23. package/src/rules/no-pipe-logic.js +1 -0
  24. package/src/rules/no-readonly-signal.js +13 -5
  25. package/src/rules/no-redundant-internal.js +21 -2
  26. package/src/rules/no-redundant-on-push-change-detection.js +28 -38
  27. package/src/rules/no-standalone-flag.js +4 -19
  28. package/src/rules/no-trivial-return-type.js +1 -0
  29. package/src/rules/no-trivial-wrapper-method.js +3 -0
  30. package/src/rules/no-type-only-import.js +7 -2
  31. package/src/rules/no-typed-injected-element-ref.js +16 -9
  32. package/src/rules/no-unused-class-member.js +28 -5
  33. package/src/rules/prefer-match-media.js +2 -2
  34. package/src/rules/prefer-scroll-state.js +3 -67
  35. package/src/rules/prefer-static-boolean-properties.js +30 -0
  36. package/src/rules/prefer-viewport-size.js +1 -7
  37. package/src/rules/require-form-submit.js +2 -28
  38. package/src/rules/require-on-push-change-detection.js +31 -11
  39. package/src/rules/require-view-encapsulation-none.js +31 -9
  40. package/src/rules/template-member-accessibility.js +4 -21
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.23
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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
8
+ const object plus a derived union type.
9
+
10
+ ### Patch Changes
11
+
12
+ - [#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.
13
+
3
14
  ## 1.0.0-next.22
4
15
 
5
16
  ### 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.23",
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',
@@ -245,6 +242,9 @@ const recommendedTs = {
245
242
  // No async/await — asynchronous work is modelled as cold Observables
246
243
  'ethlete/no-async-await': 'error',
247
244
 
245
+ // No enum / const enum — use a const object with `as const` plus a derived union type
246
+ 'ethlete/no-enum': 'error',
247
+
248
248
  // No .subscribe() inside effect() or computed()
249
249
  'ethlete/no-rxjs-in-effect': 'error',
250
250
 
@@ -272,7 +272,7 @@ const recommendedTs = {
272
272
  // No raw IntersectionObserver / MutationObserver / ResizeObserver — use @ethlete/core signal utils
273
273
  'ethlete/no-native-observers': 'error',
274
274
 
275
- // Prefer injectViewportSize() over window.innerWidth / innerHeight / outerWidth / outerHeight
275
+ // Prefer injectViewportSize() over window.innerWidth / innerHeight
276
276
  'ethlete/prefer-viewport-size': 'warn',
277
277
 
278
278
  // Prefer injectMediaQueryIsMatched() / injectBreakpointIsMatched() etc. over .matchMedia()
@@ -297,7 +297,7 @@ const recommendedTs = {
297
297
  'ethlete/no-legacy-angular-decorators': 'error',
298
298
 
299
299
  // No Angular Title/Meta services — use @ethlete/core SEO utilities instead
300
- // (injectTitleBinding, injectMetaBinding, injectLinkBinding, etc.)
300
+ // (applyHeadTitleBinding, applyMetaBinding, applyLinkBinding, etc.)
301
301
  'ethlete/no-angular-seo-services': 'error',
302
302
 
303
303
  // No JSON.parse(JSON.stringify()), structuredClone, or lodash cloneDeep / isEqual
@@ -414,20 +414,30 @@ const recommendedTemplate = {
414
414
  // (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
415
415
  // which a template rule cannot verify)
416
416
  'ethlete/prefer-static-boolean-properties': 'warn',
417
- // A form must handle its own submission, and a type="submit" control must reach a form
417
+ // A form must handle its own submission
418
418
  'ethlete/require-form-submit': 'error',
419
419
  },
420
420
  };
421
421
 
422
422
  /**
423
- * Relaxed rules for test/spec files.
424
- * Non-null assertions are common and intentional in tests.
423
+ * Relaxed rules for test/spec files, including common DOM assertions and async test orchestration.
425
424
  * @type {import('eslint').Linter.Config}
426
425
  */
427
426
  const recommendedSpec = {
428
427
  files: ['**/*.spec.ts'],
429
428
  rules: {
430
429
  '@typescript-eslint/no-non-null-assertion': 'off',
430
+ 'no-restricted-globals': 'off',
431
+ 'ethlete/no-async-await': 'off',
432
+ 'ethlete/no-direct-dom-manipulation': 'off',
433
+ 'ethlete/no-document-cookie': 'off',
434
+ 'ethlete/no-dom-query': 'off',
435
+ 'ethlete/no-native-observers': 'off',
436
+ 'ethlete/no-window-location': 'off',
437
+ 'ethlete/prefer-element-dimensions': 'off',
438
+ 'ethlete/prefer-match-media': 'off',
439
+ 'ethlete/prefer-scroll-state': 'off',
440
+ 'ethlete/prefer-viewport-size': 'off',
431
441
  },
432
442
  };
433
443
 
package/src/index.js CHANGED
@@ -59,13 +59,15 @@ 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');
62
63
  const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
64
+ const { version } = require('../package.json');
63
65
 
64
66
  /** @type {import('eslint').ESLint.Plugin} */
65
67
  const plugin = {
66
68
  meta: {
67
69
  name: '@ethlete/eslint-plugin',
68
- version: '0.0.1',
70
+ version,
69
71
  },
70
72
  rules: {
71
73
  'no-inject-chain': noInjectChain,
@@ -126,6 +128,7 @@ const plugin = {
126
128
  'no-cdk-import': noCdkImport,
127
129
  'no-legacy-query-import': noLegacyQueryImport,
128
130
  'no-async-await': noAsyncAwait,
131
+ 'no-enum': noEnum,
129
132
  },
130
133
  };
131
134
 
@@ -249,6 +249,11 @@ const collectWrittenMembers = (node, writtenMembers) => {
249
249
 
250
250
  if (node.type === 'AssignmentExpression') {
251
251
  collectAssignedMemberNames(node.left, writtenMembers);
252
+
253
+ if (node.left.type === 'MemberExpression' && node.left.object.type === 'MemberExpression') {
254
+ const memberName = getWrittenMemberName(node.left.object);
255
+ if (memberName !== null) writtenMembers.add(memberName);
256
+ }
252
257
  }
253
258
 
254
259
  if (node.type === 'UpdateExpression') {
@@ -259,6 +264,20 @@ const collectWrittenMembers = (node, writtenMembers) => {
259
264
  }
260
265
  }
261
266
 
267
+ if (
268
+ node.type === 'CallExpression' &&
269
+ node.callee.type === 'MemberExpression' &&
270
+ node.callee.object.type === 'MemberExpression' &&
271
+ !node.callee.computed &&
272
+ node.callee.property.type === 'Identifier' &&
273
+ ['add', 'clear', 'delete', 'pop', 'push', 'reverse', 'set', 'shift', 'sort', 'splice', 'unshift'].includes(
274
+ node.callee.property.name,
275
+ )
276
+ ) {
277
+ const memberName = getWrittenMemberName(node.callee.object);
278
+ if (memberName !== null) writtenMembers.add(memberName);
279
+ }
280
+
262
281
  for (const [key, value] of Object.entries(node)) {
263
282
  if (key === 'parent' || key === 'range' || key === 'loc') continue;
264
283
  if (!value || typeof value !== 'object') continue;
@@ -326,6 +345,14 @@ const classConstantProperty = {
326
345
  continue;
327
346
  }
328
347
 
348
+ if (
349
+ !member.readonly &&
350
+ ((member.value.type === 'Literal' && member.value.value === null) ||
351
+ (member.value.type === 'Identifier' && member.value.name === 'undefined'))
352
+ ) {
353
+ continue;
354
+ }
355
+
329
356
  if (memberName === 'transform' && hasPipeDecorator(node.parent)) {
330
357
  continue;
331
358
  }
@@ -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
  },
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
5
+ const { buildAccessibilityFix } = require('./internals/member-accessibility-fix');
5
6
 
6
7
  /**
7
8
  * @param {any} node
@@ -12,25 +13,6 @@ const isInjectCall = (node) => {
12
13
  return node.value.callee.type === 'Identifier' && node.value.callee.name === 'inject';
13
14
  };
14
15
 
15
- /**
16
- * @param {any} node
17
- * @param {import('eslint').SourceCode} sourceCode
18
- * @param {'private' | 'protected'} expectedAccessibility
19
- */
20
- const buildFix = (node, sourceCode, expectedAccessibility) => {
21
- const keyToken = sourceCode.getFirstToken(node.key);
22
- if (!keyToken) return null;
23
-
24
- if (node.accessibility) {
25
- const firstToken = sourceCode.getFirstToken(node);
26
- if (!firstToken) return null;
27
-
28
- return (fixer) => fixer.replaceText(firstToken, expectedAccessibility);
29
- }
30
-
31
- return (fixer) => fixer.insertTextBefore(keyToken, `${expectedAccessibility} `);
32
- };
33
-
34
16
  /** @type {import('eslint').Rule.RuleModule} */
35
17
  const injectMemberAccessibility = {
36
18
  meta: {
@@ -63,6 +45,7 @@ const injectMemberAccessibility = {
63
45
  if (!memberName) return;
64
46
 
65
47
  const isTemplateOrHostVisible = isReferencedFromTemplateOrHost(classNode, memberName, context);
48
+ const isAbstractClass = classNode.abstract === true;
66
49
 
67
50
  if (isTemplateOrHostVisible) {
68
51
  if (node.accessibility === 'protected' || node.accessibility === 'public') return;
@@ -71,7 +54,7 @@ const injectMemberAccessibility = {
71
54
  node,
72
55
  messageId: 'shouldBeProtected',
73
56
  data: { name: memberName },
74
- fix: buildFix(node, sourceCode, 'protected'),
57
+ fix: buildAccessibilityFix(node, sourceCode, 'protected'),
75
58
  });
76
59
 
77
60
  return;
@@ -79,11 +62,24 @@ const injectMemberAccessibility = {
79
62
 
80
63
  if (node.accessibility === 'private' || node.accessibility === 'public') return;
81
64
 
65
+ if (isAbstractClass) {
66
+ if (node.accessibility === 'protected') return;
67
+
68
+ context.report({
69
+ node,
70
+ messageId: 'shouldBeProtected',
71
+ data: { name: memberName },
72
+ fix: buildAccessibilityFix(node, sourceCode, 'protected'),
73
+ });
74
+
75
+ return;
76
+ }
77
+
82
78
  context.report({
83
79
  node,
84
80
  messageId: 'shouldBePrivate',
85
81
  data: { name: memberName },
86
- fix: buildFix(node, sourceCode, 'private'),
82
+ fix: buildAccessibilityFix(node, sourceCode, 'private'),
87
83
  });
88
84
  },
89
85
  };
@@ -69,10 +69,28 @@ const getReactiveIo = (node) => {
69
69
  const factory = getFactoryName(value.callee);
70
70
  if (!factory) return null;
71
71
 
72
+ if (!Object.hasOwn(IO_FACTORIES, factory)) return null;
72
73
  const kind = /** @type {Record<string, 'input' | 'model' | 'output'>} */ (IO_FACTORIES)[factory];
73
74
  if (!kind) return null;
74
75
 
75
- return { kind, name: node.key.name, keyNode: node.key };
76
+ let name = node.key.name;
77
+ for (const argument of value.arguments) {
78
+ if (argument.type !== 'ObjectExpression') continue;
79
+
80
+ const aliasProperty = argument.properties.find(
81
+ (property) =>
82
+ property.type === 'Property' &&
83
+ !property.computed &&
84
+ ((property.key.type === 'Identifier' && property.key.name === 'alias') ||
85
+ (property.key.type === 'Literal' && property.key.value === 'alias')),
86
+ );
87
+ if (aliasProperty?.value.type === 'Literal' && typeof aliasProperty.value.value === 'string') {
88
+ name = aliasProperty.value.value;
89
+ break;
90
+ }
91
+ }
92
+
93
+ return { kind, name, keyNode: node.key };
76
94
  };
77
95
 
78
96
  module.exports = { getReactiveIo };
@@ -67,8 +67,8 @@ const getStringValue = (node) => {
67
67
  if (!node) return null;
68
68
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
69
69
 
70
- if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
71
- return node.quasis[0].value.cooked || '';
70
+ if (node.type === 'TemplateLiteral') {
71
+ return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(' ');
72
72
  }
73
73
 
74
74
  return null;
@@ -111,9 +111,9 @@ const templateReferencesMember = (memberName, template) => {
111
111
 
112
112
  const escapedName = escapeRegExp(memberName);
113
113
  const patterns = [
114
- new RegExp(`\\{\\{[\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\}\\}`, 'u'),
115
- new RegExp(`@[a-zA-Z]+\\s*\\([\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\)`, 'u'),
116
- new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[\\s\\S]*?\\b${escapedName}\\b`, 'u'),
114
+ new RegExp(`\\{\\{[^{}]*\\b${escapedName}\\b[^{}]*\\}\\}`, 'u'),
115
+ new RegExp(`@[a-zA-Z]+(?:\\s+if)?\\s*\\([^)]*\\b${escapedName}\\b[^)]*\\)`, 'u'),
116
+ new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[^;]*\\b${escapedName}\\b`, 'u'),
117
117
  new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
118
118
  new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
119
119
  new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
@@ -0,0 +1,22 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * @param {import('eslint').SourceCode} sourceCode
6
+ * @param {any} metadata
7
+ * @param {any} property
8
+ */
9
+ const getMetadataEntryRemovalRange = (sourceCode, metadata, property) => {
10
+ const entries = metadata.properties;
11
+ const index = entries.indexOf(property);
12
+ const openingBrace = sourceCode.getFirstToken(metadata);
13
+ const closingBrace = sourceCode.getLastToken(metadata);
14
+
15
+ if (!openingBrace || !closingBrace || index === -1) return property.range;
16
+ if (entries.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
17
+ if (index < entries.length - 1) return [property.range[0], entries[index + 1].range[0]];
18
+
19
+ return [entries[index - 1].range[1], property.range[1]];
20
+ };
21
+
22
+ module.exports = { getMetadataEntryRemovalRange };
@@ -0,0 +1,26 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * @param {any} node
6
+ * @param {import('eslint').SourceCode} sourceCode
7
+ * @param {'private' | 'protected' | 'public'} accessibility
8
+ */
9
+ const buildAccessibilityFix = (node, sourceCode, accessibility) => {
10
+ const decoratorEnd = node.decorators?.at(-1)?.range[1] ?? node.range[0];
11
+ const tokens = sourceCode.getTokens(node);
12
+ const accessibilityToken = tokens.find(
13
+ (token) => token.range[0] >= decoratorEnd && token.value === node.accessibility,
14
+ );
15
+
16
+ if (accessibilityToken) {
17
+ return (fixer) => fixer.replaceText(accessibilityToken, accessibility);
18
+ }
19
+
20
+ const insertionToken = tokens.find((token) => token.range[0] >= decoratorEnd);
21
+ if (!insertionToken) return null;
22
+
23
+ return (fixer) => fixer.insertTextBefore(insertionToken, `${accessibility} `);
24
+ };
25
+
26
+ module.exports = { buildAccessibilityFix };
@@ -18,9 +18,9 @@
18
18
  * import { Title, Meta } from '@angular/platform-browser';
19
19
  *
20
20
  * ✅ Use the @ethlete/core SEO utilities:
21
- * import { injectTitleBinding, injectMetaBinding } from '@ethlete/core';
22
- * const titleBinding = injectTitleBinding();
23
- * const metaBinding = injectMetaBinding();
21
+ * import { applyHeadTitleBinding, applyMetaBinding } from '@ethlete/core';
22
+ * applyHeadTitleBinding(title);
23
+ * applyMetaBinding(meta);
24
24
  */
25
25
 
26
26
  /** @type {import('eslint').Rule.RuleModule} */
@@ -32,12 +32,12 @@ const rule = {
32
32
  },
33
33
  schema: [],
34
34
  messages: {
35
- noInjectTitle: "Do not inject Angular's Title service. Use injectTitleBinding() from '@ethlete/core' instead.",
36
- noInjectMeta: "Do not inject Angular's Meta service. Use injectMetaBinding() from '@ethlete/core' instead.",
35
+ noInjectTitle: "Do not inject Angular's Title service. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
36
+ noInjectMeta: "Do not inject Angular's Meta service. Use applyMetaBinding() from '@ethlete/core' instead.",
37
37
  noImportTitle:
38
- "Do not import Title from '@angular/platform-browser'. Use injectTitleBinding() from '@ethlete/core' instead.",
38
+ "Do not import Title from '@angular/platform-browser'. Use applyHeadTitleBinding() from '@ethlete/core' instead.",
39
39
  noImportMeta:
40
- "Do not import Meta from '@angular/platform-browser'. Use injectMetaBinding() from '@ethlete/core' instead.",
40
+ "Do not import Meta from '@angular/platform-browser'. Use applyMetaBinding() from '@ethlete/core' instead.",
41
41
  },
42
42
  },
43
43
  create(context) {
@@ -100,6 +100,23 @@ const noDirectDomManipulation = {
100
100
 
101
101
  const methodName = callee.property.name;
102
102
 
103
+ if (
104
+ methodName === 'setProperty' &&
105
+ callee.object.type === 'MemberExpression' &&
106
+ callee.object.property.type === 'Identifier' &&
107
+ callee.object.property.name === 'style'
108
+ ) {
109
+ context.report({
110
+ node,
111
+ messageId: 'domMutation',
112
+ data: {
113
+ method: methodName,
114
+ alternative: 'renderer.setStyle(el, prop, value, RendererStyleFlags2.DashCase)',
115
+ },
116
+ });
117
+ return;
118
+ }
119
+
103
120
  // ── classList.add / .remove / .toggle / .replace ─────────────────────
104
121
  if (
105
122
  callee.object.type === 'MemberExpression' &&
@@ -147,15 +147,17 @@ const noEffectCleanupReturn = {
147
147
  return;
148
148
  }
149
149
 
150
- // Only the mechanical shape is auto-fixed: a zero-parameter callback whose single `return` hands
151
- // back a function literal. With an existing parameter, an early `return`, or a returned reference,
152
- // the intent needs a human.
153
- // The parameter list's `(` — the first one before the body, so this works for `() => {}` and
154
- // `function () {}` alike (their first token differs).
155
150
  const openParen = sourceCode
156
151
  .getTokens(callback)
157
152
  .find((token) => token.value === '(' && token.range[1] <= callback.body.range[0]);
158
- const fixable = callback.params.length === 0 && returns.length === 1 && cleanupReturns.length === 1;
153
+ const callbackScope = sourceCode.getScope(callback);
154
+ const hasOnCleanupBinding = callbackScope.variables.some((variable) => variable.name === 'onCleanup');
155
+ const fixable =
156
+ callback.params.length === 0 &&
157
+ returns.length === 1 &&
158
+ cleanupReturns.length === 1 &&
159
+ callback.body.body.at(-1) === cleanupReturns[0] &&
160
+ !hasOnCleanupBinding;
159
161
 
160
162
  for (const statement of cleanupReturns) {
161
163
  context.report({
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
5
+
4
6
  /** @typedef {'Component' | 'Directive'} TDecoratorName */
5
7
 
6
8
  /**
@@ -28,24 +30,6 @@ const getDecoratorName = (node) => {
28
30
  return expression.type === 'Identifier' ? expression.name : null;
29
31
  };
30
32
 
31
- /**
32
- * @param {import('eslint').SourceCode} sourceCode
33
- * @param {any} metadata
34
- * @param {any} property
35
- */
36
- const getRemovalRange = (sourceCode, metadata, property) => {
37
- const properties = metadata.properties.filter((entry) => entry.type === 'Property');
38
- const propertyIndex = properties.indexOf(property);
39
- const openingBrace = sourceCode.getFirstToken(metadata);
40
- const closingBrace = sourceCode.getLastToken(metadata);
41
-
42
- if (!openingBrace || !closingBrace) return property.range;
43
- if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
44
- if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
45
-
46
- return [properties[propertyIndex - 1].range[1], property.range[1]];
47
- };
48
-
49
33
  const MESSAGE_IDS = {
50
34
  hostDirectives: 'noEmptyHostDirectives',
51
35
  imports: 'noEmptyImports',
@@ -92,7 +76,7 @@ const noEmptyAngularMetadataArrays = {
92
76
  node: property,
93
77
  messageId: MESSAGE_IDS[propertyName],
94
78
  fix(fixer) {
95
- return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
79
+ return fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), '');
96
80
  },
97
81
  });
98
82
  }
@@ -0,0 +1,115 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallow `enum` and `const enum` — use a const object plus a derived union type.
6
+ *
7
+ * An enum is the only TypeScript construct that emits runtime code from a type declaration, so it
8
+ * cannot be erased and it cannot be tree-shaken. Its members are nominal, so a plain string is not
9
+ * assignable to an enum-typed parameter even when the value matches. A `const enum` is worse: a
10
+ * transpile-only consumer (esbuild, SWC, Babel) has no cross-file type information, so it cannot
11
+ * inline the member and the build fails or emits a missing binding.
12
+ *
13
+ * A const object with `as const` plus a derived union gives the same dotted access, keeps the name
14
+ * usable in a type position, accepts a matching literal, and tree-shakes.
15
+ *
16
+ * BAD:
17
+ * export enum MatchState {
18
+ * Live = 'live',
19
+ * }
20
+ *
21
+ * GOOD:
22
+ * export const MatchState = {
23
+ * Live: 'live',
24
+ * } as const;
25
+ *
26
+ * export type MatchState = (typeof MatchState)[keyof typeof MatchState];
27
+ *
28
+ * The fixer runs only on an enum whose members all have a string literal initializer. It widens a
29
+ * member used in a type position (`state: MatchState.Live`), which then needs `typeof`.
30
+ */
31
+
32
+ /** typescript-eslint v8 nests members under a TSEnumBody; earlier versions put them on the declaration. */
33
+ const membersOf = (node) => node.body?.members ?? node.members ?? [];
34
+
35
+ const isStringLiteral = (node) => node?.type === 'Literal' && typeof node.value === 'string';
36
+
37
+ /** Only a plain string enum converts to a const object without changing what a member holds. */
38
+ const isFixable = (node) => {
39
+ if (node.declare) return false;
40
+
41
+ const members = membersOf(node);
42
+
43
+ if (!members.length) return false;
44
+
45
+ return members.every(
46
+ (member) => isStringLiteral(member.initializer) && (member.id.type === 'Identifier' || isStringLiteral(member.id)),
47
+ );
48
+ };
49
+
50
+ /** @type {import('eslint').Rule.RuleModule} */
51
+ const noEnum = {
52
+ meta: {
53
+ type: 'problem',
54
+ docs: {
55
+ description: 'Disallow enum and const enum. Use a const object with `as const` plus a derived union type.',
56
+ recommended: true,
57
+ },
58
+ fixable: 'code',
59
+ messages: {
60
+ noEnum:
61
+ "No enums. '{{name}}' emits runtime code that cannot be tree-shaken, and its members are nominal. Use a const object with 'as const' plus a derived union type.",
62
+ noConstEnum:
63
+ "No 'const enum'. A transpile-only consumer (esbuild, SWC, Babel) cannot inline '{{name}}' and its build fails. Use a const object with 'as const' plus a derived union type.",
64
+ },
65
+ schema: [],
66
+ },
67
+ create(context) {
68
+ const { sourceCode } = context;
69
+
70
+ const buildReplacement = (node, target) => {
71
+ const name = node.id.name;
72
+ const indent = ' '.repeat(target.loc.start.column);
73
+ const memberIndent = `${indent} `;
74
+ const exported = target.type === 'ExportNamedDeclaration' ? 'export ' : '';
75
+
76
+ const lines = membersOf(node).flatMap((member) => {
77
+ const comments = sourceCode.getCommentsBefore(member).flatMap((comment) =>
78
+ sourceCode
79
+ .getText(comment)
80
+ .split('\n')
81
+ // A block comment's continuation lines carry one more space so the leading `*` stays aligned.
82
+ .map((line, index) => `${memberIndent}${index === 0 ? '' : ' '}${line.trim()}`),
83
+ );
84
+
85
+ const key = sourceCode.getText(member.id);
86
+ const value = sourceCode.getText(member.initializer);
87
+
88
+ return [...comments, `${memberIndent}${key}: ${value},`];
89
+ });
90
+
91
+ return [
92
+ `${exported}const ${name} = {`,
93
+ ...lines,
94
+ `${indent}} as const;`,
95
+ '',
96
+ `${indent}${exported}type ${name} = (typeof ${name})[keyof typeof ${name}];`,
97
+ ].join('\n');
98
+ };
99
+
100
+ return {
101
+ TSEnumDeclaration(node) {
102
+ const target = node.parent?.type === 'ExportNamedDeclaration' ? node.parent : node;
103
+
104
+ context.report({
105
+ node: node.id,
106
+ messageId: node.const ? 'noConstEnum' : 'noEnum',
107
+ data: { name: node.id.name },
108
+ fix: isFixable(node) ? (fixer) => fixer.replaceTextRange(target.range, buildReplacement(node, target)) : null,
109
+ });
110
+ },
111
+ };
112
+ },
113
+ };
114
+
115
+ module.exports = noEnum;
@@ -73,11 +73,18 @@ const noLeadingUnderscoreClassMember = {
73
73
  const frame = classStack.pop();
74
74
  if (!frame) return;
75
75
 
76
+ const targetNameCounts = new Map();
77
+ for (const oldName of frame.membersByName.keys()) {
78
+ const newName = getFixedName(oldName);
79
+ targetNameCounts.set(newName, (targetNameCounts.get(newName) ?? 0) + 1);
80
+ }
81
+
76
82
  for (const [oldName, memberNodes] of frame.membersByName) {
77
83
  const newName = getFixedName(oldName);
78
84
  const canFix =
79
85
  newName.length > 0 &&
80
86
  !frame.declaredNames.has(newName) &&
87
+ targetNameCounts.get(newName) === 1 &&
81
88
  memberNodes.every((memberNode) => memberNode.accessibility === 'private');
82
89
 
83
90
  for (const memberNode of memberNodes) {