@ethlete/eslint-plugin 1.0.0-next.25 → 1.0.0-next.27
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 +35 -0
- package/README.md +4 -0
- package/migrations.json +1 -0
- package/package.json +2 -1
- package/src/configs/recommended.js +16 -18
- package/src/rules/angular-decorator-property-order.js +24 -7
- package/src/rules/class-constant-property.js +1 -1
- package/src/rules/class-member-order.js +66 -8
- package/src/rules/enforce-routing-view-naming.js +47 -24
- package/src/rules/guard-return-newline.js +4 -7
- package/src/rules/internals/angular-member-visibility.js +24 -2
- package/src/rules/internals/angular-metadata-fix.js +231 -1
- package/src/rules/internals/angular-version.js +50 -0
- package/src/rules/internals/implemented-contract-members.js +26 -11
- package/src/rules/no-angular-router-api.js +86 -33
- package/src/rules/no-angular-seo-services.js +1 -0
- package/src/rules/no-async-await.js +5 -1
- package/src/rules/no-cdk-import.js +32 -7
- package/src/rules/no-direct-dom-manipulation.js +50 -28
- package/src/rules/no-document-cookie.js +51 -8
- package/src/rules/no-dom-query.js +5 -2
- package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
- package/src/rules/no-empty-newlines-between-imports.js +4 -1
- package/src/rules/no-impure-top-level-provider.js +42 -7
- package/src/rules/no-inject-chain.js +5 -1
- package/src/rules/no-leading-underscore-class-member.js +27 -2
- package/src/rules/no-legacy-angular-decorators.js +19 -16
- package/src/rules/no-legacy-prepare-without-injector.js +57 -12
- package/src/rules/no-legacy-query-import.js +61 -16
- package/src/rules/no-member-alias.js +0 -5
- package/src/rules/no-native-observers.js +89 -24
- package/src/rules/no-pipe-logic.js +1 -2
- package/src/rules/no-readonly-signal.js +1 -5
- package/src/rules/no-redundant-internal.js +5 -2
- package/src/rules/no-redundant-on-push-change-detection.js +3 -48
- package/src/rules/no-rxjs-in-effect.js +7 -5
- package/src/rules/no-screaming-case-local.js +3 -5
- package/src/rules/no-standalone-flag.js +1 -0
- package/src/rules/no-subscribe-in-pipe.js +5 -1
- package/src/rules/no-subscribe-with-body.js +30 -1
- package/src/rules/no-template-literal-before-inline-template.js +6 -0
- package/src/rules/no-trivial-return-type.js +18 -1
- package/src/rules/no-trivial-wrapper-method.js +39 -1
- package/src/rules/no-type-only-import.js +23 -19
- package/src/rules/no-unused-class-member.js +14 -6
- package/src/rules/no-window-location.js +60 -22
- package/src/rules/prefer-clone-equal.js +61 -8
- package/src/rules/prefer-concise-angular-host-directives.js +39 -11
- package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
- package/src/rules/prefer-element-dimensions.js +264 -11
- package/src/rules/prefer-match-media.js +17 -3
- package/src/rules/prefer-rxjs-timer.js +26 -4
- package/src/rules/prefer-scroll-state.js +0 -4
- package/src/rules/prefer-static-boolean-properties.js +1 -0
- package/src/rules/prefer-viewport-size.js +153 -16
- package/src/rules/require-dollar-suffix.js +11 -3
- package/src/rules/require-form-submit.js +4 -1
- package/src/rules/require-on-push-change-detection.js +7 -259
- package/src/rules/require-view-encapsulation-none.js +6 -214
- package/src/rules/template-member-accessibility.js +13 -20
|
@@ -17,6 +17,8 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
|
17
17
|
*
|
|
18
18
|
* GOOD:
|
|
19
19
|
* // Call this.value.set(val) directly at the call sites.
|
|
20
|
+
* // A public method over a private/protected member is the encapsulation, not a wrapper:
|
|
21
|
+
* // private items = signal([]); setItems(items) { this.items.set(items); }
|
|
20
22
|
* // If the method wraps something non-trivially (transforms args, adds logic), it is fine.
|
|
21
23
|
* // `focus`/`blur`/`reset` on a @Component/@Directive are exempt - see CONTRACT_METHOD_NAMES.
|
|
22
24
|
*/
|
|
@@ -92,6 +94,39 @@ const getTrivialCallExpr = (methodNode) => {
|
|
|
92
94
|
return allMatch ? callExpr : null;
|
|
93
95
|
};
|
|
94
96
|
|
|
97
|
+
/** @param {any} member */
|
|
98
|
+
const visibilityOf = (member) => {
|
|
99
|
+
if (member.key?.type === 'PrivateIdentifier' || member.accessibility === 'private') return 0;
|
|
100
|
+
if (member.accessibility === 'protected') return 1;
|
|
101
|
+
return 2;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {any} classBody
|
|
106
|
+
* @param {any} callee
|
|
107
|
+
*/
|
|
108
|
+
const receiverVisibility = (classBody, callee) => {
|
|
109
|
+
let object = callee;
|
|
110
|
+
while (object.type === 'MemberExpression' && object.object.type !== 'ThisExpression') object = object.object;
|
|
111
|
+
if (object.type !== 'MemberExpression' || object.computed) return undefined;
|
|
112
|
+
|
|
113
|
+
const name = object.property.type === 'PrivateIdentifier' ? `#${object.property.name}` : object.property.name;
|
|
114
|
+
/** @param {any} key */
|
|
115
|
+
const keyName = (key) => (key?.type === 'PrivateIdentifier' ? `#${key.name}` : key?.name);
|
|
116
|
+
|
|
117
|
+
for (const member of classBody.body) {
|
|
118
|
+
if (member.type === 'MethodDefinition' && member.kind === 'constructor') {
|
|
119
|
+
const property = member.value.params.find(
|
|
120
|
+
(/** @type {any} */ param) => param.type === 'TSParameterProperty' && param.parameter.name === name,
|
|
121
|
+
);
|
|
122
|
+
if (property) return visibilityOf(property);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (keyName(member.key) === name) return visibilityOf(member);
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
95
130
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
96
131
|
const noTrivialWrapperMethod = {
|
|
97
132
|
meta: {
|
|
@@ -102,7 +137,7 @@ const noTrivialWrapperMethod = {
|
|
|
102
137
|
},
|
|
103
138
|
messages: {
|
|
104
139
|
noTrivialWrapperMethod:
|
|
105
|
-
"'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}'. Remove it and call '{{target}}' directly at each call site.",
|
|
140
|
+
"'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}', which callers can already reach. Remove it and call '{{target}}' directly at each call site.",
|
|
106
141
|
},
|
|
107
142
|
schema: [],
|
|
108
143
|
},
|
|
@@ -133,6 +168,9 @@ const noTrivialWrapperMethod = {
|
|
|
133
168
|
|
|
134
169
|
if (!targetName) return;
|
|
135
170
|
|
|
171
|
+
const wrapped = receiverVisibility(node.parent, callee);
|
|
172
|
+
if (wrapped !== undefined && visibilityOf(node) > wrapped) return;
|
|
173
|
+
|
|
136
174
|
context.report({
|
|
137
175
|
node,
|
|
138
176
|
messageId: 'noTrivialWrapperMethod',
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
/** @param {any} node */
|
|
5
|
+
const renderValueImport = (node) => {
|
|
6
|
+
const clauses = [];
|
|
7
|
+
const named = [];
|
|
8
|
+
|
|
9
|
+
for (const s of node.specifiers) {
|
|
10
|
+
if (s.type === 'ImportDefaultSpecifier') clauses.push(s.local.name);
|
|
11
|
+
else if (s.type === 'ImportNamespaceSpecifier') clauses.push(`* as ${s.local.name}`);
|
|
12
|
+
else {
|
|
13
|
+
const imported = s.imported.type === 'Identifier' ? s.imported.name : `'${s.imported.value}'`;
|
|
14
|
+
named.push(s.local.name === imported ? imported : `${imported} as ${s.local.name}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (named.length) clauses.push(`{ ${named.join(', ')} }`);
|
|
19
|
+
|
|
20
|
+
return `import ${clauses.join(', ')} from '${node.source.value}';`;
|
|
21
|
+
};
|
|
22
|
+
|
|
4
23
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
24
|
const noTypeOnlyImport = {
|
|
6
25
|
meta: {
|
|
@@ -11,10 +30,9 @@ const noTypeOnlyImport = {
|
|
|
11
30
|
},
|
|
12
31
|
fixable: 'code',
|
|
13
32
|
messages: {
|
|
14
|
-
noTypeImportDeclaration:
|
|
15
|
-
"Do not use `import type`. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
|
|
33
|
+
noTypeImportDeclaration: 'Do not use `import type`. Use a regular import instead: `{{replacement}}`.',
|
|
16
34
|
noInlineTypeSpecifier:
|
|
17
|
-
|
|
35
|
+
'Do not use inline `type` on import specifiers. Use a regular import instead: `{{replacement}}`.',
|
|
18
36
|
},
|
|
19
37
|
schema: [],
|
|
20
38
|
},
|
|
@@ -23,13 +41,10 @@ const noTypeOnlyImport = {
|
|
|
23
41
|
ImportDeclaration(node) {
|
|
24
42
|
const decl = /** @type {any} */ (node);
|
|
25
43
|
if (decl.importKind === 'type') {
|
|
26
|
-
const source = /** @type {string} */ (node.source.value);
|
|
27
|
-
const specifiers = node.specifiers.map((s) => s.local.name).join(', ');
|
|
28
|
-
|
|
29
44
|
context.report({
|
|
30
45
|
node,
|
|
31
46
|
messageId: 'noTypeImportDeclaration',
|
|
32
|
-
data: {
|
|
47
|
+
data: { replacement: renderValueImport(node) },
|
|
33
48
|
fix(fixer) {
|
|
34
49
|
const sourceCode = context.sourceCode;
|
|
35
50
|
const importToken = sourceCode.getFirstToken(node);
|
|
@@ -47,21 +62,10 @@ const noTypeOnlyImport = {
|
|
|
47
62
|
for (const specifier of node.specifiers) {
|
|
48
63
|
const spec = /** @type {any} */ (specifier);
|
|
49
64
|
if (specifier.type === 'ImportSpecifier' && spec.importKind === 'type') {
|
|
50
|
-
const source = /** @type {string} */ (node.source.value);
|
|
51
|
-
const allSpecifiers = node.specifiers
|
|
52
|
-
.map((s) => {
|
|
53
|
-
if (s.type === 'ImportSpecifier') {
|
|
54
|
-
const imported = s.imported.type === 'Identifier' ? s.imported.name : s.imported.value;
|
|
55
|
-
return s.local.name === imported ? imported : `${imported} as ${s.local.name}`;
|
|
56
|
-
}
|
|
57
|
-
return s.local.name;
|
|
58
|
-
})
|
|
59
|
-
.join(', ');
|
|
60
|
-
|
|
61
65
|
context.report({
|
|
62
66
|
node: specifier,
|
|
63
67
|
messageId: 'noInlineTypeSpecifier',
|
|
64
|
-
data: {
|
|
68
|
+
data: { replacement: renderValueImport(node) },
|
|
65
69
|
fix(fixer) {
|
|
66
70
|
const sourceCode = context.sourceCode;
|
|
67
71
|
const tokenBefore = sourceCode.getTokenBefore(specifier.imported ?? specifier.local);
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Disallows class members that are declared but never
|
|
7
|
+
* Disallows class members that are declared but never referenced through `this` within the class body.
|
|
8
8
|
*
|
|
9
9
|
* Only checks members that CANNOT be accessed from outside the class at compile
|
|
10
10
|
* time — so false positives (e.g. template usage) are avoided:
|
|
11
11
|
*
|
|
12
12
|
* - `private` members — always checked. Private members are only accessible
|
|
13
|
-
* within the class body, so if they are never
|
|
13
|
+
* within the class body, so if they are never referenced there they are dead code.
|
|
14
14
|
*
|
|
15
15
|
* - `protected` members — checked only when the class has an Angular decorator
|
|
16
16
|
* other than `@Component` (i.e. `@Directive`, `@Pipe`, `@Injectable`).
|
|
@@ -106,13 +106,17 @@ const noUnusedClassMember = {
|
|
|
106
106
|
meta: {
|
|
107
107
|
type: 'suggestion',
|
|
108
108
|
docs: {
|
|
109
|
-
description:
|
|
109
|
+
description:
|
|
110
|
+
'Disallow class members that are declared but never referenced through `this` within the class body.',
|
|
110
111
|
recommended: true,
|
|
111
112
|
},
|
|
112
113
|
messages: {
|
|
113
114
|
noUnused:
|
|
114
|
-
"'{{name}}' is declared but
|
|
115
|
+
"'{{name}}' is declared but never referenced through `this` within this class. " +
|
|
115
116
|
'Remove it, or widen its accessibility if it is intentionally used externally.',
|
|
117
|
+
noUnusedProtected:
|
|
118
|
+
"'{{name}}' is protected but never referenced through `this` within this class. " +
|
|
119
|
+
'If a subclass reads it, mark this class `abstract`; otherwise remove it.',
|
|
116
120
|
},
|
|
117
121
|
schema: [],
|
|
118
122
|
},
|
|
@@ -139,7 +143,6 @@ const noUnusedClassMember = {
|
|
|
139
143
|
const registerMember = (node) => {
|
|
140
144
|
const frame = classStack[classStack.length - 1];
|
|
141
145
|
if (!frame) return;
|
|
142
|
-
// Only register members that belong directly to this class body
|
|
143
146
|
if (node.parent !== frame.classBody) return;
|
|
144
147
|
|
|
145
148
|
if (node.static) return;
|
|
@@ -182,13 +185,18 @@ const noUnusedClassMember = {
|
|
|
182
185
|
if (!frame) return;
|
|
183
186
|
for (const [name, memberNode] of frame.candidates) {
|
|
184
187
|
if (!frame.usedNames.has(name)) {
|
|
185
|
-
context.report({
|
|
188
|
+
context.report({
|
|
189
|
+
node: memberNode,
|
|
190
|
+
messageId: memberNode.accessibility === 'protected' ? 'noUnusedProtected' : 'noUnused',
|
|
191
|
+
data: { name },
|
|
192
|
+
});
|
|
186
193
|
}
|
|
187
194
|
}
|
|
188
195
|
},
|
|
189
196
|
|
|
190
197
|
PropertyDefinition: registerMember,
|
|
191
198
|
MethodDefinition: registerMember,
|
|
199
|
+
AccessorProperty: registerMember,
|
|
192
200
|
|
|
193
201
|
MemberExpression(node) {
|
|
194
202
|
if (node.object?.type !== 'ThisExpression') return;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { isGlobalReference } = require('./internals/import-resolution');
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Disallows reading URL state from window.location properties.
|
|
6
8
|
*
|
|
@@ -52,29 +54,55 @@ const noWindowLocation = {
|
|
|
52
54
|
schema: [],
|
|
53
55
|
},
|
|
54
56
|
create(context) {
|
|
57
|
+
const isGlobalObject = (node) =>
|
|
58
|
+
node.type === 'Identifier' &&
|
|
59
|
+
(node.name === 'window' || node.name === 'globalThis') &&
|
|
60
|
+
isGlobalReference(context.sourceCode, node);
|
|
61
|
+
|
|
62
|
+
const findVariable = (identifier) => {
|
|
63
|
+
for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
64
|
+
const variable = scope.set.get(identifier.name);
|
|
65
|
+
if (variable) return variable;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isLocation = (node) => {
|
|
72
|
+
if (node.type === 'Identifier') {
|
|
73
|
+
if (node.name === 'location' && isGlobalReference(context.sourceCode, node)) return true;
|
|
74
|
+
|
|
75
|
+
const definitions = findVariable(node)?.defs ?? [];
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
definitions.length === 1 &&
|
|
79
|
+
definitions[0].type === 'Variable' &&
|
|
80
|
+
definitions[0].node.id.type === 'Identifier' &&
|
|
81
|
+
definitions[0].parent.kind === 'const' &&
|
|
82
|
+
!!definitions[0].node.init &&
|
|
83
|
+
isLocation(definitions[0].node.init)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
node.type === 'MemberExpression' &&
|
|
89
|
+
!node.computed &&
|
|
90
|
+
isGlobalObject(node.object) &&
|
|
91
|
+
node.property.type === 'Identifier' &&
|
|
92
|
+
node.property.name === 'location'
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
55
96
|
return {
|
|
56
|
-
// ── window.location.{prop} reads ────────────────────────────────────────
|
|
57
97
|
MemberExpression(node) {
|
|
58
|
-
if (node.property.type !== 'Identifier') return;
|
|
98
|
+
if (node.property.type !== 'Identifier' || node.computed) return;
|
|
59
99
|
const prop = node.property.name;
|
|
60
100
|
|
|
61
101
|
if (!LOCATION_STATE_PROPS.has(prop)) return;
|
|
102
|
+
if (!isLocation(node.object)) return;
|
|
62
103
|
|
|
63
|
-
// Must be window.location.{prop} — check the object is window.location
|
|
64
|
-
const parent = node.object;
|
|
65
|
-
if (
|
|
66
|
-
parent.type !== 'MemberExpression' ||
|
|
67
|
-
parent.object.type !== 'Identifier' ||
|
|
68
|
-
parent.object.name !== 'window' ||
|
|
69
|
-
parent.property.type !== 'Identifier' ||
|
|
70
|
-
parent.property.name !== 'location'
|
|
71
|
-
) {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Skip assignments TO window.location.href (navigation redirect)
|
|
76
104
|
if (prop === 'href' && node.parent.type === 'AssignmentExpression' && node.parent.left === node) {
|
|
77
|
-
return;
|
|
105
|
+
if (node.parent.operator === '=') return;
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
context.report({
|
|
@@ -84,18 +112,28 @@ const noWindowLocation = {
|
|
|
84
112
|
});
|
|
85
113
|
},
|
|
86
114
|
|
|
87
|
-
|
|
115
|
+
VariableDeclarator(node) {
|
|
116
|
+
if (node.id.type !== 'ObjectPattern' || !node.init || !isLocation(node.init)) return;
|
|
117
|
+
|
|
118
|
+
for (const property of node.id.properties) {
|
|
119
|
+
if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier') continue;
|
|
120
|
+
if (!LOCATION_STATE_PROPS.has(property.key.name)) continue;
|
|
121
|
+
|
|
122
|
+
context.report({
|
|
123
|
+
node: property,
|
|
124
|
+
messageId: 'noWindowLocation',
|
|
125
|
+
data: { prop: property.key.name, replacement: LOCATION_STATE_PROPS.get(property.key.name) },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
88
130
|
NewExpression(node) {
|
|
89
131
|
if (node.callee.type !== 'Identifier' || node.callee.name !== 'URLSearchParams') return;
|
|
90
132
|
const arg = node.arguments[0];
|
|
91
133
|
if (!arg) return;
|
|
92
134
|
if (
|
|
93
135
|
arg.type === 'MemberExpression' &&
|
|
94
|
-
arg.object
|
|
95
|
-
arg.object.object.type === 'Identifier' &&
|
|
96
|
-
arg.object.object.name === 'window' &&
|
|
97
|
-
arg.object.property.type === 'Identifier' &&
|
|
98
|
-
arg.object.property.name === 'location' &&
|
|
136
|
+
isLocation(arg.object) &&
|
|
99
137
|
arg.property.type === 'Identifier' &&
|
|
100
138
|
arg.property.name === 'search'
|
|
101
139
|
) {
|
|
@@ -25,7 +25,20 @@
|
|
|
25
25
|
* import { clone, equal } from '@ethlete/core';
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
const {
|
|
28
|
+
const {
|
|
29
|
+
MODULE_REFERENCE_SELECTOR,
|
|
30
|
+
getModuleReference,
|
|
31
|
+
isGlobalReference,
|
|
32
|
+
resolveIdentifier,
|
|
33
|
+
} = require('./internals/import-resolution');
|
|
34
|
+
|
|
35
|
+
const LODASH_SOURCES = new Set(['lodash', 'lodash-es']);
|
|
36
|
+
|
|
37
|
+
/** @type {Record<string, { messageId: 'preferClone' | 'preferEqual'; method: string }>} */
|
|
38
|
+
const LODASH_METHODS = {
|
|
39
|
+
cloneDeep: { messageId: 'preferClone', method: 'lodash cloneDeep' },
|
|
40
|
+
isEqual: { messageId: 'preferEqual', method: 'isEqual' },
|
|
41
|
+
};
|
|
29
42
|
|
|
30
43
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
31
44
|
const preferCloneEqual = {
|
|
@@ -49,7 +62,43 @@ const preferCloneEqual = {
|
|
|
49
62
|
CallExpression(node) {
|
|
50
63
|
const { callee } = node;
|
|
51
64
|
|
|
52
|
-
|
|
65
|
+
if (
|
|
66
|
+
callee.type === 'MemberExpression' &&
|
|
67
|
+
!callee.computed &&
|
|
68
|
+
callee.object.type === 'Identifier' &&
|
|
69
|
+
callee.property.type === 'Identifier' &&
|
|
70
|
+
Object.hasOwn(LODASH_METHODS, callee.property.name)
|
|
71
|
+
) {
|
|
72
|
+
const resolved = resolveIdentifier(context.sourceCode, callee.object);
|
|
73
|
+
if (
|
|
74
|
+
resolved?.source &&
|
|
75
|
+
LODASH_SOURCES.has(resolved.source) &&
|
|
76
|
+
(resolved.name === '*' || resolved.name === 'default')
|
|
77
|
+
) {
|
|
78
|
+
const { messageId, method } = LODASH_METHODS[callee.property.name];
|
|
79
|
+
context.report({ node, messageId, data: { method } });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
isGlobalReference(context.sourceCode, callee) &&
|
|
86
|
+
callee.name === 'require' &&
|
|
87
|
+
node.arguments[0]?.type === 'Literal' &&
|
|
88
|
+
LODASH_SOURCES.has(String(node.arguments[0].value))
|
|
89
|
+
) {
|
|
90
|
+
const { parent } = node;
|
|
91
|
+
const method =
|
|
92
|
+
parent.type === 'MemberExpression' && parent.object === node && parent.property.type === 'Identifier'
|
|
93
|
+
? parent.property.name
|
|
94
|
+
: null;
|
|
95
|
+
if (method && Object.hasOwn(LODASH_METHODS, method)) {
|
|
96
|
+
const { messageId, method: label } = LODASH_METHODS[method];
|
|
97
|
+
context.report({ node, messageId, data: { method: label } });
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
53
102
|
if (
|
|
54
103
|
callee.type === 'MemberExpression' &&
|
|
55
104
|
callee.object.type === 'Identifier' &&
|
|
@@ -71,7 +120,6 @@ const preferCloneEqual = {
|
|
|
71
120
|
}
|
|
72
121
|
}
|
|
73
122
|
|
|
74
|
-
// ── structuredClone(expr) ─────────────────────────────────────────────
|
|
75
123
|
if (isGlobalReference(context.sourceCode, callee) && callee.name === 'structuredClone') {
|
|
76
124
|
context.report({ node, messageId: 'preferClone', data: { method: 'structuredClone()' } });
|
|
77
125
|
}
|
|
@@ -82,9 +130,6 @@ const preferCloneEqual = {
|
|
|
82
130
|
if (!reference) return;
|
|
83
131
|
const src = reference.source;
|
|
84
132
|
|
|
85
|
-
// ── lodash cloneDeep / isEqual ────────────────────────────────────────
|
|
86
|
-
// import { cloneDeep } from 'lodash' / 'lodash-es'
|
|
87
|
-
// import { isEqual } from 'lodash' / 'lodash-es'
|
|
88
133
|
if (src === 'lodash' || src === 'lodash-es') {
|
|
89
134
|
for (const { name } of reference.named) {
|
|
90
135
|
if (name === 'cloneDeep') {
|
|
@@ -96,13 +141,21 @@ const preferCloneEqual = {
|
|
|
96
141
|
return;
|
|
97
142
|
}
|
|
98
143
|
|
|
99
|
-
|
|
144
|
+
if (src === 'lodash.clonedeep') {
|
|
145
|
+
context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (src === 'lodash.isequal') {
|
|
150
|
+
context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
100
154
|
if (src === 'lodash/cloneDeep' || src === 'lodash-es/cloneDeep') {
|
|
101
155
|
context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
|
|
102
156
|
return;
|
|
103
157
|
}
|
|
104
158
|
|
|
105
|
-
// import isEqual from 'lodash/isEqual'
|
|
106
159
|
if (src === 'lodash/isEqual' || src === 'lodash-es/isEqual') {
|
|
107
160
|
context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
|
|
108
161
|
}
|
|
@@ -7,7 +7,7 @@ const { getAngularDecoratorName } = require('./internals/import-resolution');
|
|
|
7
7
|
/** @typedef {import('estree').Property & { range: [number, number] }} TPropertyNode */
|
|
8
8
|
/** @typedef {import('estree').ArrayExpression & { elements: Array<import('estree').Expression | import('estree').SpreadElement | null> }} TArrayExpressionNode */
|
|
9
9
|
/** @typedef {import('estree').ObjectExpression & { properties: Array<TPropertyNode | import('estree').SpreadElement> }} TObjectExpressionNode */
|
|
10
|
-
/** @typedef {{ bodyText: string; hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; trailingText: string }} TPropertyEntry */
|
|
10
|
+
/** @typedef {{ bodyText: string; endsWithLineComment: boolean; hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; trailingText: string }} TPropertyEntry */
|
|
11
11
|
|
|
12
12
|
const HOST_DIRECTIVE_PROPERTY_ORDER = ['directive', 'inputs', 'outputs'];
|
|
13
13
|
|
|
@@ -20,6 +20,24 @@ const getPropertyName = (key) => {
|
|
|
20
20
|
return null;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @param {any} node
|
|
25
|
+
*/
|
|
26
|
+
const unwrapTypeAssertions = (node) => {
|
|
27
|
+
let current = node;
|
|
28
|
+
|
|
29
|
+
while (
|
|
30
|
+
current &&
|
|
31
|
+
(current.type === 'TSAsExpression' ||
|
|
32
|
+
current.type === 'TSSatisfiesExpression' ||
|
|
33
|
+
current.type === 'TSNonNullExpression')
|
|
34
|
+
) {
|
|
35
|
+
current = current.expression;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return current;
|
|
39
|
+
};
|
|
40
|
+
|
|
23
41
|
/**
|
|
24
42
|
* @param {TDecoratorName} decoratorName
|
|
25
43
|
* @param {import('estree').Node} node
|
|
@@ -35,9 +53,10 @@ const getHostDirectivesArray = (decoratorName, node) => {
|
|
|
35
53
|
for (const property of metadata.properties) {
|
|
36
54
|
if (!property || property.type !== 'Property') continue;
|
|
37
55
|
if (getPropertyName(property.key) !== 'hostDirectives') continue;
|
|
38
|
-
|
|
56
|
+
const value = unwrapTypeAssertions(property.value);
|
|
57
|
+
if (value.type !== 'ArrayExpression') return null;
|
|
39
58
|
|
|
40
|
-
return /** @type {TArrayExpressionNode} */ (
|
|
59
|
+
return /** @type {TArrayExpressionNode} */ (value);
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
return null;
|
|
@@ -66,6 +85,11 @@ const getHostDirectiveConfigProperties = (node) => {
|
|
|
66
85
|
return properties;
|
|
67
86
|
};
|
|
68
87
|
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} text
|
|
90
|
+
*/
|
|
91
|
+
const startsWithLineBreak = (text) => /^\s*/.exec(text)?.[0].includes('\n') ?? false;
|
|
92
|
+
|
|
69
93
|
/**
|
|
70
94
|
* @param {TObjectExpressionNode} node
|
|
71
95
|
* @param {import('eslint').SourceCode} sourceCode
|
|
@@ -98,6 +122,7 @@ const buildSortedObjectText = (node, sourceCode) => {
|
|
|
98
122
|
|
|
99
123
|
return {
|
|
100
124
|
bodyText,
|
|
125
|
+
endsWithLineComment: trailingComment?.type === 'Line',
|
|
101
126
|
hasComma: Boolean(comma),
|
|
102
127
|
originalIndex,
|
|
103
128
|
orderIndex: orderIndexMap.get(getPropertyName(property.key)) ?? HOST_DIRECTIVE_PROPERTY_ORDER.length,
|
|
@@ -120,10 +145,12 @@ const buildSortedObjectText = (node, sourceCode) => {
|
|
|
120
145
|
return (
|
|
121
146
|
'{' +
|
|
122
147
|
sortedEntries
|
|
123
|
-
.map(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
.map((entry, index) => {
|
|
149
|
+
const followingText = sortedEntries[index + 1]?.bodyText ?? suffix;
|
|
150
|
+
const needsLineBreak = entry.endsWithLineComment && !startsWithLineBreak(followingText);
|
|
151
|
+
|
|
152
|
+
return `${entry.bodyText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}${entry.trailingText}${needsLineBreak ? '\n' : ''}`;
|
|
153
|
+
})
|
|
127
154
|
.join('') +
|
|
128
155
|
suffix +
|
|
129
156
|
'}'
|
|
@@ -181,9 +208,10 @@ const preferConciseAngularHostDirectives = {
|
|
|
181
208
|
if (!hostDirectives) return;
|
|
182
209
|
|
|
183
210
|
for (const element of hostDirectives.elements) {
|
|
184
|
-
|
|
211
|
+
const unwrapped = unwrapTypeAssertions(element);
|
|
212
|
+
if (!unwrapped || unwrapped.type !== 'ObjectExpression') continue;
|
|
185
213
|
|
|
186
|
-
const config = /** @type {TObjectExpressionNode} */ (
|
|
214
|
+
const config = /** @type {TObjectExpressionNode} */ (unwrapped);
|
|
187
215
|
const properties = getHostDirectiveConfigProperties(config);
|
|
188
216
|
if (!properties) continue;
|
|
189
217
|
|
|
@@ -191,12 +219,12 @@ const preferConciseAngularHostDirectives = {
|
|
|
191
219
|
const directiveValue = getDirectiveValueNode(config);
|
|
192
220
|
if (!directiveValue) continue;
|
|
193
221
|
|
|
194
|
-
const hasComments = sourceCode.getCommentsInside(
|
|
222
|
+
const hasComments = sourceCode.getCommentsInside(element).length > 0;
|
|
195
223
|
|
|
196
224
|
context.report({
|
|
197
225
|
node: config,
|
|
198
226
|
messageId: 'preferShorthand',
|
|
199
|
-
fix: hasComments ? null : (fixer) => fixer.replaceText(
|
|
227
|
+
fix: hasComments ? null : (fixer) => fixer.replaceText(element, sourceCode.getText(directiveValue)),
|
|
200
228
|
});
|
|
201
229
|
|
|
202
230
|
continue;
|
|
@@ -20,9 +20,16 @@ const getPropertyName = (key) => {
|
|
|
20
20
|
* @returns {import('estree').Expression | null}
|
|
21
21
|
*/
|
|
22
22
|
const getSingleArrayElement = (property) => {
|
|
23
|
-
|
|
23
|
+
/** @type {any} */
|
|
24
|
+
let value = property.value;
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
while (value.type === 'TSAsExpression' || value.type === 'TSSatisfiesExpression') {
|
|
27
|
+
value = value.expression;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (value.type !== 'ArrayExpression' || value.elements.length !== 1) return null;
|
|
31
|
+
|
|
32
|
+
const firstElement = /** @type {TArrayElement} */ (value.elements[0]);
|
|
26
33
|
if (!firstElement || firstElement.type === 'SpreadElement') return null;
|
|
27
34
|
|
|
28
35
|
return firstElement;
|
|
@@ -35,6 +42,7 @@ const preferConciseAngularStyleMetadata = {
|
|
|
35
42
|
docs: {
|
|
36
43
|
description:
|
|
37
44
|
'Prefer `styleUrl` over single-item `styleUrls`, and avoid wrapping a single `styles` entry in an array.',
|
|
45
|
+
recommended: true,
|
|
38
46
|
},
|
|
39
47
|
fixable: 'code',
|
|
40
48
|
messages: {
|