@ethlete/eslint-plugin 0.1.0-next.0 → 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 +12 -0
- package/package.json +3 -4
- package/src/configs/recommended.js +380 -0
- package/src/index.js +126 -0
- package/src/rules/angular-decorator-property-order.js +205 -0
- package/src/rules/enforce-routing-view-naming.js +84 -0
- package/src/rules/guard-return-newline.js +81 -0
- package/src/rules/inject-member-accessibility.js +304 -0
- package/src/rules/no-angular-router-api.js +158 -0
- package/src/rules/no-angular-seo-services.js +82 -0
- package/src/rules/no-direct-dom-manipulation.js +160 -0
- package/src/rules/no-document-cookie.js +55 -0
- package/src/rules/no-dom-query.js +81 -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-inject-chain.js +44 -0
- package/src/rules/no-leading-underscore-class-member.js +120 -0
- package/src/rules/no-legacy-angular-decorators.js +405 -0
- package/src/rules/no-locale-id.js +57 -0
- package/src/rules/no-member-alias.js +151 -0
- package/src/rules/no-native-observers.js +102 -0
- package/src/rules/no-pipe-logic.js +76 -0
- package/src/rules/no-public-property.js +50 -0
- package/src/rules/no-readonly-signal.js +119 -0
- package/src/rules/no-rxjs-in-effect.js +88 -0
- package/src/rules/no-screaming-case-local.js +113 -0
- package/src/rules/no-standalone-flag.js +95 -0
- package/src/rules/no-subscribe-in-pipe.js +89 -0
- package/src/rules/no-subscribe-with-body.js +95 -0
- package/src/rules/no-trivial-return-type.js +104 -0
- package/src/rules/no-trivial-wrapper-method.js +120 -0
- package/src/rules/no-type-only-import.js +79 -0
- package/src/rules/no-unused-class-member.js +209 -0
- package/src/rules/no-window-location.js +109 -0
- package/src/rules/prefer-clone-equal.js +112 -0
- package/src/rules/prefer-concise-angular-host-directives.js +225 -0
- package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
- package/src/rules/prefer-element-dimensions.js +128 -0
- package/src/rules/prefer-linked-signal.js +124 -0
- package/src/rules/prefer-match-media.js +101 -0
- package/src/rules/prefer-rxjs-timer.js +82 -0
- package/src/rules/prefer-scroll-state.js +158 -0
- package/src/rules/prefer-viewport-size.js +75 -0
- package/src/rules/require-dollar-suffix.js +123 -0
- package/src/rules/require-on-push-change-detection.js +271 -0
- package/src/rules/require-view-encapsulation-none.js +264 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows class members that are declared but never read within the class body.
|
|
6
|
+
*
|
|
7
|
+
* Only checks members that CANNOT be accessed from outside the class at compile
|
|
8
|
+
* time — so false positives (e.g. template usage) are avoided:
|
|
9
|
+
*
|
|
10
|
+
* - `private` members — always checked. Private members are only accessible
|
|
11
|
+
* within the class body, so if they are never read there they are dead code.
|
|
12
|
+
*
|
|
13
|
+
* - `protected` members — checked only when the class has an Angular decorator
|
|
14
|
+
* other than `@Component` (i.e. `@Directive`, `@Pipe`, `@Injectable`).
|
|
15
|
+
* For `@Component`, protected members may be read in the HTML template file
|
|
16
|
+
* which is invisible to static analysis, so protected is skipped there.
|
|
17
|
+
* For plain (non-Angular) classes, protected members may be used in a
|
|
18
|
+
* subclass in another file, so they are also skipped.
|
|
19
|
+
*
|
|
20
|
+
* Members referenced in `host:` binding expressions in the class decorator are
|
|
21
|
+
* excluded from the check for `protected` (since they are accessed by Angular
|
|
22
|
+
* at runtime via the host binding mechanism).
|
|
23
|
+
*
|
|
24
|
+
* BAD:
|
|
25
|
+
* private document = inject(DOCUMENT); // fetched but never used
|
|
26
|
+
* private unusedFlag = false;
|
|
27
|
+
*
|
|
28
|
+
* GOOD:
|
|
29
|
+
* private document = inject(DOCUMENT);
|
|
30
|
+
* createElement() { return this.document.createElement('div'); }
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const ANGULAR_DECORATORS = new Set(['Component', 'Directive', 'Pipe', 'Injectable']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract all identifier-like tokens from a template expression string.
|
|
37
|
+
* Used for `host:` binding values like `'myMember ? "on" : "off"'`.
|
|
38
|
+
* @param {string} str
|
|
39
|
+
* @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
const extractIdentifiers = (str) => str.match(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g) ?? [];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns info about the Angular decorators applied to a class node.
|
|
45
|
+
* @param {any} classNode
|
|
46
|
+
* @returns {{ isComponent: boolean, isAngularClass: boolean }}
|
|
47
|
+
*/
|
|
48
|
+
const getDecoratorInfo = (classNode) => {
|
|
49
|
+
const decorators = classNode.decorators ?? [];
|
|
50
|
+
let isComponent = false;
|
|
51
|
+
let isAngularClass = false;
|
|
52
|
+
|
|
53
|
+
for (const dec of decorators) {
|
|
54
|
+
const expr = dec.expression;
|
|
55
|
+
const name =
|
|
56
|
+
expr?.type === 'CallExpression' && expr.callee?.type === 'Identifier'
|
|
57
|
+
? expr.callee.name
|
|
58
|
+
: expr?.type === 'Identifier'
|
|
59
|
+
? expr.name
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
if (name && ANGULAR_DECORATORS.has(name)) {
|
|
63
|
+
isAngularClass = true;
|
|
64
|
+
if (name === 'Component') isComponent = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { isComponent, isAngularClass };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns names of class members referenced in `host:` binding value strings
|
|
73
|
+
* found in any decorator argument on the class.
|
|
74
|
+
* @param {any} classNode
|
|
75
|
+
* @returns {Set<string>}
|
|
76
|
+
*/
|
|
77
|
+
const getHostBoundMembers = (classNode) => {
|
|
78
|
+
/** @type {Set<string>} */
|
|
79
|
+
const result = new Set();
|
|
80
|
+
const decorators = classNode.decorators ?? [];
|
|
81
|
+
|
|
82
|
+
for (const dec of decorators) {
|
|
83
|
+
const expr = dec.expression;
|
|
84
|
+
if (expr?.type !== 'CallExpression') continue;
|
|
85
|
+
|
|
86
|
+
const arg = expr.arguments?.[0];
|
|
87
|
+
if (!arg || arg.type !== 'ObjectExpression') continue;
|
|
88
|
+
|
|
89
|
+
const hostProp = arg.properties.find(
|
|
90
|
+
/** @param {any} p */
|
|
91
|
+
(p) => p.type === 'Property' && (p.key?.name === 'host' || p.key?.value === 'host'),
|
|
92
|
+
);
|
|
93
|
+
if (!hostProp || hostProp.value?.type !== 'ObjectExpression') continue;
|
|
94
|
+
|
|
95
|
+
for (const binding of hostProp.value.properties) {
|
|
96
|
+
if (binding.type !== 'Property') continue;
|
|
97
|
+
/** @type {any} */
|
|
98
|
+
const val = binding.value;
|
|
99
|
+
if (val?.type !== 'Literal' || typeof val.value !== 'string') continue;
|
|
100
|
+
for (const id of extractIdentifiers(val.value)) result.add(id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
108
|
+
const noUnusedClassMember = {
|
|
109
|
+
meta: {
|
|
110
|
+
type: 'suggestion',
|
|
111
|
+
docs: {
|
|
112
|
+
description: 'Disallow class members that are declared but never read within the class body.',
|
|
113
|
+
recommended: true,
|
|
114
|
+
},
|
|
115
|
+
messages: {
|
|
116
|
+
noUnused:
|
|
117
|
+
"'{{name}}' is declared but its value is never read within this class. " +
|
|
118
|
+
'Remove it, or widen its accessibility if it is intentionally used externally.',
|
|
119
|
+
},
|
|
120
|
+
schema: [],
|
|
121
|
+
},
|
|
122
|
+
create(context) {
|
|
123
|
+
/**
|
|
124
|
+
* Stack of class frames. Each frame tracks which members are candidates and
|
|
125
|
+
* which have been seen in a `this.x` expression.
|
|
126
|
+
*
|
|
127
|
+
* @type {Array<{
|
|
128
|
+
* classBody: import('eslint').Rule.Node,
|
|
129
|
+
* isComponent: boolean,
|
|
130
|
+
* isAngularClass: boolean,
|
|
131
|
+
* hostBound: Set<string>,
|
|
132
|
+
* candidates: Map<string, import('eslint').Rule.Node>,
|
|
133
|
+
* usedNames: Set<string>,
|
|
134
|
+
* }>}
|
|
135
|
+
*/
|
|
136
|
+
const classStack = [];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {any} node
|
|
140
|
+
*/
|
|
141
|
+
const registerMember = (node) => {
|
|
142
|
+
const frame = classStack[classStack.length - 1];
|
|
143
|
+
if (!frame) return;
|
|
144
|
+
// Only register members that belong directly to this class body
|
|
145
|
+
if (node.parent !== frame.classBody) return;
|
|
146
|
+
|
|
147
|
+
if (node.static) return;
|
|
148
|
+
|
|
149
|
+
const key = node.key;
|
|
150
|
+
if (!key || key.type !== 'Identifier') return;
|
|
151
|
+
if (node.type === 'MethodDefinition' && node.kind === 'constructor') return;
|
|
152
|
+
|
|
153
|
+
const name = key.name;
|
|
154
|
+
const accessibility = node.accessibility;
|
|
155
|
+
const { isComponent, isAngularClass, hostBound } = frame;
|
|
156
|
+
|
|
157
|
+
if (accessibility === 'private') {
|
|
158
|
+
frame.candidates.set(name, node);
|
|
159
|
+
} else if (accessibility === 'protected' && isAngularClass && !isComponent) {
|
|
160
|
+
if (!hostBound.has(name)) frame.candidates.set(name, node);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
ClassBody(node) {
|
|
166
|
+
const classNode = node.parent;
|
|
167
|
+
const { isComponent, isAngularClass } = getDecoratorInfo(classNode);
|
|
168
|
+
const hostBound = getHostBoundMembers(classNode);
|
|
169
|
+
classStack.push({
|
|
170
|
+
classBody: node,
|
|
171
|
+
isComponent,
|
|
172
|
+
isAngularClass,
|
|
173
|
+
hostBound,
|
|
174
|
+
candidates: new Map(),
|
|
175
|
+
usedNames: new Set(),
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
'ClassBody:exit'() {
|
|
180
|
+
const frame = classStack.pop();
|
|
181
|
+
if (!frame) return;
|
|
182
|
+
for (const [name, memberNode] of frame.candidates) {
|
|
183
|
+
if (!frame.usedNames.has(name)) {
|
|
184
|
+
context.report({ node: memberNode, messageId: 'noUnused', data: { name } });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
PropertyDefinition: registerMember,
|
|
190
|
+
MethodDefinition: registerMember,
|
|
191
|
+
|
|
192
|
+
MemberExpression(node) {
|
|
193
|
+
// Only care about `this.name` (non-computed direct property access)
|
|
194
|
+
if (node.computed) return;
|
|
195
|
+
if (node.object?.type !== 'ThisExpression') return;
|
|
196
|
+
if (node.property?.type !== 'Identifier') return;
|
|
197
|
+
|
|
198
|
+
// Mark used in the innermost class frame. Arrow functions don't push a
|
|
199
|
+
// new frame so they correctly share the enclosing class's `this`.
|
|
200
|
+
// Regular nested classes push a new frame so their `this.x` won't
|
|
201
|
+
// accidentally mark an outer class member as used.
|
|
202
|
+
const frame = classStack[classStack.length - 1];
|
|
203
|
+
if (frame) frame.usedNames.add(node.property.name);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
module.exports = noUnusedClassMember;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows reading URL state from window.location properties.
|
|
6
|
+
*
|
|
7
|
+
* window.location reads are:
|
|
8
|
+
* - Not reactive — they return a stale snapshot at call time
|
|
9
|
+
* - Not SSR-safe — window is undefined during server-side rendering
|
|
10
|
+
*
|
|
11
|
+
* @ethlete/core ships reactive signal utilities that replace all URL-reading use cases.
|
|
12
|
+
*
|
|
13
|
+
* BAD:
|
|
14
|
+
* window.location.pathname → injectRoute()
|
|
15
|
+
* window.location.href → injectUrl()
|
|
16
|
+
* window.location.search → injectQueryParams() / injectQueryParam(key)
|
|
17
|
+
* window.location.hash → injectFragment()
|
|
18
|
+
* new URLSearchParams(window.location.search) → injectQueryParams() / injectQueryParam(key)
|
|
19
|
+
*
|
|
20
|
+
* GOOD:
|
|
21
|
+
* import {
|
|
22
|
+
* injectUrl, injectRoute, injectQueryParam, injectQueryParams, injectFragment
|
|
23
|
+
* } from '@ethlete/core';
|
|
24
|
+
*
|
|
25
|
+
* NOTE: window.location.href = url (assignment/navigation) and
|
|
26
|
+
* window.location.hostname / window.location.origin are not flagged.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** URL state properties that have reactive signal equivalents. */
|
|
30
|
+
const LOCATION_STATE_PROPS = new Map([
|
|
31
|
+
['href', "injectUrl() from '@ethlete/core'"],
|
|
32
|
+
['pathname', "injectRoute() from '@ethlete/core'"],
|
|
33
|
+
['search', "injectQueryParams() / injectQueryParam(key) from '@ethlete/core'"],
|
|
34
|
+
['hash', "injectFragment() from '@ethlete/core'"],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
38
|
+
const noWindowLocation = {
|
|
39
|
+
meta: {
|
|
40
|
+
type: 'suggestion',
|
|
41
|
+
docs: {
|
|
42
|
+
description:
|
|
43
|
+
"Disallow reading URL state from 'window.location.*'. Use reactive signal utilities from '@ethlete/core' instead.",
|
|
44
|
+
recommended: true,
|
|
45
|
+
},
|
|
46
|
+
messages: {
|
|
47
|
+
noWindowLocation:
|
|
48
|
+
"Avoid reading 'window.location.{{prop}}'. Use {{replacement}} instead — it returns a reactive signal that is SSR-safe and updates on navigation.",
|
|
49
|
+
noURLSearchParams:
|
|
50
|
+
"Avoid 'new URLSearchParams(window.location.search)'. Use 'injectQueryParam(key)' or 'injectQueryParams()' from '@ethlete/core' instead — they return reactive signals and are SSR-safe.",
|
|
51
|
+
},
|
|
52
|
+
schema: [],
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
return {
|
|
56
|
+
// ── window.location.{prop} reads ────────────────────────────────────────
|
|
57
|
+
MemberExpression(node) {
|
|
58
|
+
if (node.property.type !== 'Identifier') return;
|
|
59
|
+
const prop = node.property.name;
|
|
60
|
+
|
|
61
|
+
if (!LOCATION_STATE_PROPS.has(prop)) return;
|
|
62
|
+
|
|
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
|
+
if (prop === 'href' && node.parent.type === 'AssignmentExpression' && node.parent.left === node) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
context.report({
|
|
81
|
+
node,
|
|
82
|
+
messageId: 'noWindowLocation',
|
|
83
|
+
data: { prop, replacement: LOCATION_STATE_PROPS.get(prop) },
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// ── new URLSearchParams(window.location.search) ─────────────────────────
|
|
88
|
+
NewExpression(node) {
|
|
89
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== 'URLSearchParams') return;
|
|
90
|
+
const arg = node.arguments[0];
|
|
91
|
+
if (!arg) return;
|
|
92
|
+
if (
|
|
93
|
+
arg.type === 'MemberExpression' &&
|
|
94
|
+
arg.object.type === 'MemberExpression' &&
|
|
95
|
+
arg.object.object.type === 'Identifier' &&
|
|
96
|
+
arg.object.object.name === 'window' &&
|
|
97
|
+
arg.object.property.type === 'Identifier' &&
|
|
98
|
+
arg.object.property.name === 'location' &&
|
|
99
|
+
arg.property.type === 'Identifier' &&
|
|
100
|
+
arg.property.name === 'search'
|
|
101
|
+
) {
|
|
102
|
+
context.report({ node, messageId: 'noURLSearchParams' });
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
module.exports = noWindowLocation;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prefers the 'clone()' and 'equal()' utilities from '@ethlete/core' over
|
|
6
|
+
* ad-hoc deep-clone and deep-equality patterns.
|
|
7
|
+
*
|
|
8
|
+
* clone():
|
|
9
|
+
* - Handles all JS types (Date, RegExp, Map, Set, ArrayBuffer, typed arrays)
|
|
10
|
+
* - Preserves prototype chains
|
|
11
|
+
* - Unlike JSON round-trip: does not drop undefined, functions, or Dates become strings
|
|
12
|
+
* - Unlike structuredClone: no transfer semantics needed
|
|
13
|
+
*
|
|
14
|
+
* equal():
|
|
15
|
+
* - Full deep equality (Date, RegExp, Map, Set, ArrayBuffer, typed arrays)
|
|
16
|
+
* - No lodash dependency needed
|
|
17
|
+
*
|
|
18
|
+
* BAD:
|
|
19
|
+
* JSON.parse(JSON.stringify(obj)) // lossy, slow, no prototype
|
|
20
|
+
* structuredClone(obj) // for components: use clone()
|
|
21
|
+
* import { cloneDeep } from 'lodash'; // unnecessary dependency
|
|
22
|
+
* import { isEqual } from 'lodash'; // unnecessary dependency
|
|
23
|
+
*
|
|
24
|
+
* GOOD:
|
|
25
|
+
* import { clone, equal } from '@ethlete/core';
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
29
|
+
const preferCloneEqual = {
|
|
30
|
+
meta: {
|
|
31
|
+
type: 'suggestion',
|
|
32
|
+
docs: {
|
|
33
|
+
description:
|
|
34
|
+
"Prefer 'clone()' / 'equal()' from '@ethlete/core' over JSON round-trip cloning, structuredClone, or lodash cloneDeep / isEqual.",
|
|
35
|
+
recommended: true,
|
|
36
|
+
},
|
|
37
|
+
messages: {
|
|
38
|
+
preferClone:
|
|
39
|
+
"Avoid '{{method}}' for deep cloning. Use 'clone()' from '@ethlete/core' instead — it handles all JS types, preserves prototypes, and does not lose undefined values or Dates.",
|
|
40
|
+
preferEqual:
|
|
41
|
+
"Avoid lodash '{{method}}' for deep equality. Use 'equal()' from '@ethlete/core' instead — no extra dependency needed.",
|
|
42
|
+
},
|
|
43
|
+
schema: [],
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
return {
|
|
47
|
+
CallExpression(node) {
|
|
48
|
+
const { callee } = node;
|
|
49
|
+
|
|
50
|
+
// ── JSON.parse(JSON.stringify(expr)) ─────────────────────────────────
|
|
51
|
+
if (
|
|
52
|
+
callee.type === 'MemberExpression' &&
|
|
53
|
+
callee.object.type === 'Identifier' &&
|
|
54
|
+
callee.object.name === 'JSON' &&
|
|
55
|
+
callee.property.type === 'Identifier' &&
|
|
56
|
+
callee.property.name === 'parse' &&
|
|
57
|
+
node.arguments[0]?.type === 'CallExpression'
|
|
58
|
+
) {
|
|
59
|
+
const innerCall = node.arguments[0];
|
|
60
|
+
if (
|
|
61
|
+
innerCall.callee.type === 'MemberExpression' &&
|
|
62
|
+
innerCall.callee.object.type === 'Identifier' &&
|
|
63
|
+
innerCall.callee.object.name === 'JSON' &&
|
|
64
|
+
innerCall.callee.property.type === 'Identifier' &&
|
|
65
|
+
innerCall.callee.property.name === 'stringify'
|
|
66
|
+
) {
|
|
67
|
+
context.report({ node, messageId: 'preferClone', data: { method: 'JSON.parse(JSON.stringify(...))' } });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── structuredClone(expr) ─────────────────────────────────────────────
|
|
73
|
+
if (callee.type === 'Identifier' && callee.name === 'structuredClone') {
|
|
74
|
+
context.report({ node, messageId: 'preferClone', data: { method: 'structuredClone()' } });
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
ImportDeclaration(node) {
|
|
79
|
+
const src = node.source.value;
|
|
80
|
+
|
|
81
|
+
// ── lodash cloneDeep / isEqual ────────────────────────────────────────
|
|
82
|
+
// import { cloneDeep } from 'lodash' / 'lodash-es'
|
|
83
|
+
// import { isEqual } from 'lodash' / 'lodash-es'
|
|
84
|
+
if (src === 'lodash' || src === 'lodash-es') {
|
|
85
|
+
for (const specifier of node.specifiers) {
|
|
86
|
+
if (specifier.type !== 'ImportSpecifier') continue;
|
|
87
|
+
const name = specifier.imported.name;
|
|
88
|
+
if (name === 'cloneDeep') {
|
|
89
|
+
context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
|
|
90
|
+
} else if (name === 'isEqual') {
|
|
91
|
+
context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// import cloneDeep from 'lodash/cloneDeep'
|
|
98
|
+
if (src === 'lodash/cloneDeep' || src === 'lodash-es/cloneDeep') {
|
|
99
|
+
context.report({ node, messageId: 'preferClone', data: { method: 'lodash cloneDeep' } });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// import isEqual from 'lodash/isEqual'
|
|
104
|
+
if (src === 'lodash/isEqual' || src === 'lodash-es/isEqual') {
|
|
105
|
+
context.report({ node, messageId: 'preferEqual', data: { method: 'isEqual' } });
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
module.exports = preferCloneEqual;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {'Component' | 'Directive'} TDecoratorName */
|
|
5
|
+
/** @typedef {import('estree').Property & { range: [number, number] }} TPropertyNode */
|
|
6
|
+
/** @typedef {import('estree').ArrayExpression & { elements: Array<import('estree').Expression | import('estree').SpreadElement | null> }} TArrayExpressionNode */
|
|
7
|
+
/** @typedef {import('estree').ObjectExpression & { properties: Array<TPropertyNode | import('estree').SpreadElement> }} TObjectExpressionNode */
|
|
8
|
+
/** @typedef {{ hasComma: boolean; originalIndex: number; orderIndex: number; property: TPropertyNode; segmentText: string }} TPropertyEntry */
|
|
9
|
+
|
|
10
|
+
const HOST_DIRECTIVE_PROPERTY_ORDER = ['directive', 'inputs', 'outputs'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('eslint').Rule.Node} node
|
|
14
|
+
*/
|
|
15
|
+
const getDecoratorName = (node) => {
|
|
16
|
+
const decorator = /** @type {any} */ (node);
|
|
17
|
+
if (decorator.type !== 'Decorator') return null;
|
|
18
|
+
|
|
19
|
+
const expression = decorator.expression;
|
|
20
|
+
if (expression.type === 'CallExpression') {
|
|
21
|
+
return expression.callee.type === 'Identifier' ? expression.callee.name : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return expression.type === 'Identifier' ? expression.name : null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {import('estree').Property['key']} key
|
|
29
|
+
*/
|
|
30
|
+
const getPropertyName = (key) => {
|
|
31
|
+
if (key.type === 'Identifier') return key.name;
|
|
32
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {TDecoratorName} decoratorName
|
|
38
|
+
* @param {import('estree').Node} node
|
|
39
|
+
*/
|
|
40
|
+
const getHostDirectivesArray = (decoratorName, node) => {
|
|
41
|
+
const decorator = /** @type {any} */ (node);
|
|
42
|
+
const expression = decorator.expression;
|
|
43
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return null;
|
|
44
|
+
|
|
45
|
+
const metadata = expression.arguments[0];
|
|
46
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return null;
|
|
47
|
+
|
|
48
|
+
for (const property of metadata.properties) {
|
|
49
|
+
if (!property || property.type !== 'Property') continue;
|
|
50
|
+
if (getPropertyName(property.key) !== 'hostDirectives') continue;
|
|
51
|
+
if (property.value.type !== 'ArrayExpression') return null;
|
|
52
|
+
|
|
53
|
+
return /** @type {TArrayExpressionNode} */ (property.value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {TObjectExpressionNode} node
|
|
61
|
+
*/
|
|
62
|
+
const getHostDirectiveConfigProperties = (node) => {
|
|
63
|
+
if (
|
|
64
|
+
node.properties.some(
|
|
65
|
+
/** @param {TPropertyNode | import('estree').SpreadElement} property */ (property) =>
|
|
66
|
+
property.type !== 'Property',
|
|
67
|
+
)
|
|
68
|
+
) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const properties = /** @type {TPropertyNode[]} */ (node.properties);
|
|
73
|
+
const names = properties.map((property) => getPropertyName(property.key));
|
|
74
|
+
|
|
75
|
+
if (names.some((name) => name === null || !HOST_DIRECTIVE_PROPERTY_ORDER.includes(name))) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return properties;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {TObjectExpressionNode} node
|
|
84
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
85
|
+
*/
|
|
86
|
+
const buildSortedObjectText = (node, sourceCode) => {
|
|
87
|
+
const properties = getHostDirectiveConfigProperties(node);
|
|
88
|
+
if (!properties || properties.length < 2) return null;
|
|
89
|
+
|
|
90
|
+
const openingBrace = sourceCode.getFirstToken(node);
|
|
91
|
+
const closingBrace = sourceCode.getLastToken(node);
|
|
92
|
+
if (!openingBrace || !closingBrace || openingBrace.value !== '{' || closingBrace.value !== '}') return null;
|
|
93
|
+
|
|
94
|
+
let segmentStart = openingBrace.range[1];
|
|
95
|
+
const orderIndexMap = new Map(HOST_DIRECTIVE_PROPERTY_ORDER.map((value, index) => [value, index]));
|
|
96
|
+
|
|
97
|
+
const entries = properties.map(
|
|
98
|
+
/** @param {TPropertyNode} property @param {number} originalIndex @returns {TPropertyEntry} */
|
|
99
|
+
(property, originalIndex) => {
|
|
100
|
+
const tokenAfter = sourceCode.getTokenAfter(property);
|
|
101
|
+
const hasComma = Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
102
|
+
const segmentEnd = hasComma && tokenAfter ? tokenAfter.range[0] : property.range[1];
|
|
103
|
+
const segmentText = sourceCode.text.slice(segmentStart, segmentEnd);
|
|
104
|
+
|
|
105
|
+
if (hasComma && tokenAfter) {
|
|
106
|
+
segmentStart = tokenAfter.range[1];
|
|
107
|
+
} else {
|
|
108
|
+
segmentStart = property.range[1];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
hasComma,
|
|
113
|
+
originalIndex,
|
|
114
|
+
orderIndex: orderIndexMap.get(getPropertyName(property.key)) ?? HOST_DIRECTIVE_PROPERTY_ORDER.length,
|
|
115
|
+
property,
|
|
116
|
+
segmentText,
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const trailingComma = entries[entries.length - 1].hasComma;
|
|
122
|
+
const suffix = sourceCode.text.slice(segmentStart, closingBrace.range[0]);
|
|
123
|
+
const sortedEntries = [...entries].sort((left, right) => {
|
|
124
|
+
if (left.orderIndex !== right.orderIndex) return left.orderIndex - right.orderIndex;
|
|
125
|
+
return left.originalIndex - right.originalIndex;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const isSorted = entries.every((entry, index) => entry.property === sortedEntries[index].property);
|
|
129
|
+
if (isSorted) return null;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
'{' +
|
|
133
|
+
sortedEntries
|
|
134
|
+
.map((entry, index) => `${entry.segmentText}${index < sortedEntries.length - 1 || trailingComma ? ',' : ''}`)
|
|
135
|
+
.join('') +
|
|
136
|
+
suffix +
|
|
137
|
+
'}'
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {TObjectExpressionNode} node
|
|
143
|
+
*/
|
|
144
|
+
const hasOnlyDirectiveProperty = (node) => {
|
|
145
|
+
const properties = getHostDirectiveConfigProperties(node);
|
|
146
|
+
if (!properties || properties.length !== 1) return false;
|
|
147
|
+
|
|
148
|
+
return getPropertyName(properties[0].key) === 'directive';
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {TObjectExpressionNode} node
|
|
153
|
+
*/
|
|
154
|
+
const getDirectiveValueNode = (node) => {
|
|
155
|
+
const properties = getHostDirectiveConfigProperties(node);
|
|
156
|
+
if (!properties || properties.length !== 1) return null;
|
|
157
|
+
|
|
158
|
+
const property = properties[0];
|
|
159
|
+
if (getPropertyName(property.key) !== 'directive') return null;
|
|
160
|
+
|
|
161
|
+
return property.value;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
165
|
+
const preferConciseAngularHostDirectives = {
|
|
166
|
+
meta: {
|
|
167
|
+
type: 'suggestion',
|
|
168
|
+
docs: {
|
|
169
|
+
description:
|
|
170
|
+
'Prefer shorthand hostDirectives entries when only directive is needed, and keep extended host directive configs ordered as directive, inputs, outputs.',
|
|
171
|
+
recommended: true,
|
|
172
|
+
},
|
|
173
|
+
fixable: 'code',
|
|
174
|
+
schema: [],
|
|
175
|
+
messages: {
|
|
176
|
+
preferShorthand: 'Use the directive directly in hostDirectives when no inputs or outputs are forwarded.',
|
|
177
|
+
hostDirectiveOrder: 'Extended hostDirectives entries should order properties as directive, inputs, outputs.',
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
create(context) {
|
|
181
|
+
const sourceCode = context.sourceCode;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
Decorator(node) {
|
|
185
|
+
const decoratorName = getDecoratorName(node);
|
|
186
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive') return;
|
|
187
|
+
|
|
188
|
+
const hostDirectives = getHostDirectivesArray(decoratorName, node);
|
|
189
|
+
if (!hostDirectives) return;
|
|
190
|
+
|
|
191
|
+
for (const element of hostDirectives.elements) {
|
|
192
|
+
if (!element || element.type !== 'ObjectExpression') continue;
|
|
193
|
+
|
|
194
|
+
const config = /** @type {TObjectExpressionNode} */ (element);
|
|
195
|
+
const properties = getHostDirectiveConfigProperties(config);
|
|
196
|
+
if (!properties) continue;
|
|
197
|
+
|
|
198
|
+
if (hasOnlyDirectiveProperty(config)) {
|
|
199
|
+
const directiveValue = getDirectiveValueNode(config);
|
|
200
|
+
if (!directiveValue) continue;
|
|
201
|
+
|
|
202
|
+
context.report({
|
|
203
|
+
node: config,
|
|
204
|
+
messageId: 'preferShorthand',
|
|
205
|
+
fix: (fixer) => fixer.replaceText(config, sourceCode.getText(directiveValue)),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sortedObjectText = buildSortedObjectText(config, sourceCode);
|
|
212
|
+
if (!sortedObjectText) continue;
|
|
213
|
+
|
|
214
|
+
context.report({
|
|
215
|
+
node: config,
|
|
216
|
+
messageId: 'hostDirectiveOrder',
|
|
217
|
+
fix: (fixer) => fixer.replaceText(config, sortedObjectText),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
module.exports = preferConciseAngularHostDirectives;
|