@ethlete/eslint-plugin 0.1.0-next.1 → 0.1.0-next.3
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 +12 -0
- package/package.json +3 -2
- package/src/configs/recommended.js +51 -10
- package/src/index.js +22 -2
- package/src/rules/angular-decorator-property-order.js +3 -15
- package/src/rules/class-constant-property.js +344 -0
- package/src/rules/class-member-order.js +431 -0
- package/src/rules/inject-member-accessibility.js +5 -198
- package/src/rules/internals/angular-member-visibility.js +233 -0
- package/src/rules/internals/implemented-contract-members.js +294 -0
- package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
- package/src/rules/no-empty-newlines-between-imports.js +45 -0
- package/src/rules/no-leading-underscore-class-member.js +120 -0
- package/src/rules/no-legacy-angular-decorators.js +196 -1
- package/src/rules/no-public-property.js +47 -1
- package/src/rules/no-redundant-internal.js +94 -0
- package/src/rules/no-standalone-flag.js +95 -0
- package/src/rules/no-typed-injected-element-ref.js +88 -0
- package/src/rules/require-on-push-change-detection.js +271 -0
- package/src/rules/require-view-encapsulation-none.js +215 -52
- package/src/rules/template-member-accessibility.js +190 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} name
|
|
6
|
+
*/
|
|
7
|
+
const getFixedName = (name) => name.replace(/^_+/, '');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {any} node
|
|
11
|
+
*/
|
|
12
|
+
const getDirectMemberName = (node) => {
|
|
13
|
+
if (node.parent?.type !== 'ClassBody') return null;
|
|
14
|
+
if (node.static || node.computed) return null;
|
|
15
|
+
if (node.type === 'MethodDefinition' && node.kind === 'constructor') return null;
|
|
16
|
+
return node.key?.type === 'Identifier' ? node.key.name : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
20
|
+
const noLeadingUnderscoreClassMember = {
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'suggestion',
|
|
23
|
+
docs: {
|
|
24
|
+
description: 'Disallow leading underscores on class members and rename private members automatically when safe.',
|
|
25
|
+
},
|
|
26
|
+
fixable: 'code',
|
|
27
|
+
schema: [],
|
|
28
|
+
messages: {
|
|
29
|
+
noLeadingUnderscore: "Class members must not use a leading underscore. Rename '{{oldName}}' to '{{newName}}'.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
create(context) {
|
|
33
|
+
/**
|
|
34
|
+
* @type {Array<{
|
|
35
|
+
* classBody: import('eslint').Rule.Node,
|
|
36
|
+
* declaredNames: Set<string>,
|
|
37
|
+
* membersByName: Map<string, any[]>,
|
|
38
|
+
* memberReads: any[],
|
|
39
|
+
* }>}
|
|
40
|
+
*/
|
|
41
|
+
const classStack = [];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {any} node
|
|
45
|
+
*/
|
|
46
|
+
const registerMember = (node) => {
|
|
47
|
+
const frame = classStack[classStack.length - 1];
|
|
48
|
+
if (!frame || node.parent !== frame.classBody) return;
|
|
49
|
+
|
|
50
|
+
const name = getDirectMemberName(node);
|
|
51
|
+
if (!name) return;
|
|
52
|
+
|
|
53
|
+
frame.declaredNames.add(name);
|
|
54
|
+
|
|
55
|
+
if (!name.startsWith('_')) return;
|
|
56
|
+
|
|
57
|
+
const members = frame.membersByName.get(name) ?? [];
|
|
58
|
+
members.push(node);
|
|
59
|
+
frame.membersByName.set(name, members);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
ClassBody(node) {
|
|
64
|
+
classStack.push({
|
|
65
|
+
classBody: node,
|
|
66
|
+
declaredNames: new Set(),
|
|
67
|
+
membersByName: new Map(),
|
|
68
|
+
memberReads: [],
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
'ClassBody:exit'() {
|
|
73
|
+
const frame = classStack.pop();
|
|
74
|
+
if (!frame) return;
|
|
75
|
+
|
|
76
|
+
for (const [oldName, memberNodes] of frame.membersByName) {
|
|
77
|
+
const newName = getFixedName(oldName);
|
|
78
|
+
const canFix =
|
|
79
|
+
newName.length > 0 &&
|
|
80
|
+
!frame.declaredNames.has(newName) &&
|
|
81
|
+
memberNodes.every((memberNode) => memberNode.accessibility === 'private');
|
|
82
|
+
|
|
83
|
+
for (const memberNode of memberNodes) {
|
|
84
|
+
context.report({
|
|
85
|
+
node: memberNode.key,
|
|
86
|
+
messageId: 'noLeadingUnderscore',
|
|
87
|
+
data: { oldName, newName },
|
|
88
|
+
fix: canFix
|
|
89
|
+
? (fixer) => {
|
|
90
|
+
const fixes = memberNodes.map((node) => fixer.replaceText(node.key, newName));
|
|
91
|
+
|
|
92
|
+
for (const memberExpression of frame.memberReads) {
|
|
93
|
+
if (memberExpression.property.name !== oldName) continue;
|
|
94
|
+
fixes.push(fixer.replaceText(memberExpression.property, newName));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return fixes;
|
|
98
|
+
}
|
|
99
|
+
: null,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
PropertyDefinition: registerMember,
|
|
106
|
+
MethodDefinition: registerMember,
|
|
107
|
+
|
|
108
|
+
MemberExpression(node) {
|
|
109
|
+
const frame = classStack[classStack.length - 1];
|
|
110
|
+
if (!frame) return;
|
|
111
|
+
if (node.object?.type !== 'ThisExpression' || node.computed) return;
|
|
112
|
+
if (node.property?.type !== 'Identifier') return;
|
|
113
|
+
|
|
114
|
+
frame.memberReads.push(node);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
module.exports = noLeadingUnderscoreClassMember;
|
|
@@ -51,6 +51,180 @@ const getMemberName = (node) => {
|
|
|
51
51
|
return null;
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* @param {import('estree').Property['key']} key
|
|
56
|
+
* @returns {string | null}
|
|
57
|
+
*/
|
|
58
|
+
const getPropertyName = (key) => {
|
|
59
|
+
if (key.type === 'Identifier') return key.name;
|
|
60
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
66
|
+
* @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
|
|
67
|
+
*/
|
|
68
|
+
const getIndent = (sourceCode, node) => {
|
|
69
|
+
if (!node.loc) return '';
|
|
70
|
+
const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
|
|
71
|
+
return line.slice(0, node.loc.start.column);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {any} classNode
|
|
76
|
+
*/
|
|
77
|
+
const getAngularMetadata = (classNode) => {
|
|
78
|
+
for (const decorator of classNode.decorators ?? []) {
|
|
79
|
+
const decoratorName = getDecoratorName(decorator);
|
|
80
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive') continue;
|
|
81
|
+
|
|
82
|
+
const expression = decorator.expression;
|
|
83
|
+
if (expression.type !== 'CallExpression') continue;
|
|
84
|
+
|
|
85
|
+
const metadata = expression.arguments[0];
|
|
86
|
+
if (metadata?.type !== 'ObjectExpression') continue;
|
|
87
|
+
|
|
88
|
+
return metadata;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {any} objectExpression
|
|
96
|
+
* @param {string} propertyName
|
|
97
|
+
*/
|
|
98
|
+
const findObjectProperty = (objectExpression, propertyName) =>
|
|
99
|
+
objectExpression.properties.find(
|
|
100
|
+
(property) => property.type === 'Property' && getPropertyName(property.key) === propertyName,
|
|
101
|
+
) ?? null;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
105
|
+
* @param {any} objectExpression
|
|
106
|
+
* @param {string} entryText
|
|
107
|
+
*/
|
|
108
|
+
const buildObjectTextWithAppendedProperty = (sourceCode, objectExpression, entryText) => {
|
|
109
|
+
const properties = objectExpression.properties.filter((property) => property.type === 'Property');
|
|
110
|
+
const isMultiline = Boolean(
|
|
111
|
+
objectExpression.loc && objectExpression.loc.start.line !== objectExpression.loc.end.line,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!isMultiline) {
|
|
115
|
+
const existingText = properties.map((property) => sourceCode.getText(property));
|
|
116
|
+
return existingText.length === 0 ? `{ ${entryText} }` : `{ ${existingText.join(', ')}, ${entryText} }`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const openingBrace = sourceCode.getFirstToken(objectExpression);
|
|
120
|
+
const closingBrace = sourceCode.getLastToken(objectExpression);
|
|
121
|
+
const closingIndent = closingBrace ? getIndent(sourceCode, closingBrace) : '';
|
|
122
|
+
const propertyIndent = properties[0] ? getIndent(sourceCode, properties[0]) : `${closingIndent} `;
|
|
123
|
+
const existingText = properties.map((property) => `${propertyIndent}${sourceCode.getText(property)}`);
|
|
124
|
+
|
|
125
|
+
if (existingText.length === 0) {
|
|
126
|
+
return `{
|
|
127
|
+
${propertyIndent}${entryText}
|
|
128
|
+
${closingIndent}}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return `{
|
|
132
|
+
${existingText.join(',\n')},
|
|
133
|
+
${propertyIndent}${entryText}
|
|
134
|
+
${closingIndent}}`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
139
|
+
* @param {any} objectExpression
|
|
140
|
+
*/
|
|
141
|
+
const hasTrailingComma = (sourceCode, objectExpression) => {
|
|
142
|
+
const properties = objectExpression.properties.filter((property) => property.type === 'Property');
|
|
143
|
+
const lastProperty = properties[properties.length - 1];
|
|
144
|
+
if (!lastProperty) return false;
|
|
145
|
+
|
|
146
|
+
const tokenAfter = sourceCode.getTokenAfter(lastProperty);
|
|
147
|
+
return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
152
|
+
* @param {any} decoratorNode
|
|
153
|
+
* @param {any} memberNode
|
|
154
|
+
* @param {string} bindingKey
|
|
155
|
+
* @param {string} memberName
|
|
156
|
+
*/
|
|
157
|
+
const buildHostBindingFix = (sourceCode, decoratorNode, memberNode, bindingKey, memberName) => {
|
|
158
|
+
const classBody = memberNode.parent;
|
|
159
|
+
const classNode = classBody?.parent;
|
|
160
|
+
if (!classNode) return null;
|
|
161
|
+
|
|
162
|
+
const metadata = getAngularMetadata(classNode);
|
|
163
|
+
if (!metadata) return null;
|
|
164
|
+
|
|
165
|
+
const hostProperty = findObjectProperty(metadata, 'host');
|
|
166
|
+
const hostEntryText = `'${bindingKey}': '${memberName}'`;
|
|
167
|
+
const fixes = [];
|
|
168
|
+
|
|
169
|
+
if (hostProperty) {
|
|
170
|
+
if (hostProperty.value.type !== 'ObjectExpression') return null;
|
|
171
|
+
if (
|
|
172
|
+
hostProperty.value.properties.some(
|
|
173
|
+
(property) => property.type === 'Property' && getPropertyName(property.key) === bindingKey,
|
|
174
|
+
)
|
|
175
|
+
) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fixes.push((fixer) =>
|
|
180
|
+
fixer.replaceText(
|
|
181
|
+
hostProperty.value,
|
|
182
|
+
buildObjectTextWithAppendedProperty(sourceCode, hostProperty.value, hostEntryText),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
187
|
+
if (!closingBrace) return null;
|
|
188
|
+
|
|
189
|
+
const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
|
|
190
|
+
const closingIndent = getIndent(sourceCode, closingBrace);
|
|
191
|
+
const propertyIndent = metadata.properties[0]
|
|
192
|
+
? getIndent(sourceCode, metadata.properties[0])
|
|
193
|
+
: `${getIndent(sourceCode, closingBrace)} `;
|
|
194
|
+
const hostPropertyText = `host: { ${hostEntryText} }`;
|
|
195
|
+
const separator = metadata.properties.length > 0 ? (hasTrailingComma(sourceCode, metadata) ? '' : ',') : '';
|
|
196
|
+
|
|
197
|
+
if (isMultiline && metadata.properties.length > 0) {
|
|
198
|
+
const lastProperty = metadata.properties[metadata.properties.length - 1];
|
|
199
|
+
const tokenAfterLastProperty = sourceCode.getTokenAfter(lastProperty);
|
|
200
|
+
const rangeStart =
|
|
201
|
+
separator === '' && tokenAfterLastProperty ? tokenAfterLastProperty.range[1] : lastProperty.range[1];
|
|
202
|
+
|
|
203
|
+
fixes.push((fixer) =>
|
|
204
|
+
fixer.replaceTextRange(
|
|
205
|
+
[rangeStart, closingBrace.range[0]],
|
|
206
|
+
`${separator}\n${propertyIndent}${hostPropertyText},\n${closingIndent}`,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
} else {
|
|
210
|
+
const insertion = isMultiline
|
|
211
|
+
? `\n${propertyIndent}${hostPropertyText}\n${closingIndent}`
|
|
212
|
+
: `${metadata.properties.length > 0 ? `${separator} ` : ' '}${hostPropertyText}${metadata.properties.length > 0 ? '' : ' '}`;
|
|
213
|
+
|
|
214
|
+
fixes.push((fixer) => fixer.insertTextBefore(closingBrace, insertion));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const nextToken = sourceCode.getTokenAfter(decoratorNode);
|
|
219
|
+
fixes.push(
|
|
220
|
+
nextToken
|
|
221
|
+
? (fixer) => fixer.removeRange([decoratorNode.range[0], nextToken.range[0]])
|
|
222
|
+
: (fixer) => fixer.remove(decoratorNode),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return (fixer) => fixes.map((applyFix) => applyFix(fixer));
|
|
226
|
+
};
|
|
227
|
+
|
|
54
228
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
55
229
|
const rule = {
|
|
56
230
|
meta: {
|
|
@@ -59,6 +233,7 @@ const rule = {
|
|
|
59
233
|
description:
|
|
60
234
|
'Disallow legacy Angular decorators (@Input, @Output, @ViewChild, etc.) in favour of signal-based APIs and host: {} bindings.',
|
|
61
235
|
},
|
|
236
|
+
fixable: 'code',
|
|
62
237
|
schema: [],
|
|
63
238
|
messages: {
|
|
64
239
|
useInput: 'Use the input() signal function instead of @Input().',
|
|
@@ -195,7 +370,27 @@ const rule = {
|
|
|
195
370
|
break;
|
|
196
371
|
|
|
197
372
|
case 'HostBinding':
|
|
198
|
-
context.report({
|
|
373
|
+
context.report({
|
|
374
|
+
node,
|
|
375
|
+
messageId: 'useHostBinding',
|
|
376
|
+
fix: (() => {
|
|
377
|
+
const memberName = getMemberName(parent);
|
|
378
|
+
if (!memberName) return null;
|
|
379
|
+
|
|
380
|
+
const expression = anyNode.expression;
|
|
381
|
+
if (expression.type !== 'CallExpression' && expression.type !== 'Identifier') return null;
|
|
382
|
+
|
|
383
|
+
const bindingName =
|
|
384
|
+
expression.type === 'CallExpression'
|
|
385
|
+
? expression.arguments[0]?.type === 'Literal' && typeof expression.arguments[0].value === 'string'
|
|
386
|
+
? expression.arguments[0].value
|
|
387
|
+
: memberName
|
|
388
|
+
: memberName;
|
|
389
|
+
|
|
390
|
+
const bindingKey = bindingName.startsWith('[') ? bindingName : `[${bindingName}]`;
|
|
391
|
+
return buildHostBindingFix(context.sourceCode, anyNode, parent, bindingKey, memberName);
|
|
392
|
+
})(),
|
|
393
|
+
});
|
|
199
394
|
break;
|
|
200
395
|
|
|
201
396
|
case 'HostListener':
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* @param {any} node
|
|
6
8
|
*/
|
|
@@ -18,19 +20,63 @@ const noPublicProperty = {
|
|
|
18
20
|
'Disallow explicit public on class properties, except for injected members that intentionally expose an external API.',
|
|
19
21
|
recommended: true,
|
|
20
22
|
},
|
|
23
|
+
fixable: 'code',
|
|
21
24
|
schema: [],
|
|
22
25
|
messages: {
|
|
23
26
|
noPublicProperty:
|
|
24
27
|
'No public keyword on class properties. The only exception is an injected member that intentionally exposes an external API.',
|
|
28
|
+
shouldBeProtected:
|
|
29
|
+
'Member `{{name}}` should be protected instead of public because it is referenced from a template or host binding.',
|
|
25
30
|
},
|
|
26
31
|
},
|
|
27
32
|
create(context) {
|
|
33
|
+
/**
|
|
34
|
+
* @param {any} node
|
|
35
|
+
*/
|
|
36
|
+
const removePublicFix = (node) => {
|
|
37
|
+
const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
|
|
38
|
+
const nextToken = publicToken ? context.sourceCode.getTokenAfter(publicToken) : null;
|
|
39
|
+
if (!publicToken || !nextToken) return null;
|
|
40
|
+
|
|
41
|
+
return (fixer) => fixer.removeRange([publicToken.range[0], nextToken.range[0]]);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {any} node
|
|
46
|
+
*/
|
|
47
|
+
const replacePublicWithProtectedFix = (node) => {
|
|
48
|
+
const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
|
|
49
|
+
if (!publicToken) return null;
|
|
50
|
+
|
|
51
|
+
return (fixer) => fixer.replaceText(publicToken, 'protected');
|
|
52
|
+
};
|
|
53
|
+
|
|
28
54
|
return {
|
|
29
55
|
PropertyDefinition(node) {
|
|
30
56
|
if (node.accessibility !== 'public') return;
|
|
31
57
|
if (isInjectCall(node.value)) return;
|
|
32
58
|
|
|
33
|
-
|
|
59
|
+
const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
|
|
60
|
+
const memberName = getMemberName(node);
|
|
61
|
+
const isTemplateVisible =
|
|
62
|
+
classNode && memberName ? isReferencedFromTemplateOrHost(classNode, memberName, context) : false;
|
|
63
|
+
|
|
64
|
+
if (isTemplateVisible) {
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: 'shouldBeProtected',
|
|
68
|
+
data: { name: memberName },
|
|
69
|
+
fix: replacePublicWithProtectedFix(node),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
context.report({
|
|
76
|
+
node,
|
|
77
|
+
messageId: 'noPublicProperty',
|
|
78
|
+
fix: removePublicFix(node),
|
|
79
|
+
});
|
|
34
80
|
},
|
|
35
81
|
};
|
|
36
82
|
},
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
6
|
+
* @param {import('eslint').Rule.Node} node
|
|
7
|
+
*/
|
|
8
|
+
const getAttachedInternalComment = (sourceCode, node) => {
|
|
9
|
+
const comments = sourceCode.getCommentsBefore(node);
|
|
10
|
+
const attachedComment = comments[comments.length - 1];
|
|
11
|
+
|
|
12
|
+
if (!attachedComment || attachedComment.type !== 'Block') {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const textBetween = sourceCode.text.slice(attachedComment.range[1], node.range[0]);
|
|
17
|
+
|
|
18
|
+
if (textBetween.trim() !== '') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return /@internal\b/u.test(attachedComment.value) ? attachedComment : null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {any} node
|
|
27
|
+
*/
|
|
28
|
+
const getMemberName = (node) => {
|
|
29
|
+
if (node.key?.type === 'Identifier') return node.key.name;
|
|
30
|
+
if (node.key?.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
|
|
31
|
+
|
|
32
|
+
return 'member';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {any} node
|
|
37
|
+
*/
|
|
38
|
+
const getMemberKind = (node) => {
|
|
39
|
+
if (node.type === 'MethodDefinition') return 'method';
|
|
40
|
+
return 'property';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
44
|
+
const noRedundantInternal = {
|
|
45
|
+
meta: {
|
|
46
|
+
type: 'suggestion',
|
|
47
|
+
docs: {
|
|
48
|
+
description: 'Disallow redundant @internal usage on class members that are already hidden by accessibility.',
|
|
49
|
+
recommended: true,
|
|
50
|
+
},
|
|
51
|
+
fixable: 'code',
|
|
52
|
+
schema: [],
|
|
53
|
+
messages: {
|
|
54
|
+
redundantInternal:
|
|
55
|
+
'`@internal` is redundant on {{kind}} `{{name}}` because {{accessibility}} members are already hidden from consumers.',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
create(context) {
|
|
59
|
+
const sourceCode = context.sourceCode;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {any} node
|
|
63
|
+
*/
|
|
64
|
+
const checkMember = (node) => {
|
|
65
|
+
const internalComment = getAttachedInternalComment(sourceCode, node);
|
|
66
|
+
|
|
67
|
+
if (!internalComment) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (node.accessibility !== 'private' && node.accessibility !== 'protected') {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
context.report({
|
|
76
|
+
node: internalComment,
|
|
77
|
+
messageId: 'redundantInternal',
|
|
78
|
+
data: {
|
|
79
|
+
accessibility: node.accessibility,
|
|
80
|
+
kind: getMemberKind(node),
|
|
81
|
+
name: getMemberName(node),
|
|
82
|
+
},
|
|
83
|
+
fix: (fixer) => fixer.removeRange([internalComment.range[0], node.range[0]]),
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
PropertyDefinition: checkMember,
|
|
89
|
+
MethodDefinition: checkMember,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
module.exports = noRedundantInternal;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {'Component' | 'Directive' | 'Pipe'} TDecoratorName */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('estree').Property['key']} key
|
|
8
|
+
*/
|
|
9
|
+
const getPropertyName = (key) => {
|
|
10
|
+
if (key.type === 'Identifier') return key.name;
|
|
11
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
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
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
50
|
+
const noStandaloneFlag = {
|
|
51
|
+
meta: {
|
|
52
|
+
type: 'suggestion',
|
|
53
|
+
docs: {
|
|
54
|
+
description:
|
|
55
|
+
'Disallow standalone metadata on Angular decorators because standalone is the default and should be omitted.',
|
|
56
|
+
},
|
|
57
|
+
fixable: 'code',
|
|
58
|
+
schema: [],
|
|
59
|
+
messages: {
|
|
60
|
+
noStandalone: 'Remove standalone from Angular metadata. Standalone is implicit and should not be declared.',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
create(context) {
|
|
64
|
+
const sourceCode = context.sourceCode;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
Decorator(node) {
|
|
68
|
+
const decoratorName = getDecoratorName(node);
|
|
69
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive' && decoratorName !== 'Pipe') return;
|
|
70
|
+
|
|
71
|
+
const decorator = /** @type {any} */ (node);
|
|
72
|
+
const expression = decorator.expression;
|
|
73
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
74
|
+
|
|
75
|
+
const metadata = expression.arguments[0];
|
|
76
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
77
|
+
|
|
78
|
+
for (const property of metadata.properties) {
|
|
79
|
+
if (property.type !== 'Property') continue;
|
|
80
|
+
if (getPropertyName(property.key) !== 'standalone') continue;
|
|
81
|
+
|
|
82
|
+
context.report({
|
|
83
|
+
node: property,
|
|
84
|
+
messageId: 'noStandalone',
|
|
85
|
+
fix(fixer) {
|
|
86
|
+
return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
module.exports = noStandaloneFlag;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
|
+
const noTypedInjectedElementRef = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'suggestion',
|
|
8
|
+
docs: {
|
|
9
|
+
description:
|
|
10
|
+
'Require that inject(ElementRef) uses a generic type parameter on inject() itself, not on ElementRef.',
|
|
11
|
+
recommended: true,
|
|
12
|
+
},
|
|
13
|
+
messages: {
|
|
14
|
+
missingGeneric:
|
|
15
|
+
'Provide a generic type on inject() itself: `inject<ElementRef<HTMLElement>>(ElementRef)`. Passing a type argument on ElementRef directly is also disallowed.',
|
|
16
|
+
},
|
|
17
|
+
schema: [],
|
|
18
|
+
fixable: 'code',
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
return {
|
|
22
|
+
CallExpression(node) {
|
|
23
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== 'inject') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const firstArg = node.arguments[0];
|
|
28
|
+
if (!firstArg) return;
|
|
29
|
+
|
|
30
|
+
const isPlainElementRef = firstArg.type === 'Identifier' && firstArg.name === 'ElementRef';
|
|
31
|
+
const isInstantiatedElementRef =
|
|
32
|
+
firstArg.type === 'TSInstantiationExpression' &&
|
|
33
|
+
firstArg.expression &&
|
|
34
|
+
firstArg.expression.type === 'Identifier' &&
|
|
35
|
+
firstArg.expression.name === 'ElementRef';
|
|
36
|
+
|
|
37
|
+
if (!isPlainElementRef && !isInstantiatedElementRef) return;
|
|
38
|
+
|
|
39
|
+
const src = context.sourceCode;
|
|
40
|
+
const existingTypeParams = node.typeParameters || node.typeArguments;
|
|
41
|
+
const hasInjectTypeParam =
|
|
42
|
+
existingTypeParams && existingTypeParams.params && existingTypeParams.params.length > 0;
|
|
43
|
+
|
|
44
|
+
if (isPlainElementRef && !hasInjectTypeParam) {
|
|
45
|
+
// inject(ElementRef) → inject<ElementRef<HTMLElement>>(ElementRef)
|
|
46
|
+
context.report({
|
|
47
|
+
node,
|
|
48
|
+
messageId: 'missingGeneric',
|
|
49
|
+
fix(fixer) {
|
|
50
|
+
return fixer.insertTextAfter(node.callee, '<ElementRef<HTMLElement>>');
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isInstantiatedElementRef) {
|
|
57
|
+
// Capture the type args text from the token, e.g. "<HTMLElement>"
|
|
58
|
+
const tokenTypeParamText = src.getText(firstArg.typeParameters || firstArg.typeArguments);
|
|
59
|
+
|
|
60
|
+
if (!hasInjectTypeParam) {
|
|
61
|
+
// inject(ElementRef<HTMLElement>) → inject<ElementRef<HTMLElement>>(ElementRef)
|
|
62
|
+
context.report({
|
|
63
|
+
node,
|
|
64
|
+
messageId: 'missingGeneric',
|
|
65
|
+
fix(fixer) {
|
|
66
|
+
return [
|
|
67
|
+
fixer.insertTextAfter(node.callee, `<ElementRef${tokenTypeParamText}>`),
|
|
68
|
+
fixer.replaceText(firstArg, 'ElementRef'),
|
|
69
|
+
];
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
// inject<X>(ElementRef<Y>) — token must not carry type args; keep inject's type param
|
|
74
|
+
context.report({
|
|
75
|
+
node,
|
|
76
|
+
messageId: 'missingGeneric',
|
|
77
|
+
fix(fixer) {
|
|
78
|
+
return fixer.replaceText(firstArg, 'ElementRef');
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
module.exports = noTypedInjectedElementRef;
|