@ethlete/eslint-plugin 1.0.0-next.26 → 1.0.0-next.28

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.28
4
+
5
+ ### Minor Changes
6
+
7
+ - The recommended config now also bans functional route guards and resolvers (`canDeactivate` stays allowed), and barrel imports through `'.'`, `'..'` or `./index.js`.
8
+
9
+ ### Patch Changes
10
+
11
+ - Fix wrong reports and unsafe autofixes in ten rules: `--fix` no longer makes template members `private`, adds `readonly` to template-written fields, widens a return type to `any`, or drops comments.
12
+
13
+ ## 1.0.0-next.27
14
+
15
+ ### Minor Changes
16
+
17
+ - `recommendedSpec` also covers `*.test.ts`, stories and `e2e/`, `no-trivial-wrapper-method` allows public methods over private members, and two other rules get fixes.
18
+
19
+ ### Patch Changes
20
+
21
+ - 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.
22
+ - `class-member-order`: the fixer keeps blank lines between members in place instead of leaving one after the opening brace.
23
+ - `no-inject-chain` no longer reports a constructor called straight off `inject()`, as in `new (inject(Foo).Bar)()`.
24
+ - `no-subscribe-in-pipe` no longer reports a `.subscribe()` inside a callback of the observable that `.pipe()` is called on, such as `defer(() => …).pipe(…)`.
25
+ - `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.
26
+ - 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.
27
+ - Mark every migration with a `level`, so `et update` runs only the required ones.
28
+ - `enforce-routing-view-naming` only checks object literals that have a `path` key.
29
+
3
30
  ## 1.0.0-next.26
4
31
 
5
32
  ### 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.28",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -157,17 +157,18 @@ const recommendedTs = {
157
157
  // No route guards — handle access control inside the component itself
158
158
  {
159
159
  selector:
160
- 'TSClassImplements > Identifier.expression[name=/^CanActivate$|^CanDeactivate$|^CanActivateChild$|^CanMatch$|^CanLoad$/]',
160
+ 'TSClassImplements > Identifier.expression[name=/^CanActivate$|^CanDeactivate$|^CanActivateChild$|^CanMatch$|^CanLoad$/], TSTypeReference > Identifier.typeName[name=/^(CanActivate|CanActivateChild|CanMatch)Fn$/], ObjectExpression:has(> Property[key.name=/^(path|component|loadComponent|children|loadChildren)$/]) > Property[key.name=/^(canActivate|canActivateChild|canMatch|canLoad)$/]',
161
161
  message: 'No route guards. Handle access control inside the component itself.',
162
162
  },
163
163
  // No route resolvers — use the query library to fetch data
164
164
  {
165
- selector: "TSClassImplements > Identifier.expression[name='Resolve']",
165
+ selector:
166
+ "TSClassImplements > Identifier.expression[name='Resolve'], TSTypeReference > Identifier.typeName[name='ResolveFn'], ObjectExpression:has(> Property[key.name=/^(path|component|loadComponent|children|loadChildren)$/]) > Property[key.name='resolve']",
166
167
  message: 'No route resolvers. Use the query library to fetch data instead.',
167
168
  },
168
169
  // No barrel imports — import directly from the source file
169
170
  {
170
- selector: 'ImportDeclaration[source.value=/(^|[/])index$/]',
171
+ selector: 'ImportDeclaration[source.value=/(^|[/])(index([.][cm]?[jt]s)?|[.]|[.][.])?[/]?$/]',
171
172
  message:
172
173
  'No barrel imports (index files). Import directly from the source file to avoid breaking lazy loading.',
173
174
  },
@@ -432,13 +433,20 @@ const recommendedTemplate = {
432
433
  };
433
434
 
434
435
  /**
435
- * Relaxed rules for test/spec files, including common DOM assertions and async test orchestration.
436
+ * Relaxed rules for specs, test harnesses, stories and e2e files: DOM assertions, async test
437
+ * orchestration, collecting emissions in a subscribe body, and fixture helpers with many parameters.
436
438
  * @type {import('eslint').Linter.Config}
437
439
  */
438
440
  const recommendedSpec = {
439
- files: ['**/*.spec.ts'],
441
+ files: ['**/*.spec.ts', '**/*.test.ts', '**/testing/**/*.ts', '**/*.stories.ts', '**/e2e/**/*.ts'],
440
442
  rules: {
441
443
  '@typescript-eslint/no-non-null-assertion': 'off',
444
+ 'max-params': 'off',
445
+ 'ethlete/no-subscribe-in-pipe': 'off',
446
+ 'ethlete/no-subscribe-with-body': 'off',
447
+ 'ethlete/no-unused-class-member': 'off',
448
+ 'ethlete/prefer-clone-equal': 'off',
449
+ 'ethlete/prefer-rxjs-timer': 'off',
442
450
  'no-restricted-globals': 'off',
443
451
  'ethlete/no-async-await': 'off',
444
452
  'ethlete/no-csp-unsafe': 'off',
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
  'use strict';
3
3
 
4
+ const { getAngularMetadata, isWrittenFromTemplateOrHost } = require('./internals/angular-member-visibility');
4
5
  const { getAngularDecoratorName } = require('./internals/import-resolution');
5
6
 
6
7
  const SCREAMING_CASE_RE = /^[A-Z][A-Z0-9_]*$/;
@@ -327,6 +328,7 @@ const classConstantProperty = {
327
328
  return {
328
329
  ClassBody(node) {
329
330
  const writtenMembers = getWrittenMembers(node);
331
+ const isAngularClass = getAngularMetadata(context.sourceCode, node.parent) !== null;
330
332
 
331
333
  for (const member of node.body) {
332
334
  if (!isConstantProperty(member)) {
@@ -347,6 +349,11 @@ const classConstantProperty = {
347
349
  continue;
348
350
  }
349
351
 
352
+ if (isAngularClass && !member.readonly) {
353
+ if (member.accessibility !== 'private' && member.accessibility !== 'protected') continue;
354
+ if (isWrittenFromTemplateOrHost(node.parent, memberName, context)) continue;
355
+ }
356
+
350
357
  if (memberName === 'transform' && hasPipeDecorator(context.sourceCode, node.parent)) {
351
358
  continue;
352
359
  }
@@ -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;
@@ -127,6 +127,115 @@ const getContextFilename = (context) => {
127
127
  return filename;
128
128
  };
129
129
 
130
+ /**
131
+ * @param {string} memberName
132
+ */
133
+ const memberNamePattern = (memberName) =>
134
+ new RegExp(`(?:(?<![\\w$.])|(?<=(?<![\\w$])this\\.)|(?<=\\.\\.\\.))${escapeRegExp(memberName)}(?![\\w$])`, 'u');
135
+
136
+ const BINDING_ATTRIBUTE_PATTERN =
137
+ /(?:^|[\s<])(\[\([^)]*\)\]|\[[^\]]*\]|\([^)]*\)|\*[\w-]+|bind-[\w-]+|on-[\w-]+|bindon-[\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu;
138
+
139
+ const BLOCK_HEADER_PATTERN = /@(?:else\s+if|[a-zA-Z]+)\s*\(/gu;
140
+
141
+ const LET_DECLARATION_PATTERN = /@let\s+[\w$]+\s*=([^;]*)/gu;
142
+
143
+ /**
144
+ * @param {string} text
145
+ * @param {number} openIndex
146
+ */
147
+ const readBalancedParens = (text, openIndex) => {
148
+ let depth = 0;
149
+ /** @type {string | null} */
150
+ let quote = null;
151
+
152
+ for (let index = openIndex; index < text.length; index++) {
153
+ const char = text[index];
154
+
155
+ if (quote) {
156
+ if (char === quote && text[index - 1] !== '\\') quote = null;
157
+ continue;
158
+ }
159
+
160
+ if (char === '"' || char === "'" || char === '`') quote = char;
161
+ else if (char === '(') depth++;
162
+ else if (char === ')' && --depth === 0) return text.slice(openIndex + 1, index);
163
+ }
164
+
165
+ return text.slice(openIndex + 1);
166
+ };
167
+
168
+ /**
169
+ * @param {string} template
170
+ */
171
+ const getInterpolations = (template) => {
172
+ /** @type {string[]} */
173
+ const expressions = [];
174
+ let searchFrom = 0;
175
+
176
+ while (true) {
177
+ const start = template.indexOf('{{', searchFrom);
178
+ if (start === -1) break;
179
+
180
+ let depth = 0;
181
+ let index = start + 2;
182
+
183
+ for (; index < template.length; index++) {
184
+ if (template[index] === '{') depth++;
185
+ else if (template[index] === '}') {
186
+ if (depth > 0) depth--;
187
+ else if (template[index + 1] === '}') break;
188
+ }
189
+ }
190
+
191
+ expressions.push(template.slice(start + 2, index));
192
+ searchFrom = index + 2;
193
+ }
194
+
195
+ return expressions;
196
+ };
197
+
198
+ /**
199
+ * @typedef {{ name: string; expression: string }} TemplateBinding
200
+ */
201
+
202
+ /**
203
+ * @param {string} template
204
+ * @returns {TemplateBinding[]}
205
+ */
206
+ const getBindingAttributes = (template) =>
207
+ [...template.matchAll(BINDING_ATTRIBUTE_PATTERN)].map((match) => ({
208
+ name: match[1] ?? '',
209
+ expression: match[2] ?? match[3] ?? '',
210
+ }));
211
+
212
+ /**
213
+ * @param {string} template
214
+ */
215
+ const getTemplateExpressions = (template) => {
216
+ const expressions = getInterpolations(template);
217
+
218
+ for (const binding of getBindingAttributes(template)) {
219
+ expressions.push(binding.expression);
220
+ }
221
+
222
+ for (const match of template.matchAll(BLOCK_HEADER_PATTERN)) {
223
+ expressions.push(readBalancedParens(template, (match.index ?? 0) + match[0].length - 1));
224
+ }
225
+
226
+ for (const match of template.matchAll(LET_DECLARATION_PATTERN)) {
227
+ expressions.push(match[1] ?? '');
228
+ }
229
+
230
+ return expressions;
231
+ };
232
+
233
+ /**
234
+ * @param {string} bindingName
235
+ */
236
+ const isWritingBinding = (bindingName) =>
237
+ bindingName.startsWith('(') || bindingName.startsWith('[(') || /^(?:on|bindon)-/u.test(bindingName);
238
+
130
239
  /**
131
240
  * @param {string} memberName
132
241
  * @param {string | null} template
@@ -134,20 +243,23 @@ const getContextFilename = (context) => {
134
243
  const templateReferencesMember = (memberName, template) => {
135
244
  if (!template) return false;
136
245
 
137
- const escapedName = escapeRegExp(memberName);
138
- const patterns = [
139
- new RegExp(`\\{\\{[^{}]*\\b${escapedName}\\b[^{}]*\\}\\}`, 'u'),
140
- new RegExp(`@[a-zA-Z]+(?:\\s+if)?\\s*\\([^)]*\\b${escapedName}\\b[^)]*\\)`, 'u'),
141
- new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[^;]*\\b${escapedName}\\b`, 'u'),
142
- new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
143
- new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
144
- new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
145
- new RegExp(`\\[[^\\]]+\\]\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
146
- new RegExp(`\\([^\\)]+\\)\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
147
- new RegExp(`\\([^\\)]+\\)\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
148
- ];
149
-
150
- return patterns.some((pattern) => pattern.test(template));
246
+ const pattern = memberNamePattern(memberName);
247
+
248
+ return getTemplateExpressions(template).some((expression) => pattern.test(expression));
249
+ };
250
+
251
+ /**
252
+ * @param {string} memberName
253
+ * @param {string | null} template
254
+ */
255
+ const templateWritesMember = (memberName, template) => {
256
+ if (!template) return false;
257
+
258
+ const pattern = memberNamePattern(memberName);
259
+
260
+ return getBindingAttributes(template).some(
261
+ (binding) => isWritingBinding(binding.name) && pattern.test(binding.expression),
262
+ );
151
263
  };
