@ethlete/eslint-plugin 1.0.0-next.25 → 1.0.0-next.26
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 +18 -0
- package/package.json +2 -1
- package/src/configs/recommended.js +7 -16
- package/src/rules/angular-decorator-property-order.js +24 -7
- package/src/rules/class-constant-property.js +1 -1
- package/src/rules/class-member-order.js +60 -8
- package/src/rules/enforce-routing-view-naming.js +34 -23
- package/src/rules/guard-return-newline.js +4 -7
- package/src/rules/internals/angular-member-visibility.js +24 -2
- package/src/rules/internals/angular-metadata-fix.js +231 -1
- package/src/rules/internals/angular-version.js +50 -0
- package/src/rules/internals/implemented-contract-members.js +26 -11
- package/src/rules/no-angular-router-api.js +86 -33
- package/src/rules/no-angular-seo-services.js +1 -0
- package/src/rules/no-async-await.js +5 -1
- package/src/rules/no-cdk-import.js +32 -7
- package/src/rules/no-direct-dom-manipulation.js +50 -28
- package/src/rules/no-document-cookie.js +51 -8
- package/src/rules/no-dom-query.js +5 -2
- package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
- package/src/rules/no-empty-newlines-between-imports.js +4 -1
- package/src/rules/no-impure-top-level-provider.js +42 -7
- package/src/rules/no-leading-underscore-class-member.js +27 -2
- package/src/rules/no-legacy-angular-decorators.js +11 -6
- package/src/rules/no-legacy-prepare-without-injector.js +57 -12
- package/src/rules/no-legacy-query-import.js +61 -16
- package/src/rules/no-member-alias.js +0 -5
- package/src/rules/no-native-observers.js +89 -24
- package/src/rules/no-pipe-logic.js +1 -2
- package/src/rules/no-readonly-signal.js +1 -5
- package/src/rules/no-redundant-internal.js +5 -2
- package/src/rules/no-redundant-on-push-change-detection.js +3 -48
- package/src/rules/no-rxjs-in-effect.js +7 -5
- package/src/rules/no-screaming-case-local.js +3 -5
- package/src/rules/no-standalone-flag.js +1 -0
- package/src/rules/no-subscribe-with-body.js +30 -1
- package/src/rules/no-template-literal-before-inline-template.js +6 -0
- package/src/rules/no-trivial-return-type.js +18 -1
- package/src/rules/no-unused-class-member.js +1 -1
- package/src/rules/no-window-location.js +60 -22
- package/src/rules/prefer-clone-equal.js +61 -8
- package/src/rules/prefer-concise-angular-host-directives.js +39 -11
- package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
- package/src/rules/prefer-element-dimensions.js +264 -11
- package/src/rules/prefer-match-media.js +17 -3
- package/src/rules/prefer-rxjs-timer.js +26 -4
- package/src/rules/prefer-scroll-state.js +0 -4
- package/src/rules/prefer-static-boolean-properties.js +1 -0
- package/src/rules/prefer-viewport-size.js +153 -16
- package/src/rules/require-dollar-suffix.js +11 -3
- package/src/rules/require-form-submit.js +4 -1
- package/src/rules/require-on-push-change-detection.js +7 -259
- package/src/rules/require-view-encapsulation-none.js +6 -214
- package/src/rules/template-member-accessibility.js +12 -4
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
* element.classList.add('active') → renderer.addClass(element, 'active')
|
|
18
18
|
* element.classList.remove('active') → renderer.removeClass(element, 'active')
|
|
19
19
|
* element.style.color = 'red' → renderer.setStyle(element, 'color', 'red')
|
|
20
|
+
* Object.assign(element.style, { … }) → renderer.setStyle(element, prop, value) per property
|
|
20
21
|
*
|
|
21
22
|
* GOOD:
|
|
22
23
|
* private renderer = injectRenderer(); // from @ethlete/core
|
|
23
24
|
*
|
|
24
|
-
* NOTE: Calls where the receiver name
|
|
25
|
+
* NOTE: Calls where the receiver name ends in 'renderer' (e.g. this.renderer.createElement)
|
|
25
26
|
* are excluded — that is the correct injectRenderer() usage.
|
|
26
27
|
*/
|
|
27
28
|
|
|
@@ -62,16 +63,39 @@ const CLASSLIST_METHODS = new Map([
|
|
|
62
63
|
['replace', 'renderer.removeClass(el, old) + renderer.addClass(el, new)'],
|
|
63
64
|
]);
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* @param {any} node
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
const getMemberName = (node) => {
|
|
71
|
+
if (node.type !== 'MemberExpression') return null;
|
|
72
|
+
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
|
|
73
|
+
if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') {
|
|
74
|
+
return node.property.value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {any} node
|
|
82
|
+
* @returns {string | null}
|
|
83
|
+
*/
|
|
84
|
+
const getTerminalName = (node) => {
|
|
85
|
+
if (node.type === 'Identifier') return node.name;
|
|
86
|
+
if (node.type === 'MemberExpression') return getMemberName(node);
|
|
87
|
+
if (node.type === 'CallExpression') return getTerminalName(node.callee);
|
|
88
|
+
if (node.type === 'ChainExpression' || node.type === 'TSNonNullExpression') return getTerminalName(node.expression);
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
};
|
|
92
|
+
|
|
65
93
|
/**
|
|
66
94
|
* Returns true when the receiver of a MemberExpression looks like a Renderer2 instance,
|
|
67
95
|
* which means the call is already using the correct API.
|
|
68
|
-
* @param {
|
|
69
|
-
* @param {import('eslint').Rule.RuleContext} context
|
|
96
|
+
* @param {any} objectNode
|
|
70
97
|
*/
|
|
71
|
-
const isRendererReceiver = (objectNode
|
|
72
|
-
const receiverText = context.sourceCode.getText(objectNode).toLowerCase();
|
|
73
|
-
return receiverText.includes('renderer');
|
|
74
|
-
};
|
|
98
|
+
const isRendererReceiver = (objectNode) => /renderer2?$/i.test(getTerminalName(objectNode) ?? '');
|
|
75
99
|
|
|
76
100
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
77
101
|
const noDirectDomManipulation = {
|
|
@@ -96,16 +120,25 @@ const noDirectDomManipulation = {
|
|
|
96
120
|
CallExpression(node) {
|
|
97
121
|
const { callee } = node;
|
|
98
122
|
if (callee.type !== 'MemberExpression') return;
|
|
99
|
-
if (callee.property.type !== 'Identifier') return;
|
|
100
123
|
|
|
101
|
-
const
|
|
124
|
+
const [target] = node.arguments;
|
|
102
125
|
|
|
103
126
|
if (
|
|
104
|
-
|
|
105
|
-
callee.object.
|
|
106
|
-
callee
|
|
107
|
-
|
|
127
|
+
callee.object.type === 'Identifier' &&
|
|
128
|
+
callee.object.name === 'Object' &&
|
|
129
|
+
getMemberName(callee) === 'assign' &&
|
|
130
|
+
target &&
|
|
131
|
+
getMemberName(target) === 'style' &&
|
|
132
|
+
!isRendererReceiver(target.object)
|
|
108
133
|
) {
|
|
134
|
+
context.report({ node, messageId: 'domStyle' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const methodName = getMemberName(callee);
|
|
139
|
+
if (!methodName) return;
|
|
140
|
+
|
|
141
|
+
if (methodName === 'setProperty' && getMemberName(callee.object) === 'style') {
|
|
109
142
|
context.report({
|
|
110
143
|
node,
|
|
111
144
|
messageId: 'domMutation',
|
|
@@ -117,12 +150,7 @@ const noDirectDomManipulation = {
|
|
|
117
150
|
return;
|
|
118
151
|
}
|
|
119
152
|
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
callee.object.type === 'MemberExpression' &&
|
|
123
|
-
callee.object.property.type === 'Identifier' &&
|
|
124
|
-
callee.object.property.name === 'classList'
|
|
125
|
-
) {
|
|
153
|
+
if (getMemberName(callee.object) === 'classList') {
|
|
126
154
|
const alternative = CLASSLIST_METHODS.get(methodName);
|
|
127
155
|
if (alternative) {
|
|
128
156
|
context.report({
|
|
@@ -134,10 +162,8 @@ const noDirectDomManipulation = {
|
|
|
134
162
|
return;
|
|
135
163
|
}
|
|
136
164
|
|
|
137
|
-
|
|
138
|
-
if (isRendererReceiver(callee.object, context)) return;
|
|
165
|
+
if (isRendererReceiver(callee.object)) return;
|
|
139
166
|
|
|
140
|
-
// ── DOM creation ──────────────────────────────────────────────────────
|
|
141
167
|
if (DOM_CREATE_METHODS.has(methodName)) {
|
|
142
168
|
context.report({
|
|
143
169
|
node,
|
|
@@ -147,7 +173,6 @@ const noDirectDomManipulation = {
|
|
|
147
173
|
return;
|
|
148
174
|
}
|
|
149
175
|
|
|
150
|
-
// ── DOM mutation ──────────────────────────────────────────────────────
|
|
151
176
|
if (DOM_MUTATION_METHODS.has(methodName)) {
|
|
152
177
|
context.report({
|
|
153
178
|
node,
|
|
@@ -157,15 +182,12 @@ const noDirectDomManipulation = {
|
|
|
157
182
|
}
|
|
158
183
|
},
|
|
159
184
|
|
|
160
|
-
// ── el.style.prop = value ─────────────────────────────────────────────
|
|
161
185
|
AssignmentExpression(node) {
|
|
162
186
|
const { left } = node;
|
|
163
187
|
if (
|
|
164
188
|
left.type === 'MemberExpression' &&
|
|
165
|
-
left.object
|
|
166
|
-
left.object.
|
|
167
|
-
left.object.property.name === 'style' &&
|
|
168
|
-
!isRendererReceiver(left.object.object, context)
|
|
189
|
+
getMemberName(left.object) === 'style' &&
|
|
190
|
+
!isRendererReceiver(left.object.object)
|
|
169
191
|
) {
|
|
170
192
|
context.report({ node, messageId: 'domStyle' });
|
|
171
193
|
}
|
|
@@ -39,16 +39,59 @@ const noDocumentCookie = {
|
|
|
39
39
|
schema: [],
|
|
40
40
|
},
|
|
41
41
|
create(context) {
|
|
42
|
+
const documentAliases = new Set();
|
|
43
|
+
/** @type {any[]} */
|
|
44
|
+
const candidates = [];
|
|
45
|
+
|
|
46
|
+
const propertyName = (node) => {
|
|
47
|
+
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
|
|
48
|
+
if (node.computed && node.property.type === 'Literal') return String(node.property.value);
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const isGlobalObject = (node) =>
|
|
54
|
+
node.type === 'Identifier' &&
|
|
55
|
+
(node.name === 'window' || node.name === 'globalThis') &&
|
|
56
|
+
isGlobalReference(context.sourceCode, node);
|
|
57
|
+
|
|
58
|
+
const isInjectDocument = (node) =>
|
|
59
|
+
node?.type === 'CallExpression' &&
|
|
60
|
+
node.callee.type === 'Identifier' &&
|
|
61
|
+
node.callee.name === 'inject' &&
|
|
62
|
+
node.arguments[0]?.type === 'Identifier' &&
|
|
63
|
+
node.arguments[0].name === 'DOCUMENT';
|
|
64
|
+
|
|
65
|
+
const isDocument = (node) => {
|
|
66
|
+
if (node.type === 'Identifier') {
|
|
67
|
+
return node.name === 'document' && isGlobalReference(context.sourceCode, node);
|
|
68
|
+
}
|
|
69
|
+
if (node.type !== 'MemberExpression') return false;
|
|
70
|
+
if (propertyName(node) === 'document' && isGlobalObject(node.object)) return true;
|
|
71
|
+
|
|
72
|
+
return node.object.type === 'ThisExpression' && documentAliases.has(propertyName(node));
|
|
73
|
+
};
|
|
74
|
+
|
|
42
75
|
return {
|
|
76
|
+
VariableDeclarator(node) {
|
|
77
|
+
if (node.id.type === 'Identifier' && isInjectDocument(node.init)) documentAliases.add(node.id.name);
|
|
78
|
+
},
|
|
79
|
+
PropertyDefinition(node) {
|
|
80
|
+
if (!node.computed && node.key.type === 'Identifier' && isInjectDocument(node.value)) {
|
|
81
|
+
documentAliases.add(node.key.name);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
43
84
|
MemberExpression(node) {
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
85
|
+
if (propertyName(node) === 'cookie') candidates.push(node);
|
|
86
|
+
},
|
|
87
|
+
'Program:exit'() {
|
|
88
|
+
for (const node of candidates) {
|
|
89
|
+
const object = node.object;
|
|
90
|
+
const isLocalAlias = object.type === 'Identifier' && documentAliases.has(object.name);
|
|
91
|
+
|
|
92
|
+
if (isLocalAlias || isDocument(object)) {
|
|
93
|
+
context.report({ node, messageId: 'noDocumentCookie' });
|
|
94
|
+
}
|
|
52
95
|
}
|
|
53
96
|
},
|
|
54
97
|
};
|
|
@@ -62,9 +62,12 @@ const noDomQuery = {
|
|
|
62
62
|
CallExpression(node) {
|
|
63
63
|
const { callee } = node;
|
|
64
64
|
if (callee.type !== 'MemberExpression') return;
|
|
65
|
-
if (callee.property.type !== 'Identifier') return;
|
|
66
65
|
|
|
67
|
-
|
|
66
|
+
let methodName;
|
|
67
|
+
if (!callee.computed && callee.property.type === 'Identifier') methodName = callee.property.name;
|
|
68
|
+
else if (callee.computed && callee.property.type === 'Literal') methodName = String(callee.property.value);
|
|
69
|
+
else return;
|
|
70
|
+
|
|
68
71
|
const alternative = DOM_QUERY_METHODS.get(methodName);
|
|
69
72
|
if (!alternative) return;
|
|
70
73
|
|
|
@@ -27,6 +27,7 @@ const noEmptyAngularMetadataArrays = {
|
|
|
27
27
|
type: 'suggestion',
|
|
28
28
|
docs: {
|
|
29
29
|
description: 'Disallow empty Angular metadata arrays such as imports: [] and hostDirectives: [].',
|
|
30
|
+
recommended: true,
|
|
30
31
|
},
|
|
31
32
|
fixable: 'code',
|
|
32
33
|
schema: [],
|
|
@@ -56,6 +57,7 @@ const noEmptyAngularMetadataArrays = {
|
|
|
56
57
|
|
|
57
58
|
const propertyName = getPropertyName(property.key);
|
|
58
59
|
if (propertyName !== 'imports' && propertyName !== 'hostDirectives') continue;
|
|
60
|
+
if (propertyName === 'imports' && decoratorName !== 'Component') continue;
|
|
59
61
|
if (property.value.type !== 'ArrayExpression' || property.value.elements.length > 0) continue;
|
|
60
62
|
|
|
61
63
|
context.report({
|
|
@@ -7,6 +7,7 @@ const noEmptyNewlinesBetweenImports = {
|
|
|
7
7
|
type: 'layout',
|
|
8
8
|
docs: {
|
|
9
9
|
description: 'Disallow empty blank lines between consecutive import declarations.',
|
|
10
|
+
recommended: true,
|
|
10
11
|
},
|
|
11
12
|
fixable: 'code',
|
|
12
13
|
schema: [],
|
|
@@ -33,7 +34,9 @@ const noEmptyNewlinesBetweenImports = {
|
|
|
33
34
|
node: currentImport,
|
|
34
35
|
messageId: 'noEmptyLine',
|
|
35
36
|
fix(fixer) {
|
|
36
|
-
|
|
37
|
+
const lineBreak = textBetween.includes('\r\n') ? '\r\n' : '\n';
|
|
38
|
+
|
|
39
|
+
return fixer.replaceTextRange([previousImport.range[1], currentImport.range[0]], lineBreak);
|
|
37
40
|
},
|
|
38
41
|
});
|
|
39
42
|
}
|
|
@@ -34,14 +34,36 @@ const calleeName = (node) => {
|
|
|
34
34
|
return null;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
const TRANSPARENT_WRAPPERS = new Set([
|
|
38
|
+
'TSAsExpression',
|
|
39
|
+
'TSSatisfiesExpression',
|
|
40
|
+
'TSNonNullExpression',
|
|
41
|
+
'TSTypeAssertion',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const unwrap = (node) => (TRANSPARENT_WRAPPERS.has(node.type) ? unwrap(node.expression) : node);
|
|
45
|
+
|
|
46
|
+
const isConstantExpression = (node) =>
|
|
47
|
+
node.type === 'Literal' ||
|
|
48
|
+
(node.type === 'UnaryExpression' && isConstantExpression(node.argument)) ||
|
|
49
|
+
(node.type === 'BinaryExpression' && isConstantExpression(node.left) && isConstantExpression(node.right));
|
|
50
|
+
|
|
51
|
+
/** Everything in an initializer that really is evaluated when the module is imported and that a bundler cannot drop. */
|
|
52
|
+
const collectImportTimeNodes = (root) => {
|
|
53
|
+
const calls = [];
|
|
54
|
+
const reads = [];
|
|
40
55
|
|
|
41
56
|
const visit = (node) => {
|
|
42
57
|
if (!node || typeof node.type !== 'string' || NOT_EVALUATED_ON_IMPORT.has(node.type)) return;
|
|
43
58
|
|
|
44
|
-
if (node.type === 'CallExpression' || node.type === 'NewExpression')
|
|
59
|
+
if (node.type === 'CallExpression' || node.type === 'NewExpression') calls.push(node);
|
|
60
|
+
else if (node.type === 'SpreadElement') reads.push(node);
|
|
61
|
+
else if (node.type === 'BinaryExpression' && !isConstantExpression(node)) reads.push(node);
|
|
62
|
+
else if (
|
|
63
|
+
node.type === 'MemberExpression' &&
|
|
64
|
+
!(node.parent.type === 'CallExpression' && node.parent.callee === node)
|
|
65
|
+
)
|
|
66
|
+
reads.push(node);
|
|
45
67
|
|
|
46
68
|
for (const key of Object.keys(node)) {
|
|
47
69
|
if (key === 'parent') continue;
|
|
@@ -55,7 +77,7 @@ const collectImportTimeCalls = (root) => {
|
|
|
55
77
|
|
|
56
78
|
visit(root);
|
|
57
79
|
|
|
58
|
-
return
|
|
80
|
+
return { calls, reads };
|
|
59
81
|
};
|
|
60
82
|
|
|
61
83
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
@@ -65,6 +87,7 @@ const noImpureTopLevelProvider = {
|
|
|
65
87
|
docs: {
|
|
66
88
|
description:
|
|
67
89
|
'Disallow module-scope destructuring of a factory call, and require a pure annotation on module-scope calls in library source.',
|
|
90
|
+
recommended: true,
|
|
68
91
|
},
|
|
69
92
|
fixable: 'code',
|
|
70
93
|
schema: [
|
|
@@ -76,6 +99,11 @@ const noImpureTopLevelProvider = {
|
|
|
76
99
|
description:
|
|
77
100
|
'Also require `@__PURE__` on every call evaluated at module scope. Turn this on in publishable library source; it is pointless in application code, where every statement is retained anyway.',
|
|
78
101
|
},
|
|
102
|
+
forbidImpureReads: {
|
|
103
|
+
type: 'boolean',
|
|
104
|
+
description:
|
|
105
|
+
'Also forbid member reads, spreads and arithmetic on identifiers in module-scope initializers, which bundlers cannot prove side-effect free.',
|
|
106
|
+
},
|
|
79
107
|
},
|
|
80
108
|
additionalProperties: false,
|
|
81
109
|
},
|
|
@@ -83,6 +111,8 @@ const noImpureTopLevelProvider = {
|
|
|
83
111
|
messages: {
|
|
84
112
|
noDestructuring:
|
|
85
113
|
'Do not destructure a factory call at module scope. Destructuring invokes the iterator protocol, so no bundler can drop the statement — everything the factory closes over ships to every consumer. Assign the result to one `const` and name each binding with its own annotated extractor (`toProvideFn` / `toInjectFn` / `toToken`).',
|
|
114
|
+
impureRead:
|
|
115
|
+
'This module-scope member read, spread or arithmetic on an identifier cannot be proven side-effect free, so the whole declaration is retained in every consumer bundle. Write the literal key or number instead, or move the expression inside a function.',
|
|
86
116
|
missingPure:
|
|
87
117
|
'This module-scope call to `{{name}}` needs a `/* @__PURE__ */` annotation, or the whole declaration is retained in every consumer bundle — one unannotated call anywhere in an initializer (including inside an argument or an object literal) is enough. If the call is genuinely not side-effect free at import time, move it inside a function instead of annotating it: library code must not do work on import.',
|
|
88
118
|
},
|
|
@@ -90,12 +120,13 @@ const noImpureTopLevelProvider = {
|
|
|
90
120
|
create(context) {
|
|
91
121
|
const sourceCode = context.sourceCode;
|
|
92
122
|
const requirePureAnnotation = context.options[0]?.requirePureAnnotation === true;
|
|
123
|
+
const forbidImpureReads = context.options[0]?.forbidImpureReads === true;
|
|
93
124
|
|
|
94
125
|
return {
|
|
95
126
|
VariableDeclarator(node) {
|
|
96
127
|
if (!isModuleScope(node) || !node.init) return;
|
|
97
128
|
|
|
98
|
-
const initializer = node.init
|
|
129
|
+
const initializer = unwrap(node.init);
|
|
99
130
|
|
|
100
131
|
if (
|
|
101
132
|
initializer.type === 'CallExpression' &&
|
|
@@ -108,7 +139,11 @@ const noImpureTopLevelProvider = {
|
|
|
108
139
|
|
|
109
140
|
if (!requirePureAnnotation || node.id.type !== 'Identifier') return;
|
|
110
141
|
|
|
111
|
-
|
|
142
|
+
const { calls, reads } = collectImportTimeNodes(initializer);
|
|
143
|
+
|
|
144
|
+
if (forbidImpureReads) for (const read of reads) context.report({ node: read, messageId: 'impureRead' });
|
|
145
|
+
|
|
146
|
+
for (const call of calls) {
|
|
112
147
|
const name = calleeName(call);
|
|
113
148
|
|
|
114
149
|
if (name === null || hasPureAnnotation(sourceCode, call)) continue;
|
|
@@ -11,7 +11,7 @@ const getFixedName = (name) => name.replace(/^_+/, '');
|
|
|
11
11
|
*/
|
|
12
12
|
const getDirectMemberName = (node) => {
|
|
13
13
|
if (node.parent?.type !== 'ClassBody') return null;
|
|
14
|
-
if (node.
|
|
14
|
+
if (node.computed) return null;
|
|
15
15
|
if (node.type === 'MethodDefinition' && node.kind === 'constructor') return null;
|
|
16
16
|
return node.key?.type === 'Identifier' ? node.key.name : null;
|
|
17
17
|
};
|
|
@@ -32,6 +32,7 @@ const noLeadingUnderscoreClassMember = {
|
|
|
32
32
|
type: 'suggestion',
|
|
33
33
|
docs: {
|
|
34
34
|
description: 'Disallow leading underscores on class members and rename private members automatically when safe.',
|
|
35
|
+
recommended: true,
|
|
35
36
|
},
|
|
36
37
|
fixable: 'code',
|
|
37
38
|
schema: [],
|
|
@@ -98,7 +99,13 @@ const noLeadingUnderscoreClassMember = {
|
|
|
98
99
|
!frame.declaredNames.has(newName) &&
|
|
99
100
|
targetNameCounts.get(newName) === 1 &&
|
|
100
101
|
!frame.untrackedNames.has(oldName) &&
|
|
101
|
-
memberNodes.every(
|
|
102
|
+
memberNodes.every(
|
|
103
|
+
(memberNode) =>
|
|
104
|
+
memberNode.accessibility === 'private' &&
|
|
105
|
+
!memberNode.static &&
|
|
106
|
+
!memberNode.abstract &&
|
|
107
|
+
!memberNode.parameterProperty,
|
|
108
|
+
);
|
|
102
109
|
|
|
103
110
|
for (const memberNode of memberNodes) {
|
|
104
111
|
context.report({
|
|
@@ -124,6 +131,24 @@ const noLeadingUnderscoreClassMember = {
|
|
|
124
131
|
|
|
125
132
|
PropertyDefinition: registerMember,
|
|
126
133
|
MethodDefinition: registerMember,
|
|
134
|
+
AccessorProperty: registerMember,
|
|
135
|
+
TSAbstractPropertyDefinition: registerMember,
|
|
136
|
+
TSAbstractMethodDefinition: registerMember,
|
|
137
|
+
TSAbstractAccessorProperty: registerMember,
|
|
138
|
+
|
|
139
|
+
TSParameterProperty(node) {
|
|
140
|
+
const frame = classStack[classStack.length - 1];
|
|
141
|
+
const parameter = node.parameter.type === 'AssignmentPattern' ? node.parameter.left : node.parameter;
|
|
142
|
+
if (!frame || parameter.type !== 'Identifier') return;
|
|
143
|
+
|
|
144
|
+
frame.declaredNames.add(parameter.name);
|
|
145
|
+
|
|
146
|
+
if (!parameter.name.startsWith('_')) return;
|
|
147
|
+
|
|
148
|
+
const members = frame.membersByName.get(parameter.name) ?? [];
|
|
149
|
+
members.push({ key: parameter, accessibility: node.accessibility, parameterProperty: true });
|
|
150
|
+
frame.membersByName.set(parameter.name, members);
|
|
151
|
+
},
|
|
127
152
|
|
|
128
153
|
MemberExpression(node) {
|
|
129
154
|
const frame = classStack[classStack.length - 1];
|
|
@@ -27,6 +27,15 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
|
27
27
|
* → ✅ host: { '[class.active]': 'isActive', '(click)': 'onClick()' }
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
const CLASS_MEMBER_TYPES = new Set([
|
|
31
|
+
'PropertyDefinition',
|
|
32
|
+
'MethodDefinition',
|
|
33
|
+
'AccessorProperty',
|
|
34
|
+
'TSAbstractPropertyDefinition',
|
|
35
|
+
'TSAbstractMethodDefinition',
|
|
36
|
+
'TSAbstractAccessorProperty',
|
|
37
|
+
]);
|
|
38
|
+
|
|
30
39
|
/**
|
|
31
40
|
* Returns the static string key of a class member, or null for computed keys.
|
|
32
41
|
*
|
|
@@ -219,6 +228,7 @@ const rule = {
|
|
|
219
228
|
docs: {
|
|
220
229
|
description:
|
|
221
230
|
'Disallow legacy Angular decorators (@Input, @Output, @ViewChild, etc.) in favour of signal-based APIs and host: {} bindings.',
|
|
231
|
+
recommended: true,
|
|
222
232
|
},
|
|
223
233
|
fixable: 'code',
|
|
224
234
|
schema: [],
|
|
@@ -256,7 +266,6 @@ const rule = {
|
|
|
256
266
|
const reportedInputNames = new Set();
|
|
257
267
|
const reportedOutputNames = new Set();
|
|
258
268
|
|
|
259
|
-
// Detect @Input() x + @Output() xChange pairs → suggest model()
|
|
260
269
|
for (const [outputName, outputDecoratorNode] of ctx.outputs) {
|
|
261
270
|
if (!outputName.endsWith('Change')) continue;
|
|
262
271
|
const inputName = outputName.slice(0, -'Change'.length);
|
|
@@ -277,14 +286,12 @@ const rule = {
|
|
|
277
286
|
});
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
// Report remaining @Input() decorators that are not part of a pair
|
|
281
289
|
for (const [name, decoratorNode] of ctx.inputs) {
|
|
282
290
|
if (!reportedInputNames.has(name)) {
|
|
283
291
|
context.report({ node: decoratorNode, messageId: 'useInput' });
|
|
284
292
|
}
|
|
285
293
|
}
|
|
286
294
|
|
|
287
|
-
// Report remaining @Output() decorators that are not part of a pair
|
|
288
295
|
for (const [name, decoratorNode] of ctx.outputs) {
|
|
289
296
|
if (!reportedOutputNames.has(name)) {
|
|
290
297
|
context.report({ node: decoratorNode, messageId: 'useOutput' });
|
|
@@ -303,10 +310,9 @@ const rule = {
|
|
|
303
310
|
const decoratorName = getAngularDecoratorName(context.sourceCode, anyNode);
|
|
304
311
|
if (!decoratorName) return;
|
|
305
312
|
|
|
306
|
-
// Only act on decorators attached to class members, not to the class itself
|
|
307
313
|
const parent = anyNode.parent;
|
|
308
314
|
if (!parent) return;
|
|
309
|
-
if (
|
|
315
|
+
if (!CLASS_MEMBER_TYPES.has(parent.type)) return;
|
|
310
316
|
|
|
311
317
|
const ctx = currentClass();
|
|
312
318
|
|
|
@@ -320,7 +326,6 @@ const rule = {
|
|
|
320
326
|
if (memberName !== null) {
|
|
321
327
|
ctx.inputs.set(memberName, node);
|
|
322
328
|
} else {
|
|
323
|
-
// Computed key — cannot be part of a two-way binding pair, report immediately
|
|
324
329
|
context.report({ node, messageId: 'useInput' });
|
|
325
330
|
}
|
|
326
331
|
break;
|
|
@@ -101,9 +101,16 @@ const isContextProvidingHost = (host) => {
|
|
|
101
101
|
*/
|
|
102
102
|
const isTransparentHost = (host) =>
|
|
103
103
|
!!host &&
|
|
104
|
-
host.callee.type === '
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
((host.callee.type === 'Identifier' && host.callee.name === 'untracked') ||
|
|
105
|
+
(host.callee.type === 'MemberExpression' &&
|
|
106
|
+
host.callee.property.type === 'Identifier' &&
|
|
107
|
+
TRANSPARENT_ARRAY_METHODS.has(host.callee.property.name)));
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {any} fn
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
const isImmediatelyInvoked = (fn) => fn.parent?.type === 'CallExpression' && fn.parent.callee === fn;
|
|
107
114
|
|
|
108
115
|
/**
|
|
109
116
|
* Whether the function's own body calls `inject()`. Such a function can only be called from an injection
|
|
@@ -175,7 +182,7 @@ const classifyCallSite = (node) => {
|
|
|
175
182
|
return { needsInjector: false, boundary: 'injection context' };
|
|
176
183
|
}
|
|
177
184
|
|
|
178
|
-
if (!isTransparentHost(host)) {
|
|
185
|
+
if (!isTransparentHost(host) && !isImmediatelyInvoked(current)) {
|
|
179
186
|
if (!host && callsInject(current)) {
|
|
180
187
|
return { needsInjector: false, boundary: 'function that injects' };
|
|
181
188
|
}
|
|
@@ -281,6 +288,31 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
281
288
|
}
|
|
282
289
|
/** Names known to be legacy creators: imported under the naming convention, or declared locally. */
|
|
283
290
|
const creatorNames = new Set();
|
|
291
|
+
/** @type {Set<string>} */
|
|
292
|
+
const namespaceNames = new Set();
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {any} object
|
|
296
|
+
* @returns {string | null}
|
|
297
|
+
*/
|
|
298
|
+
const getCreatorName = (object) => {
|
|
299
|
+
if (object.type === 'Identifier') {
|
|
300
|
+
return creatorNames.has(object.name) ? object.name : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
object.type === 'MemberExpression' &&
|
|
305
|
+
!object.computed &&
|
|
306
|
+
object.object.type === 'Identifier' &&
|
|
307
|
+
namespaceNames.has(object.object.name) &&
|
|
308
|
+
object.property.type === 'Identifier' &&
|
|
309
|
+
creatorPattern.test(object.property.name)
|
|
310
|
+
) {
|
|
311
|
+
return `${object.object.name}.${object.property.name}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return null;
|
|
315
|
+
};
|
|
284
316
|
|
|
285
317
|
/**
|
|
286
318
|
* @param {any} classBody
|
|
@@ -288,11 +320,12 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
288
320
|
*/
|
|
289
321
|
const buildInjectorMemberFix = (classBody, injectorName) => {
|
|
290
322
|
const firstMember = classBody.body[0];
|
|
291
|
-
const
|
|
323
|
+
const insertBefore = firstMember ? (sourceCode.getCommentsBefore(firstMember)[0] ?? firstMember) : null;
|
|
324
|
+
const indentation = ' '.repeat(insertBefore ? insertBefore.loc.start.column : 2);
|
|
292
325
|
const declaration = `private ${injectorName} = inject(Injector);`;
|
|
293
326
|
|
|
294
|
-
return
|
|
295
|
-
? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(
|
|
327
|
+
return insertBefore
|
|
328
|
+
? /** @param {any} fixer */ (fixer) => fixer.insertTextBefore(insertBefore, `${declaration}\n\n${indentation}`)
|
|
296
329
|
: /** @param {any} fixer */ (fixer) => fixer.replaceText(classBody, `{\n${indentation}${declaration}\n}`);
|
|
297
330
|
};
|
|
298
331
|
|
|
@@ -393,7 +426,15 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
393
426
|
if (node.type === 'ImportDeclaration') {
|
|
394
427
|
node.specifiers.forEach(
|
|
395
428
|
/** @param {any} specifier */ (specifier) => {
|
|
396
|
-
if (specifier.type === '
|
|
429
|
+
if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
430
|
+
namespaceNames.add(specifier.local.name);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (specifier.type !== 'ImportSpecifier') return;
|
|
434
|
+
|
|
435
|
+
const importedName = specifier.imported.name ?? specifier.imported.value;
|
|
436
|
+
|
|
437
|
+
if (creatorPattern.test(specifier.local.name) || creatorPattern.test(importedName)) {
|
|
397
438
|
creatorNames.add(specifier.local.name);
|
|
398
439
|
}
|
|
399
440
|
},
|
|
@@ -433,13 +474,17 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
433
474
|
if (
|
|
434
475
|
callee.type !== 'MemberExpression' ||
|
|
435
476
|
callee.property.type !== 'Identifier' ||
|
|
436
|
-
callee.property.name !== 'prepare'
|
|
437
|
-
callee.object.type !== 'Identifier' ||
|
|
438
|
-
!creatorNames.has(callee.object.name)
|
|
477
|
+
callee.property.name !== 'prepare'
|
|
439
478
|
) {
|
|
440
479
|
return;
|
|
441
480
|
}
|
|
442
481
|
|
|
482
|
+
const creatorName = getCreatorName(callee.object);
|
|
483
|
+
|
|
484
|
+
if (!creatorName) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
443
488
|
const firstArgument = /** @type {any} */ (node.arguments[0]);
|
|
444
489
|
const hasInjector =
|
|
445
490
|
firstArgument?.type === 'ObjectExpression' &&
|
|
@@ -479,7 +524,7 @@ const noLegacyPrepareWithoutInjector = {
|
|
|
479
524
|
context.report({
|
|
480
525
|
node,
|
|
481
526
|
messageId: 'missingInjector',
|
|
482
|
-
data: { creator:
|
|
527
|
+
data: { creator: creatorName, boundary },
|
|
483
528
|
// Only the in-class shape is auto-fixed: a standalone function has no obvious injector to reach
|
|
484
529
|
// for, and inventing an `inject()` call there would move the failure rather than fix it.
|
|
485
530
|
fix:
|