@ethlete/eslint-plugin 1.0.0-next.26 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.27
4
+
5
+ ### Minor Changes
6
+
7
+ - `recommendedSpec` also covers `*.test.ts`, stories and `e2e/`, `no-trivial-wrapper-method` allows public methods over private members, and two other rules get fixes.
8
+
9
+ ### Patch Changes
10
+
11
+ - Angular metadata fixers (`no-legacy-angular-decorators`, `require-on-push-change-detection`, `require-view-encapsulation-none`) now insert properties with the surrounding comma style and no stray spaces.
12
+ - `class-member-order`: the fixer keeps blank lines between members in place instead of leaving one after the opening brace.
13
+ - `no-inject-chain` no longer reports a constructor called straight off `inject()`, as in `new (inject(Foo).Bar)()`.
14
+ - `no-subscribe-in-pipe` no longer reports a `.subscribe()` inside a callback of the observable that `.pipe()` is called on, such as `defer(() => …).pipe(…)`.
15
+ - `template-member-accessibility` now fixes an unneeded `protected` to `public` in one pass, instead of deleting it (and any comment after it) and leaving an implicitly public member to fix again.
16
+ - The `no-unused-class-member` message now says the member is never referenced through `this`, matching what the rule checks: a member that is only written still counts as used.
17
+ - Mark every migration with a `level`, so `et update` runs only the required ones.
18
+ - `enforce-routing-view-naming` only checks object literals that have a `path` key.
19
+
3
20
  ## 1.0.0-next.26
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -17,6 +17,10 @@ import ethlete from '@ethlete/eslint-plugin';
17
17
  export default [...ethlete.configs.recommended];
