@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,210 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows legacy Angular decorators in favour of their modern signal-based
|
|
6
|
+
* equivalents and the `host: {}` shorthand.
|
|
7
|
+
*
|
|
8
|
+
* Signal-based replacements:
|
|
9
|
+
* ❌ @Input() → ✅ input()
|
|
10
|
+
* ❌ @Output() → ✅ output()
|
|
11
|
+
* ❌ @ViewChild() → ✅ viewChild()
|
|
12
|
+
* ❌ @ViewChildren() → ✅ viewChildren()
|
|
13
|
+
* ❌ @ContentChild() → ✅ contentChild()
|
|
14
|
+
* ❌ @ContentChildren() → ✅ contentChildren()
|
|
15
|
+
*
|
|
16
|
+
* Pair detection — when @Input() x and @Output() xChange are declared in the
|
|
17
|
+
* same class they form a two-way binding pattern that must be replaced by
|
|
18
|
+
* model():
|
|
19
|
+
* ❌ @Input() value; @Output() valueChange = new EventEmitter();
|
|
20
|
+
* → ✅ value = model();
|
|
21
|
+
*
|
|
22
|
+
* Host bindings — use the host: {} property instead:
|
|
23
|
+
* ❌ @HostBinding('class.active') isActive = false;
|
|
24
|
+
* ❌ @HostListener('click') onClick() {}
|
|
25
|
+
* → ✅ host: { '[class.active]': 'isActive', '(click)': 'onClick()' }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the identifier name of a decorator.
|
|
30
|
+
* Works for both `@Foo` (Identifier) and `@Foo(...)` (CallExpression).
|
|
31
|
+
*
|
|
32
|
+
* @param {any} decorator
|
|
33
|
+
* @returns {string | null}
|
|
34
|
+
*/
|
|
35
|
+
const getDecoratorName = (decorator) => {
|
|
36
|
+
const expr = decorator.expression;
|
|
37
|
+
if (expr.type === 'Identifier') return expr.name;
|
|
38
|
+
if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier') return expr.callee.name;
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns the static string key of a class member, or null for computed keys.
|
|
44
|
+
*
|
|
45
|
+
* @param {any} node - PropertyDefinition or MethodDefinition
|
|
46
|
+
* @returns {string | null}
|
|
47
|
+
*/
|
|
48
|
+
const getMemberName = (node) => {
|
|
49
|
+
if (!node.computed && node.key.type === 'Identifier') return node.key.name;
|
|
50
|
+
if (!node.computed && node.key.type === 'Literal') return String(node.key.value);
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
55
|
+
const rule = {
|
|
56
|
+
meta: {
|
|
57
|
+
type: 'suggestion',
|
|
58
|
+
docs: {
|
|
59
|
+
description:
|
|
60
|
+
'Disallow legacy Angular decorators (@Input, @Output, @ViewChild, etc.) in favour of signal-based APIs and host: {} bindings.',
|
|
61
|
+
},
|
|
62
|
+
schema: [],
|
|
63
|
+
messages: {
|
|
64
|
+
useInput: 'Use the input() signal function instead of @Input().',
|
|
65
|
+
useOutput: 'Use the output() signal function instead of @Output().',
|
|
66
|
+
useViewChild: 'Use the viewChild() signal function instead of @ViewChild().',
|
|
67
|
+
useViewChildren: 'Use the viewChildren() signal function instead of @ViewChildren().',
|
|
68
|
+
useContentChild: 'Use the contentChild() signal function instead of @ContentChild().',
|
|
69
|
+
useContentChildren: 'Use the contentChildren() signal function instead of @ContentChildren().',
|
|
70
|
+
useHostBinding: 'Use the host: {} property in @Component/@Directive instead of @HostBinding().',
|
|
71
|
+
useHostListener: 'Use the host: {} property in @Component/@Directive instead of @HostListener().',
|
|
72
|
+
useModel:
|
|
73
|
+
"Use model() instead of the @Input()/@Output() two-way binding pattern ('{{inputName}}' / '{{outputName}}').",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
create(context) {
|
|
77
|
+
/**
|
|
78
|
+
* Stack of per-class contexts used to detect two-way binding pairs.
|
|
79
|
+
* We defer @Input / @Output reporting to class exit so we can check whether
|
|
80
|
+
* a `xChange` output has a matching `x` input (→ model()).
|
|
81
|
+
*
|
|
82
|
+
* @type {Array<{ inputs: Map<string, any>; outputs: Map<string, any> }>}
|
|
83
|
+
*/
|
|
84
|
+
const classStack = [];
|
|
85
|
+
|
|
86
|
+
const currentClass = () => classStack[classStack.length - 1] ?? null;
|
|
87
|
+
|
|
88
|
+
const pushClass = () => classStack.push({ inputs: new Map(), outputs: new Map() });
|
|
89
|
+
|
|
90
|
+
const popClass = () => {
|
|
91
|
+
const ctx = classStack.pop();
|
|
92
|
+
if (!ctx) return;
|
|
93
|
+
|
|
94
|
+
const reportedInputNames = new Set();
|
|
95
|
+
const reportedOutputNames = new Set();
|
|
96
|
+
|
|
97
|
+
// Detect @Input() x + @Output() xChange pairs → suggest model()
|
|
98
|
+
for (const [outputName, outputDecoratorNode] of ctx.outputs) {
|
|
99
|
+
if (!outputName.endsWith('Change')) continue;
|
|
100
|
+
const inputName = outputName.slice(0, -'Change'.length);
|
|
101
|
+
if (!ctx.inputs.has(inputName)) continue;
|
|
102
|
+
|
|
103
|
+
reportedInputNames.add(inputName);
|
|
104
|
+
reportedOutputNames.add(outputName);
|
|
105
|
+
|
|
106
|
+
context.report({
|
|
107
|
+
node: ctx.inputs.get(inputName),
|
|
108
|
+
messageId: 'useModel',
|
|
109
|
+
data: { inputName, outputName },
|
|
110
|
+
});
|
|
111
|
+
context.report({
|
|
112
|
+
node: outputDecoratorNode,
|
|
113
|
+
messageId: 'useModel',
|
|
114
|
+
data: { inputName, outputName },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Report remaining @Input() decorators that are not part of a pair
|
|
119
|
+
for (const [name, decoratorNode] of ctx.inputs) {
|
|
120
|
+
if (!reportedInputNames.has(name)) {
|
|
121
|
+
context.report({ node: decoratorNode, messageId: 'useInput' });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Report remaining @Output() decorators that are not part of a pair
|
|
126
|
+
for (const [name, decoratorNode] of ctx.outputs) {
|
|
127
|
+
if (!reportedOutputNames.has(name)) {
|
|
128
|
+
context.report({ node: decoratorNode, messageId: 'useOutput' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
ClassDeclaration: pushClass,
|
|
135
|
+
ClassExpression: pushClass,
|
|
136
|
+
'ClassDeclaration:exit': popClass,
|
|
137
|
+
'ClassExpression:exit': popClass,
|
|
138
|
+
|
|
139
|
+
Decorator(node) {
|
|
140
|
+
const anyNode = /** @type {any} */ (node);
|
|
141
|
+
const decoratorName = getDecoratorName(anyNode);
|
|
142
|
+
if (!decoratorName) return;
|
|
143
|
+
|
|
144
|
+
// Only act on decorators attached to class members, not to the class itself
|
|
145
|
+
const parent = anyNode.parent;
|
|
146
|
+
if (!parent) return;
|
|
147
|
+
if (parent.type !== 'PropertyDefinition' && parent.type !== 'MethodDefinition') return;
|
|
148
|
+
|
|
149
|
+
const ctx = currentClass();
|
|
150
|
+
|
|
151
|
+
switch (decoratorName) {
|
|
152
|
+
case 'Input': {
|
|
153
|
+
if (!ctx) {
|
|
154
|
+
context.report({ node, messageId: 'useInput' });
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const memberName = getMemberName(parent);
|
|
158
|
+
if (memberName !== null) {
|
|
159
|
+
ctx.inputs.set(memberName, node);
|
|
160
|
+
} else {
|
|
161
|
+
// Computed key — cannot be part of a two-way binding pair, report immediately
|
|
162
|
+
context.report({ node, messageId: 'useInput' });
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'Output': {
|
|
168
|
+
if (!ctx) {
|
|
169
|
+
context.report({ node, messageId: 'useOutput' });
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const memberName = getMemberName(parent);
|
|
173
|
+
if (memberName !== null) {
|
|
174
|
+
ctx.outputs.set(memberName, node);
|
|
175
|
+
} else {
|
|
176
|
+
context.report({ node, messageId: 'useOutput' });
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'ViewChild':
|
|
182
|
+
context.report({ node, messageId: 'useViewChild' });
|
|
183
|
+
break;
|
|
184
|
+
|
|
185
|
+
case 'ViewChildren':
|
|
186
|
+
context.report({ node, messageId: 'useViewChildren' });
|
|
187
|
+
break;
|
|
188
|
+
|
|
189
|
+
case 'ContentChild':
|
|
190
|
+
context.report({ node, messageId: 'useContentChild' });
|
|
191
|
+
break;
|
|
192
|
+
|
|
193
|
+
case 'ContentChildren':
|
|
194
|
+
context.report({ node, messageId: 'useContentChildren' });
|
|
195
|
+
break;
|
|
196
|
+
|
|
197
|
+
case 'HostBinding':
|
|
198
|
+
context.report({ node, messageId: 'useHostBinding' });
|
|
199
|
+
break;
|
|
200
|
+
|
|
201
|
+
case 'HostListener':
|
|
202
|
+
context.report({ node, messageId: 'useHostListener' });
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
module.exports = rule;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows injecting Angular's LOCALE_ID token directly.
|
|
6
|
+
*
|
|
7
|
+
* @ethlete/core provides injectLocale() which wraps LOCALE_ID in a signal
|
|
8
|
+
* and exposes helper utilities for locale-aware formatting.
|
|
9
|
+
*
|
|
10
|
+
* BAD:
|
|
11
|
+
* inject(LOCALE_ID)
|
|
12
|
+
* import { LOCALE_ID } from '@angular/core';
|
|
13
|
+
*
|
|
14
|
+
* GOOD:
|
|
15
|
+
* import { injectLocale } from '@ethlete/core';
|
|
16
|
+
* const locale = injectLocale();
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
20
|
+
const noLocaleId = {
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'suggestion',
|
|
23
|
+
docs: {
|
|
24
|
+
description: "Disallow injecting 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core' instead.",
|
|
25
|
+
recommended: true,
|
|
26
|
+
},
|
|
27
|
+
messages: {
|
|
28
|
+
noLocaleId:
|
|
29
|
+
"Do not inject 'LOCALE_ID' directly. Use 'injectLocale()' from '@ethlete/core' instead — it wraps LOCALE_ID and provides a clean, signal-compatible API.",
|
|
30
|
+
},
|
|
31
|
+
schema: [],
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
return {
|
|
35
|
+
CallExpression(node) {
|
|
36
|
+
const { callee } = node;
|
|
37
|
+
if (callee.type !== 'Identifier' || callee.name !== 'inject') return;
|
|
38
|
+
const arg = node.arguments[0];
|
|
39
|
+
if (arg?.type === 'Identifier' && arg.name === 'LOCALE_ID') {
|
|
40
|
+
context.report({ node, messageId: 'noLocaleId' });
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
ImportDeclaration(node) {
|
|
45
|
+
if (node.source.value !== '@angular/core') return;
|
|
46
|
+
for (const specifier of node.specifiers) {
|
|
47
|
+
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'LOCALE_ID') {
|
|
48
|
+
context.report({ node, messageId: 'noLocaleId' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
module.exports = noLocaleId;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows class members that exist solely as aliases for a nested property or
|
|
6
|
+
* zero-arg method of another class member accessed via `this`.
|
|
7
|
+
*
|
|
8
|
+
* These aliases add an extra layer of indirection without any benefit. Instead,
|
|
9
|
+
* the source member should be widened in accessibility so it can be referenced
|
|
10
|
+
* directly at the call/template site.
|
|
11
|
+
*
|
|
12
|
+
* BAD — property alias:
|
|
13
|
+
* private stackDirective = inject(NotificationStackDirective);
|
|
14
|
+
* protected displayRefs = this.stackDirective.displayRefs; // just an alias
|
|
15
|
+
*
|
|
16
|
+
* BAD — zero-arg method alias:
|
|
17
|
+
* context = inject(SomeContextToken);
|
|
18
|
+
* retry() { this.context.retry(); }
|
|
19
|
+
*
|
|
20
|
+
* GOOD:
|
|
21
|
+
* protected stackDirective = inject(NotificationStackDirective);
|
|
22
|
+
* // use stackDirective.displayRefs directly in the template
|
|
23
|
+
*
|
|
24
|
+
* context = inject(SomeContextToken);
|
|
25
|
+
* // callers use directive.context.retry() directly
|
|
26
|
+
*
|
|
27
|
+
* Property rule fires when the entire initializer is a single `this.a.b`
|
|
28
|
+
* expression — it will not flag deeper chains, computed accesses, or any
|
|
29
|
+
* expression that does more than a plain property read.
|
|
30
|
+
*
|
|
31
|
+
* Method rule fires when a zero-param method body contains exactly one
|
|
32
|
+
* statement that calls `this.a.b()` with zero arguments AND the method name
|
|
33
|
+
* matches the target method name exactly. When the names differ the method is
|
|
34
|
+
* an intentional API rename (e.g. retry → playerResource.reload) and is not
|
|
35
|
+
* flagged.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Checks whether a method node is a pure zero-arg forwarding alias of the form:
|
|
40
|
+
* method() { this.source.method(); }
|
|
41
|
+
* method() { return this.source.method(); }
|
|
42
|
+
*
|
|
43
|
+
* @param {any} node MethodDefinition
|
|
44
|
+
* @returns {{ sourceName: string, propName: string } | null}
|
|
45
|
+
*/
|
|
46
|
+
const getMethodAliasInfo = (node) => {
|
|
47
|
+
if (node.kind === 'constructor' || node.kind === 'get' || node.kind === 'set') return null;
|
|
48
|
+
|
|
49
|
+
const fn = node.value;
|
|
50
|
+
if (!fn || fn.params.length !== 0) return null;
|
|
51
|
+
|
|
52
|
+
const body = fn.body;
|
|
53
|
+
if (!body || body.type !== 'BlockStatement' || body.body.length !== 1) return null;
|
|
54
|
+
|
|
55
|
+
const stmt = body.body[0];
|
|
56
|
+
let callExpr = null;
|
|
57
|
+
if (stmt.type === 'ExpressionStatement') {
|
|
58
|
+
callExpr = stmt.expression;
|
|
59
|
+
} else if (stmt.type === 'ReturnStatement' && stmt.argument) {
|
|
60
|
+
callExpr = stmt.argument;
|
|
61
|
+
}
|
|
62
|
+
if (!callExpr || callExpr.type !== 'CallExpression') return null;
|
|
63
|
+
|
|
64
|
+
// Must have zero arguments
|
|
65
|
+
if (callExpr.arguments.length !== 0) return null;
|
|
66
|
+
|
|
67
|
+
const callee = callExpr.callee;
|
|
68
|
+
// callee must be: this.something.method
|
|
69
|
+
if (callee.type !== 'MemberExpression') return null;
|
|
70
|
+
if (callee.computed) return null;
|
|
71
|
+
if (callee.property.type !== 'Identifier') return null;
|
|
72
|
+
|
|
73
|
+
const obj = callee.object;
|
|
74
|
+
if (obj.type !== 'MemberExpression') return null;
|
|
75
|
+
if (obj.computed) return null;
|
|
76
|
+
if (obj.object.type !== 'ThisExpression') return null;
|
|
77
|
+
if (obj.property.type !== 'Identifier') return null;
|
|
78
|
+
|
|
79
|
+
return { sourceName: obj.property.name, propName: callee.property.name };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
83
|
+
const noMemberAlias = {
|
|
84
|
+
meta: {
|
|
85
|
+
type: 'suggestion',
|
|
86
|
+
docs: {
|
|
87
|
+
description:
|
|
88
|
+
'Disallow class members that are pure aliases for a nested property or zero-arg method of another member accessed via `this`.',
|
|
89
|
+
recommended: true,
|
|
90
|
+
},
|
|
91
|
+
messages: {
|
|
92
|
+
noAlias:
|
|
93
|
+
'`{{alias}}` is a pure alias for `this.{{source}}.{{prop}}`. ' +
|
|
94
|
+
'Remove the alias and widen the accessibility of `{{source}}` so it can be used directly.',
|
|
95
|
+
},
|
|
96
|
+
schema: [],
|
|
97
|
+
},
|
|
98
|
+
create(context) {
|
|
99
|
+
return {
|
|
100
|
+
PropertyDefinition(node) {
|
|
101
|
+
const value = node.value;
|
|
102
|
+
if (!value) return;
|
|
103
|
+
|
|
104
|
+
// Must be: this.something.property
|
|
105
|
+
if (value.type !== 'MemberExpression') return;
|
|
106
|
+
// Outer property must be a plain identifier (not computed)
|
|
107
|
+
if (value.computed) return;
|
|
108
|
+
if (value.property.type !== 'Identifier') return;
|
|
109
|
+
|
|
110
|
+
const obj = value.object;
|
|
111
|
+
// Object must itself be: this.something
|
|
112
|
+
if (obj.type !== 'MemberExpression') return;
|
|
113
|
+
if (obj.computed) return;
|
|
114
|
+
if (obj.object.type !== 'ThisExpression') return;
|
|
115
|
+
if (obj.property.type !== 'Identifier') return;
|
|
116
|
+
|
|
117
|
+
const aliasName = node.key.type === 'Identifier' ? node.key.name : null;
|
|
118
|
+
if (!aliasName) return;
|
|
119
|
+
|
|
120
|
+
const sourceName = obj.property.name;
|
|
121
|
+
const propName = value.property.name;
|
|
122
|
+
|
|
123
|
+
context.report({
|
|
124
|
+
node,
|
|
125
|
+
messageId: 'noAlias',
|
|
126
|
+
data: { alias: aliasName, source: sourceName, prop: propName },
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
MethodDefinition(node) {
|
|
131
|
+
const aliasName = node.key.type === 'Identifier' ? node.key.name : null;
|
|
132
|
+
if (!aliasName) return;
|
|
133
|
+
|
|
134
|
+
const info = getMethodAliasInfo(node);
|
|
135
|
+
if (!info) return;
|
|
136
|
+
|
|
137
|
+
// Only flag when names match — different names indicate an intentional
|
|
138
|
+
// API rename, which is not a pure alias.
|
|
139
|
+
if (aliasName !== info.propName) return;
|
|
140
|
+
|
|
141
|
+
context.report({
|
|
142
|
+
node,
|
|
143
|
+
messageId: 'noAlias',
|
|
144
|
+
data: { alias: aliasName, source: info.sourceName, prop: info.propName },
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
module.exports = noMemberAlias;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows raw browser Observer APIs (IntersectionObserver, MutationObserver,
|
|
6
|
+
* ResizeObserver, PerformanceObserver). Use the signal-based utilities from
|
|
7
|
+
* @ethlete/core instead.
|
|
8
|
+
*
|
|
9
|
+
* The signal utilities:
|
|
10
|
+
* - Integrate automatically with Angular's reactivity model
|
|
11
|
+
* - Tear down the observer when the component is destroyed (no memory leaks)
|
|
12
|
+
* - Are composable with other signals and computed()
|
|
13
|
+
* - Require no manual disconnect() calls
|
|
14
|
+
*
|
|
15
|
+
* BAD:
|
|
16
|
+
* new IntersectionObserver(cb, options)
|
|
17
|
+
* new MutationObserver(cb)
|
|
18
|
+
* new ResizeObserver(cb)
|
|
19
|
+
*
|
|
20
|
+
* GOOD:
|
|
21
|
+
* // IntersectionObserver → from @ethlete/core
|
|
22
|
+
* signalElementIntersection(elementRef, options)
|
|
23
|
+
* signalHostElementIntersection(options) // shortcut inside a directive/component
|
|
24
|
+
*
|
|
25
|
+
* // MutationObserver → from @ethlete/core
|
|
26
|
+
* signalElementMutations(elementRef, options)
|
|
27
|
+
* signalHostElementMutations(options)
|
|
28
|
+
*
|
|
29
|
+
* // ResizeObserver → from @ethlete/core
|
|
30
|
+
* signalElementDimensions(elementRef)
|
|
31
|
+
* signalHostElementDimensions()
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Maps Observer constructor names to their @ethlete/core signal equivalents.
|
|
36
|
+
* @type {Record<string, { alternatives: string[], from: string } | null>}
|
|
37
|
+
*/
|
|
38
|
+
const OBSERVER_ALTERNATIVES = {
|
|
39
|
+
IntersectionObserver: {
|
|
40
|
+
alternatives: ['signalElementIntersection', 'signalHostElementIntersection'],
|
|
41
|
+
from: '@ethlete/core',
|
|
42
|
+
},
|
|
43
|
+
MutationObserver: {
|
|
44
|
+
alternatives: ['signalElementMutations', 'signalHostElementMutations'],
|
|
45
|
+
from: '@ethlete/core',
|
|
46
|
+
},
|
|
47
|
+
ResizeObserver: {
|
|
48
|
+
alternatives: ['signalElementDimensions', 'signalHostElementDimensions'],
|
|
49
|
+
from: '@ethlete/core',
|
|
50
|
+
},
|
|
51
|
+
PerformanceObserver: null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
55
|
+
const noNativeObservers = {
|
|
56
|
+
meta: {
|
|
57
|
+
type: 'suggestion',
|
|
58
|
+
docs: {
|
|
59
|
+
description: "Disallow raw browser Observer APIs. Use signal-based utilities from '@ethlete/core' instead.",
|
|
60
|
+
recommended: true,
|
|
61
|
+
},
|
|
62
|
+
messages: {
|
|
63
|
+
useSignalUtil:
|
|
64
|
+
"Use '{{alternatives}}' from '{{from}}' instead of 'new {{observer}}()'. The signal utility auto-cleans up when the component is destroyed.",
|
|
65
|
+
avoidObserver:
|
|
66
|
+
"Avoid 'new {{observer}}()'. Prefer a reactive signal utility from '@ethlete/core' where possible, or ensure you call disconnect() in inject(DestroyRef).onDestroy().",
|
|
67
|
+
},
|
|
68
|
+
schema: [],
|
|
69
|
+
},
|
|
70
|
+
create(context) {
|
|
71
|
+
return {
|
|
72
|
+
NewExpression(node) {
|
|
73
|
+
if (node.callee.type !== 'Identifier') return;
|
|
74
|
+
|
|
75
|
+
const name = node.callee.name;
|
|
76
|
+
if (!(name in OBSERVER_ALTERNATIVES)) return;
|
|
77
|
+
|
|
78
|
+
const info = OBSERVER_ALTERNATIVES[name];
|
|
79
|
+
|
|
80
|
+
if (info) {
|
|
81
|
+
context.report({
|
|
82
|
+
node,
|
|
83
|
+
messageId: 'useSignalUtil',
|
|
84
|
+
data: {
|
|
85
|
+
observer: name,
|
|
86
|
+
alternatives: info.alternatives.join(' or '),
|
|
87
|
+
from: info.from,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
context.report({
|
|
92
|
+
node,
|
|
93
|
+
messageId: 'avoidObserver',
|
|
94
|
+
data: { observer: name },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
module.exports = noNativeObservers;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks whether a class node has a @Pipe decorator.
|
|
6
|
+
* @param {import('eslint').Rule.Node} classNode
|
|
7
|
+
*/
|
|
8
|
+
const hasPipeDecorator = (classNode) => {
|
|
9
|
+
const decorators = /** @type {any} */ (classNode).decorators ?? [];
|
|
10
|
+
return decorators.some((/** @type {any} */ dec) => {
|
|
11
|
+
const expr = dec.expression;
|
|
12
|
+
if (expr.type === 'CallExpression') {
|
|
13
|
+
return expr.callee.type === 'Identifier' && expr.callee.name === 'Pipe';
|
|
14
|
+
}
|
|
15
|
+
if (expr.type === 'Identifier') {
|
|
16
|
+
return expr.name === 'Pipe';
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
23
|
+
const noPipeLogic = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'suggestion',
|
|
26
|
+
docs: {
|
|
27
|
+
description:
|
|
28
|
+
'Pipes must not contain logic in the transform method. Extract to a utility function and assign it: `transform = myUtil;`',
|
|
29
|
+
},
|
|
30
|
+
messages: {
|
|
31
|
+
noLogicInTransform:
|
|
32
|
+
'Pipes must not contain logic in transform. Extract to a utility function and assign it: `transform = myUtil;`',
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
return {
|
|
38
|
+
// Case 1: transform() { ... } — method with a body
|
|
39
|
+
MethodDefinition(node) {
|
|
40
|
+
const key = /** @type {any} */ (node).key;
|
|
41
|
+
if (key.type !== 'Identifier' || key.name !== 'transform') return;
|
|
42
|
+
|
|
43
|
+
const classBody = node.parent;
|
|
44
|
+
if (!classBody || classBody.type !== 'ClassBody') return;
|
|
45
|
+
|
|
46
|
+
const classNode = classBody.parent;
|
|
47
|
+
if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
|
|
48
|
+
|
|
49
|
+
if (!hasPipeDecorator(classNode)) return;
|
|
50
|
+
|
|
51
|
+
context.report({ node, messageId: 'noLogicInTransform' });
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Case 2: transform = () => { ... } or transform = function() { ... }
|
|
55
|
+
PropertyDefinition(node) {
|
|
56
|
+
const key = /** @type {any} */ (node).key;
|
|
57
|
+
if (key.type !== 'Identifier' || key.name !== 'transform') return;
|
|
58
|
+
|
|
59
|
+
const classBody = node.parent;
|
|
60
|
+
if (!classBody || classBody.type !== 'ClassBody') return;
|
|
61
|
+
|
|
62
|
+
const classNode = classBody.parent;
|
|
63
|
+
if (!classNode || (classNode.type !== 'ClassDeclaration' && classNode.type !== 'ClassExpression')) return;
|
|
64
|
+
|
|
65
|
+
if (!hasPipeDecorator(classNode)) return;
|
|
66
|
+
|
|
67
|
+
const value = /** @type {any} */ (node).value;
|
|
68
|
+
if (value && (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression')) {
|
|
69
|
+
context.report({ node, messageId: 'noLogicInTransform' });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
module.exports = noPipeLogic;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {any} node
|
|
6
|
+
*/
|
|
7
|
+
const isInjectCall = (node) => {
|
|
8
|
+
if (!node || node.type !== 'CallExpression') return false;
|
|
9
|
+
return node.callee.type === 'Identifier' && node.callee.name === 'inject';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
13
|
+
const noPublicProperty = {
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'problem',
|
|
16
|
+
docs: {
|
|
17
|
+
description:
|
|
18
|
+
'Disallow explicit public on class properties, except for injected members that intentionally expose an external API.',
|
|
19
|
+
recommended: true,
|
|
20
|
+
},
|
|
21
|
+
schema: [],
|
|
22
|
+
messages: {
|
|
23
|
+
noPublicProperty:
|
|
24
|
+
'No public keyword on class properties. The only exception is an injected member that intentionally exposes an external API.',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
create(context) {
|
|
28
|
+
return {
|
|
29
|
+
PropertyDefinition(node) {
|
|
30
|
+
if (node.accessibility !== 'public') return;
|
|
31
|
+
if (isInjectCall(node.value)) return;
|
|
32
|
+
|
|
33
|
+
context.report({ node, messageId: 'noPublicProperty' });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
module.exports = noPublicProperty;
|