@ethlete/eslint-plugin 0.1.0-next.1 → 0.1.0-next.10
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 +58 -0
- package/package.json +4 -2
- package/src/configs/recommended.js +63 -11
- package/src/index.js +24 -2
- package/src/rules/angular-decorator-property-order.js +3 -15
- package/src/rules/class-constant-property.js +365 -0
- package/src/rules/class-member-order.js +456 -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-redundant-on-push-change-detection.js +237 -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 +320 -0
- package/src/rules/require-view-encapsulation-none.js +215 -52
- package/src/rules/template-member-accessibility.js +204 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallow explicit `changeDetection: ChangeDetectionStrategy.OnPush` on
|
|
6
|
+
* `@Component` decorators. Since Angular 22, OnPush is the default change
|
|
7
|
+
* detection strategy, so declaring it is redundant noise.
|
|
8
|
+
*
|
|
9
|
+
* The rule is version-aware: it only activates when the workspace's installed
|
|
10
|
+
* Angular is v22 or newer (or when the version cannot be determined). On older
|
|
11
|
+
* Angular versions — where OnPush is NOT the default — it stays silent so that
|
|
12
|
+
* removing it would not change behaviour.
|
|
13
|
+
*
|
|
14
|
+
* BAD (Angular >= 22):
|
|
15
|
+
* @Component({ selector: 'et-x', template: '', changeDetection: ChangeDetectionStrategy.OnPush })
|
|
16
|
+
*
|
|
17
|
+
* GOOD (Angular >= 22):
|
|
18
|
+
* @Component({ selector: 'et-x', template: '' })
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REQUIRED_MAJOR = 22;
|
|
22
|
+
|
|
23
|
+
/** @type {number | null | undefined} */
|
|
24
|
+
let cachedAngularMajor;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Detect the workspace's installed Angular major version. Cached across files.
|
|
28
|
+
*
|
|
29
|
+
* @returns {number | null} major version, or null when it cannot be determined
|
|
30
|
+
*/
|
|
31
|
+
const detectAngularMajor = () => {
|
|
32
|
+
if (cachedAngularMajor !== undefined) return cachedAngularMajor;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const pkg = require('@angular/core/package.json');
|
|
36
|
+
const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
|
|
37
|
+
cachedAngularMajor = Number.isNaN(major) ? null : major;
|
|
38
|
+
} catch {
|
|
39
|
+
cachedAngularMajor = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return cachedAngularMajor;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the Angular major version to gate on. An explicit
|
|
47
|
+
* `settings.ethlete.angularMajor` wins over auto-detection — handy for
|
|
48
|
+
* pinning behaviour and for tests.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
51
|
+
* @returns {number | null}
|
|
52
|
+
*/
|
|
53
|
+
const getAngularMajor = (context) => {
|
|
54
|
+
const settings = /** @type {any} */ (context.settings);
|
|
55
|
+
const override = settings?.ethlete?.angularMajor;
|
|
56
|
+
if (typeof override === 'number') return override;
|
|
57
|
+
|
|
58
|
+
return detectAngularMajor();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {import('estree').Property['key']} key
|
|
63
|
+
*/
|
|
64
|
+
const getPropertyName = (key) => {
|
|
65
|
+
if (key.type === 'Identifier') return key.name;
|
|
66
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {import('eslint').Rule.Node} node
|
|
72
|
+
*/
|
|
73
|
+
const isComponentDecorator = (node) => {
|
|
74
|
+
const decorator = /** @type {any} */ (node);
|
|
75
|
+
if (decorator.type !== 'Decorator') return false;
|
|
76
|
+
|
|
77
|
+
const expression = decorator.expression;
|
|
78
|
+
if (expression.type === 'CallExpression') {
|
|
79
|
+
return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return expression.type === 'Identifier' && expression.name === 'Component';
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {any} value
|
|
87
|
+
*/
|
|
88
|
+
const isOnPushValue = (value) =>
|
|
89
|
+
value.type === 'MemberExpression' &&
|
|
90
|
+
value.object.type === 'Identifier' &&
|
|
91
|
+
value.object.name === 'ChangeDetectionStrategy' &&
|
|
92
|
+
value.property.type === 'Identifier' &&
|
|
93
|
+
value.property.name === 'OnPush';
|
|
94
|
+
|
|
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
|
+
/**
|
|
117
|
+
* Range that removes a single named import specifier together with its comma.
|
|
118
|
+
*
|
|
119
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
120
|
+
* @param {any} importNode
|
|
121
|
+
* @param {any} specifier
|
|
122
|
+
*/
|
|
123
|
+
const getSpecifierRemovalRange = (sourceCode, importNode, specifier) => {
|
|
124
|
+
const named = importNode.specifiers.filter((entry) => entry.type === 'ImportSpecifier');
|
|
125
|
+
const index = named.indexOf(specifier);
|
|
126
|
+
|
|
127
|
+
// Sole specifier of the whole import — drop the entire declaration.
|
|
128
|
+
if (importNode.specifiers.length === 1) return importNode.range;
|
|
129
|
+
if (index < named.length - 1) return [specifier.range[0], named[index + 1].range[0]];
|
|
130
|
+
|
|
131
|
+
return [named[index - 1].range[1], specifier.range[1]];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* True when every reference of the `ChangeDetectionStrategy` binding is a
|
|
136
|
+
* `changeDetection: ChangeDetectionStrategy.OnPush` property value — i.e.
|
|
137
|
+
* removing those properties leaves the import unused and safe to drop.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('eslint').Scope.Reference} reference
|
|
140
|
+
*/
|
|
141
|
+
const isOnPushChangeDetectionReference = (reference) => {
|
|
142
|
+
const identifier = /** @type {any} */ (reference.identifier);
|
|
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
|
+
};
|
|
152
|
+
|
|
153
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
154
|
+
const noRedundantOnPushChangeDetection = {
|
|
155
|
+
meta: {
|
|
156
|
+
type: 'suggestion',
|
|
157
|
+
docs: {
|
|
158
|
+
description:
|
|
159
|
+
'Disallow redundant `changeDetection: ChangeDetectionStrategy.OnPush`. OnPush is the default since Angular 22.',
|
|
160
|
+
},
|
|
161
|
+
fixable: 'code',
|
|
162
|
+
schema: [],
|
|
163
|
+
messages: {
|
|
164
|
+
redundant:
|
|
165
|
+
'`changeDetection: ChangeDetectionStrategy.OnPush` is redundant. OnPush is the default change detection strategy since Angular 22.',
|
|
166
|
+
redundantImport:
|
|
167
|
+
'Remove the now-unused `ChangeDetectionStrategy` import. OnPush is the default change detection strategy since Angular 22.',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
create(context) {
|
|
171
|
+
// Only enforce on Angular >= 22, where OnPush is the default. Stay silent on
|
|
172
|
+
// older versions (removing it there would change behaviour) — but enforce
|
|
173
|
+
// when the version is unknown, since this workspace targets modern Angular.
|
|
174
|
+
const angularMajor = getAngularMajor(context);
|
|
175
|
+
if (angularMajor !== null && angularMajor < REQUIRED_MAJOR) return {};
|
|
176
|
+
|
|
177
|
+
const sourceCode = context.sourceCode;
|
|
178
|
+
/** @type {any} */
|
|
179
|
+
let changeDetectionSpecifier = null;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
Decorator(node) {
|
|
183
|
+
if (!isComponentDecorator(node)) return;
|
|
184
|
+
|
|
185
|
+
const expression = /** @type {any} */ (node).expression;
|
|
186
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
187
|
+
|
|
188
|
+
const metadata = expression.arguments[0];
|
|
189
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
190
|
+
|
|
191
|
+
const property = metadata.properties.find(
|
|
192
|
+
(entry) => entry.type === 'Property' && getPropertyName(entry.key) === 'changeDetection',
|
|
193
|
+
);
|
|
194
|
+
if (!property || !isOnPushValue(property.value)) return;
|
|
195
|
+
|
|
196
|
+
context.report({
|
|
197
|
+
node: property,
|
|
198
|
+
messageId: 'redundant',
|
|
199
|
+
fix: (fixer) => fixer.replaceTextRange(getPropertyRemovalRange(sourceCode, metadata, property), ''),
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
ImportDeclaration(node) {
|
|
204
|
+
if (node.source.value !== '@angular/core') return;
|
|
205
|
+
|
|
206
|
+
for (const specifier of node.specifiers) {
|
|
207
|
+
if (
|
|
208
|
+
specifier.type === 'ImportSpecifier' &&
|
|
209
|
+
specifier.imported.type === 'Identifier' &&
|
|
210
|
+
specifier.imported.name === 'ChangeDetectionStrategy'
|
|
211
|
+
) {
|
|
212
|
+
changeDetectionSpecifier = specifier;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
'Program:exit'() {
|
|
219
|
+
if (!changeDetectionSpecifier) return;
|
|
220
|
+
|
|
221
|
+
const [variable] = sourceCode.getDeclaredVariables(changeDetectionSpecifier);
|
|
222
|
+
if (!variable || variable.references.length === 0) return;
|
|
223
|
+
if (!variable.references.every(isOnPushChangeDetectionReference)) return;
|
|
224
|
+
|
|
225
|
+
const importNode = changeDetectionSpecifier.parent;
|
|
226
|
+
context.report({
|
|
227
|
+
node: changeDetectionSpecifier,
|
|
228
|
+
messageId: 'redundantImport',
|
|
229
|
+
fix: (fixer) =>
|
|
230
|
+
fixer.replaceTextRange(getSpecifierRemovalRange(sourceCode, importNode, changeDetectionSpecifier), ''),
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
module.exports = noRedundantOnPushChangeDetection;
|
|
@@ -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;
|