@ethlete/eslint-plugin 1.0.0-next.22 → 1.0.0-next.24
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 +23 -0
- package/package.json +4 -5
- package/src/configs/recommended.js +23 -10
- package/src/index.js +6 -1
- package/src/rules/angular-decorator-property-order.js +20 -20
- package/src/rules/class-constant-property.js +35 -14
- package/src/rules/class-member-order.js +77 -46
- package/src/rules/guard-return-newline.js +5 -4
- package/src/rules/inject-member-accessibility.js +22 -24
- package/src/rules/internals/angular-io.js +31 -14
- package/src/rules/internals/angular-member-visibility.js +16 -12
- package/src/rules/internals/angular-metadata-fix.js +22 -0
- package/src/rules/internals/import-resolution.js +191 -0
- package/src/rules/internals/member-accessibility-fix.js +26 -0
- package/src/rules/no-angular-router-api.js +23 -29
- package/src/rules/no-angular-seo-services.js +21 -16
- package/src/rules/no-cdk-import.js +7 -14
- package/src/rules/no-direct-dom-manipulation.js +17 -0
- package/src/rules/no-document-cookie.js +3 -0
- package/src/rules/no-effect-cleanup-return.js +15 -9
- package/src/rules/no-empty-angular-metadata-arrays.js +6 -36
- package/src/rules/no-enum.js +115 -0
- package/src/rules/no-inject-chain.js +3 -1
- package/src/rules/no-leading-underscore-class-member.js +33 -3
- package/src/rules/no-legacy-angular-decorators.js +17 -21
- package/src/rules/no-legacy-prepare-without-injector.js +42 -26
- package/src/rules/no-legacy-query-import.js +7 -16
- package/src/rules/no-locale-id.js +6 -18
- package/src/rules/no-member-alias.js +21 -0
- package/src/rules/no-native-html-input-name.js +1 -1
- package/src/rules/no-pipe-logic.js +11 -17
- 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 +32 -56
- package/src/rules/no-rxjs-in-effect.js +12 -8
- package/src/rules/no-screaming-case-local.js +1 -0
- package/src/rules/no-standalone-flag.js +7 -36
- package/src/rules/no-subscribe-in-pipe.js +3 -2
- package/src/rules/no-template-literal-before-inline-template.js +3 -14
- package/src/rules/no-trivial-return-type.js +2 -0
- package/src/rules/no-trivial-wrapper-method.js +13 -16
- package/src/rules/no-type-only-import.js +7 -2
- package/src/rules/no-typed-injected-element-ref.js +23 -19
- package/src/rules/no-unused-class-member.js +34 -14
- package/src/rules/prefer-clone-equal.js +8 -6
- package/src/rules/prefer-concise-angular-host-directives.js +23 -29
- package/src/rules/prefer-concise-angular-style-metadata.js +8 -16
- package/src/rules/prefer-element-dimensions.js +9 -10
- package/src/rules/prefer-linked-signal.js +7 -4
- package/src/rules/prefer-match-media.js +12 -16
- package/src/rules/prefer-present-tense-output.js +1 -1
- package/src/rules/prefer-rxjs-timer.js +3 -1
- 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-dollar-suffix.js +7 -4
- package/src/rules/require-form-submit.js +2 -28
- package/src/rules/require-on-push-change-detection.js +48 -36
- package/src/rules/require-view-encapsulation-none.js +48 -34
- package/src/rules/take-until-destroyed-last.js +64 -0
- package/src/rules/template-member-accessibility.js +12 -27
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const { createRequire } = require('node:module');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const fs = require('node:fs');
|
|
7
|
+
const { MODULE_REFERENCE_SELECTOR, getModuleReference } = require('./internals/import-resolution');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Disallows importing from `@ethlete/cdk`, the maintenance-mode predecessor of `@ethlete/components`,
|
|
@@ -52,13 +53,6 @@ const loadMigrationMap = (mapPath, cwd) => {
|
|
|
52
53
|
return map;
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
/** @param {any} specifier */
|
|
56
|
-
const importedName = (specifier) => {
|
|
57
|
-
if (specifier.type !== 'ImportSpecifier') return null;
|
|
58
|
-
|
|
59
|
-
return specifier.imported.name ?? specifier.imported.value ?? null;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
56
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
63
57
|
const noCdkImport = {
|
|
64
58
|
meta: {
|
|
@@ -95,13 +89,13 @@ const noCdkImport = {
|
|
|
95
89
|
const entryFor = (name) => map?.[name] ?? null;
|
|
96
90
|
|
|
97
91
|
return {
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const source =
|
|
92
|
+
[MODULE_REFERENCE_SELECTOR](node) {
|
|
93
|
+
const reference = getModuleReference(node);
|
|
94
|
+
const source = reference?.source;
|
|
101
95
|
|
|
102
|
-
if (
|
|
96
|
+
if (!reference || (source !== CDK_PACKAGE && !source?.startsWith(`${CDK_PACKAGE}/`))) return;
|
|
103
97
|
|
|
104
|
-
const named
|
|
98
|
+
const { named } = reference;
|
|
105
99
|
|
|
106
100
|
if (!named.length) {
|
|
107
101
|
context.report({ node, messageId: 'module', data: { cdk: CDK_PACKAGE, docs: migrationDocs } });
|
|
@@ -109,8 +103,7 @@ const noCdkImport = {
|
|
|
109
103
|
return;
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
for (const specifier of named) {
|
|
113
|
-
const name = /** @type {string} */ (importedName(specifier));
|
|
106
|
+
for (const { name, node: specifier } of named) {
|
|
114
107
|
const entry = entryFor(name);
|
|
115
108
|
|
|
116
109
|
if (!entry) {
|
|
@@ -100,6 +100,23 @@ const noDirectDomManipulation = {
|
|
|
100
100
|
|
|
101
101
|
const methodName = callee.property.name;
|
|
102
102
|
|
|
103
|
+
if (
|
|
104
|
+
methodName === 'setProperty' &&
|
|
105
|
+
callee.object.type === 'MemberExpression' &&
|
|
106
|
+
callee.object.property.type === 'Identifier' &&
|
|
107
|
+
callee.object.property.name === 'style'
|
|
108
|
+
) {
|
|
109
|
+
context.report({
|
|
110
|
+
node,
|
|
111
|
+
messageId: 'domMutation',
|
|
112
|
+
data: {
|
|
113
|
+
method: methodName,
|
|
114
|
+
alternative: 'renderer.setStyle(el, prop, value, RendererStyleFlags2.DashCase)',
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
// ── classList.add / .remove / .toggle / .replace ─────────────────────
|
|
104
121
|
if (
|
|
105
122
|
callee.object.type === 'MemberExpression' &&
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* deleteCookie('name');
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
const { isGlobalReference } = require('./internals/import-resolution');
|
|
25
|
+
|
|
24
26
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
25
27
|
const noDocumentCookie = {
|
|
26
28
|
meta: {
|
|
@@ -42,6 +44,7 @@ const noDocumentCookie = {
|
|
|
42
44
|
if (
|
|
43
45
|
node.object.type === 'Identifier' &&
|
|
44
46
|
node.object.name === 'document' &&
|
|
47
|
+
isGlobalReference(context.sourceCode, node.object) &&
|
|
45
48
|
node.property.type === 'Identifier' &&
|
|
46
49
|
node.property.name === 'cookie'
|
|
47
50
|
) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { ANGULAR_CORE, getImportedName } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Disallows returning a cleanup function from an `effect()` callback.
|
|
6
8
|
*
|
|
@@ -115,7 +117,9 @@ const noEffectCleanupReturn = {
|
|
|
115
117
|
CallExpression(node) {
|
|
116
118
|
const { callee } = node;
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
const calleeName = getImportedName(context.sourceCode, callee, ANGULAR_CORE);
|
|
121
|
+
|
|
122
|
+
if (!calleeName || !EFFECT_CALLEES.has(calleeName)) {
|
|
119
123
|
return;
|
|
120
124
|
}
|
|
121
125
|
|
|
@@ -130,7 +134,7 @@ const noEffectCleanupReturn = {
|
|
|
130
134
|
context.report({
|
|
131
135
|
node: callback.body,
|
|
132
136
|
messageId: 'returnedCleanup',
|
|
133
|
-
data: { callee:
|
|
137
|
+
data: { callee: calleeName },
|
|
134
138
|
});
|
|
135
139
|
|
|
136
140
|
return;
|
|
@@ -147,21 +151,23 @@ const noEffectCleanupReturn = {
|
|
|
147
151
|
return;
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
// Only the mechanical shape is auto-fixed: a zero-parameter callback whose single `return` hands
|
|
151
|
-
// back a function literal. With an existing parameter, an early `return`, or a returned reference,
|
|
152
|
-
// the intent needs a human.
|
|
153
|
-
// The parameter list's `(` — the first one before the body, so this works for `() => {}` and
|
|
154
|
-
// `function () {}` alike (their first token differs).
|
|
155
154
|
const openParen = sourceCode
|
|
156
155
|
.getTokens(callback)
|
|
157
156
|
.find((token) => token.value === '(' && token.range[1] <= callback.body.range[0]);
|
|
158
|
-
const
|
|
157
|
+
const callbackScope = sourceCode.getScope(callback);
|
|
158
|
+
const hasOnCleanupBinding = callbackScope.variables.some((variable) => variable.name === 'onCleanup');
|
|
159
|
+
const fixable =
|
|
160
|
+
callback.params.length === 0 &&
|
|
161
|
+
returns.length === 1 &&
|
|
162
|
+
cleanupReturns.length === 1 &&
|
|
163
|
+
callback.body.body.at(-1) === cleanupReturns[0] &&
|
|
164
|
+
!hasOnCleanupBinding;
|
|
159
165
|
|
|
160
166
|
for (const statement of cleanupReturns) {
|
|
161
167
|
context.report({
|
|
162
168
|
node: statement.argument,
|
|
163
169
|
messageId: 'returnedCleanup',
|
|
164
|
-
data: { callee:
|
|
170
|
+
data: { callee: calleeName },
|
|
165
171
|
fix:
|
|
166
172
|
fixable && openParen
|
|
167
173
|
? (fixer) => [
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
5
|
+
|
|
6
|
+
const { getMetadataEntryRemovalRange } = require('./internals/angular-metadata-fix');
|
|
7
|
+
|
|
4
8
|
/** @typedef {'Component' | 'Directive'} TDecoratorName */
|
|
5
9
|
|
|
6
10
|
/**
|
|
@@ -12,40 +16,6 @@ const getPropertyName = (key) => {
|
|
|
12
16
|
return null;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
|
-
/**
|
|
16
|
-
* @param {import('eslint').Rule.Node} node
|
|
17
|
-
* @returns {TDecoratorName | null}
|
|
18
|
-
*/
|
|
19
|
-
const getDecoratorName = (node) => {
|
|
20
|
-
const decorator = /** @type {any} */ (node);
|
|
21
|
-
if (decorator.type !== 'Decorator') return null;
|
|
22
|
-
|
|
23
|
-
const expression = decorator.expression;
|
|
24
|
-
if (expression.type === 'CallExpression') {
|
|
25
|
-
return expression.callee.type === 'Identifier' ? expression.callee.name : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return expression.type === 'Identifier' ? expression.name : null;
|
|
29
|
-
};
|
|
30
|
-
|
|
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
19
|
const MESSAGE_IDS = {
|
|
50
20
|
hostDirectives: 'noEmptyHostDirectives',
|
|
51
21
|
imports: 'noEmptyImports',
|
|
@@ -71,7 +41,7 @@ const noEmptyAngularMetadataArrays = {
|
|
|
71
41
|
return {
|
|
72
42
|
/** @param {import('eslint').Rule.Node} node */
|
|
73
43
|
Decorator(node) {
|
|
74
|
-
const decoratorName =
|
|
44
|
+
const decoratorName = getAngularDecoratorName(context.sourceCode, node);
|
|
75
45
|
if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
|
|
76
46
|
|
|
77
47
|
const decorator = /** @type {any} */ (node);
|
|
@@ -92,7 +62,7 @@ const noEmptyAngularMetadataArrays = {
|
|
|
92
62
|
node: property,
|
|
93
63
|
messageId: MESSAGE_IDS[propertyName],
|
|
94
64
|
fix(fixer) {
|
|
95
|
-
return fixer.replaceTextRange(
|
|
65
|
+
return fixer.replaceTextRange(getMetadataEntryRemovalRange(sourceCode, metadata, property), '');
|
|
96
66
|
},
|
|
97
67
|
});
|
|
98
68
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallow `enum` and `const enum` — use a const object plus a derived union type.
|
|
6
|
+
*
|
|
7
|
+
* An enum is the only TypeScript construct that emits runtime code from a type declaration, so it
|
|
8
|
+
* cannot be erased and it cannot be tree-shaken. Its members are nominal, so a plain string is not
|
|
9
|
+
* assignable to an enum-typed parameter even when the value matches. A `const enum` is worse: a
|
|
10
|
+
* transpile-only consumer (esbuild, SWC, Babel) has no cross-file type information, so it cannot
|
|
11
|
+
* inline the member and the build fails or emits a missing binding.
|
|
12
|
+
*
|
|
13
|
+
* A const object with `as const` plus a derived union gives the same dotted access, keeps the name
|
|
14
|
+
* usable in a type position, accepts a matching literal, and tree-shakes.
|
|
15
|
+
*
|
|
16
|
+
* BAD:
|
|
17
|
+
* export enum MatchState {
|
|
18
|
+
* Live = 'live',
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* GOOD:
|
|
22
|
+
* export const MatchState = {
|
|
23
|
+
* Live: 'live',
|
|
24
|
+
* } as const;
|
|
25
|
+
*
|
|
26
|
+
* export type MatchState = (typeof MatchState)[keyof typeof MatchState];
|
|
27
|
+
*
|
|
28
|
+
* The fixer runs only on an enum whose members all have a string literal initializer. It widens a
|
|
29
|
+
* member used in a type position (`state: MatchState.Live`), which then needs `typeof`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** typescript-eslint v8 nests members under a TSEnumBody; earlier versions put them on the declaration. */
|
|
33
|
+
const membersOf = (node) => node.body?.members ?? node.members ?? [];
|
|
34
|
+
|
|
35
|
+
const isStringLiteral = (node) => node?.type === 'Literal' && typeof node.value === 'string';
|
|
36
|
+
|
|
37
|
+
/** Only a plain string enum converts to a const object without changing what a member holds. */
|
|
38
|
+
const isFixable = (node) => {
|
|
39
|
+
if (node.declare) return false;
|
|
40
|
+
|
|
41
|
+
const members = membersOf(node);
|
|
42
|
+
|
|
43
|
+
if (!members.length) return false;
|
|
44
|
+
|
|
45
|
+
return members.every(
|
|
46
|
+
(member) => isStringLiteral(member.initializer) && (member.id.type === 'Identifier' || isStringLiteral(member.id)),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
51
|
+
const noEnum = {
|
|
52
|
+
meta: {
|
|
53
|
+
type: 'problem',
|
|
54
|
+
docs: {
|
|
55
|
+
description: 'Disallow enum and const enum. Use a const object with `as const` plus a derived union type.',
|
|
56
|
+
recommended: true,
|
|
57
|
+
},
|
|
58
|
+
fixable: 'code',
|
|
59
|
+
messages: {
|
|
60
|
+
noEnum:
|
|
61
|
+
"No enums. '{{name}}' emits runtime code that cannot be tree-shaken, and its members are nominal. Use a const object with 'as const' plus a derived union type.",
|
|
62
|
+
noConstEnum:
|
|
63
|
+
"No 'const enum'. A transpile-only consumer (esbuild, SWC, Babel) cannot inline '{{name}}' and its build fails. Use a const object with 'as const' plus a derived union type.",
|
|
64
|
+
},
|
|
65
|
+
schema: [],
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const { sourceCode } = context;
|
|
69
|
+
|
|
70
|
+
const buildReplacement = (node, target) => {
|
|
71
|
+
const name = node.id.name;
|
|
72
|
+
const indent = ' '.repeat(target.loc.start.column);
|
|
73
|
+
const memberIndent = `${indent} `;
|
|
74
|
+
const exported = target.type === 'ExportNamedDeclaration' ? 'export ' : '';
|
|
75
|
+
|
|
76
|
+
const lines = membersOf(node).flatMap((member) => {
|
|
77
|
+
const comments = sourceCode.getCommentsBefore(member).flatMap((comment) =>
|
|
78
|
+
sourceCode
|
|
79
|
+
.getText(comment)
|
|
80
|
+
.split('\n')
|
|
81
|
+
// A block comment's continuation lines carry one more space so the leading `*` stays aligned.
|
|
82
|
+
.map((line, index) => `${memberIndent}${index === 0 ? '' : ' '}${line.trim()}`),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const key = sourceCode.getText(member.id);
|
|
86
|
+
const value = sourceCode.getText(member.initializer);
|
|
87
|
+
|
|
88
|
+
return [...comments, `${memberIndent}${key}: ${value},`];
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
`${exported}const ${name} = {`,
|
|
93
|
+
...lines,
|
|
94
|
+
`${indent}} as const;`,
|
|
95
|
+
'',
|
|
96
|
+
`${indent}${exported}type ${name} = (typeof ${name})[keyof typeof ${name}];`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
TSEnumDeclaration(node) {
|
|
102
|
+
const target = node.parent?.type === 'ExportNamedDeclaration' ? node.parent : node;
|
|
103
|
+
|
|
104
|
+
context.report({
|
|
105
|
+
node: node.id,
|
|
106
|
+
messageId: node.const ? 'noConstEnum' : 'noEnum',
|
|
107
|
+
data: { name: node.id.name },
|
|
108
|
+
fix: isFixable(node) ? (fixer) => fixer.replaceTextRange(target.range, buildReplacement(node, target)) : null,
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
module.exports = noEnum;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { isImportedAs } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
7
|
const noInjectChain = {
|
|
6
8
|
meta: {
|
|
@@ -19,7 +21,7 @@ const noInjectChain = {
|
|
|
19
21
|
return {
|
|
20
22
|
MemberExpression(node) {
|
|
21
23
|
const obj = node.object;
|
|
22
|
-
if (obj.type === 'CallExpression' &&
|
|
24
|
+
if (obj.type === 'CallExpression' && isImportedAs(context.sourceCode, obj.callee, 'inject')) {
|
|
23
25
|
// Allow inject(X).method() — immediately-invoked patterns like
|
|
24
26
|
// inject(DestroyRef).onDestroy(...) are intentional Angular idioms.
|
|
25
27
|
const parent = node.parent;
|
|
@@ -16,6 +16,16 @@ const getDirectMemberName = (node) => {
|
|
|
16
16
|
return node.key?.type === 'Identifier' ? node.key.name : null;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* @param {any} key
|
|
21
|
+
* @param {boolean} computed
|
|
22
|
+
*/
|
|
23
|
+
const getReferencedName = (key, computed) => {
|
|
24
|
+
if (!computed && key?.type === 'Identifier') return key.name;
|
|
25
|
+
if (key?.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
19
29
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
20
30
|
const noLeadingUnderscoreClassMember = {
|
|
21
31
|
meta: {
|
|
@@ -36,6 +46,7 @@ const noLeadingUnderscoreClassMember = {
|
|
|
36
46
|
* declaredNames: Set<string>,
|
|
37
47
|
* membersByName: Map<string, any[]>,
|
|
38
48
|
* memberReads: any[],
|
|
49
|
+
* untrackedNames: Set<string>,
|
|
39
50
|
* }>}
|
|
40
51
|
*/
|
|
41
52
|
const classStack = [];
|
|
@@ -66,6 +77,7 @@ const noLeadingUnderscoreClassMember = {
|
|
|
66
77
|
declaredNames: new Set(),
|
|
67
78
|
membersByName: new Map(),
|
|
68
79
|
memberReads: [],
|
|
80
|
+
untrackedNames: new Set(),
|
|
69
81
|
});
|
|
70
82
|
},
|
|
71
83
|
|
|
@@ -73,11 +85,19 @@ const noLeadingUnderscoreClassMember = {
|
|
|
73
85
|
const frame = classStack.pop();
|
|
74
86
|
if (!frame) return;
|
|
75
87
|
|
|
88
|
+
const targetNameCounts = new Map();
|
|
89
|
+
for (const oldName of frame.membersByName.keys()) {
|
|
90
|
+
const newName = getFixedName(oldName);
|
|
91
|
+
targetNameCounts.set(newName, (targetNameCounts.get(newName) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
for (const [oldName, memberNodes] of frame.membersByName) {
|
|
77
95
|
const newName = getFixedName(oldName);
|
|
78
96
|
const canFix =
|
|
79
97
|
newName.length > 0 &&
|
|
80
98
|
!frame.declaredNames.has(newName) &&
|
|
99
|
+
targetNameCounts.get(newName) === 1 &&
|
|
100
|
+
!frame.untrackedNames.has(oldName) &&
|
|
81
101
|
memberNodes.every((memberNode) => memberNode.accessibility === 'private');
|
|
82
102
|
|
|
83
103
|
for (const memberNode of memberNodes) {
|
|
@@ -108,10 +128,20 @@ const noLeadingUnderscoreClassMember = {
|
|
|
108
128
|
MemberExpression(node) {
|
|
109
129
|
const frame = classStack[classStack.length - 1];
|
|
110
130
|
if (!frame) return;
|
|
111
|
-
if (node.object?.type !== 'ThisExpression' || node.computed) return;
|
|
112
|
-
if (node.property?.type !== 'Identifier') return;
|
|
113
131
|
|
|
114
|
-
|
|
132
|
+
if (node.object?.type === 'ThisExpression' && !node.computed && node.property?.type === 'Identifier') {
|
|
133
|
+
frame.memberReads.push(node);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const name = getReferencedName(node.property, node.computed);
|
|
138
|
+
if (name) frame.untrackedNames.add(name);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
'ObjectPattern > Property'(node) {
|
|
142
|
+
const frame = classStack[classStack.length - 1];
|
|
143
|
+
const name = getReferencedName(node.key, node.computed);
|
|
144
|
+
if (frame && name) frame.untrackedNames.add(name);
|
|
115
145
|
},
|
|
116
146
|
};
|
|
117
147
|
},
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Disallows legacy Angular decorators in favour of their modern signal-based
|
|
6
8
|
* equivalents and the `host: {}` shorthand.
|
|
@@ -25,20 +27,6 @@
|
|
|
25
27
|
* → ✅ host: { '[class.active]': 'isActive', '(click)': 'onClick()' }
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
|
-
/**
|
|
29
|
-
* Returns the identifier name of a decorator.
|
|
30
|
-
* Works for both `@Foo` (Identifier) and `@Foo(...)` (CallExpression).
|
|
31
|
-
*
|
|
32
|
-
* @param {any} decorator
|
|
33
|
-
* @returns {string | null}
|
|
34
|
-
*/
|
|
35
|
-
const getDecoratorName = (decorator) => {
|
|
36
|
-
const expr = decorator.expression;
|
|
37
|
-
if (expr.type === 'Identifier') return expr.name;
|
|
38
|
-
if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier') return expr.callee.name;
|
|
39
|
-
return null;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
30
|
/**
|
|
43
31
|
* Returns the static string key of a class member, or null for computed keys.
|
|
44
32
|
*
|
|
@@ -72,11 +60,12 @@ const getIndent = (sourceCode, node) => {
|
|
|
72
60
|
};
|
|
73
61
|
|
|
74
62
|
/**
|
|
63
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
75
64
|
* @param {any} classNode
|
|
76
65
|
*/
|
|
77
|
-
const getAngularMetadata = (classNode) => {
|
|
66
|
+
const getAngularMetadata = (sourceCode, classNode) => {
|
|
78
67
|
for (const decorator of classNode.decorators ?? []) {
|
|
79
|
-
const decoratorName =
|
|
68
|
+
const decoratorName = getAngularDecoratorName(sourceCode, decorator);
|
|
80
69
|
if (decoratorName !== 'Component' && decoratorName !== 'Directive') continue;
|
|
81
70
|
|
|
82
71
|
const expression = decorator.expression;
|
|
@@ -106,7 +95,7 @@ const findObjectProperty = (objectExpression, propertyName) =>
|
|
|
106
95
|
* @param {string} entryText
|
|
107
96
|
*/
|
|
108
97
|
const buildObjectTextWithAppendedProperty = (sourceCode, objectExpression, entryText) => {
|
|
109
|
-
const properties = objectExpression.properties
|
|
98
|
+
const properties = objectExpression.properties;
|
|
110
99
|
const isMultiline = Boolean(
|
|
111
100
|
objectExpression.loc && objectExpression.loc.start.line !== objectExpression.loc.end.line,
|
|
112
101
|
);
|
|
@@ -138,8 +127,7 @@ ${closingIndent}}`;
|
|
|
138
127
|
* @param {any} objectExpression
|
|
139
128
|
*/
|
|
140
129
|
const hasTrailingComma = (sourceCode, objectExpression) => {
|
|
141
|
-
const
|
|
142
|
-
const lastProperty = properties[properties.length - 1];
|
|
130
|
+
const lastProperty = objectExpression.properties.at(-1);
|
|
143
131
|
if (!lastProperty) return false;
|
|
144
132
|
|
|
145
133
|
const tokenAfter = sourceCode.getTokenAfter(lastProperty);
|
|
@@ -158,7 +146,7 @@ const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey,
|
|
|
158
146
|
const classNode = classBody?.parent;
|
|
159
147
|
if (!classNode) return null;
|
|
160
148
|
|
|
161
|
-
const metadata = getAngularMetadata(classNode);
|
|
149
|
+
const metadata = getAngularMetadata(sourceCode, classNode);
|
|
162
150
|
if (!metadata) return null;
|
|
163
151
|
|
|
164
152
|
const hostProperty = findObjectProperty(metadata, 'host');
|
|
@@ -312,7 +300,7 @@ const rule = {
|
|
|
312
300
|
|
|
313
301
|
Decorator(node) {
|
|
314
302
|
const anyNode = /** @type {any} */ (node);
|
|
315
|
-
const decoratorName =
|
|
303
|
+
const decoratorName = getAngularDecoratorName(context.sourceCode, anyNode);
|
|
316
304
|
if (!decoratorName) return;
|
|
317
305
|
|
|
318
306
|
// Only act on decorators attached to class members, not to the class itself
|
|
@@ -379,6 +367,14 @@ const rule = {
|
|
|
379
367
|
const expression = anyNode.expression;
|
|
380
368
|
if (expression.type !== 'CallExpression' && expression.type !== 'Identifier') return null;
|
|
381
369
|
|
|
370
|
+
if (
|
|
371
|
+
expression.type === 'CallExpression' &&
|
|
372
|
+
expression.arguments.length > 0 &&
|
|
373
|
+
(expression.arguments[0]?.type !== 'Literal' || typeof expression.arguments[0].value !== 'string')
|
|
374
|
+
) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
382
378
|
const bindingName =
|
|
383
379
|
expression.type === 'CallExpression'
|
|
384
380
|
? 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({
|