@ethlete/eslint-plugin 0.1.0-next.1 → 0.1.0-next.2
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 +6 -0
- package/package.json +1 -1
- package/src/configs/recommended.js +20 -2
- package/src/index.js +10 -0
- package/src/rules/angular-decorator-property-order.js +3 -15
- package/src/rules/inject-member-accessibility.js +20 -2
- 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 +12 -1
- package/src/rules/no-standalone-flag.js +95 -0
- package/src/rules/require-on-push-change-detection.js +271 -0
- package/src/rules/require-view-encapsulation-none.js +215 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-next.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#2957](https://github.com/ethlete-io/ethdk/pull/2957) [`81173d4`](https://github.com/ethlete-io/ethdk/commit/81173d426d1a53ea2a7af0d16d6eb0fe4a16dbd9) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add missing auto fixers
|
|
8
|
+
|
|
3
9
|
## 0.1.0-next.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -71,10 +71,16 @@ const recommendedTs = {
|
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
selector: ['variable', 'parameter', 'property', 'parameterProperty', 'accessor'],
|
|
74
|
-
leadingUnderscore: '
|
|
74
|
+
leadingUnderscore: 'allow',
|
|
75
75
|
trailingUnderscore: 'forbid',
|
|
76
76
|
format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
selector: 'method',
|
|
80
|
+
leadingUnderscore: 'allow',
|
|
81
|
+
trailingUnderscore: 'forbid',
|
|
82
|
+
format: null,
|
|
83
|
+
},
|
|
78
84
|
{
|
|
79
85
|
selector: 'typeLike',
|
|
80
86
|
leadingUnderscore: 'forbid',
|
|
@@ -269,6 +275,9 @@ const recommendedTs = {
|
|
|
269
275
|
// No `import type { Foo }` or `import { type Foo }` — use regular value imports
|
|
270
276
|
'ethlete/no-type-only-import': 'error',
|
|
271
277
|
|
|
278
|
+
// No empty blank lines between import declarations
|
|
279
|
+
'ethlete/no-empty-newlines-between-imports': 'error',
|
|
280
|
+
|
|
272
281
|
// No legacy Angular decorators — use signal-based APIs (input, output, viewChild, etc.)
|
|
273
282
|
// and host: {} bindings (instead of @HostBinding / @HostListener)
|
|
274
283
|
'ethlete/no-legacy-angular-decorators': 'error',
|
|
@@ -304,6 +313,9 @@ const recommendedTs = {
|
|
|
304
313
|
// — widen the accessibility of the source member instead
|
|
305
314
|
'ethlete/no-member-alias': 'error',
|
|
306
315
|
|
|
316
|
+
// No leading underscores on class members; auto-fix private members when safe
|
|
317
|
+
'ethlete/no-leading-underscore-class-member': 'error',
|
|
318
|
+
|
|
307
319
|
// No declared-but-never-read private/protected class members
|
|
308
320
|
'ethlete/no-unused-class-member': 'error',
|
|
309
321
|
|
|
@@ -311,6 +323,12 @@ const recommendedTs = {
|
|
|
311
323
|
// — use viewChild() / viewChildren() or contentChild() / contentChildren() instead
|
|
312
324
|
'ethlete/no-dom-query': 'error',
|
|
313
325
|
|
|
326
|
+
// Remove empty imports: [] and hostDirectives: [] metadata noise
|
|
327
|
+
'ethlete/no-empty-angular-metadata-arrays': 'error',
|
|
328
|
+
|
|
329
|
+
// Remove standalone metadata — standalone is implicit and should not be declared
|
|
330
|
+
'ethlete/no-standalone-flag': 'error',
|
|
331
|
+
|
|
314
332
|
// ── Angular outputs ─────────────────────────────────────────────────────
|
|
315
333
|
|
|
316
334
|
// No on-prefixed outputs (onSelectDate → selectDate)
|
|
@@ -322,7 +340,7 @@ const recommendedTs = {
|
|
|
322
340
|
// ── Angular components ──────────────────────────────────────────────────
|
|
323
341
|
|
|
324
342
|
// Always use ChangeDetectionStrategy.OnPush
|
|
325
|
-
'
|
|
343
|
+
'ethlete/require-on-push-change-detection': 'error',
|
|
326
344
|
|
|
327
345
|
// Always use ViewEncapsulation.None (angular-eslint's use-component-view-encapsulation does the
|
|
328
346
|
// opposite — it disallows None, so we use our own custom rule instead)
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,7 @@ const noSubscribeWithBody = require('./rules/no-subscribe-with-body');
|
|
|
22
22
|
const requireDollarSuffix = require('./rules/require-dollar-suffix');
|
|
23
23
|
const noPipeLogic = require('./rules/no-pipe-logic');
|
|
24
24
|
const enforceRoutingViewNaming = require('./rules/enforce-routing-view-naming');
|
|
25
|
+
const requireOnPushChangeDetection = require('./rules/require-on-push-change-detection');
|
|
25
26
|
const requireViewEncapsulationNone = require('./rules/require-view-encapsulation-none');
|
|
26
27
|
const noLegacyAngularDecorators = require('./rules/no-legacy-angular-decorators');
|
|
27
28
|
const noAngularSeoServices = require('./rules/no-angular-seo-services');
|
|
@@ -35,6 +36,10 @@ const noLocaleId = require('./rules/no-locale-id');
|
|
|
35
36
|
const noMemberAlias = require('./rules/no-member-alias');
|
|
36
37
|
const noUnusedClassMember = require('./rules/no-unused-class-member');
|
|
37
38
|
const noDomQuery = require('./rules/no-dom-query');
|
|
39
|
+
const noLeadingUnderscoreClassMember = require('./rules/no-leading-underscore-class-member');
|
|
40
|
+
const noEmptyAngularMetadataArrays = require('./rules/no-empty-angular-metadata-arrays');
|
|
41
|
+
const noStandaloneFlag = require('./rules/no-standalone-flag');
|
|
42
|
+
const noEmptyNewlinesBetweenImports = require('./rules/no-empty-newlines-between-imports');
|
|
38
43
|
const angularDecoratorPropertyOrder = require('./rules/angular-decorator-property-order');
|
|
39
44
|
const preferConciseAngularHostDirectives = require('./rules/prefer-concise-angular-host-directives');
|
|
40
45
|
const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angular-style-metadata');
|
|
@@ -68,6 +73,7 @@ const plugin = {
|
|
|
68
73
|
'require-dollar-suffix': requireDollarSuffix,
|
|
69
74
|
'no-pipe-logic': noPipeLogic,
|
|
70
75
|
'enforce-routing-view-naming': enforceRoutingViewNaming,
|
|
76
|
+
'require-on-push-change-detection': requireOnPushChangeDetection,
|
|
71
77
|
'require-view-encapsulation-none': requireViewEncapsulationNone,
|
|
72
78
|
'no-legacy-angular-decorators': noLegacyAngularDecorators,
|
|
73
79
|
'no-angular-seo-services': noAngularSeoServices,
|
|
@@ -81,6 +87,10 @@ const plugin = {
|
|
|
81
87
|
'no-member-alias': noMemberAlias,
|
|
82
88
|
'no-unused-class-member': noUnusedClassMember,
|
|
83
89
|
'no-dom-query': noDomQuery,
|
|
90
|
+
'no-leading-underscore-class-member': noLeadingUnderscoreClassMember,
|
|
91
|
+
'no-empty-angular-metadata-arrays': noEmptyAngularMetadataArrays,
|
|
92
|
+
'no-standalone-flag': noStandaloneFlag,
|
|
93
|
+
'no-empty-newlines-between-imports': noEmptyNewlinesBetweenImports,
|
|
84
94
|
'angular-decorator-property-order': angularDecoratorPropertyOrder,
|
|
85
95
|
'prefer-concise-angular-host-directives': preferConciseAngularHostDirectives,
|
|
86
96
|
'prefer-concise-angular-style-metadata': preferConciseAngularStyleMetadata,
|
|
@@ -29,28 +29,16 @@ const DECORATOR_CONFIG = {
|
|
|
29
29
|
'outputs',
|
|
30
30
|
'queries',
|
|
31
31
|
'preserveWhitespaces',
|
|
32
|
-
'standalone',
|
|
33
32
|
'schemas',
|
|
34
33
|
'jit',
|
|
35
34
|
'exportAs',
|
|
36
35
|
],
|
|
37
36
|
orderText:
|
|
38
|
-
'selector, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces,
|
|
37
|
+
'selector, template/templateUrl, styleUrl/styleUrls, encapsulation, changeDetection, imports, providers, viewProviders, animations, hostDirectives, host, styles, inputs, outputs, queries, preserveWhitespaces, schemas, jit, exportAs',
|
|
39
38
|
},
|
|
40
39
|
Directive: {
|
|
41
|
-
order: [
|
|
42
|
-
|
|
43
|
-
'exportAs',
|
|
44
|
-
'providers',
|
|
45
|
-
'inputs',
|
|
46
|
-
'outputs',
|
|
47
|
-
'queries',
|
|
48
|
-
'hostDirectives',
|
|
49
|
-
'host',
|
|
50
|
-
'standalone',
|
|
51
|
-
'jit',
|
|
52
|
-
],
|
|
53
|
-
orderText: 'selector, exportAs, providers, inputs, outputs, queries, hostDirectives, host, standalone, jit',
|
|
40
|
+
order: ['selector', 'exportAs', 'providers', 'inputs', 'outputs', 'queries', 'hostDirectives', 'host', 'jit'],
|
|
41
|
+
orderText: 'selector, exportAs, providers, inputs, outputs, queries, hostDirectives, host, jit',
|
|
54
42
|
},
|
|
55
43
|
};
|
|
56
44
|
|
|
@@ -89,6 +89,19 @@ const readFileIfExists = (filePath) => {
|
|
|
89
89
|
return null;
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
94
|
+
*/
|
|
95
|
+
const getContextFilename = (context) => {
|
|
96
|
+
const filename = context.physicalFilename || context.filename;
|
|
97
|
+
|
|
98
|
+
if (!filename || filename === '<input>' || filename === '<text>') {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return filename;
|
|
103
|
+
};
|
|
104
|
+
|
|
92
105
|
/**
|
|
93
106
|
* @param {string} memberName
|
|
94
107
|
* @param {string | null} template
|
|
@@ -100,6 +113,9 @@ const templateReferencesMember = (memberName, template) => {
|
|
|
100
113
|
const patterns = [
|
|
101
114
|
new RegExp(`\\{\\{[\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\}\\}`, 'u'),
|
|
102
115
|
new RegExp(`@[a-zA-Z]+\\s*\\([\\s\\S]*?\\b${escapedName}\\b[\\s\\S]*?\\)`, 'u'),
|
|
116
|
+
new RegExp(`@let\\s+[a-zA-Z_$][\\w$]*\\s*=\\s*[\\s\\S]*?\\b${escapedName}\\b`, 'u'),
|
|
117
|
+
new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
118
|
+
new RegExp(`\\*[a-zA-Z0-9_\\-]+\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
|
|
103
119
|
new RegExp(`\\[[^\\]]+\\]\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
104
120
|
new RegExp(`\\[[^\\]]+\\]\\s*=\\s*'[^']*\\b${escapedName}\\b[^']*'`, 'u'),
|
|
105
121
|
new RegExp(`\\([^\\)]+\\)\\s*=\\s*"[^"]*\\b${escapedName}\\b[^"]*"`, 'u'),
|
|
@@ -160,8 +176,10 @@ const isReferencedFromTemplateOrHost = (memberName, metadata, context) => {
|
|
|
160
176
|
const templateUrlProperty = getMetadataProperty(metadata, 'templateUrl');
|
|
161
177
|
if (templateUrlProperty) {
|
|
162
178
|
const templateUrl = getStringValue(templateUrlProperty.value);
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
const filename = getContextFilename(context);
|
|
180
|
+
|
|
181
|
+
if (templateUrl && filename) {
|
|
182
|
+
const templatePath = path.resolve(path.dirname(filename), templateUrl);
|
|
165
183
|
const templateText = readFileIfExists(templatePath);
|
|
166
184
|
if (templateReferencesMember(memberName, templateText)) {
|
|
167
185
|
return true;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {'Component' | 'Directive'} 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
|
+
const MESSAGE_IDS = {
|
|
50
|
+
hostDirectives: 'noEmptyHostDirectives',
|
|
51
|
+
imports: 'noEmptyImports',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
55
|
+
const noEmptyAngularMetadataArrays = {
|
|
56
|
+
meta: {
|
|
57
|
+
type: 'suggestion',
|
|
58
|
+
docs: {
|
|
59
|
+
description: 'Disallow empty Angular metadata arrays such as imports: [] and hostDirectives: [].',
|
|
60
|
+
},
|
|
61
|
+
fixable: 'code',
|
|
62
|
+
schema: [],
|
|
63
|
+
messages: {
|
|
64
|
+
noEmptyImports: 'Remove empty imports: [] from Angular metadata.',
|
|
65
|
+
noEmptyHostDirectives: 'Remove empty hostDirectives: [] from Angular metadata.',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
const sourceCode = context.sourceCode;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
/** @param {import('eslint').Rule.Node} node */
|
|
73
|
+
Decorator(node) {
|
|
74
|
+
const decoratorName = getDecoratorName(node);
|
|
75
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
|
|
76
|
+
|
|
77
|
+
const decorator = /** @type {any} */ (node);
|
|
78
|
+
const expression = decorator.expression;
|
|
79
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
80
|
+
|
|
81
|
+
const metadata = expression.arguments[0];
|
|
82
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
83
|
+
|
|
84
|
+
for (const property of metadata.properties) {
|
|
85
|
+
if (property.type !== 'Property') continue;
|
|
86
|
+
|
|
87
|
+
const propertyName = getPropertyName(property.key);
|
|
88
|
+
if (propertyName !== 'imports' && propertyName !== 'hostDirectives') continue;
|
|
89
|
+
if (property.value.type !== 'ArrayExpression' || property.value.elements.length > 0) continue;
|
|
90
|
+
|
|
91
|
+
context.report({
|
|
92
|
+
node: property,
|
|
93
|
+
messageId: MESSAGE_IDS[propertyName],
|
|
94
|
+
fix(fixer) {
|
|
95
|
+
return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
module.exports = noEmptyAngularMetadataArrays;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
|
+
const noEmptyNewlinesBetweenImports = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'layout',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow empty blank lines between consecutive import declarations.',
|
|
10
|
+
},
|
|
11
|
+
fixable: 'code',
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
noEmptyLine: 'Remove empty blank lines between import declarations.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
create(context) {
|
|
18
|
+
const sourceCode = context.sourceCode;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
Program(node) {
|
|
22
|
+
const importDeclarations = node.body.filter((statement) => statement.type === 'ImportDeclaration');
|
|
23
|
+
|
|
24
|
+
for (let index = 1; index < importDeclarations.length; index += 1) {
|
|
25
|
+
const previousImport = importDeclarations[index - 1];
|
|
26
|
+
const currentImport = importDeclarations[index];
|
|
27
|
+
const textBetween = sourceCode.text.slice(previousImport.range[1], currentImport.range[0]);
|
|
28
|
+
|
|
29
|
+
if (/\S/.test(textBetween)) continue;
|
|
30
|
+
if (!/\n\s*\n/.test(textBetween)) continue;
|
|
31
|
+
|
|
32
|
+
context.report({
|
|
33
|
+
node: currentImport,
|
|
34
|
+
messageId: 'noEmptyLine',
|
|
35
|
+
fix(fixer) {
|
|
36
|
+
return fixer.replaceTextRange([previousImport.range[1], currentImport.range[0]], '\n');
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
module.exports = noEmptyNewlinesBetweenImports;
|
|
@@ -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':
|