@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-next.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`5d1d3ac`](https://github.com/ethlete-io/ethdk/commit/5d1d3accbd4a7657bc50cdc0653a7ca24fe761e2) Thanks [@TomTomB](https://github.com/TomTomB)! - Fix minor lint edge cases
|
|
8
|
+
|
|
9
|
+
## 0.1.0-next.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#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
|
|
14
|
+
|
|
3
15
|
## 0.1.0-next.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethlete/eslint-plugin",
|
|
3
|
-
"version": "0.1.0-next.
|
|
3
|
+
"version": "0.1.0-next.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"@angular-eslint/eslint-plugin-template": ">=21.0.0",
|
|
9
9
|
"@typescript-eslint/eslint-plugin": ">=8.0.0",
|
|
10
10
|
"eslint": ">=9.0.0",
|
|
11
|
-
"tslib": "2.8.1"
|
|
11
|
+
"tslib": "2.8.1",
|
|
12
|
+
"typescript": "5.9.3"
|
|
12
13
|
}
|
|
13
14
|
}
|
|
@@ -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',
|
|
@@ -133,11 +139,6 @@ const recommendedTs = {
|
|
|
133
139
|
selector: 'FunctionExpression[async=true]',
|
|
134
140
|
message: 'No async/await. Use RxJS for all async operations.',
|
|
135
141
|
},
|
|
136
|
-
// No public keyword on class members
|
|
137
|
-
{
|
|
138
|
-
selector: "MethodDefinition[accessibility='public']",
|
|
139
|
-
message: 'No public keyword on class members.',
|
|
140
|
-
},
|
|
141
142
|
// No static class members
|
|
142
143
|
{
|
|
143
144
|
selector: 'MethodDefinition[static=true]',
|
|
@@ -212,8 +213,18 @@ const recommendedTs = {
|
|
|
212
213
|
// No inject(X).member chaining — assign to a const first
|
|
213
214
|
'ethlete/no-inject-chain': 'error',
|
|
214
215
|
|
|
215
|
-
//
|
|
216
|
-
'ethlete/no-
|
|
216
|
+
// inject(ElementRef) must use inject<ElementRef<HTMLElement>>(ElementRef) — not inject(ElementRef) or inject(ElementRef<...>)
|
|
217
|
+
'ethlete/no-typed-injected-element-ref': 'error',
|
|
218
|
+
|
|
219
|
+
// No @internal on members already hidden by private/protected
|
|
220
|
+
'ethlete/no-redundant-internal': 'error',
|
|
221
|
+
|
|
222
|
+
// Non-injected Angular members should use explicit accessibility when they
|
|
223
|
+
// participate in the reachable surface. Template/host members must be
|
|
224
|
+
// explicit, other implicit-public members must be public. @internal does
|
|
225
|
+
// not replace explicit public; protected remains valid for template-only
|
|
226
|
+
// members.
|
|
227
|
+
'ethlete/template-member-accessibility': 'error',
|
|
217
228
|
|
|
218
229
|
// No explicit trivial return types TypeScript can infer
|
|
219
230
|
'ethlete/no-trivial-return-type': 'error',
|
|
@@ -269,6 +280,9 @@ const recommendedTs = {
|
|
|
269
280
|
// No `import type { Foo }` or `import { type Foo }` — use regular value imports
|
|
270
281
|
'ethlete/no-type-only-import': 'error',
|
|
271
282
|
|
|
283
|
+
// No empty blank lines between import declarations
|
|
284
|
+
'ethlete/no-empty-newlines-between-imports': 'error',
|
|
285
|
+
|
|
272
286
|
// No legacy Angular decorators — use signal-based APIs (input, output, viewChild, etc.)
|
|
273
287
|
// and host: {} bindings (instead of @HostBinding / @HostListener)
|
|
274
288
|
'ethlete/no-legacy-angular-decorators': 'error',
|
|
@@ -304,6 +318,9 @@ const recommendedTs = {
|
|
|
304
318
|
// — widen the accessibility of the source member instead
|
|
305
319
|
'ethlete/no-member-alias': 'error',
|
|
306
320
|
|
|
321
|
+
// No leading underscores on class members; auto-fix private members when safe
|
|
322
|
+
'ethlete/no-leading-underscore-class-member': 'error',
|
|
323
|
+
|
|
307
324
|
// No declared-but-never-read private/protected class members
|
|
308
325
|
'ethlete/no-unused-class-member': 'error',
|
|
309
326
|
|
|
@@ -311,6 +328,12 @@ const recommendedTs = {
|
|
|
311
328
|
// — use viewChild() / viewChildren() or contentChild() / contentChildren() instead
|
|
312
329
|
'ethlete/no-dom-query': 'error',
|
|
313
330
|
|
|
331
|
+
// Remove empty imports: [] and hostDirectives: [] metadata noise
|
|
332
|
+
'ethlete/no-empty-angular-metadata-arrays': 'error',
|
|
333
|
+
|
|
334
|
+
// Remove standalone metadata — standalone is implicit and should not be declared
|
|
335
|
+
'ethlete/no-standalone-flag': 'error',
|
|
336
|
+
|
|
314
337
|
// ── Angular outputs ─────────────────────────────────────────────────────
|
|
315
338
|
|
|
316
339
|
// No on-prefixed outputs (onSelectDate → selectDate)
|
|
@@ -322,7 +345,7 @@ const recommendedTs = {
|
|
|
322
345
|
// ── Angular components ──────────────────────────────────────────────────
|
|
323
346
|
|
|
324
347
|
// Always use ChangeDetectionStrategy.OnPush
|
|
325
|
-
'
|
|
348
|
+
'ethlete/require-on-push-change-detection': 'error',
|
|
326
349
|
|
|
327
350
|
// Always use ViewEncapsulation.None (angular-eslint's use-component-view-encapsulation does the
|
|
328
351
|
// opposite — it disallows None, so we use our own custom rule instead)
|
|
@@ -331,6 +354,12 @@ const recommendedTs = {
|
|
|
331
354
|
// Keep Angular @Component / @Directive metadata in a consistent order
|
|
332
355
|
'ethlete/angular-decorator-property-order': 'error',
|
|
333
356
|
|
|
357
|
+
// Keep class members in the documented styleguide order
|
|
358
|
+
'ethlete/class-member-order': 'error',
|
|
359
|
+
|
|
360
|
+
// True class constants should be readonly and use SCREAMING_CASE
|
|
361
|
+
'ethlete/class-constant-property': 'error',
|
|
362
|
+
|
|
334
363
|
// Prefer shorthand hostDirectives entries and keep extended host directive configs ordered
|
|
335
364
|
'ethlete/prefer-concise-angular-host-directives': 'error',
|
|
336
365
|
|
|
@@ -359,4 +388,16 @@ const recommendedTemplate = {
|
|
|
359
388
|
},
|
|
360
389
|
};
|
|
361
390
|
|
|
362
|
-
|
|
391
|
+
/**
|
|
392
|
+
* Relaxed rules for test/spec files.
|
|
393
|
+
* Non-null assertions are common and intentional in tests.
|
|
394
|
+
* @type {import('eslint').Linter.Config}
|
|
395
|
+
*/
|
|
396
|
+
const recommendedSpec = {
|
|
397
|
+
files: ['**/*.spec.ts'],
|
|
398
|
+
rules: {
|
|
399
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
module.exports = { recommendedTs, recommendedTemplate, recommendedSpec };
|
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');
|
|
@@ -29,16 +30,25 @@ const preferCloneEqual = require('./rules/prefer-clone-equal');
|
|
|
29
30
|
const noDocumentCookie = require('./rules/no-document-cookie');
|
|
30
31
|
const injectMemberAccessibility = require('./rules/inject-member-accessibility');
|
|
31
32
|
const noPublicProperty = require('./rules/no-public-property');
|
|
33
|
+
const noRedundantInternal = require('./rules/no-redundant-internal');
|
|
34
|
+
const templateMemberAccessibility = require('./rules/template-member-accessibility');
|
|
32
35
|
const noAngularRouterApi = require('./rules/no-angular-router-api');
|
|
33
36
|
const noWindowLocation = require('./rules/no-window-location');
|
|
34
37
|
const noLocaleId = require('./rules/no-locale-id');
|
|
35
38
|
const noMemberAlias = require('./rules/no-member-alias');
|
|
36
39
|
const noUnusedClassMember = require('./rules/no-unused-class-member');
|
|
37
40
|
const noDomQuery = require('./rules/no-dom-query');
|
|
41
|
+
const noLeadingUnderscoreClassMember = require('./rules/no-leading-underscore-class-member');
|
|
42
|
+
const noEmptyAngularMetadataArrays = require('./rules/no-empty-angular-metadata-arrays');
|
|
43
|
+
const noStandaloneFlag = require('./rules/no-standalone-flag');
|
|
44
|
+
const noEmptyNewlinesBetweenImports = require('./rules/no-empty-newlines-between-imports');
|
|
38
45
|
const angularDecoratorPropertyOrder = require('./rules/angular-decorator-property-order');
|
|
46
|
+
const classMemberOrder = require('./rules/class-member-order');
|
|
47
|
+
const classConstantProperty = require('./rules/class-constant-property');
|
|
39
48
|
const preferConciseAngularHostDirectives = require('./rules/prefer-concise-angular-host-directives');
|
|
40
49
|
const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angular-style-metadata');
|
|
41
|
-
const
|
|
50
|
+
const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref');
|
|
51
|
+
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
42
52
|
|
|
43
53
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
44
54
|
const plugin = {
|
|
@@ -68,6 +78,7 @@ const plugin = {
|
|
|
68
78
|
'require-dollar-suffix': requireDollarSuffix,
|
|
69
79
|
'no-pipe-logic': noPipeLogic,
|
|
70
80
|
'enforce-routing-view-naming': enforceRoutingViewNaming,
|
|
81
|
+
'require-on-push-change-detection': requireOnPushChangeDetection,
|
|
71
82
|
'require-view-encapsulation-none': requireViewEncapsulationNone,
|
|
72
83
|
'no-legacy-angular-decorators': noLegacyAngularDecorators,
|
|
73
84
|
'no-angular-seo-services': noAngularSeoServices,
|
|
@@ -75,15 +86,24 @@ const plugin = {
|
|
|
75
86
|
'no-document-cookie': noDocumentCookie,
|
|
76
87
|
'inject-member-accessibility': injectMemberAccessibility,
|
|
77
88
|
'no-public-property': noPublicProperty,
|
|
89
|
+
'no-redundant-internal': noRedundantInternal,
|
|
90
|
+
'template-member-accessibility': templateMemberAccessibility,
|
|
78
91
|
'no-angular-router-api': noAngularRouterApi,
|
|
79
92
|
'no-window-location': noWindowLocation,
|
|
80
93
|
'no-locale-id': noLocaleId,
|
|
81
94
|
'no-member-alias': noMemberAlias,
|
|
82
95
|
'no-unused-class-member': noUnusedClassMember,
|
|
83
96
|
'no-dom-query': noDomQuery,
|
|
97
|
+
'no-leading-underscore-class-member': noLeadingUnderscoreClassMember,
|
|
98
|
+
'no-empty-angular-metadata-arrays': noEmptyAngularMetadataArrays,
|
|
99
|
+
'no-standalone-flag': noStandaloneFlag,
|
|
100
|
+
'no-empty-newlines-between-imports': noEmptyNewlinesBetweenImports,
|
|
84
101
|
'angular-decorator-property-order': angularDecoratorPropertyOrder,
|
|
102
|
+
'class-member-order': classMemberOrder,
|
|
103
|
+
'class-constant-property': classConstantProperty,
|
|
85
104
|
'prefer-concise-angular-host-directives': preferConciseAngularHostDirectives,
|
|
86
105
|
'prefer-concise-angular-style-metadata': preferConciseAngularStyleMetadata,
|
|
106
|
+
'no-typed-injected-element-ref': noTypedInjectedElementRef,
|
|
87
107
|
},
|
|
88
108
|
};
|
|
89
109
|
|
|
@@ -108,7 +128,7 @@ const configs = {
|
|
|
108
128
|
* Both together as a flat array — the most common usage:
|
|
109
129
|
* export default [...baseConfig, ...ethlete.configs.recommended]
|
|
110
130
|
*/
|
|
111
|
-
recommended: [recommendedTsWithPlugin, recommendedTemplateWithPlugin],
|
|
131
|
+
recommended: [recommendedTsWithPlugin, recommendedTemplateWithPlugin, recommendedSpec],
|
|
112
132
|
};
|
|
113
133
|
|
|
114
134
|
const ethletePlugin = { ...plugin, configs };
|
|
@@ -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
|
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const SCREAMING_CASE_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
5
|
+
|
|
6
|
+
const REACTIVE_APIS = new Set([
|
|
7
|
+
'signal',
|
|
8
|
+
'computed',
|
|
9
|
+
'linkedSignal',
|
|
10
|
+
'input',
|
|
11
|
+
'model',
|
|
12
|
+
'output',
|
|
13
|
+
'outputFromObservable',
|
|
14
|
+
'viewChild',
|
|
15
|
+
'viewChildren',
|
|
16
|
+
'contentChild',
|
|
17
|
+
'contentChildren',
|
|
18
|
+
'inject',
|
|
19
|
+
'effect',
|
|
20
|
+
'afterNextRender',
|
|
21
|
+
'afterRender',
|
|
22
|
+
'toSignal',
|
|
23
|
+
'toObservable',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {any} node
|
|
28
|
+
*/
|
|
29
|
+
const getMemberName = (node) => {
|
|
30
|
+
if (!node || !node.key) return null;
|
|
31
|
+
|
|
32
|
+
if (node.key.type === 'Identifier' || node.key.type === 'PrivateIdentifier') {
|
|
33
|
+
return node.key.name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
|
|
37
|
+
return node.key.value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {any} node
|
|
45
|
+
*/
|
|
46
|
+
const getCallRootName = (node) => {
|
|
47
|
+
if (!node || node.type !== 'CallExpression') return null;
|
|
48
|
+
|
|
49
|
+
const { callee } = node;
|
|
50
|
+
|
|
51
|
+
if (callee.type === 'Identifier') {
|
|
52
|
+
return callee.name;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') {
|
|
56
|
+
return callee.object.name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {any} node
|
|
64
|
+
*/
|
|
65
|
+
const isReactiveInitializer = (node) => {
|
|
66
|
+
const apiName = getCallRootName(node);
|
|
67
|
+
return apiName !== null && REACTIVE_APIS.has(apiName);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {any} node
|
|
72
|
+
*/
|
|
73
|
+
const isThisReference = (node) => node && (node.type === 'ThisExpression' || node.type === 'Super');
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {any} node
|
|
77
|
+
*/
|
|
78
|
+
const isConstantExpression = (node) => {
|
|
79
|
+
if (!node) return false;
|
|
80
|
+
|
|
81
|
+
switch (node.type) {
|
|
82
|
+
case 'Literal':
|
|
83
|
+
case 'Identifier':
|
|
84
|
+
return true;
|
|
85
|
+
case 'TemplateLiteral':
|
|
86
|
+
return node.expressions.every((expression) => isConstantExpression(expression));
|
|
87
|
+
case 'UnaryExpression':
|
|
88
|
+
case 'UpdateExpression':
|
|
89
|
+
return isConstantExpression(node.argument);
|
|
90
|
+
case 'BinaryExpression':
|
|
91
|
+
case 'LogicalExpression':
|
|
92
|
+
return isConstantExpression(node.left) && isConstantExpression(node.right);
|
|
93
|
+
case 'ConditionalExpression':
|
|
94
|
+
return (
|
|
95
|
+
isConstantExpression(node.test) && isConstantExpression(node.consequent) && isConstantExpression(node.alternate)
|
|
96
|
+
);
|
|
97
|
+
case 'ArrayExpression':
|
|
98
|
+
return node.elements.every((element) => {
|
|
99
|
+
if (element === null) return true;
|
|
100
|
+
|
|
101
|
+
if (element.type === 'SpreadElement') {
|
|
102
|
+
return isConstantExpression(element.argument);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return isConstantExpression(element);
|
|
106
|
+
});
|
|
107
|
+
case 'ObjectExpression':
|
|
108
|
+
return node.properties.every((property) => {
|
|
109
|
+
if (property.type === 'SpreadElement') {
|
|
110
|
+
return isConstantExpression(property.argument);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (property.type !== 'Property' || property.kind !== 'init') {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (property.computed && !isConstantExpression(property.key)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return isConstantExpression(property.value);
|
|
122
|
+
});
|
|
123
|
+
case 'MemberExpression':
|
|
124
|
+
if (isThisReference(node.object)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isConstantExpression(node.object)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return node.computed ? isConstantExpression(node.property) : true;
|
|
133
|
+
case 'TSAsExpression':
|
|
134
|
+
case 'TSTypeAssertion':
|
|
135
|
+
case 'TSNonNullExpression':
|
|
136
|
+
case 'ChainExpression':
|
|
137
|
+
return isConstantExpression(node.expression);
|
|
138
|
+
case 'ParenthesizedExpression':
|
|
139
|
+
return isConstantExpression(node.expression);
|
|
140
|
+
case 'SequenceExpression':
|
|
141
|
+
return node.expressions.every((expression) => isConstantExpression(expression));
|
|
142
|
+
default:
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {any} node
|
|
149
|
+
*/
|
|
150
|
+
const getWrittenMemberName = (node) => {
|
|
151
|
+
if (!node || node.type !== 'MemberExpression' || !isThisReference(node.object)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!node.computed && (node.property.type === 'Identifier' || node.property.type === 'PrivateIdentifier')) {
|
|
156
|
+
return node.property.name;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') {
|
|
160
|
+
return node.property.value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {any} target
|
|
168
|
+
* @param {Set<string>} writtenMembers
|
|
169
|
+
*/
|
|
170
|
+
const collectAssignedMemberNames = (target, writtenMembers) => {
|
|
171
|
+
if (!target) return;
|
|
172
|
+
|
|
173
|
+
if (target.type === 'MemberExpression') {
|
|
174
|
+
const memberName = getWrittenMemberName(target);
|
|
175
|
+
|
|
176
|
+
if (memberName !== null) {
|
|
177
|
+
writtenMembers.add(memberName);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (target.type === 'ArrayPattern') {
|
|
184
|
+
for (const element of target.elements) {
|
|
185
|
+
collectAssignedMemberNames(element, writtenMembers);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (target.type === 'ObjectPattern') {
|
|
192
|
+
for (const property of target.properties) {
|
|
193
|
+
if (property.type === 'Property') {
|
|
194
|
+
collectAssignedMemberNames(property.value, writtenMembers);
|
|
195
|
+
} else if (property.type === 'RestElement') {
|
|
196
|
+
collectAssignedMemberNames(property.argument, writtenMembers);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (target.type === 'AssignmentPattern') {
|
|
204
|
+
collectAssignedMemberNames(target.left, writtenMembers);
|
|
205
|
+
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (target.type === 'RestElement') {
|
|
210
|
+
collectAssignedMemberNames(target.argument, writtenMembers);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {any} node
|
|
216
|
+
* @param {Set<string>} writtenMembers
|
|
217
|
+
*/
|
|
218
|
+
const collectWrittenMembers = (node, writtenMembers) => {
|
|
219
|
+
if (!node || typeof node !== 'object') return;
|
|
220
|
+
|
|
221
|
+
if (Array.isArray(node)) {
|
|
222
|
+
for (const entry of node) {
|
|
223
|
+
collectWrittenMembers(entry, writtenMembers);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (node.type === 'AssignmentExpression') {
|
|
234
|
+
collectAssignedMemberNames(node.left, writtenMembers);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (node.type === 'UpdateExpression') {
|
|
238
|
+
const memberName = getWrittenMemberName(node.argument);
|
|
239
|
+
|
|
240
|
+
if (memberName !== null) {
|
|
241
|
+
writtenMembers.add(memberName);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const [key, value] of Object.entries(node)) {
|
|
246
|
+
if (key === 'parent' || key === 'range' || key === 'loc') continue;
|
|
247
|
+
if (!value || typeof value !== 'object') continue;
|
|
248
|
+
|
|
249
|
+
collectWrittenMembers(value, writtenMembers);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {any} classBodyNode
|
|
255
|
+
*/
|
|
256
|
+
const getWrittenMembers = (classBodyNode) => {
|
|
257
|
+
const writtenMembers = new Set();
|
|
258
|
+
|
|
259
|
+
for (const member of classBodyNode.body) {
|
|
260
|
+
collectWrittenMembers(member, writtenMembers);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return writtenMembers;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {any} node
|
|
268
|
+
*/
|
|
269
|
+
const isConstantProperty = (node) => {
|
|
270
|
+
if (node.type !== 'PropertyDefinition' || node.static || node.computed || !node.value) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isReactiveInitializer(node.value)) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return isConstantExpression(node.value);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
282
|
+
const classConstantProperty = {
|
|
283
|
+
meta: {
|
|
284
|
+
type: 'suggestion',
|
|
285
|
+
fixable: 'code',
|
|
286
|
+
docs: {
|
|
287
|
+
description: 'Require true class constants to use readonly and SCREAMING_CASE.',
|
|
288
|
+
recommended: true,
|
|
289
|
+
},
|
|
290
|
+
schema: [],
|
|
291
|
+
messages: {
|
|
292
|
+
shouldBeReadonly: "'{{name}}' is a class constant and should be declared readonly.",
|
|
293
|
+
shouldUseScreamingCase: "Readonly class constant '{{name}}' should use SCREAMING_CASE.",
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
create(context) {
|
|
297
|
+
return {
|
|
298
|
+
ClassBody(node) {
|
|
299
|
+
const writtenMembers = getWrittenMembers(node);
|
|
300
|
+
|
|
301
|
+
for (const member of node.body) {
|
|
302
|
+
if (!isConstantProperty(member)) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const memberName = getMemberName(member);
|
|
307
|
+
|
|
308
|
+
if (memberName === null || writtenMembers.has(memberName)) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!member.readonly) {
|
|
313
|
+
context.report({
|
|
314
|
+
node: member,
|
|
315
|
+
messageId: 'shouldBeReadonly',
|
|
316
|
+
data: { name: memberName },
|
|
317
|
+
fix(fixer) {
|
|
318
|
+
const keyToken = context.sourceCode.getFirstToken(member.key);
|
|
319
|
+
|
|
320
|
+
if (!keyToken) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return fixer.insertTextBefore(keyToken, 'readonly ');
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!SCREAMING_CASE_RE.test(memberName)) {
|
|
332
|
+
context.report({
|
|
333
|
+
node: member.key,
|
|
334
|
+
messageId: 'shouldUseScreamingCase',
|
|
335
|
+
data: { name: memberName },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
module.exports = classConstantProperty;
|