@ethlete/eslint-plugin 1.0.0-next.27 → 1.0.0-next.29
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 +16 -0
- package/package.json +6 -1
- package/src/configs/recommended.js +4 -3
- package/src/rules/class-constant-property.js +7 -0
- package/src/rules/internals/angular-member-visibility.js +193 -42
- package/src/rules/no-angular-router-api.js +7 -4
- package/src/rules/no-enum.js +36 -8
- package/src/rules/no-leading-underscore-class-member.js +16 -1
- package/src/rules/no-legacy-angular-decorators.js +39 -24
- package/src/rules/no-readonly-signal.js +2 -2
- package/src/rules/no-trivial-return-type.js +11 -5
- package/src/rules/no-unused-class-member.js +7 -3
- package/src/rules/take-until-destroyed-last.js +42 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.29
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- `no-angular-router-api` names `injectRouterState().id` / `.trigger` as the replacement for `router.currentNavigation` and `lastSuccessfulNavigation`.
|
|
8
|
+
|
|
9
|
+
## 1.0.0-next.28
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- The recommended config now also bans functional route guards and resolvers (`canDeactivate` stays allowed), and barrel imports through `'.'`, `'..'` or `./index.js`.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## 1.0.0-next.27
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethlete/eslint-plugin",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.29",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/ethlete-io/ethdk",
|
|
7
|
+
"directory": "libs/eslint-plugin"
|
|
8
|
+
},
|
|
4
9
|
"license": "MIT",
|
|
5
10
|
"type": "commonjs",
|
|
6
11
|
"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:
|
|
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
|
},
|
|
@@ -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
|
}
|
|
@@ -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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return true;
|
|
199
|
-
}
|
|
305
|
+
const getTemplateTexts = (metadata, context) => {
|
|
306
|
+
/** @type {string[]} */
|
|
307
|
+
const templates = [];
|
|
200
308
|
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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
|
};
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* this.router.events → injectRouterEvent()
|
|
20
20
|
* this.router.routerState → injectRouterState()
|
|
21
21
|
* this.router.snapshot → injectRouterState()
|
|
22
|
-
* this.router.lastSuccessfulNavigation → injectRouterState()
|
|
23
|
-
* this.router.currentNavigation → injectRouterState()
|
|
22
|
+
* this.router.lastSuccessfulNavigation → injectRouterState().id / .trigger
|
|
23
|
+
* this.router.currentNavigation → injectRouterState().id / .trigger (navigation state: injectRouterNavigationState())
|
|
24
24
|
*
|
|
25
25
|
* ✅ inject(Router) is still fine for navigation:
|
|
26
26
|
* router.navigate(['/path'])
|
|
@@ -47,8 +47,11 @@ const ROUTER_STATE_PROPS = new Map([
|
|
|
47
47
|
['url', "injectUrl() from '@ethlete/core'"],
|
|
48
48
|
['events', "injectRouterEvent() from '@ethlete/core'"],
|
|
49
49
|
['routerState', "injectRouterState() from '@ethlete/core'"],
|
|
50
|
-
['lastSuccessfulNavigation', "injectRouterState() from '@ethlete/core'"],
|
|
51
|
-
[
|
|
50
|
+
['lastSuccessfulNavigation', "injectRouterState() from '@ethlete/core' (its 'id' and 'trigger')"],
|
|
51
|
+
[
|
|
52
|
+
'currentNavigation',
|
|
53
|
+
"injectRouterState() from '@ethlete/core' (its 'id' and 'trigger'), or injectRouterNavigationState() for the navigation's state",
|
|
54
|
+
],
|
|
52
55
|
]);
|
|
53
56
|
|
|
54
57
|
/** @type {import('eslint').Rule.RuleModule} */
|
package/src/rules/no-enum.js
CHANGED
|
@@ -73,21 +73,49 @@ const noEnum = {
|
|
|
73
73
|
const memberIndent = `${indent} `;
|
|
74
74
|
const exported = target.type === 'ExportNamedDeclaration' ? 'export ' : '';
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 [...
|
|
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,
|
|
@@ -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
|
|
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
|
|
120
|
-
const existingText = properties.map((property) => `${propertyIndent}${sourceCode.getText(property)}`);
|
|
129
|
+
const lastProperty = properties.at(-1);
|
|
121
130
|
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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((
|
|
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;
|
|
@@ -216,7 +231,7 @@ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey,
|
|
|
216
231
|
: (fixer) => fixer.remove(decoratorNode),
|
|
217
232
|
);
|
|
218
233
|
|
|
219
|
-
return (fixer) => fixes.
|
|
234
|
+
return (fixer) => fixes.flatMap((applyFix) => applyFix(fixer));
|
|
220
235
|
};
|
|
221
236
|
|
|
222
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}}' —
|
|
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);
|
|
@@ -40,14 +40,16 @@ const TRIVIAL_LABELS = {
|
|
|
40
40
|
const noTrivialReturnType = {
|
|
41
41
|
meta: {
|
|
42
42
|
type: 'suggestion',
|
|
43
|
-
|
|
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:
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
|
@@ -112,10 +112,10 @@ const noUnusedClassMember = {
|
|
|
112
112
|
},
|
|
113
113
|
messages: {
|
|
114
114
|
noUnused:
|
|
115
|
-
"'{{name}}' is declared but never referenced
|
|
115
|
+
"'{{name}}' is declared but never referenced within this class. " +
|
|
116
116
|
'Remove it, or widen its accessibility if it is intentionally used externally.',
|
|
117
117
|
noUnusedProtected:
|
|
118
|
-
"'{{name}}' is protected but never referenced
|
|
118
|
+
"'{{name}}' is protected but never referenced within this class. " +
|
|
119
119
|
'If a subclass reads it, mark this class `abstract`; otherwise remove it.',
|
|
120
120
|
},
|
|
121
121
|
schema: [],
|
|
@@ -199,7 +199,6 @@ const noUnusedClassMember = {
|
|
|
199
199
|
AccessorProperty: registerMember,
|
|
200
200
|
|
|
201
201
|
MemberExpression(node) {
|
|
202
|
-
if (node.object?.type !== 'ThisExpression') return;
|
|
203
202
|
const name =
|
|
204
203
|
!node.computed && node.property?.type === 'Identifier'
|
|
205
204
|
? node.property.name
|
|
@@ -208,6 +207,11 @@ const noUnusedClassMember = {
|
|
|
208
207
|
: null;
|
|
209
208
|
if (!name) return;
|
|
210
209
|
|
|
210
|
+
if (node.object?.type !== 'ThisExpression') {
|
|
211
|
+
for (const frame of classStack) frame.usedNames.add(name);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
211
215
|
// Mark used in the innermost class frame. Arrow functions don't push a
|
|
212
216
|
// new frame so they correctly share the enclosing class's `this`.
|
|
213
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,
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
if (index
|
|
56
|
-
|
|
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
|
};
|