18
18
  ```
19
19
 
20
+ Composing the configs one by one (`recommendedTs`, `recommendedAngularTs`, `recommendedTemplate`)?
21
+ Add `ethlete.configs.recommendedSpec` as the last entry - without it, specs, stories and test
22
+ harnesses are linted with the strict rules.
23
+
20
24
  ## Documentation
21
25
 
22
26
  The full rule reference (all rules, grouped, with defaults and auto-fix support) and
package/migrations.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "name": "module-augmentation-interfaces",
5
5
  "version": "1.0.0-next.25",
6
6
  "kind": "auto",
7
+ "level": "required",
7
8
  "description": "Turn the type aliases the old consistent-type-definitions fix left in declare module / declare global blocks back into interfaces, and rename its eslint-disable directives to ethlete/consistent-type-definitions.",
8
9
  "generator": "@ethlete/eslint-plugin:restore-module-augmentation-interfaces",
9
10
  "docs": "/eslint/rules"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.26",
3
+ "version": "1.0.0-next.27",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -432,13 +432,20 @@ const recommendedTemplate = {
432
432
  };
433
433
 
434
434
  /**
435
- * Relaxed rules for test/spec files, including common DOM assertions and async test orchestration.
435
+ * Relaxed rules for specs, test harnesses, stories and e2e files: DOM assertions, async test
436
+ * orchestration, collecting emissions in a subscribe body, and fixture helpers with many parameters.
436
437
  * @type {import('eslint').Linter.Config}
437
438
  */
438
439
  const recommendedSpec = {
439
- files: ['**/*.spec.ts'],
440
+ files: ['**/*.spec.ts', '**/*.test.ts', '**/testing/**/*.ts', '**/*.stories.ts', '**/e2e/**/*.ts'],
440
441
  rules: {
441
442
  '@typescript-eslint/no-non-null-assertion': 'off',
443
+ 'max-params': 'off',
444
+ 'ethlete/no-subscribe-in-pipe': 'off',
445
+ 'ethlete/no-subscribe-with-body': 'off',
446
+ 'ethlete/no-unused-class-member': 'off',
447
+ 'ethlete/prefer-clone-equal': 'off',
448
+ 'ethlete/prefer-rxjs-timer': 'off',
442
449
  'no-restricted-globals': 'off',
443
450
  'ethlete/no-async-await': 'off',
444
451
  'ethlete/no-csp-unsafe': 'off',
@@ -416,18 +416,24 @@ const buildClassOrderFix = (sourceCode, classBodyNode, members) => {
416
416
  rankedIndexes.has(index) ? sortedMembers[nextSortedMember++].originalIndex : index,
417
417
  );
418
418
 
419
+ const gaps = segmentTexts.map((text) => /^\s*/.exec(text)?.[0] ?? '');
420
+ const bodies = segmentTexts.map((text, index) => text.slice(gaps[index].length));
421
+
419
422
  const reorderedBody =
420
423
  order
421
424
  .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,
425
+ const nextIndex = order[position + 1];
426
+ const nextText = nextIndex === undefined ? suffix : gaps[position + 1] + bodies[nextIndex];
427
+
428
+ return (
429
+ gaps[position] +
430
+ joinMovedSegment(
431
+ sourceCode,
432
+ classBodyNode.body[originalIndex],
433
+ bodies[originalIndex],
434
+ nextIndex === undefined ? null : classBodyNode.body[nextIndex],
435
+ nextText,
436
+ )
431
437
  );
432
438
  })
433
439
  .join('') + suffix;
@@ -2,7 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * Checks that a `loadComponent:` route property:
5
+ * Checks that a `loadComponent:` property of a route object (one with a `path` key):
6
6
  * 1. Imports from a path containing "-view"
7
7
  * 2. Accesses a class name ending in "ViewComponent" in the .then() callback
8
8
  *
@@ -12,6 +12,17 @@
12
12
 
13
13
  const VIEW_PATH = /-view(?![\w-])/;
14
14
 
15
+ /** @param {any} objectNode */
16
+ const isRoute = (objectNode) =>
17
+ objectNode?.type === 'ObjectExpression' &&
18
+ objectNode.properties.some(
19
+ /** @param {any} prop */ (prop) =>
20
+ prop.type === 'Property' &&
21
+ !prop.computed &&
22
+ ((prop.key.type === 'Identifier' && prop.key.name === 'path') ||
23
+ (prop.key.type === 'Literal' && prop.key.value === 'path')),
24
+ );
25
+
15
26
  /** @type {import('eslint').Rule.RuleModule} */
16
27
  const enforceRoutingViewNaming = {
17
28
  meta: {
@@ -46,6 +57,7 @@ const enforceRoutingViewNaming = {
46
57
  Property(node) {
47
58
  const key = /** @type {any} */ (node).key;
48
59
  if (key.type !== 'Identifier' || key.name !== 'loadComponent') return;
60
+ if (!isRoute(/** @type {any} */ (node).parent)) return;
49
61
 
50
62
  const arrow = /** @type {any} */ (node).value;
51
63
  if (!arrow || arrow.type !== 'ArrowFunctionExpression') return;
@@ -195,7 +195,7 @@ const buildMetadataFix = (sourceCode, metadata, spec) => {
195
195
  return (fixer) =>
196
196
  fixer.replaceTextRange(
197
197
  [rangeStart, closingBrace.range[0]],
198
- `${separator}\n${propertyIndent}${propertyText}\n${closingIndent}`,
198
+ `${separator}\n${propertyIndent}${propertyText}${separator === '' ? ',' : ''}\n${closingIndent}`,
199
199
  );
200
200
  }
201
201
 
@@ -25,7 +25,11 @@ const noInjectChain = {
25
25
  // Allow inject(X).method() — immediately-invoked patterns like
26
26
  // inject(DestroyRef).onDestroy(...) are intentional Angular idioms.
27
27
  const parent = node.parent;
28
- if (parent && parent.type === 'CallExpression' && parent.callee === node) {
28
+ if (
29
+ parent &&
30
+ (parent.type === 'CallExpression' || parent.type === 'NewExpression') &&
31
+ parent.callee === node
32
+ ) {
29
33
  return;
30
34
  }
31
35
 
@@ -188,24 +188,22 @@ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey,
188
188
  ? getIndent(sourceCode, metadata.properties[0])
189
189
  : `${getIndent(sourceCode, closingBrace)} `;
190
190
  const hostPropertyText = `host: { ${hostEntryText} }`;
191
- const separator = metadata.properties.length > 0 ? (hasTrailingComma(sourceCode, metadata) ? '' : ',') : '';
191
+ const lastProperty = metadata.properties.at(-1);
192
192
 
193
- if (isMultiline && metadata.properties.length > 0) {
194
- const lastProperty = metadata.properties[metadata.properties.length - 1];
193
+ if (lastProperty) {
194
+ const separator = hasTrailingComma(sourceCode, metadata) ? '' : ',';
195
195
  const tokenAfterLastProperty = sourceCode.getTokenAfter(lastProperty);
196
196
  const rangeStart =
197
197
  separator === '' && tokenAfterLastProperty ? tokenAfterLastProperty.range[1] : lastProperty.range[1];
198
+ const replacement = isMultiline
199
+ ? `${separator}\n${propertyIndent}${hostPropertyText}${separator === '' ? ',' : ''}\n${closingIndent}`
200
+ : `${separator} ${hostPropertyText} `;
198
201
 
199
- fixes.push((fixer) =>
200
- fixer.replaceTextRange(
201
- [rangeStart, closingBrace.range[0]],
202
- `${separator}\n${propertyIndent}${hostPropertyText},\n${closingIndent}`,
203
- ),
204
- );
202
+ fixes.push((fixer) => fixer.replaceTextRange([rangeStart, closingBrace.range[0]], replacement));
205
203
  } else {
206
204
  const insertion = isMultiline
207
205
  ? `\n${propertyIndent}${hostPropertyText}\n${closingIndent}`
208
- : `${metadata.properties.length > 0 ? `${separator} ` : ' '}${hostPropertyText}${metadata.properties.length > 0 ? '' : ' '}`;
206
+ : ` ${hostPropertyText} `;
209
207
 
210
208
  fixes.push((fixer) => fixer.insertTextBefore(closingBrace, insertion));
211
209
  }
@@ -43,6 +43,8 @@ const noSubscribeInPipe = {
43
43
  */
44
44
  const isInsidePipeCallback = (node) => {
45
45
  let crossedFunctionBoundary = false;
46
+ /** @type {import('eslint').Rule.Node} */
47
+ let previous = node;
46
48
  let current = node.parent;
47
49
  while (current) {
48
50
  // A `new Observable(factory)` is its own subscription boundary — subscribing
@@ -62,10 +64,12 @@ const noSubscribeInPipe = {
62
64
  current.type === 'CallExpression' &&
63
65
  current.callee.type === 'MemberExpression' &&
64
66
  current.callee.property.type === 'Identifier' &&
65
- current.callee.property.name === 'pipe'
67
+ current.callee.property.name === 'pipe' &&
68
+ current.arguments.includes(/** @type {any} */ (previous))
66
69
  ) {
67
70
  return true;
68
71
  }
72
+ previous = current;
69
73
  current = current.parent;
70
74
  }
71
75
  return false;
@@ -17,6 +17,8 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
17
17
  *
18
18
  * GOOD:
19
19
  * // Call this.value.set(val) directly at the call sites.
20
+ * // A public method over a private/protected member is the encapsulation, not a wrapper:
21
+ * // private items = signal([]); setItems(items) { this.items.set(items); }
20
22
  * // If the method wraps something non-trivially (transforms args, adds logic), it is fine.
21
23
  * // `focus`/`blur`/`reset` on a @Component/@Directive are exempt - see CONTRACT_METHOD_NAMES.
22
24
  */
@@ -92,6 +94,39 @@ const getTrivialCallExpr = (methodNode) => {
92
94
  return allMatch ? callExpr : null;
93
95
  };
94
96
 
97
+ /** @param {any} member */
98
+ const visibilityOf = (member) => {
99
+ if (member.key?.type === 'PrivateIdentifier' || member.accessibility === 'private') return 0;
100
+ if (member.accessibility === 'protected') return 1;
101
+ return 2;
102
+ };
103
+
104
+ /**
105
+ * @param {any} classBody
106
+ * @param {any} callee
107
+ */
108
+ const receiverVisibility = (classBody, callee) => {
109
+ let object = callee;
110
+ while (object.type === 'MemberExpression' && object.object.type !== 'ThisExpression') object = object.object;
111
+ if (object.type !== 'MemberExpression' || object.computed) return undefined;
112
+
113
+ const name = object.property.type === 'PrivateIdentifier' ? `#${object.property.name}` : object.property.name;
114
+ /** @param {any} key */
115
+ const keyName = (key) => (key?.type === 'PrivateIdentifier' ? `#${key.name}` : key?.name);
116
+
117
+ for (const member of classBody.body) {
118
+ if (member.type === 'MethodDefinition' && member.kind === 'constructor') {
119
+ const property = member.value.params.find(
120
+ (/** @type {any} */ param) => param.type === 'TSParameterProperty' && param.parameter.name === name,
121
+ );
122
+ if (property) return visibilityOf(property);
123
+ continue;
124
+ }
125
+ if (keyName(member.key) === name) return visibilityOf(member);
126
+ }
127
+ return undefined;
128
+ };
129
+
95
130
  /** @type {import('eslint').Rule.RuleModule} */
