@ethlete/eslint-plugin 1.0.0-next.22 → 1.0.0-next.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +3 -4
- package/src/configs/recommended.js +20 -10
- package/src/index.js +4 -1
- package/src/rules/class-constant-property.js +27 -0
- package/src/rules/guard-return-newline.js +5 -4
- package/src/rules/inject-member-accessibility.js +17 -21
- package/src/rules/internals/angular-io.js +19 -1
- package/src/rules/internals/angular-member-visibility.js +5 -5
- package/src/rules/internals/angular-metadata-fix.js +22 -0
- package/src/rules/internals/member-accessibility-fix.js +26 -0
- package/src/rules/no-angular-seo-services.js +7 -7
- package/src/rules/no-direct-dom-manipulation.js +17 -0
- package/src/rules/no-effect-cleanup-return.js +8 -6
- package/src/rules/no-empty-angular-metadata-arrays.js +3 -19
- package/src/rules/no-enum.js +115 -0
- package/src/rules/no-leading-underscore-class-member.js +7 -0
- package/src/rules/no-legacy-angular-decorators.js +8 -0
- package/src/rules/no-legacy-prepare-without-injector.js +42 -26
- package/src/rules/no-legacy-query-import.js +1 -1
- package/src/rules/no-locale-id.js +2 -14
- package/src/rules/no-member-alias.js +17 -0
- package/src/rules/no-pipe-logic.js +1 -0
- package/src/rules/no-readonly-signal.js +13 -5
- package/src/rules/no-redundant-internal.js +21 -2
- package/src/rules/no-redundant-on-push-change-detection.js +28 -38
- package/src/rules/no-standalone-flag.js +4 -19
- package/src/rules/no-trivial-return-type.js +1 -0
- package/src/rules/no-trivial-wrapper-method.js +3 -0
- package/src/rules/no-type-only-import.js +7 -2
- package/src/rules/no-typed-injected-element-ref.js +16 -9
- package/src/rules/no-unused-class-member.js +28 -5
- package/src/rules/prefer-match-media.js +2 -2
- package/src/rules/prefer-scroll-state.js +3 -67
- package/src/rules/prefer-static-boolean-properties.js +30 -0
- package/src/rules/prefer-viewport-size.js +1 -7
- package/src/rules/require-form-submit.js +2 -28
- package/src/rules/require-on-push-change-detection.js +31 -11
- package/src/rules/require-view-encapsulation-none.js +31 -9
- package/src/rules/template-member-accessibility.js +4 -21
|
@@ -379,6 +379,14 @@ const rule = {
|
|
|
379
379
|
const expression = anyNode.expression;
|
|
380
380
|
if (expression.type !== 'CallExpression' && expression.type !== 'Identifier') return null;
|
|
381
381
|
|
|
382
|
+
if (
|
|
383
|
+
expression.type === 'CallExpression' &&
|
|
384
|
+
expression.arguments.length > 0 &&
|
|
385
|
+
(expression.arguments[0]?.type !== 'Literal' || typeof expression.arguments[0].value !== 'string')
|
|
386
|
+
) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
382
390
|
const bindingName =
|
|
383
391
|
expression.type === 'CallExpression'
|
|
384
392
|
? expression.arguments[0]?.type === 'Literal' && typeof expression.arguments[0].value === 'string'
|
|
@@ -273,7 +273,12 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
273
273
|
},
|
|
274
274
|
create(context) {
|
|
275
275
|
const sourceCode = context.sourceCode;
|
|
276
|
-
|
|
276
|
+
let creatorPattern;
|
|
277
|
+
try {
|
|
278
|
+
creatorPattern = new RegExp(context.options[0]?.creatorPattern ?? '^legacy');
|
|
279
|
+
} catch {
|
|
280
|
+
creatorPattern = /^legacy/u;
|
|
281
|
+
}
|
|
277
282
|
/** Names known to be legacy creators: imported under the naming convention, or declared locally. */
|
|
278
283
|
const creatorNames = new Set();
|
|
279
284
|
|
|
@@ -297,38 +302,42 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
297
302
|
* @param {string[]} needed
|
|
298
303
|
*/
|
|
299
304
|
const buildCoreImportFix = (needed) => {
|
|
300
|
-
const
|
|
301
|
-
sourceCode.ast.body.
|
|
305
|
+
const coreImports = /** @type {any[]} */ (
|
|
306
|
+
sourceCode.ast.body.filter(
|
|
302
307
|
(node) =>
|
|
303
308
|
node.type === 'ImportDeclaration' &&
|
|
304
309
|
node.source.type === 'Literal' &&
|
|
305
310
|
node.source.value === '@angular/core',
|
|
306
311
|
)
|
|
307
312
|
);
|
|
313
|
+
const existing = new Set(
|
|
314
|
+
coreImports.flatMap((coreImport) =>
|
|
315
|
+
coreImport.specifiers
|
|
316
|
+
.filter((specifier) => specifier.type === 'ImportSpecifier' && coreImport.importKind !== 'type')
|
|
317
|
+
.map((specifier) => specifier.imported.name),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
const missing = needed.filter((name) => !existing.has(name));
|
|
321
|
+
if (missing.length === 0) return null;
|
|
322
|
+
|
|
323
|
+
const editableImport = coreImports.find(
|
|
324
|
+
(coreImport) =>
|
|
325
|
+
coreImport.importKind !== 'type' &&
|
|
326
|
+
coreImport.specifiers.length > 0 &&
|
|
327
|
+
coreImport.specifiers.every((specifier) => specifier.type === 'ImportSpecifier'),
|
|
328
|
+
);
|
|
308
329
|
|
|
309
|
-
if (!
|
|
330
|
+
if (!editableImport) {
|
|
310
331
|
const imports = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
|
|
311
|
-
const lastImport = imports
|
|
312
|
-
const text = `import { ${
|
|
332
|
+
const lastImport = coreImports.at(-1) ?? imports.at(-1);
|
|
333
|
+
const text = `import { ${missing.join(', ')} } from '@angular/core';`;
|
|
313
334
|
|
|
314
335
|
return lastImport
|
|
315
336
|
? /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastImport, `\n${text}`)
|
|
316
337
|
: /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(sourceCode.ast.body[0], `${text}\n\n`);
|
|
317
338
|
}
|
|
318
339
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
if (specifiers.some(/** @param {any} specifier */ (specifier) => specifier.type !== 'ImportSpecifier')) {
|
|
322
|
-
return null;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const existing = new Set(specifiers.map(/** @param {any} specifier */ (specifier) => specifier.imported.name));
|
|
326
|
-
const missing = needed.filter((name) => !existing.has(name));
|
|
327
|
-
const lastSpecifier = specifiers[specifiers.length - 1];
|
|
328
|
-
|
|
329
|
-
if (missing.length === 0 || !lastSpecifier) {
|
|
330
|
-
return null;
|
|
331
|
-
}
|
|
340
|
+
const lastSpecifier = editableImport.specifiers.at(-1);
|
|
332
341
|
|
|
333
342
|
return /** @param {any} fixer */ (fixer) => fixer.insertTextAfter(lastSpecifier, `, ${missing.join(', ')}`);
|
|
334
343
|
};
|
|
@@ -368,12 +377,6 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
368
377
|
);
|
|
369
378
|
}
|
|
370
379
|
|
|
371
|
-
// `prepare(args)` - spreading is the only way to keep whatever the variable holds.
|
|
372
|
-
if (argument.type === 'Identifier' || argument.type === 'MemberExpression') {
|
|
373
|
-
return /** @param {any} fixer */ (fixer) =>
|
|
374
|
-
fixer.replaceText(argument, `{ ...${sourceCode.getText(argument)}, ${injectorProperty} }`);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
380
|
return null;
|
|
378
381
|
};
|
|
379
382
|
|
|
@@ -457,7 +460,20 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
457
460
|
|
|
458
461
|
const classBody = findEnclosingClassBody(node);
|
|
459
462
|
const existingInjector = classBody ? findInjectorMemberName(classBody) : null;
|
|
460
|
-
const
|
|
463
|
+
const declaredNames = new Set(
|
|
464
|
+
classBody?.body
|
|
465
|
+
.map((member) => (member.key?.type === 'Identifier' ? member.key.name : null))
|
|
466
|
+
.filter(Boolean) ?? [],
|
|
467
|
+
);
|
|
468
|
+
let injectorName = existingInjector ?? 'injector';
|
|
469
|
+
if (!existingInjector && declaredNames.has(injectorName)) {
|
|
470
|
+
injectorName = 'queryInjector';
|
|
471
|
+
let suffix = 2;
|
|
472
|
+
while (declaredNames.has(injectorName)) {
|
|
473
|
+
injectorName = `queryInjector${suffix}`;
|
|
474
|
+
suffix += 1;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
461
477
|
const argumentFix = classBody ? buildArgumentFix(node, `this.${injectorName}`) : null;
|
|
462
478
|
|
|
463
479
|
context.report({
|
|
@@ -48,7 +48,7 @@ const LEGACY_SYMBOLS = {
|
|
|
48
48
|
queryStateErrorSignal: { to: '`query.error()`', docs: '/query/queries#the-query-object' },
|
|
49
49
|
queryStateLoadingSignal: { to: '`query.loading()`', docs: '/query/queries#the-query-object' },
|
|
50
50
|
validateWithV2Query: { to: '`validateWithQuery`', docs: '/query/errors#validating-against-the-server-as-the-user-types' }, // prettier-ignore
|
|
51
|
-
provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query
|
|
51
|
+
provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query`, which registers every client at once', docs: '/query-devtools/' }, // prettier-ignore
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
/** The current-system counterparts of the names the legacy system had to give up its half of. */
|
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Disallows injecting Angular's LOCALE_ID token directly.
|
|
6
6
|
*
|
|
7
|
-
* @ethlete/core provides
|
|
8
|
-
* and exposes helper utilities for locale-aware formatting.
|
|
7
|
+
* @ethlete/core provides a signal-based locale store for application code.
|
|
9
8
|
*
|
|
10
9
|
* BAD:
|
|
11
10
|
* inject(LOCALE_ID)
|
|
12
|
-
* import { LOCALE_ID } from '@angular/core';
|
|
13
11
|
*
|
|
14
12
|
* GOOD:
|
|
15
13
|
* import { injectLocale } from '@ethlete/core';
|
|
@@ -26,7 +24,7 @@ const noLocaleId = {
|
|
|
26
24
|
},
|
|
27
25
|
messages: {
|
|
28
26
|
noLocaleId:
|
|
29
|
-
"Do not inject 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core'
|
|
27
|
+
"Do not inject 'LOCALE_ID' directly. Use the signal-based 'injectLocale()' API from '@ethlete/core' for application locale state.",
|
|
30
28
|
},
|
|
31
29
|
schema: [],
|
|
32
30
|
},
|
|
@@ -40,16 +38,6 @@ const noLocaleId = {
|
|
|
40
38
|
context.report({ node, messageId: 'noLocaleId' });
|
|
41
39
|
}
|
|
42
40
|
},
|
|
43
|
-
|
|
44
|
-
ImportDeclaration(node) {
|
|
45
|
-
if (node.source.value !== '@angular/core') return;
|
|
46
|
-
for (const specifier of node.specifiers) {
|
|
47
|
-
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'LOCALE_ID') {
|
|
48
|
-
context.report({ node, messageId: 'noLocaleId' });
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
41
|
};
|
|
54
42
|
},
|
|
55
43
|
};
|
|
@@ -79,6 +79,16 @@ const getMethodAliasInfo = (node) => {
|
|
|
79
79
|
return { sourceName: obj.property.name, propName: callee.property.name };
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
const CONTRACT_METHOD_NAMES = new Set(['blur', 'focus', 'reset']);
|
|
83
|
+
|
|
84
|
+
/** @param {any} methodNode */
|
|
85
|
+
const isInAngularClass = (methodNode) =>
|
|
86
|
+
methodNode.parent?.parent?.decorators?.some((decorator) => {
|
|
87
|
+
const expression = decorator.expression;
|
|
88
|
+
const callee = expression.type === 'CallExpression' ? expression.callee : expression;
|
|
89
|
+
return callee.type === 'Identifier' && (callee.name === 'Component' || callee.name === 'Directive');
|
|
90
|
+
}) ?? false;
|
|
91
|
+
|
|
82
92
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
83
93
|
const noMemberAlias = {
|
|
84
94
|
meta: {
|
|
@@ -119,6 +129,10 @@ const noMemberAlias = {
|
|
|
119
129
|
|
|
120
130
|
const sourceName = obj.property.name;
|
|
121
131
|
const propName = value.property.name;
|
|
132
|
+
const sourceMember = node.parent?.body?.find(
|
|
133
|
+
(member) => !member.computed && member.key?.type === 'Identifier' && member.key.name === sourceName,
|
|
134
|
+
);
|
|
135
|
+
if (node.accessibility === 'private' && sourceMember?.accessibility === 'private') return;
|
|
122
136
|
|
|
123
137
|
context.report({
|
|
124
138
|
node,
|
|
@@ -133,6 +147,9 @@ const noMemberAlias = {
|
|
|
133
147
|
|
|
134
148
|
const info = getMethodAliasInfo(node);
|
|
135
149
|
if (!info) return;
|
|
150
|
+
const classNode = node.parent?.parent;
|
|
151
|
+
if (node.override || classNode?.superClass || classNode?.implements?.length) return;
|
|
152
|
+
if (isInAngularClass(node) && CONTRACT_METHOD_NAMES.has(aliasName)) return;
|
|
136
153
|
|
|
137
154
|
// Only flag when names match — different names indicate an intentional
|
|
138
155
|
// API rename, which is not a pure alias.
|
|
@@ -39,6 +39,7 @@ const noPipeLogic = {
|
|
|
39
39
|
MethodDefinition(node) {
|
|
40
40
|
const key = /** @type {any} */ (node).key;
|
|
41
41
|
if (key.type !== 'Identifier' || key.name !== 'transform') return;
|
|
42
|
+
if (!node.value?.body || node.value.body.type === 'TSEmptyBodyFunctionExpression') return;
|
|
42
43
|
|
|
43
44
|
const classBody = node.parent;
|
|
44
45
|
if (!classBody || classBody.type !== 'ClassBody') return;
|
|
@@ -50,6 +50,10 @@ const REACTIVE_APIS = new Set([
|
|
|
50
50
|
'effect',
|
|
51
51
|
'afterNextRender',
|
|
52
52
|
'afterRender',
|
|
53
|
+
'afterRenderEffect',
|
|
54
|
+
'resource',
|
|
55
|
+
'rxResource',
|
|
56
|
+
'httpResource',
|
|
53
57
|
// RxJS interop
|
|
54
58
|
'toSignal',
|
|
55
59
|
'toObservable',
|
|
@@ -83,7 +87,7 @@ const noReadonlySignal = {
|
|
|
83
87
|
let apiName = null;
|
|
84
88
|
|
|
85
89
|
// Simple call: signal(), input(), computed(), inject(), etc.
|
|
86
|
-
if (callee.type === 'Identifier' && REACTIVE_APIS.has(callee.name)) {
|
|
90
|
+
if (callee.type === 'Identifier' && (REACTIVE_APIS.has(callee.name) || callee.name.startsWith('inject'))) {
|
|
87
91
|
apiName = callee.name;
|
|
88
92
|
}
|
|
89
93
|
// Member call: input.required(), outputFromObservable.something(), etc.
|
|
@@ -101,13 +105,17 @@ const noReadonlySignal = {
|
|
|
101
105
|
messageId: 'noReadonlySignal',
|
|
102
106
|
data: { name: apiName },
|
|
103
107
|
fix(fixer) {
|
|
104
|
-
// Find and remove the "readonly" token
|
|
105
108
|
const sourceCode = context.sourceCode;
|
|
106
109
|
const tokens = sourceCode.getTokens(node);
|
|
107
|
-
const
|
|
110
|
+
const decoratorEnd = node.decorators?.at(-1)?.range[1] ?? node.range[0];
|
|
111
|
+
const readonlyToken = tokens.find(
|
|
112
|
+
(token) =>
|
|
113
|
+
token.value === 'readonly' && token.range[0] >= decoratorEnd && token.range[1] <= node.key.range[0],
|
|
114
|
+
);
|
|
108
115
|
if (!readonlyToken) return null;
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
const nextToken = sourceCode.getTokenAfter(readonlyToken);
|
|
117
|
+
const rangeEnd = nextToken ? nextToken.range[0] : readonlyToken.range[1];
|
|
118
|
+
return fixer.removeRange([readonlyToken.range[0], rangeEnd]);
|
|
111
119
|
},
|
|
112
120
|
});
|
|
113
121
|
}
|
|
@@ -40,6 +40,25 @@ const getMemberKind = (node) => {
|
|
|
40
40
|
return 'property';
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
45
|
+
* @param {import('eslint').Rule.Node} node
|
|
46
|
+
* @param {import('eslint').AST.Token} comment
|
|
47
|
+
*/
|
|
48
|
+
const buildInternalTagFix = (sourceCode, node, comment) => {
|
|
49
|
+
const commentText = sourceCode.getText(comment);
|
|
50
|
+
const remainingText = commentText
|
|
51
|
+
.replace(/^\s*\/\*\*?\s*@internal\s*\*\/\s*$/u, '')
|
|
52
|
+
.replace(/^\s*\*\s*@internal\s*\r?\n/mu, '')
|
|
53
|
+
.replace(/@internal\b\s*/u, '');
|
|
54
|
+
|
|
55
|
+
if (remainingText.trim() === '') {
|
|
56
|
+
return (fixer) => fixer.removeRange([comment.range[0], node.range[0]]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (fixer) => fixer.replaceText(comment, remainingText);
|
|
60
|
+
};
|
|
61
|
+
|
|
43
62
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
44
63
|
const noRedundantInternal = {
|
|
45
64
|
meta: {
|
|
@@ -68,7 +87,7 @@ const noRedundantInternal = {
|
|
|
68
87
|
return;
|
|
69
88
|
}
|
|
70
89
|
|
|
71
|
-
if (node.accessibility !== 'private'
|
|
90
|
+
if (node.accessibility !== 'private') {
|
|
72
91
|
return;
|
|
73
92
|
}
|
|
74
93
|
|
|
@@ -80,7 +99,7 @@ const noRedundantInternal = {
|
|
|
80
99
|
kind: getMemberKind(node),
|
|
81
100
|
name: getMemberName(node),
|
|
82
101
|
},
|
|
83
|
-
fix: (
|
|
102
|
+
fix: buildInternalTagFix(sourceCode, node, internalComment),
|
|
84
103
|
});
|
|
85
104
|
};
|
|
86
105
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Disallow explicit `changeDetection: ChangeDetectionStrategy.OnPush` on
|
|
6
8
|
* `@Component` decorators. Since Angular 22, OnPush is the default change
|
|
@@ -85,34 +87,13 @@ const isComponentDecorator = (node) => {
|
|
|
85
87
|
/**
|
|
86
88
|
* @param {any} value
|
|
87
89
|
*/
|
|
88
|
-
const isOnPushValue = (value) =>
|
|
90
|
+
const isOnPushValue = (value, localName) =>
|
|
89
91
|
value.type === 'MemberExpression' &&
|
|
90
92
|
value.object.type === 'Identifier' &&
|
|
91
|
-
value.object.name ===
|
|
93
|
+
value.object.name === localName &&
|
|
92
94
|
value.property.type === 'Identifier' &&
|
|
93
95
|
value.property.name === 'OnPush';
|
|
94
96
|
|
|
95
|
-
/**
|
|
96
|
-
* Range that removes a property together with the comma that joins it to its
|
|
97
|
-
* siblings (leading comma when it is the last property, otherwise trailing).
|
|
98
|
-
*
|
|
99
|
-
* @param {import('eslint').SourceCode} sourceCode
|
|
100
|
-
* @param {any} metadata
|
|
101
|
-
* @param {any} property
|
|
102
|
-
*/
|
|
103
|
-
const getPropertyRemovalRange = (sourceCode, metadata, property) => {
|
|
104
|
-
const properties = metadata.properties.filter((entry) => entry.type === 'Property');
|
|
105
|
-
const index = properties.indexOf(property);
|
|
106
|
-
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
107
|
-
const closingBrace = sourceCode.getLastToken(metadata);
|
|
108
|
-
|
|
109
|
-
if (!openingBrace || !closingBrace) return property.range;
|
|
110
|
-
if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
|
|
111
|
-
if (index < properties.length - 1) return [property.range[0], properties[index + 1].range[0]];
|
|
112
|
-
|
|
113
|
-
return [properties[index - 1].range[1], property.range[1]];
|
|
114
|
-
};
|
|
115
|
-
|
|
116
97
|
/**
|
|
117
98
|
* Range that removes a single named import specifier together with its comma.
|
|
118
99
|
*
|
|
@@ -126,6 +107,15 @@ const getSpecifierRemovalRange = (sourceCode, importNode, specifier) => {
|
|
|
126
107
|
|
|
127
108
|
// Sole specifier of the whole import — drop the entire declaration.
|
|
128
109
|
if (importNode.specifiers.length === 1) return importNode.range;
|
|
110
|
+
if (named.length === 1) {
|
|
111
|
+
const openingBrace = sourceCode.getTokenBefore(specifier, (token) => token.value === '{');
|
|
112
|
+
const closingBrace = sourceCode.getTokenAfter(specifier, (token) => token.value === '}');
|
|
113
|
+
const precedingComma = openingBrace ? sourceCode.getTokenBefore(openingBrace) : null;
|
|
114
|
+
|
|
115
|
+
if (openingBrace && closingBrace && precedingComma?.value === ',') {
|
|
116
|
+
return [precedingComma.range[0], closingBrace.range[1]];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
129
119
|
if (index < named.length - 1) return [specifier.range[0], named[index + 1].range[0]];
|
|
130
120
|
|
|
131
121
|
return [named[index - 1].range[1], specifier.range[1]];
|
|
@@ -137,18 +127,10 @@ const getSpecifierRemovalRange = (sourceCode, importNode, specifier) => {
|
|
|
137
127
|
* removing those properties leaves the import unused and safe to drop.
|
|
138
128
|
*
|
|
139
129
|
* @param {import('eslint').Scope.Reference} reference
|
|
130
|
+
* @param {Set<any>} removableIdentifiers
|
|
140
131
|
*/
|
|
141
|
-
const isOnPushChangeDetectionReference = (reference) =>
|
|
142
|
-
|
|
143
|
-
const member = identifier.parent;
|
|
144
|
-
if (!member || member.type !== 'MemberExpression' || member.object !== identifier) return false;
|
|
145
|
-
if (member.property.type !== 'Identifier' || member.property.name !== 'OnPush') return false;
|
|
146
|
-
|
|
147
|
-
const property = member.parent;
|
|
148
|
-
if (!property || property.type !== 'Property' || property.value !== member) return false;
|
|
149
|
-
|
|
150
|
-
return getPropertyName(property.key) === 'changeDetection';
|
|
151
|
-
};
|
|
132
|
+
const isOnPushChangeDetectionReference = (reference, removableIdentifiers) =>
|
|
133
|
+
removableIdentifiers.has(reference.identifier);
|
|
152
134
|
|
|
153
135
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
154
136
|
const noRedundantOnPushChangeDetection = {
|
|
@@ -177,6 +159,8 @@ const noRedundantOnPushChangeDetection = {
|
|
|
177
159
|
const sourceCode = context.sourceCode;
|
|
178
160
|
/** @type {any} */
|
|
179
161
|
let changeDetectionSpecifier = null;
|
|
162
|
+
let changeDetectionLocalName = 'ChangeDetectionStrategy';
|
|
163
|
+
const removableIdentifiers = new Set();
|
|
180
164
|
|
|
181
165
|
return {
|
|
182
166
|
Decorator(node) {
|
|
@@ -191,12 +175,13 @@ const noRedundantOnPushChangeDetection = {
|
|
|
191
175
|
const property = metadata.properties.find(
|
|
192
176
|
(entry) => entry.type === 'Property' && getPropertyName(entry.key) === 'changeDetection',
|
|
193
177
|
);
|
|
194
|
-
if (!property || !isOnPushValue(property.value)) return;
|
|
178
|
+
if (!property || !isOnPushValue(property.value, changeDetectionLocalName)) return;
|
|
179
|
+
removableIdentifiers.add(property.value.object);
|
|
195
180
|
|
|
196
181
|
context.report({
|
|
197
182
|
node: property,
|
|
198
183
|
messageId: 'redundant',
|
|
199
|
-
fix: (fixer) => fixer.replaceTextRange(
|
|
184
|
+
fix: (fixer) => fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), ''),
|
|
200
185
|
});
|
|
201
186
|
},
|
|
202
187
|
|
|
@@ -210,6 +195,7 @@ const noRedundantOnPushChangeDetection = {
|
|
|
210
195
|
specifier.imported.name === 'ChangeDetectionStrategy'
|
|
211
196
|
) {
|
|
212
197
|
changeDetectionSpecifier = specifier;
|
|
198
|
+
changeDetectionLocalName = specifier.local.name;
|
|
213
199
|
return;
|
|
214
200
|
}
|
|
215
201
|
}
|
|
@@ -219,8 +205,12 @@ const noRedundantOnPushChangeDetection = {
|
|
|
219
205
|
if (!changeDetectionSpecifier) return;
|
|
220
206
|
|
|
221
207
|
const [variable] = sourceCode.getDeclaredVariables(changeDetectionSpecifier);
|
|
222
|
-
if (!variable
|
|
223
|
-
if (
|
|
208
|
+
if (!variable) return;
|
|
209
|
+
if (
|
|
210
|
+
!variable.references.every((reference) => isOnPushChangeDetectionReference(reference, removableIdentifiers))
|
|
211
|
+
) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
224
214
|
|
|
225
215
|
const importNode = changeDetectionSpecifier.parent;
|
|
226
216
|
context.report({
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
|
|
5
|
+
|
|
4
6
|
/** @typedef {'Component' | 'Directive' | 'Pipe'} TDecoratorName */
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -28,24 +30,6 @@ const getDecoratorName = (node) => {
|
|
|
28
30
|
return expression.type === 'Identifier' ? expression.name : null;
|
|
29
31
|
};
|
|
30
32
|
|
|
31
|
-
/**
|
|
32
|
-
* @param {import('eslint').SourceCode} sourceCode
|
|
33
|
-
* @param {any} metadata
|
|
34
|
-
* @param {any} property
|
|
35
|
-
*/
|
|
36
|
-
const getRemovalRange = (sourceCode, metadata, property) => {
|
|
37
|
-
const properties = metadata.properties.filter((entry) => entry.type === 'Property');
|
|
38
|
-
const propertyIndex = properties.indexOf(property);
|
|
39
|
-
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
40
|
-
const closingBrace = sourceCode.getLastToken(metadata);
|
|
41
|
-
|
|
42
|
-
if (!openingBrace || !closingBrace) return property.range;
|
|
43
|
-
if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
|
|
44
|
-
if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
|
|
45
|
-
|
|
46
|
-
return [properties[propertyIndex - 1].range[1], property.range[1]];
|
|
47
|
-
};
|
|
48
|
-
|
|
49
33
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
50
34
|
const noStandaloneFlag = {
|
|
51
35
|
meta: {
|
|
@@ -78,12 +62,13 @@ const noStandaloneFlag = {
|
|
|
78
62
|
for (const property of metadata.properties) {
|
|
79
63
|
if (property.type !== 'Property') continue;
|
|
80
64
|
if (getPropertyName(property.key) !== 'standalone') continue;
|
|
65
|
+
if (property.value.type !== 'Literal' || property.value.value !== true) continue;
|
|
81
66
|
|
|
82
67
|
context.report({
|
|
83
68
|
node: property,
|
|
84
69
|
messageId: 'noStandalone',
|
|
85
70
|
fix(fixer) {
|
|
86
|
-
return fixer.replaceTextRange(
|
|
71
|
+
return fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), '');
|
|
87
72
|
},
|
|
88
73
|
});
|
|
89
74
|
}
|
|
@@ -173,6 +173,7 @@ const noTrivialReturnType = {
|
|
|
173
173
|
|
|
174
174
|
const annotation = fn.returnType.typeAnnotation;
|
|
175
175
|
if (!TRIVIAL_TYPES.has(annotation.type)) return;
|
|
176
|
+
if (fn.body.type !== 'BlockStatement') return;
|
|
176
177
|
if (isSelfReferencing(fn)) return;
|
|
177
178
|
|
|
178
179
|
const label = TRIVIAL_LABELS[annotation.type] ?? annotation.type;
|
|
@@ -124,6 +124,9 @@ const noTrivialWrapperMethod = {
|
|
|
124
124
|
const methodName = node.key.type === 'Identifier' ? node.key.name : null;
|
|
125
125
|
if (!methodName) return;
|
|
126
126
|
|
|
127
|
+
const classNode = node.parent?.parent;
|
|
128
|
+
if (node.override || classNode?.superClass || classNode?.implements?.length) return;
|
|
129
|
+
if (node.static && methodName === 'ngTemplateContextGuard') return;
|
|
127
130
|
if (CONTRACT_METHOD_NAMES.has(methodName) && isInAngularClass(node)) return;
|
|
128
131
|
|
|
129
132
|
const callee = callExpr.callee;
|
|
@@ -31,8 +31,13 @@ const noTypeOnlyImport = {
|
|
|
31
31
|
messageId: 'noTypeImportDeclaration',
|
|
32
32
|
data: { specifiers, source },
|
|
33
33
|
fix(fixer) {
|
|
34
|
-
const
|
|
35
|
-
|
|
34
|
+
const sourceCode = context.sourceCode;
|
|
35
|
+
const importToken = sourceCode.getFirstToken(node);
|
|
36
|
+
const typeToken = importToken ? sourceCode.getTokenAfter(importToken) : null;
|
|
37
|
+
const nextToken = typeToken ? sourceCode.getTokenAfter(typeToken) : null;
|
|
38
|
+
|
|
39
|
+
if (typeToken?.value !== 'type' || !nextToken) return null;
|
|
40
|
+
return fixer.removeRange([typeToken.range[0], nextToken.range[0]]);
|
|
36
41
|
},
|
|
37
42
|
});
|
|
38
43
|
|
|
@@ -38,16 +38,23 @@ const noTypedInjectedElementRef = {
|
|
|
38
38
|
|
|
39
39
|
const src = context.sourceCode;
|
|
40
40
|
const existingTypeParams = node.typeParameters || node.typeArguments;
|
|
41
|
-
const
|
|
42
|
-
|
|
41
|
+
const injectType = existingTypeParams?.params?.[0];
|
|
42
|
+
const hasTypedElementRef =
|
|
43
|
+
existingTypeParams?.params?.length === 1 &&
|
|
44
|
+
injectType?.type === 'TSTypeReference' &&
|
|
45
|
+
injectType.typeName?.type === 'Identifier' &&
|
|
46
|
+
injectType.typeName.name === 'ElementRef' &&
|
|
47
|
+
(injectType.typeArguments || injectType.typeParameters)?.params?.length > 0;
|
|
43
48
|
|
|
44
|
-
if (isPlainElementRef && !
|
|
49
|
+
if (isPlainElementRef && !hasTypedElementRef) {
|
|
45
50
|
// inject(ElementRef) → inject<ElementRef<HTMLElement>>(ElementRef)
|
|
46
51
|
context.report({
|
|
47
52
|
node,
|
|
48
53
|
messageId: 'missingGeneric',
|
|
49
54
|
fix(fixer) {
|
|
50
|
-
return
|
|
55
|
+
return existingTypeParams
|
|
56
|
+
? fixer.replaceText(existingTypeParams, '<ElementRef<HTMLElement>>')
|
|
57
|
+
: fixer.insertTextAfter(node.callee, '<ElementRef<HTMLElement>>');
|
|
51
58
|
},
|
|
52
59
|
});
|
|
53
60
|
return;
|
|
@@ -57,16 +64,16 @@ const noTypedInjectedElementRef = {
|
|
|
57
64
|
// Capture the type args text from the token, e.g. "<HTMLElement>"
|
|
58
65
|
const tokenTypeParamText = src.getText(firstArg.typeParameters || firstArg.typeArguments);
|
|
59
66
|
|
|
60
|
-
if (!
|
|
67
|
+
if (!hasTypedElementRef) {
|
|
61
68
|
// inject(ElementRef<HTMLElement>) → inject<ElementRef<HTMLElement>>(ElementRef)
|
|
62
69
|
context.report({
|
|
63
70
|
node,
|
|
64
71
|
messageId: 'missingGeneric',
|
|
65
72
|
fix(fixer) {
|
|
66
|
-
|
|
67
|
-
fixer.
|
|
68
|
-
fixer.
|
|
69
|
-
];
|
|
73
|
+
const typeFix = existingTypeParams
|
|
74
|
+
? fixer.replaceText(existingTypeParams, `<ElementRef${tokenTypeParamText}>`)
|
|
75
|
+
: fixer.insertTextAfter(node.callee, `<ElementRef${tokenTypeParamText}>`);
|
|
76
|
+
return [typeFix, fixer.replaceText(firstArg, 'ElementRef')];
|
|
70
77
|
},
|
|
71
78
|
});
|
|
72
79
|
} else {
|
|
@@ -126,6 +126,7 @@ const noUnusedClassMember = {
|
|
|
126
126
|
*
|
|
127
127
|
* @type {Array<{
|
|
128
128
|
* classBody: import('eslint').Rule.Node,
|
|
129
|
+
* isAbstract: boolean,
|
|
129
130
|
* isComponent: boolean,
|
|
130
131
|
* isAngularClass: boolean,
|
|
131
132
|
* hostBound: Set<string>,
|
|
@@ -145,6 +146,7 @@ const noUnusedClassMember = {
|
|
|
145
146
|
if (node.parent !== frame.classBody) return;
|
|
146
147
|
|
|
147
148
|
if (node.static) return;
|
|
149
|
+
if (node.override || node.abstract || node.decorators?.length) return;
|
|
148
150
|
|
|
149
151
|
const key = node.key;
|
|
150
152
|
if (!key || key.type !== 'Identifier') return;
|
|
@@ -153,9 +155,10 @@ const noUnusedClassMember = {
|
|
|
153
155
|
const name = key.name;
|
|
154
156
|
const accessibility = node.accessibility;
|
|
155
157
|
const { isComponent, isAngularClass, hostBound } = frame;
|
|
158
|
+
if (frame.isAbstract && accessibility === 'protected') return;
|
|
156
159
|
|
|
157
160
|
if (accessibility === 'private') {
|
|
158
|
-
frame.candidates.set(name, node);
|
|
161
|
+
if (!hostBound.has(name)) frame.candidates.set(name, node);
|
|
159
162
|
} else if (accessibility === 'protected' && isAngularClass && !isComponent) {
|
|
160
163
|
if (!hostBound.has(name)) frame.candidates.set(name, node);
|
|
161
164
|
}
|
|
@@ -168,6 +171,7 @@ const noUnusedClassMember = {
|
|
|
168
171
|
const hostBound = getHostBoundMembers(classNode);
|
|
169
172
|
classStack.push({
|
|
170
173
|
classBody: node,
|
|
174
|
+
isAbstract: classNode.abstract === true,
|
|
171
175
|
isComponent,
|
|
172
176
|
isAngularClass,
|
|
173
177
|
hostBound,
|
|
@@ -190,17 +194,36 @@ const noUnusedClassMember = {
|
|
|
190
194
|
MethodDefinition: registerMember,
|
|
191
195
|
|
|
192
196
|
MemberExpression(node) {
|
|
193
|
-
// Only care about `this.name` (non-computed direct property access)
|
|
194
|
-
if (node.computed) return;
|
|
195
197
|
if (node.object?.type !== 'ThisExpression') return;
|
|
196
|
-
|
|
198
|
+
const name =
|
|
199
|
+
!node.computed && node.property?.type === 'Identifier'
|
|
200
|
+
? node.property.name
|
|
201
|
+
: node.computed && node.property?.type === 'Literal' && typeof node.property.value === 'string'
|
|
202
|
+
? node.property.value
|
|
203
|
+
: null;
|
|
204
|
+
if (!name) return;
|
|
197
205
|
|
|
198
206
|
// Mark used in the innermost class frame. Arrow functions don't push a
|
|
199
207
|
// new frame so they correctly share the enclosing class's `this`.
|
|
200
208
|
// Regular nested classes push a new frame so their `this.x` won't
|
|
201
209
|
// accidentally mark an outer class member as used.
|
|
202
210
|
const frame = classStack[classStack.length - 1];
|
|
203
|
-
if (frame) frame.usedNames.add(
|
|
211
|
+
if (frame) frame.usedNames.add(name);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
VariableDeclarator(node) {
|
|
215
|
+
if (node.init?.type !== 'ThisExpression' || node.id?.type !== 'ObjectPattern') return;
|
|
216
|
+
|
|
217
|
+
const frame = classStack[classStack.length - 1];
|
|
218
|
+
if (!frame) return;
|
|
219
|
+
|
|
220
|
+
for (const property of node.id.properties) {
|
|
221
|
+
if (property.type !== 'Property') continue;
|
|
222
|
+
if (property.key.type === 'Identifier') frame.usedNames.add(property.key.name);
|
|
223
|
+
if (property.key.type === 'Literal' && typeof property.key.value === 'string') {
|
|
224
|
+
frame.usedNames.add(property.key.value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
204
227
|
},
|
|
205
228
|
};
|
|
206
229
|
},
|