152
264
 
153
265
  /**
@@ -183,50 +295,73 @@ const getAngularMetadata = (sourceCode, classNode) => {
183
295
  const textReferencesMember = (memberName, text) => {
184
296
  if (!text) return false;
185
297
 
186
- const pattern = new RegExp(`\\b${escapeRegExp(memberName)}\\b`, 'u');
187
- return pattern.test(text);
298
+ return memberNamePattern(memberName).test(text);
188
299
  };
189
300
 
190
301
  /**
191
- * @param {string} memberName
192
302
  * @param {any} metadata
193
303
  * @param {import('eslint').Rule.RuleContext} context
194
304
  */
195
- const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) => {
196
- const templateProperty = getMetadataProperty(metadata, 'template');
197
- if (templateProperty && templateReferencesMember(memberName, getStringValue(templateProperty.value))) {
198
- return true;
199
- }
305
+ const getTemplateTexts = (metadata, context) => {
306
+ /** @type {string[]} */
307
+ const templates = [];
200
308
 
201
- const templateUrlProperty = getMetadataProperty(metadata, 'templateUrl');
202
- if (templateUrlProperty) {
203
- const templateUrl = getStringValue(templateUrlProperty.value);
204
- const filename = getContextFilename(context);
309
+ const inlineTemplate = getStringValue(getMetadataProperty(metadata, 'template')?.value);
310
+ if (inlineTemplate) templates.push(inlineTemplate);
205
311
 
206
- if (templateUrl && filename) {
207
- const templatePath = path.resolve(path.dirname(filename), templateUrl);
208
- const templateText = readTemplateText(context.sourceCode, templatePath);
209
- if (templateReferencesMember(memberName, templateText)) {
210
- return true;
211
- }
212
- }
312
+ const templateUrl = getStringValue(getMetadataProperty(metadata, 'templateUrl')?.value);
313
+ const filename = getContextFilename(context);
314
+
315
+ if (templateUrl && filename) {
316
+ const templateText = readTemplateText(context.sourceCode, path.resolve(path.dirname(filename), templateUrl));
317
+ if (templateText) templates.push(templateText);
213
318
  }
214
319
 
320
+ return templates;
321
+ };
322
+
323
+ /**
324
+ * @param {any} metadata
325
+ */
326
+ const getHostBindings = (metadata) => {
215
327
  const hostProperty = getMetadataProperty(metadata, 'host');
216
- if (hostProperty && hostProperty.value.type === 'ObjectExpression') {
217
- for (const property of hostProperty.value.properties) {
218
- if (!property || property.type !== 'Property') continue;
219
- if (!isDynamicHostBindingKey(getPropertyKeyName(property))) continue;
328
+ if (!hostProperty || hostProperty.value.type !== 'ObjectExpression') return [];
220
329
 
221
- if (textReferencesMember(memberName, getStringValue(property.value))) {
222
- return true;
223
- }
224
- }
330
+ /** @type {TemplateBinding[]} */
331
+ const bindings = [];
332
+
333
+ for (const property of hostProperty.value.properties) {
334
+ if (!property || property.type !== 'Property') continue;
335
+
336
+ const keyName = getPropertyKeyName(property);
337
+ if (!isDynamicHostBindingKey(keyName)) continue;
338
+
339
+ bindings.push({ name: keyName ?? '', expression: getStringValue(property.value) ?? '' });
225
340
  }
226
341
 
227
- return false;
342
+ return bindings;
228
343
  };
229
344
 
345
+ /**
346
+ * @param {string} memberName
347
+ * @param {any} metadata
348
+ * @param {import('eslint').Rule.RuleContext} context
349
+ */
350
+ const isReferencedFromTemplateOrHostMetadata = (memberName, metadata, context) =>
351
+ getTemplateTexts(metadata, context).some((template) => templateReferencesMember(memberName, template)) ||
352
+ getHostBindings(metadata).some((binding) => textReferencesMember(memberName, binding.expression));
353
+
354
+ /**
355
+ * @param {string} memberName
356
+ * @param {any} metadata
357
+ * @param {import('eslint').Rule.RuleContext} context
358
+ */
359
+ const isWrittenFromTemplateOrHostMetadata = (memberName, metadata, context) =>
360
+ getTemplateTexts(metadata, context).some((template) => templateWritesMember(memberName, template)) ||
361
+ getHostBindings(metadata).some(
362
+ (binding) => isWritingBinding(binding.name) && textReferencesMember(memberName, binding.expression),
363
+ );
364
+
230
365
  /**
231
366
  * @param {any} node
232
367
  */
@@ -252,8 +387,24 @@ const isReferencedFromTemplateOrHost = (classNode, memberName, context) => {
252
387
  return isReferencedFromTemplateOrHostMetadata(memberName, angularMetadata.metadata, context);
253
388
  };
254
389
 
390
+ /**
391
+ * @param {any} classNode
392
+ * @param {string} memberName
393
+ * @param {import('eslint').Rule.RuleContext} context
394
+ */
395
+ const isWrittenFromTemplateOrHost = (classNode, memberName, context) => {
396
+ const angularMetadata = getAngularMetadata(context.sourceCode, classNode);
397
+
398
+ if (!angularMetadata) {
399
+ return false;
400
+ }
401
+
402
+ return isWrittenFromTemplateOrHostMetadata(memberName, angularMetadata.metadata, context);
403
+ };
404
+
255
405
  module.exports = {
256
406
  getAngularMetadata,
407
+ isWrittenFromTemplateOrHost,
257
408
  getMemberName,
258
409
  isReferencedFromTemplateOrHost,
259
410
  };
@@ -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
 
@@ -73,21 +73,49 @@ const noEnum = {
73
73
  const memberIndent = `${indent} `;
74
74
  const exported = target.type === 'ExportNamedDeclaration' ? 'export ' : '';
75
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()}`),
76
+ const members = membersOf(node);
77
+ const openBrace = sourceCode.getTokenBefore(members[0], { filter: (token) => token.value === '{' });
78
+ const bodyStart = openBrace ? openBrace.range[1] : node.id.range[1];
79
+ const bodyComments = sourceCode.getCommentsInside(node).filter((comment) => comment.range[0] >= bodyStart);
80
+
81
+ const formatComment = (comment) =>
82
+ sourceCode
83
+ .getText(comment)
84
+ .split('\n')
85
+ // A block comment's continuation lines carry one more space so the leading `*` stays aligned.
86
+ .map((line, index) => `${memberIndent}${index === 0 ? '' : ' '}${line.trim()}`);
87
+
88
+ const trailingOf = new Map(members.map((member) => [member, []]));
89
+ const leadingOf = new Map(members.map((member) => [member, []]));
90
+ const afterLast = [];
91
+
92
+ for (const comment of bodyComments) {
93
+ const owner = members.find(
94
+ (member) => member.loc.end.line === comment.loc.start.line && member.range[1] <= comment.range[0],
83
95
  );
84
96
 
97
+ if (owner) {
98
+ trailingOf.get(owner).push(comment);
99
+ continue;
100
+ }
101
+
102
+ const next = members.find((member) => member.range[0] >= comment.range[1]);
103
+ (next ? leadingOf.get(next) : afterLast).push(comment);
104
+ }
105
+
106
+ const lines = members.flatMap((member) => {
85
107
  const key = sourceCode.getText(member.id);
86
108
  const value = sourceCode.getText(member.initializer);
109
+ const trailing = trailingOf
110
+ .get(member)
111
+ .map((comment) => ` ${sourceCode.getText(comment)}`)
112
+ .join('');
87
113
 
88
- return [...comments, `${memberIndent}${key}: ${value},`];
114
+ return [...leadingOf.get(member).flatMap(formatComment), `${memberIndent}${key}: ${value},${trailing}`];
89
115
  });
90
116
 
117
+ lines.push(...afterLast.flatMap(formatComment));
118
+
91
119
  return [
92
120
  `${exported}const ${name} = {`,
93
121
  ...lines,
@@ -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
 
@@ -26,6 +26,20 @@ const getReferencedName = (key, computed) => {
26
26
  return null;
27
27
  };
28
28
 
29
+ /**
30
+ * @param {any} thisExpression
31
+ * @param {any} classBody
32
+ */
33
+ const isClassThis = (thisExpression, classBody) => {
34
+ for (let node = thisExpression.parent; node && node !== classBody; node = node.parent) {
35
+ if (node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration') {
36
+ return node.parent?.type.endsWith('MethodDefinition') && node.parent.parent === classBody;
37
+ }
38
+ }
39
+
40
+ return true;
41
+ };
42
+
29
43
  /** @type {import('eslint').Rule.RuleModule} */
30
44
  const noLeadingUnderscoreClassMember = {
31
45
  meta: {
@@ -155,7 +169,8 @@ const noLeadingUnderscoreClassMember = {
155
169
  if (!frame) return;
156
170
 
157
171
  if (node.object?.type === 'ThisExpression' && !node.computed && node.property?.type === 'Identifier') {
158
- frame.memberReads.push(node);
172
+ if (isClassThis(node.object, frame.classBody)) frame.memberReads.push(node);
173
+ else frame.untrackedNames.add(node.property.name);
159
174
  return;
160
175
  }
161
176
 
@@ -98,37 +98,57 @@ const findObjectProperty = (objectExpression, propertyName) =>
98
98
  (property) => property.type === 'Property' && getPropertyName(property.key) === propertyName,
99
99
  ) ?? null;
100
100
 
101
+ /**
102
+ * @param {import('eslint').SourceCode} sourceCode
103
+ * @param {any} token
104
+ */
105
+ const getEndOfSameLineComments = (sourceCode, token) => {
106
+ let end = token.range[1];
107
+
108
+ for (const comment of sourceCode.getCommentsAfter(token)) {
109
+ if (comment.loc?.start.line !== token.loc.end.line) break;
110
+ end = comment.range?.[1] ?? end;
111
+ }
112
+
113
+ return end;
114
+ };
115
+
101
116
  /**
102
117
  * @param {import('eslint').SourceCode} sourceCode
103
118
  * @param {any} objectExpression
104
119
  * @param {string} entryText
120
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => import('eslint').Rule.Fix[]}
105
121
  */
106
- const buildObjectTextWithAppendedProperty = (sourceCode, objectExpression, entryText) => {
122
+ const buildAppendPropertyFix = (sourceCode, objectExpression, entryText) => {
107
123
  const properties = objectExpression.properties;
108
124
  const isMultiline = Boolean(
109
125
  objectExpression.loc && objectExpression.loc.start.line !== objectExpression.loc.end.line,
110
126
  );
111
-
112
- if (!isMultiline) {
113
- const existingText = properties.map((property) => sourceCode.getText(property));
114
- return existingText.length === 0 ? `{ ${entryText} }` : `{ ${existingText.join(', ')}, ${entryText} }`;
115
- }
116
-
117
127
  const closingBrace = sourceCode.getLastToken(objectExpression);
118
128
  const closingIndent = closingBrace ? getIndent(sourceCode, closingBrace) : '';
119
- const propertyIndent = properties[0] ? getIndent(sourceCode, properties[0]) : `${closingIndent} `;
120
- const existingText = properties.map((property) => `${propertyIndent}${sourceCode.getText(property)}`);
129
+ const lastProperty = properties.at(-1);
121
130
 
122
- if (existingText.length === 0) {
123
- return `{
124
- ${propertyIndent}${entryText}
125
- ${closingIndent}}`;
131
+ if (!lastProperty) {
132
+ const insertion = isMultiline ? `\n${closingIndent} ${entryText}\n${closingIndent}` : ` ${entryText} `;
133
+ return (fixer) => [
134
+ fixer.replaceTextRange([objectExpression.range[0] + 1, objectExpression.range[1] - 1], insertion),
135
+ ];
136
+ }
137
+
138
+ const propertyIndent = getIndent(sourceCode, properties[0]);
139
+ const tokenAfter = sourceCode.getTokenAfter(lastProperty);
140
+ const hasComma = tokenAfter?.type === 'Punctuator' && tokenAfter.value === ',';
141
+ const anchorToken = hasComma ? tokenAfter : sourceCode.getLastToken(lastProperty);
142
+ const insertAt = isMultiline ? getEndOfSameLineComments(sourceCode, anchorToken) : anchorToken.range[1];
143
+ const entry = isMultiline
144
+ ? `\n${propertyIndent}${entryText}${hasComma ? ',' : ''}`
145
+ : ` ${entryText}${hasComma ? ',' : ''}`;
146
+
147
+ if (hasComma || insertAt === lastProperty.range[1]) {
148
+ return (fixer) => [fixer.insertTextAfterRange([insertAt, insertAt], `${hasComma ? '' : ','}${entry}`)];
126
149
  }
127
150
 
128
- return `{
129
- ${existingText.join(',\n')},
130
- ${propertyIndent}${entryText}
131
- ${closingIndent}}`;
151
+ return (fixer) => [fixer.insertTextAfter(lastProperty, ','), fixer.insertTextAfterRange([insertAt, insertAt], entry)];
132
152
  };
133
153
 
134
154
  /**
@@ -172,12 +192,7 @@ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey,
172
192
  return null;
173
193
  }
174
194
 
175
- fixes.push((fixer) =>
176
- fixer.replaceText(
177
- hostProperty.value,
178
- buildObjectTextWithAppendedProperty(sourceCode, hostProperty.value, hostEntryText),
179
- ),
180
- );
195
+ fixes.push(buildAppendPropertyFix(sourceCode, hostProperty.value, hostEntryText));
181
196
  } else {
182
197
  const closingBrace = sourceCode.getLastToken(metadata);
183
198
  if (!closingBrace) return null;
@@ -188,24 +203,22 @@ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey,
188
203
  ? getIndent(sourceCode, metadata.properties[0])
189
204
  : `${getIndent(sourceCode, closingBrace)} `;
190
205
  const hostPropertyText = `host: { ${hostEntryText} }`;
191
- const separator = metadata.properties.length > 0 ? (hasTrailingComma(sourceCode, metadata) ? '' : ',') : '';
206
+ const lastProperty = metadata.properties.at(-1);
192
207
 
193
- if (isMultiline && metadata.properties.length > 0) {
194
- const lastProperty = metadata.properties[metadata.properties.length - 1];
208
+ if (lastProperty) {
209
+ const separator = hasTrailingComma(sourceCode, metadata) ? '' : ',';
195
210
  const tokenAfterLastProperty = sourceCode.getTokenAfter(lastProperty);
196
211
  const rangeStart =
197
212
  separator === '' && tokenAfterLastProperty ? tokenAfterLastProperty.range[1] : lastProperty.range[1];
213
+ const replacement = isMultiline
214
+ ? `${separator}\n${propertyIndent}${hostPropertyText}${separator === '' ? ',' : ''}\n${closingIndent}`
215
+ : `${separator} ${hostPropertyText} `;
198
216
 
199
- fixes.push((fixer) =>
200
- fixer.replaceTextRange(
201
- [rangeStart, closingBrace.range[0]],
202
- `${separator}\n${propertyIndent}${hostPropertyText},\n${closingIndent}`,
203
- ),
204
- );
217
+ fixes.push((fixer) => fixer.replaceTextRange([rangeStart, closingBrace.range[0]], replacement));
205
218
  } else {
206
219
  const insertion = isMultiline
207
220
  ? `\n${propertyIndent}${hostPropertyText}\n${closingIndent}`
208
- : `${metadata.properties.length > 0 ? `${separator} ` : ' '}${hostPropertyText}${metadata.properties.length > 0 ? '' : ' '}`;
221
+ : ` ${hostPropertyText} `;
209
222
 
210
223
  fixes.push((fixer) => fixer.insertTextBefore(closingBrace, insertion));
211
224
  }
@@ -218,7 +231,7 @@ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey,
218
231
  : (fixer) => fixer.remove(decoratorNode),
219
232
  );
220
233
 
221
- return (fixer) => fixes.map((applyFix) => applyFix(fixer));
234
+ return (fixer) => fixes.flatMap((applyFix) => applyFix(fixer));
222
235
  };
223
236
 
224
237
  /** @type {import('eslint').Rule.RuleModule} */
@@ -70,7 +70,7 @@ const noReadonlySignal = {
70
70
  },
71
71
  messages: {
72
72
  noReadonlySignal:
73
- "Remove `readonly` from '{{name}}' — reactive APIs return mutable references, and `readonly` gives a false sense of immutability.",
73
+ "Remove `readonly` from '{{name}}' — {{api}}() returns a mutable reference, and `readonly` gives a false sense of immutability.",
74
74
  },
75
75
  schema: [],
76
76
  },
@@ -99,7 +99,7 @@ const noReadonlySignal = {
99
99
  context.report({
100
100
  node,
101
101
  messageId: 'noReadonlySignal',
102
- data: { name: apiName },
102
+ data: { name: context.sourceCode.getText(node.key), api: apiName },
103
103
  fix(fixer) {
104
104
  const sourceCode = context.sourceCode;
105
105
  const tokens = sourceCode.getTokens(node);
@@ -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;
@@ -40,14 +40,16 @@ const TRIVIAL_LABELS = {
40
40
  const noTrivialReturnType = {
41
41
  meta: {
42
42
  type: 'suggestion',
43
- fixable: 'code',
43
+ hasSuggestions: true,
44
44
  docs: {
45
45
  description:
46
46
  'Disallow explicit return type annotations that TypeScript can infer (void, boolean, string, number, undefined, null).',
47
47
  recommended: true,
48
48
  },
49
49
  messages: {
50
- trivialReturnType: "Omit the explicit ': {{type}}' return type — TypeScript infers it.",
50
+ trivialReturnType:
51
+ "Omit the explicit ': {{type}}' return type — TypeScript infers it. Keep it when the returned value is `any`, or the inferred type would differ.",
52
+ removeReturnType: "Remove the ': {{type}}' return type.",
51
53
  },
52
54
  schema: [],
53
55
  },
@@ -199,9 +201,13 @@ const noTrivialReturnType = {
199
201
  node: fn.returnType,
200
202
  messageId: 'trivialReturnType',
201
203
  data: { type: label },
202
- fix(fixer) {
203
- return fixer.remove(fn.returnType);
204
- },
204
+ suggest: [
205
+ {
206
+ messageId: 'removeReturnType',
207
+ data: { type: label },
208
+ fix: (fixer) => fixer.remove(fn.returnType),
209
+ },
210
+ ],
205
211
  });
206
212
  };
207
213
 
@@ -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 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 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
  },
@@ -191,7 +199,6 @@ const noUnusedClassMember = {
191
199
  AccessorProperty: registerMember,
192
200
 
193
201
  MemberExpression(node) {
194
- if (node.object?.type !== 'ThisExpression') return;
195
202
  const name =
196
203
  !node.computed && node.property?.type === 'Identifier'
197
204
  ? node.property.name
@@ -200,6 +207,11 @@ const noUnusedClassMember = {
200
207
  : null;
201
208
  if (!name) return;
202
209
 
210
+ if (node.object?.type !== 'ThisExpression') {
211
+ for (const frame of classStack) frame.usedNames.add(name);
212
+ return;
213
+ }
214
+
203
215
  // Mark used in the innermost class frame. Arrow functions don't push a
204
216
  // new frame so they correctly share the enclosing class's `this`.
205
217
  // Regular nested classes push a new frame so their `this.x` won't
@@ -19,6 +19,33 @@ const { isImportedAs } = require('./internals/import-resolution');
19
19
  * ).subscribe();
20
20
  */
21
21
 
22
+ const ALLOWED_AFTER = new Set([
23
+ 'finalize',
24
+ 'defaultIfEmpty',
25
+ 'endWith',
26
+ 'toArray',
27
+ 'last',
28
+ 'count',
29
+ 'reduce',
30
+ 'takeLast',
31
+ ]);
32
+
33
+ /**
34
+ * @param {any} node
35
+ */
36
+ const isPipeCall = (node) =>
37
+ node?.type === 'CallExpression' &&
38
+ node.callee.type === 'MemberExpression' &&
39
+ !node.callee.computed &&
40
+ node.callee.property.type === 'Identifier' &&
41
+ node.callee.property.name === 'pipe';
42
+
43
+ /**
44
+ * @param {any} node
45
+ */
46
+ const isAllowedAfter = (node) =>
47
+ node.type === 'CallExpression' && node.callee.type === 'Identifier' && ALLOWED_AFTER.has(node.callee.name);
48
+
22
49
  /** @type {import('eslint').Rule.RuleModule} */
23
50
  const takeUntilDestroyedLast = {
24
51
  meta: {
@@ -29,7 +56,7 @@ const takeUntilDestroyedLast = {
29
56
  },
30
57
  messages: {
31
58
  takeUntilDestroyedLast:
32
- 'takeUntilDestroyed() must be the last operator in the pipe. An operator after it (switchMap, shareReplay, …) can keep the subscription alive after destroy.',
59
+ 'takeUntilDestroyed() must be the last operator in the pipe. An operator after it that subscribes to an inner or shared source (switchMap, mergeMap, share without refCount, …) outlives destroy. Move takeUntilDestroyed() after it.',
33
60
  },
34
61
  schema: [],
35
62
  },
@@ -41,20 +68,22 @@ const takeUntilDestroyedLast = {
41
68
 
42
69
  return {
43
70
  CallExpression(node) {
44
- const { callee } = node;
45
- if (
46
- callee.type !== 'MemberExpression' ||
47
- callee.property.type !== 'Identifier' ||
48
- callee.property.name !== 'pipe'
49
- ) {
50
- return;
71
+ if (!isPipeCall(node)) return;
72
+
73
+ const parent = /** @type {any} */ (node).parent;
74
+ if (parent?.type === 'MemberExpression' && parent.object === node && isPipeCall(parent.parent)) return;
75
+
76
+ /** @type {any[]} */
77
+ const operators = [];
78
+ for (let call = /** @type {any} */ (node); isPipeCall(call); call = call.callee.object) {
79
+ operators.unshift(...call.arguments);
51
80
  }
52
81
 
53
- const lastIndex = node.arguments.length - 1;
54
- node.arguments.forEach((arg, index) => {
55
- if (index < lastIndex && isTakeUntilDestroyedCall(arg)) {
56
- context.report({ node: arg, messageId: 'takeUntilDestroyedLast' });
57
- }
82
+ operators.forEach((operator, index) => {
83
+ if (!isTakeUntilDestroyedCall(operator)) return;
84
+ if (operators.slice(index + 1).every(isAllowedAfter)) return;
85
+
86
+ context.report({ node: operator, messageId: 'takeUntilDestroyedLast' });
58
87
  });
59
88
  },
60
89
  };
@@ -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;