@ethlete/eslint-plugin 0.1.0-next.0 → 0.1.0-next.1
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 +3 -4
- package/src/configs/recommended.js +362 -0
- package/src/index.js +116 -0
- package/src/rules/angular-decorator-property-order.js +217 -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 +286 -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-inject-chain.js +44 -0
- package/src/rules/no-legacy-angular-decorators.js +210 -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 +39 -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-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-view-encapsulation-none.js +101 -0
|
@@ -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;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('estree').Property} TPropertyNode */
|
|
5
|
+
/** @typedef {import('estree').Expression | import('estree').SpreadElement | null} TArrayElement */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {import('eslint').Rule.Node} node
|
|
9
|
+
*/
|
|
10
|
+
const isComponentDecorator = (node) => {
|
|
11
|
+
const decorator = /** @type {any} */ (node);
|
|
12
|
+
if (decorator.type !== 'Decorator') return false;
|
|
13
|
+
|
|
14
|
+
const expression = decorator.expression;
|
|
15
|
+
if (expression.type === 'CallExpression') {
|
|
16
|
+
return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return expression.type === 'Identifier' && expression.name === 'Component';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('estree').Property['key']} key
|
|
24
|
+
*/
|
|
25
|
+
const getPropertyName = (key) => {
|
|
26
|
+
if (key.type === 'Identifier') return key.name;
|
|
27
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {TPropertyNode} property
|
|
33
|
+
* @returns {import('estree').Expression | null}
|
|
34
|
+
*/
|
|
35
|
+
const getSingleArrayElement = (property) => {
|
|
36
|
+
if (property.value.type !== 'ArrayExpression' || property.value.elements.length !== 1) return null;
|
|
37
|
+
|
|
38
|
+
const firstElement = /** @type {TArrayElement} */ (property.value.elements[0]);
|
|
39
|
+
if (!firstElement || firstElement.type === 'SpreadElement') return null;
|
|
40
|
+
|
|
41
|
+
return firstElement;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
45
|
+
const preferConciseAngularStyleMetadata = {
|
|
46
|
+
meta: {
|
|
47
|
+
type: 'suggestion',
|
|
48
|
+
docs: {
|
|
49
|
+
description:
|
|
50
|
+
'Prefer `styleUrl` over single-item `styleUrls`, and avoid wrapping a single `styles` entry in an array.',
|
|
51
|
+
},
|
|
52
|
+
fixable: 'code',
|
|
53
|
+
messages: {
|
|
54
|
+
preferStyleUrl: 'Use `styleUrl` instead of `styleUrls` when there is only one style path.',
|
|
55
|
+
preferSingleStyle: 'Use `styles: <value>` instead of a single-item `styles` array.',
|
|
56
|
+
},
|
|
57
|
+
schema: [],
|
|
58
|
+
},
|
|
59
|
+
create(context) {
|
|
60
|
+
const sourceCode = context.sourceCode;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
/** @param {import('eslint').Rule.Node} node */
|
|
64
|
+
Decorator(node) {
|
|
65
|
+
if (!isComponentDecorator(node)) return;
|
|
66
|
+
|
|
67
|
+
const decorator = /** @type {any} */ (node);
|
|
68
|
+
const expression = decorator.expression;
|
|
69
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
70
|
+
|
|
71
|
+
const metadata = expression.arguments[0];
|
|
72
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
73
|
+
|
|
74
|
+
for (const property of metadata.properties) {
|
|
75
|
+
if (property.type !== 'Property') continue;
|
|
76
|
+
|
|
77
|
+
const propertyName = getPropertyName(property.key);
|
|
78
|
+
if (propertyName !== 'styleUrls' && propertyName !== 'styles') continue;
|
|
79
|
+
|
|
80
|
+
const singleElement = getSingleArrayElement(property);
|
|
81
|
+
if (!singleElement) continue;
|
|
82
|
+
|
|
83
|
+
if (propertyName === 'styleUrls') {
|
|
84
|
+
context.report({
|
|
85
|
+
node: property,
|
|
86
|
+
messageId: 'preferStyleUrl',
|
|
87
|
+
fix(fixer) {
|
|
88
|
+
return fixer.replaceText(property, `styleUrl: ${sourceCode.getText(singleElement)}`);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
context.report({
|
|
96
|
+
node: property,
|
|
97
|
+
messageId: 'preferSingleStyle',
|
|
98
|
+
fix(fixer) {
|
|
99
|
+
return fixer.replaceText(property, `styles: ${sourceCode.getText(singleElement)}`);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
module.exports = preferConciseAngularStyleMetadata;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prefers signalElementDimensions / signalHostElementDimensions from @ethlete/core
|
|
6
|
+
* over imperatively reading element size properties inside reactive contexts
|
|
7
|
+
* (effect(), computed(), or signal initializers).
|
|
8
|
+
*
|
|
9
|
+
* Why: reading `.getBoundingClientRect()`, `.offsetWidth`, `.clientWidth`, etc.
|
|
10
|
+
* inside a reactive context creates a one-shot snapshot that never updates. The
|
|
11
|
+
* signal utilities set up a ResizeObserver-backed reactive signal that stays in
|
|
12
|
+
* sync automatically and cleans itself up when the component is destroyed.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: This rule is a WARNING (not an error) because imperative one-shot reads
|
|
15
|
+
* (e.g. inside animation callbacks, event handlers) are perfectly valid. The rule
|
|
16
|
+
* only fires when the read is inside a reactive context.
|
|
17
|
+
*
|
|
18
|
+
* BAD (reactive context):
|
|
19
|
+
* effect(() => {
|
|
20
|
+
* const w = this.el.nativeElement.offsetWidth; // stale after resize ❌
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* computed(() => someEl.getBoundingClientRect().width); // ❌
|
|
24
|
+
*
|
|
25
|
+
* GOOD:
|
|
26
|
+
* dimensions = signalHostElementDimensions(); // from @ethlete/core
|
|
27
|
+
* // or
|
|
28
|
+
* dimensions = signalElementDimensions(inject(ElementRef));
|
|
29
|
+
* // Then:
|
|
30
|
+
* effect(() => { const w = this.dimensions().rect.width; }); // ✅ reactive
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Size-related properties read from DOM elements. */
|
|
34
|
+
const ELEMENT_SIZE_PROPS = new Set([
|
|
35
|
+
'offsetWidth',
|
|
36
|
+
'offsetHeight',
|
|
37
|
+
'clientWidth',
|
|
38
|
+
'clientHeight',
|
|
39
|
+
'scrollWidth',
|
|
40
|
+
'scrollHeight',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** Size-related methods called on DOM elements. */
|
|
44
|
+
const ELEMENT_SIZE_METHODS = new Set(['getBoundingClientRect', 'getClientRects']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns the name of the wrapping reactive context ('effect' | 'computed') if
|
|
48
|
+
* the node is inside one, crossing exactly one function boundary.
|
|
49
|
+
* @param {import('eslint').Rule.Node} node
|
|
50
|
+
* @returns {string | null}
|
|
51
|
+
*/
|
|
52
|
+
const getReactiveContext = (node) => {
|
|
53
|
+
let crossedFunctionBoundary = false;
|
|
54
|
+
let current = node.parent;
|
|
55
|
+
|
|
56
|
+
while (current) {
|
|
57
|
+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
|
|
58
|
+
crossedFunctionBoundary = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
crossedFunctionBoundary &&
|
|
63
|
+
current.type === 'CallExpression' &&
|
|
64
|
+
current.callee.type === 'Identifier' &&
|
|
65
|
+
(current.callee.name === 'effect' || current.callee.name === 'computed')
|
|
66
|
+
) {
|
|
67
|
+
return current.callee.name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
current = current.parent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
77
|
+
const preferElementDimensions = {
|
|
78
|
+
meta: {
|
|
79
|
+
type: 'suggestion',
|
|
80
|
+
docs: {
|
|
81
|
+
description:
|
|
82
|
+
"Prefer 'signalElementDimensions()' or 'signalHostElementDimensions()' from '@ethlete/core' over reading element size properties inside reactive contexts.",
|
|
83
|
+
recommended: true,
|
|
84
|
+
},
|
|
85
|
+
messages: {
|
|
86
|
+
preferElementDimensions:
|
|
87
|
+
"Avoid reading '{{prop}}' inside {{context}}() — it creates a stale snapshot. Use 'signalElementDimensions(elementRef)' or 'signalHostElementDimensions()' from '@ethlete/core' instead — they provide a reactive signal backed by ResizeObserver.",
|
|
88
|
+
},
|
|
89
|
+
schema: [],
|
|
90
|
+
},
|
|
91
|
+
create(context) {
|
|
92
|
+
return {
|
|
93
|
+
// el.offsetWidth, el.clientWidth, etc.
|
|
94
|
+
MemberExpression(node) {
|
|
95
|
+
if (node.property.type !== 'Identifier') return;
|
|
96
|
+
if (!ELEMENT_SIZE_PROPS.has(node.property.name)) return;
|
|
97
|
+
|
|
98
|
+
const reactiveCtx = getReactiveContext(node);
|
|
99
|
+
if (!reactiveCtx) return;
|
|
100
|
+
|
|
101
|
+
context.report({
|
|
102
|
+
node,
|
|
103
|
+
messageId: 'preferElementDimensions',
|
|
104
|
+
data: { prop: node.property.name, context: reactiveCtx },
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// el.getBoundingClientRect(), el.getClientRects()
|
|
109
|
+
CallExpression(node) {
|
|
110
|
+
const { callee } = node;
|
|
111
|
+
if (callee.type !== 'MemberExpression') return;
|
|
112
|
+
if (callee.property.type !== 'Identifier') return;
|
|
113
|
+
if (!ELEMENT_SIZE_METHODS.has(callee.property.name)) return;
|
|
114
|
+
|
|
115
|
+
const reactiveCtx = getReactiveContext(node);
|
|
116
|
+
if (!reactiveCtx) return;
|
|
117
|
+
|
|
118
|
+
context.report({
|
|
119
|
+
node,
|
|
120
|
+
messageId: 'preferElementDimensions',
|
|
121
|
+
data: { prop: callee.property.name + '()', context: reactiveCtx },
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
module.exports = preferElementDimensions;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prefer linkedSignal over setting a signal inside an effect().
|
|
6
|
+
*
|
|
7
|
+
* When a signal is set inside an effect(), it usually means the new value is
|
|
8
|
+
* derived from other reactive state. That pattern is better expressed with
|
|
9
|
+
* linkedSignal(), which is declarative, avoids the scheduling overhead of
|
|
10
|
+
* effect(), and makes the dependency chain explicit.
|
|
11
|
+
*
|
|
12
|
+
* BAD:
|
|
13
|
+
* selectedItem = signal<Item | null>(null);
|
|
14
|
+
*
|
|
15
|
+
* constructor() {
|
|
16
|
+
* effect(() => {
|
|
17
|
+
* // Reset selection whenever the list changes
|
|
18
|
+
* this.selectedItem.set(this.items()[0] ?? null); // ❌
|
|
19
|
+
* });
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* GOOD:
|
|
23
|
+
* selectedItem = linkedSignal(() => this.items()[0] ?? null);
|
|
24
|
+
*
|
|
25
|
+
* NOTE: Only flag when .set() is called directly inside an effect() callback.
|
|
26
|
+
* Setting signals outside effects (in methods, event handlers, etc.) is fine.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns true only when the `.set()` call node is a *pure derivation* inside
|
|
31
|
+
* an effect() callback — meaning:
|
|
32
|
+
*
|
|
33
|
+
* 1. The IMMEDIATE enclosing function (the first function boundary walking up
|
|
34
|
+
* from the node) IS the first argument passed to `effect()`. This ensures
|
|
35
|
+
* that `.set()` calls nested inside `untracked(() => ...)` or other inner
|
|
36
|
+
* functions within the effect are NOT flagged — those are intentional
|
|
37
|
+
* write-back patterns, not derivations.
|
|
38
|
+
*
|
|
39
|
+
* 2. The `.set()` call is the SOLE expression in that callback:
|
|
40
|
+
* - Arrow with expression body: `effect(() => sig.set(x))`
|
|
41
|
+
* - Block body with a single ExpressionStatement: `effect(() => { sig.set(x); })`
|
|
42
|
+
*
|
|
43
|
+
* Multi-statement blocks (even if they end with a `.set()`) indicate that the
|
|
44
|
+
* effect is performing side effects alongside the signal write, so they are
|
|
45
|
+
* not flagged either.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('eslint').Rule.Node} node The `.set()` CallExpression node
|
|
48
|
+
*/
|
|
49
|
+
const isPureSetInDirectEffectCallback = (node) => {
|
|
50
|
+
// Find the immediate enclosing function (cross exactly one boundary).
|
|
51
|
+
let current = node.parent;
|
|
52
|
+
let immediateFunction = null;
|
|
53
|
+
|
|
54
|
+
while (current) {
|
|
55
|
+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
|
|
56
|
+
immediateFunction = current;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
current = current.parent;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!immediateFunction) return false;
|
|
63
|
+
|
|
64
|
+
// The immediate function must be the first argument to an effect() call.
|
|
65
|
+
const funcParent = immediateFunction.parent;
|
|
66
|
+
if (
|
|
67
|
+
!funcParent ||
|
|
68
|
+
funcParent.type !== 'CallExpression' ||
|
|
69
|
+
funcParent.callee.type !== 'Identifier' ||
|
|
70
|
+
funcParent.callee.name !== 'effect' ||
|
|
71
|
+
funcParent.arguments[0] !== immediateFunction
|
|
72
|
+
) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The .set() must be the sole expression — no surrounding statements.
|
|
77
|
+
// Case A: arrow with expression body — the body IS the .set() call.
|
|
78
|
+
if (immediateFunction.type === 'ArrowFunctionExpression' && immediateFunction.body === node) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Case B: block body with exactly one ExpressionStatement wrapping the .set() call.
|
|
83
|
+
const body = immediateFunction.body;
|
|
84
|
+
return (
|
|
85
|
+
body.type === 'BlockStatement' &&
|
|
86
|
+
body.body.length === 1 &&
|
|
87
|
+
body.body[0].type === 'ExpressionStatement' &&
|
|
88
|
+
body.body[0].expression === node
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
93
|
+
const preferLinkedSignal = {
|
|
94
|
+
meta: {
|
|
95
|
+
type: 'suggestion',
|
|
96
|
+
docs: {
|
|
97
|
+
description: "Prefer 'linkedSignal' over setting a signal inside effect().",
|
|
98
|
+
recommended: true,
|
|
99
|
+
},
|
|
100
|
+
messages: {
|
|
101
|
+
preferLinkedSignal:
|
|
102
|
+
"Setting a signal inside 'effect()' usually means the value is derived from reactive state. Use 'linkedSignal(() => ...)' instead — it is declarative and avoids effect scheduling overhead.",
|
|
103
|
+
},
|
|
104
|
+
schema: [],
|
|
105
|
+
},
|
|
106
|
+
create(context) {
|
|
107
|
+
return {
|
|
108
|
+
CallExpression(node) {
|
|
109
|
+
const { callee } = node;
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
callee.type === 'MemberExpression' &&
|
|
113
|
+
callee.property.type === 'Identifier' &&
|
|
114
|
+
callee.property.name === 'set' &&
|
|
115
|
+
isPureSetInDirectEffectCallback(node)
|
|
116
|
+
) {
|
|
117
|
+
context.report({ node, messageId: 'preferLinkedSignal' });
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
module.exports = preferLinkedSignal;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prefers the typed media-query signal utilities from @ethlete/core over
|
|
6
|
+
* calling window.matchMedia() directly or using Angular CDK's BreakpointObserver.
|
|
7
|
+
*
|
|
8
|
+
* The utilities:
|
|
9
|
+
* - Return reactive signals that update when the media query result changes
|
|
10
|
+
* - Are SSR-safe (no window access during server rendering)
|
|
11
|
+
* - Are automatically cleaned up when the component is destroyed
|
|
12
|
+
*
|
|
13
|
+
* BAD:
|
|
14
|
+
* window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
15
|
+
* const mq = window.matchMedia('(max-width: 768px)');
|
|
16
|
+
* mq.addEventListener('change', handler);
|
|
17
|
+
* inject(BreakpointObserver);
|
|
18
|
+
* import { BreakpointObserver } from '@angular/cdk/layout';
|
|
19
|
+
*
|
|
20
|
+
* GOOD:
|
|
21
|
+
* // For arbitrary media queries:
|
|
22
|
+
* const isDark = injectMediaQueryIsMatched('(prefers-color-scheme: dark)');
|
|
23
|
+
*
|
|
24
|
+
* // For standard breakpoints:
|
|
25
|
+
* const isMobile = injectBreakpointIsMatched({ max: 'sm' });
|
|
26
|
+
*
|
|
27
|
+
* // For reactive BreakpointObserver access:
|
|
28
|
+
* const bo = injectBreakpointObserver(); // from @ethlete/core
|
|
29
|
+
*
|
|
30
|
+
* // For common checks that are pre-built:
|
|
31
|
+
* const canHover = injectCanHover();
|
|
32
|
+
* const hasTouchInput = injectHasTouchInput();
|
|
33
|
+
* const isPortrait = injectIsPortrait();
|
|
34
|
+
* const isLandscape = injectIsLandscape();
|
|
35
|
+
* const isXs = injectIsXs();
|
|
36
|
+
* const isSm = injectIsSm();
|
|
37
|
+
* // … etc. All from @ethlete/core.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
41
|
+
const preferMatchMedia = {
|
|
42
|
+
meta: {
|
|
43
|
+
type: 'suggestion',
|
|
44
|
+
docs: {
|
|
45
|
+
description: "Prefer reactive media-query signal utilities from '@ethlete/core' over 'window.matchMedia()'.",
|
|
46
|
+
recommended: true,
|
|
47
|
+
},
|
|
48
|
+
messages: {
|
|
49
|
+
preferMatchMedia:
|
|
50
|
+
"Avoid 'window.matchMedia()' directly. Use 'injectMediaQueryIsMatched(query)' or a pre-built helper (injectCanHover, injectHasTouchInput, injectIsPortrait, injectBreakpointIsMatched, …) from '@ethlete/core' — they return reactive signals and are SSR-safe.",
|
|
51
|
+
noBreakpointObserver:
|
|
52
|
+
"Do not use Angular CDK's 'BreakpointObserver' directly. Use 'injectBreakpointObserver()' from '@ethlete/core' instead, which wraps it with a reactive signal API and proper DI scoping.",
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
return {
|
|
58
|
+
CallExpression(node) {
|
|
59
|
+
const { callee } = node;
|
|
60
|
+
|
|
61
|
+
// ── window.matchMedia() ───────────────────────────────────────────────
|
|
62
|
+
if (
|
|
63
|
+
callee.type === 'MemberExpression' &&
|
|
64
|
+
callee.property.type === 'Identifier' &&
|
|
65
|
+
callee.property.name === 'matchMedia'
|
|
66
|
+
) {
|
|
67
|
+
// Flag any .matchMedia() call — the object is almost certainly window or
|
|
68
|
+
// document.defaultView, but even a forwarded reference should be replaced.
|
|
69
|
+
context.report({ node, messageId: 'preferMatchMedia' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── inject(BreakpointObserver) ────────────────────────────────────────
|
|
74
|
+
if (
|
|
75
|
+
callee.type === 'Identifier' &&
|
|
76
|
+
callee.name === 'inject' &&
|
|
77
|
+
node.arguments[0]?.type === 'Identifier' &&
|
|
78
|
+
node.arguments[0].name === 'BreakpointObserver'
|
|
79
|
+
) {
|
|
80
|
+
context.report({ node, messageId: 'noBreakpointObserver' });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
ImportDeclaration(node) {
|
|
85
|
+
// ── import { BreakpointObserver } from '@angular/cdk/layout' ─────────
|
|
86
|
+
if (node.source.value !== '@angular/cdk/layout') return;
|
|
87
|
+
const hasBreakpointObserver = node.specifiers.some(
|
|
88
|
+
(s) =>
|
|
89
|
+
s.type === 'ImportSpecifier' &&
|
|
90
|
+
s.imported.type === 'Identifier' &&
|
|
91
|
+
s.imported.name === 'BreakpointObserver',
|
|
92
|
+
);
|
|
93
|
+
if (hasBreakpointObserver) {
|
|
94
|
+
context.report({ node, messageId: 'noBreakpointObserver' });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
module.exports = preferMatchMedia;
|