96
131
  const noTrivialWrapperMethod = {
97
132
  meta: {
@@ -102,7 +137,7 @@ const noTrivialWrapperMethod = {
102
137
  },
103
138
  messages: {
104
139
  noTrivialWrapperMethod:
105
- "'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}'. Remove it and call '{{target}}' directly at each call site.",
140
+ "'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}', which callers can already reach. Remove it and call '{{target}}' directly at each call site.",
106
141
  },
107
142
  schema: [],
108
143
  },
@@ -133,6 +168,9 @@ const noTrivialWrapperMethod = {
133
168
 
134
169
  if (!targetName) return;
135
170
 
171
+ const wrapped = receiverVisibility(node.parent, callee);
172
+ if (wrapped !== undefined && visibilityOf(node) > wrapped) return;
173
+
136
174
  context.report({
137
175
  node,
138
176
  messageId: 'noTrivialWrapperMethod',
@@ -1,6 +1,25 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ /** @param {any} node */
5
+ const renderValueImport = (node) => {
6
+ const clauses = [];
7
+ const named = [];
8
+
9
+ for (const s of node.specifiers) {
10
+ if (s.type === 'ImportDefaultSpecifier') clauses.push(s.local.name);
11
+ else if (s.type === 'ImportNamespaceSpecifier') clauses.push(`* as ${s.local.name}`);
12
+ else {
13
+ const imported = s.imported.type === 'Identifier' ? s.imported.name : `'${s.imported.value}'`;
14
+ named.push(s.local.name === imported ? imported : `${imported} as ${s.local.name}`);
15
+ }
16
+ }
17
+
18
+ if (named.length) clauses.push(`{ ${named.join(', ')} }`);
19
+
20
+ return `import ${clauses.join(', ')} from '${node.source.value}';`;
21
+ };
22
+
4
23
  /** @type {import('eslint').Rule.RuleModule} */
5
24
  const noTypeOnlyImport = {
6
25
  meta: {
@@ -11,10 +30,9 @@ const noTypeOnlyImport = {
11
30
  },
12
31
  fixable: 'code',
13
32
  messages: {
14
- noTypeImportDeclaration:
15
- "Do not use `import type`. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
33
+ noTypeImportDeclaration: 'Do not use `import type`. Use a regular import instead: `{{replacement}}`.',
16
34
  noInlineTypeSpecifier:
17
- "Do not use inline `type` on import specifiers. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
35
+ 'Do not use inline `type` on import specifiers. Use a regular import instead: `{{replacement}}`.',
18
36
  },
19
37
  schema: [],
20
38
  },
@@ -23,13 +41,10 @@ const noTypeOnlyImport = {
23
41
  ImportDeclaration(node) {
24
42
  const decl = /** @type {any} */ (node);
25
43
  if (decl.importKind === 'type') {
26
- const source = /** @type {string} */ (node.source.value);
27
- const specifiers = node.specifiers.map((s) => s.local.name).join(', ');
28
-
29
44
  context.report({
30
45
  node,
31
46
  messageId: 'noTypeImportDeclaration',
32
- data: { specifiers, source },
47
+ data: { replacement: renderValueImport(node) },
33
48
  fix(fixer) {
34
49
  const sourceCode = context.sourceCode;
35
50
  const importToken = sourceCode.getFirstToken(node);
@@ -47,21 +62,10 @@ const noTypeOnlyImport = {
47
62
  for (const specifier of node.specifiers) {
48
63
  const spec = /** @type {any} */ (specifier);
49
64
  if (specifier.type === 'ImportSpecifier' && spec.importKind === 'type') {
50
- const source = /** @type {string} */ (node.source.value);
51
- const allSpecifiers = node.specifiers
52
- .map((s) => {
53
- if (s.type === 'ImportSpecifier') {
54
- const imported = s.imported.type === 'Identifier' ? s.imported.name : s.imported.value;
55
- return s.local.name === imported ? imported : `${imported} as ${s.local.name}`;
56
- }
57
- return s.local.name;
58
- })
59
- .join(', ');
60
-
61
65
  context.report({
62
66
  node: specifier,
63
67
  messageId: 'noInlineTypeSpecifier',
64
- data: { specifiers: allSpecifiers, source },
68
+ data: { replacement: renderValueImport(node) },
65
69
  fix(fixer) {
66
70
  const sourceCode = context.sourceCode;
67
71
  const tokenBefore = sourceCode.getTokenBefore(specifier.imported ?? specifier.local);
@@ -4,13 +4,13 @@
4
4
  const { getAngularDecoratorName } = require('./internals/import-resolution');
5
5
 
6
6
  /**
7
- * Disallows class members that are declared but never read within the class body.
7
+ * Disallows class members that are declared but never referenced through `this` within the class body.
8
8
  *
9
9
  * Only checks members that CANNOT be accessed from outside the class at compile
10
10
  * time — so false positives (e.g. template usage) are avoided:
11
11
  *
12
12
  * - `private` members — always checked. Private members are only accessible
13
- * within the class body, so if they are never read there they are dead code.
13
+ * within the class body, so if they are never referenced there they are dead code.
14
14
  *
15
15
  * - `protected` members — checked only when the class has an Angular decorator
16
16
  * other than `@Component` (i.e. `@Directive`, `@Pipe`, `@Injectable`).
@@ -106,13 +106,17 @@ const noUnusedClassMember = {
106
106
  meta: {
107
107
  type: 'suggestion',
108
108
  docs: {
109
- description: 'Disallow class members that are declared but never read within the class body.',
109
+ description:
110
+ 'Disallow class members that are declared but never referenced through `this` within the class body.',
110
111
  recommended: true,
111
112
  },
112
113
  messages: {
113
114
  noUnused:
114
- "'{{name}}' is declared but its value is never read within this class. " +
115
+ "'{{name}}' is declared but never referenced through `this` within this class. " +
115
116
  'Remove it, or widen its accessibility if it is intentionally used externally.',
117
+ noUnusedProtected:
118
+ "'{{name}}' is protected but never referenced through `this` within this class. " +
119
+ 'If a subclass reads it, mark this class `abstract`; otherwise remove it.',
116
120
  },
117
121
  schema: [],
118
122
  },
@@ -181,7 +185,11 @@ const noUnusedClassMember = {
181
185
  if (!frame) return;
182
186
  for (const [name, memberNode] of frame.candidates) {
183
187
  if (!frame.usedNames.has(name)) {
184
- context.report({ node: memberNode, messageId: 'noUnused', data: { name } });
188
+ context.report({
189
+ node: memberNode,
190
+ messageId: memberNode.accessibility === 'protected' ? 'noUnusedProtected' : 'noUnused',
191
+ data: { name },
192
+ });
185
193
  }
186
194
  }
187
195
  },
@@ -20,21 +20,6 @@ const isInjectCall = (sourceCode, node) => {
20
20
  return isImportedAs(sourceCode, node.value.callee, 'inject');
21
21
  };
22
22
 
23
- /**
24
- * @param {any} node
25
- * @param {import('eslint').SourceCode} sourceCode
26
- */
27
- const buildRemoveProtectedFix = (node, sourceCode) => {
28
- const protectedToken = sourceCode.getFirstToken(node, (token) => token.value === 'protected');
29
- const nextToken = protectedToken ? sourceCode.getTokenAfter(protectedToken) : null;
30
-
31
- if (!protectedToken || !nextToken) {
32
- return null;
33
- }
34
-
35
- return (fixer) => fixer.removeRange([protectedToken.range[0], nextToken.range[0]]);
36
- };
37
-
38
23
  const MEMBER_TYPES = new Set([
39
24
  'PropertyDefinition',
40
25
  'AccessorProperty',
@@ -75,7 +60,7 @@ const templateMemberAccessibility = {
75
60
  shouldBeExplicit:
76
61
  'Member `{{name}}` is referenced from a template or host binding. Use `public` when it is intentionally exposed outside the component; otherwise use `protected`.',
77
62
  shouldNotBeProtected:
78
- 'Member `{{name}}` should not be protected because it is not referenced from a template or host binding.',
63
+ 'Member `{{name}}` should not be protected because it is not referenced from a template or host binding. If a subclass reads it, mark this class `abstract`.',
79
64
  },
80
65
  },
81
66
  create(context) {
@@ -141,13 +126,13 @@ const templateMemberAccessibility = {
141
126
  }
142
127
 
143
128
  if (node.accessibility !== 'protected') return;
144
- if (node.type.startsWith('TSAbstract')) return;
129
+ if (node.type.startsWith('TSAbstract') || classNode.abstract) return;
145
130
 
146
131
  context.report({
147
132
  node,
148
133
  messageId: 'shouldNotBeProtected',
149
134
  data: { name: memberName },
150
- fix: buildRemoveProtectedFix(node, sourceCode),
135
+ fix: buildAccessibilityFix(node, sourceCode, 'public'),
151
136
  });
152
137
 
153
138
  return;