@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,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,50 @@
|
|
|
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
|
+
fixable: 'code',
|
|
22
|
+
schema: [],
|
|
23
|
+
messages: {
|
|
24
|
+
noPublicProperty:
|
|
25
|
+
'No public keyword on class properties. The only exception is an injected member that intentionally exposes an external API.',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
return {
|
|
30
|
+
PropertyDefinition(node) {
|
|
31
|
+
if (node.accessibility !== 'public') return;
|
|
32
|
+
if (isInjectCall(node.value)) return;
|
|
33
|
+
|
|
34
|
+
context.report({
|
|
35
|
+
node,
|
|
36
|
+
messageId: 'noPublicProperty',
|
|
37
|
+
fix(fixer) {
|
|
38
|
+
const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
|
|
39
|
+
const nextToken = publicToken ? context.sourceCode.getTokenAfter(publicToken) : null;
|
|
40
|
+
if (!publicToken || !nextToken) return null;
|
|
41
|
+
|
|
42
|
+
return fixer.removeRange([publicToken.range[0], nextToken.range[0]]);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
module.exports = noPublicProperty;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows `readonly` on class properties initialized with reactive Angular/RxJS APIs.
|
|
6
|
+
*
|
|
7
|
+
* The `readonly` modifier on a signal, input, computed, or inject() call is misleading —
|
|
8
|
+
* the "readonly" constraint applies to the reference (you can't reassign the property),
|
|
9
|
+
* but the underlying value is mutable. It also prevents common patterns like
|
|
10
|
+
* resetting or swapping the signal.
|
|
11
|
+
*
|
|
12
|
+
* BAD:
|
|
13
|
+
* readonly mySignal = signal(false);
|
|
14
|
+
* readonly myInput = input(false);
|
|
15
|
+
* readonly myComputed = computed(() => this.x() + 1);
|
|
16
|
+
* readonly myService = inject(MyService);
|
|
17
|
+
*
|
|
18
|
+
* GOOD (constants that truly never change):
|
|
19
|
+
* readonly MAX_ITEMS = 100;
|
|
20
|
+
* readonly PLATFORM = inject(PLATFORM_ID); // ← still a violation; assign without readonly
|
|
21
|
+
*
|
|
22
|
+
* GOOD (actual usage):
|
|
23
|
+
* mySignal = signal(false);
|
|
24
|
+
* myInput = input(false);
|
|
25
|
+
* myComputed = computed(() => this.x() + 1);
|
|
26
|
+
* private myService = inject(MyService);
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Names of Angular/RxJS reactive APIs that should never be marked `readonly`.
|
|
31
|
+
*/
|
|
32
|
+
const REACTIVE_APIS = new Set([
|
|
33
|
+
// Angular signals
|
|
34
|
+
'signal',
|
|
35
|
+
'computed',
|
|
36
|
+
'linkedSignal',
|
|
37
|
+
// Angular models / inputs / outputs
|
|
38
|
+
'input',
|
|
39
|
+
'model',
|
|
40
|
+
'output',
|
|
41
|
+
'outputFromObservable',
|
|
42
|
+
// Angular view queries
|
|
43
|
+
'viewChild',
|
|
44
|
+
'viewChildren',
|
|
45
|
+
'contentChild',
|
|
46
|
+
'contentChildren',
|
|
47
|
+
// Angular DI
|
|
48
|
+
'inject',
|
|
49
|
+
// Angular lifecycle
|
|
50
|
+
'effect',
|
|
51
|
+
'afterNextRender',
|
|
52
|
+
'afterRender',
|
|
53
|
+
// RxJS interop
|
|
54
|
+
'toSignal',
|
|
55
|
+
'toObservable',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
59
|
+
const noReadonlySignal = {
|
|
60
|
+
meta: {
|
|
61
|
+
type: 'suggestion',
|
|
62
|
+
fixable: 'code',
|
|
63
|
+
docs: {
|
|
64
|
+
description: 'Disallow `readonly` on class properties initialized with reactive Angular/RxJS APIs.',
|
|
65
|
+
recommended: true,
|
|
66
|
+
},
|
|
67
|
+
messages: {
|
|
68
|
+
noReadonlySignal:
|
|
69
|
+
"Remove `readonly` from '{{name}}' — reactive APIs return mutable references, and `readonly` gives a false sense of immutability.",
|
|
70
|
+
},
|
|
71
|
+
schema: [],
|
|
72
|
+
},
|
|
73
|
+
create(context) {
|
|
74
|
+
return {
|
|
75
|
+
PropertyDefinition(node) {
|
|
76
|
+
// Only flag when readonly is set
|
|
77
|
+
if (!node.readonly) return;
|
|
78
|
+
|
|
79
|
+
const init = node.value;
|
|
80
|
+
if (!init || init.type !== 'CallExpression') return;
|
|
81
|
+
|
|
82
|
+
const { callee } = init;
|
|
83
|
+
let apiName = null;
|
|
84
|
+
|
|
85
|
+
// Simple call: signal(), input(), computed(), inject(), etc.
|
|
86
|
+
if (callee.type === 'Identifier' && REACTIVE_APIS.has(callee.name)) {
|
|
87
|
+
apiName = callee.name;
|
|
88
|
+
}
|
|
89
|
+
// Member call: input.required(), outputFromObservable.something(), etc.
|
|
90
|
+
else if (
|
|
91
|
+
callee.type === 'MemberExpression' &&
|
|
92
|
+
callee.object.type === 'Identifier' &&
|
|
93
|
+
REACTIVE_APIS.has(callee.object.name)
|
|
94
|
+
) {
|
|
95
|
+
apiName = `${callee.object.name}.${callee.property.type === 'Identifier' ? callee.property.name : '...'}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (apiName) {
|
|
99
|
+
context.report({
|
|
100
|
+
node,
|
|
101
|
+
messageId: 'noReadonlySignal',
|
|
102
|
+
data: { name: apiName },
|
|
103
|
+
fix(fixer) {
|
|
104
|
+
// Find and remove the "readonly" token
|
|
105
|
+
const sourceCode = context.sourceCode;
|
|
106
|
+
const tokens = sourceCode.getTokens(node);
|
|
107
|
+
const readonlyToken = tokens.find((t) => t.value === 'readonly');
|
|
108
|
+
if (!readonlyToken) return null;
|
|
109
|
+
// Remove "readonly " (including the trailing space)
|
|
110
|
+
return fixer.removeRange([readonlyToken.range[0], readonlyToken.range[1] + 1]);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
module.exports = noReadonlySignal;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows calling .subscribe() inside Angular's effect() or computed() callbacks.
|
|
6
|
+
*
|
|
7
|
+
* This creates unmanaged subscriptions and causes memory leaks because neither
|
|
8
|
+
* effect() nor computed() provides a lifecycle-tied cleanup mechanism for subscriptions.
|
|
9
|
+
*
|
|
10
|
+
* BAD:
|
|
11
|
+
* effect(() => {
|
|
12
|
+
* const page = this.page();
|
|
13
|
+
* this.api.fetch(page).subscribe(res => this.data.set(res)); // ❌
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* GOOD:
|
|
17
|
+
* toObservable(this.page)
|
|
18
|
+
* .pipe(
|
|
19
|
+
* switchMap(page => this.api.fetch(page)),
|
|
20
|
+
* takeUntilDestroyed(),
|
|
21
|
+
* )
|
|
22
|
+
* .subscribe();
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
26
|
+
const noRxjsInEffect = {
|
|
27
|
+
meta: {
|
|
28
|
+
type: 'problem',
|
|
29
|
+
docs: {
|
|
30
|
+
description: 'Disallow .subscribe() inside effect() or computed() callbacks.',
|
|
31
|
+
recommended: true,
|
|
32
|
+
},
|
|
33
|
+
messages: {
|
|
34
|
+
noSubscribeInEffect:
|
|
35
|
+
'Avoid calling .subscribe() inside {{context}}(). Use toObservable() + switchMap() + takeUntilDestroyed() instead.',
|
|
36
|
+
},
|
|
37
|
+
schema: [],
|
|
38
|
+
},
|
|
39
|
+
create(context) {
|
|
40
|
+
/**
|
|
41
|
+
* Returns the name of the wrapping reactive context ('effect' | 'computed') if
|
|
42
|
+
* the given node is inside one, or null otherwise.
|
|
43
|
+
* @param {import('eslint').Rule.Node} node
|
|
44
|
+
* @returns {string | null}
|
|
45
|
+
*/
|
|
46
|
+
const getReactiveContext = (node) => {
|
|
47
|
+
let crossedFunctionBoundary = false;
|
|
48
|
+
let current = node.parent;
|
|
49
|
+
while (current) {
|
|
50
|
+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
|
|
51
|
+
crossedFunctionBoundary = true;
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
crossedFunctionBoundary &&
|
|
55
|
+
current.type === 'CallExpression' &&
|
|
56
|
+
current.callee.type === 'Identifier' &&
|
|
57
|
+
(current.callee.name === 'effect' || current.callee.name === 'computed')
|
|
58
|
+
) {
|
|
59
|
+
return current.callee.name;
|
|
60
|
+
}
|
|
61
|
+
current = current.parent;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
CallExpression(node) {
|
|
68
|
+
const { callee } = node;
|
|
69
|
+
if (
|
|
70
|
+
callee.type === 'MemberExpression' &&
|
|
71
|
+
callee.property.type === 'Identifier' &&
|
|
72
|
+
callee.property.name === 'subscribe'
|
|
73
|
+
) {
|
|
74
|
+
const reactiveContext = getReactiveContext(node);
|
|
75
|
+
if (reactiveContext) {
|
|
76
|
+
context.report({
|
|
77
|
+
node,
|
|
78
|
+
messageId: 'noSubscribeInEffect',
|
|
79
|
+
data: { context: reactiveContext },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
module.exports = noRxjsInEffect;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows SCREAMING_CASE variable names inside function bodies.
|
|
6
|
+
*
|
|
7
|
+
* At module level, SCREAMING_CASE is fine for true constants. Inside functions,
|
|
8
|
+
* all local variables must be camelCase — there is no meaningful distinction
|
|
9
|
+
* between a "constant" and a regular local variable inside a function body.
|
|
10
|
+
*
|
|
11
|
+
* Also disallows SCREAMING_CASE names for function-valued variables at any scope,
|
|
12
|
+
* since a function is never a static constant.
|
|
13
|
+
*
|
|
14
|
+
* BAD:
|
|
15
|
+
* const myFn = () => {
|
|
16
|
+
* const RANDOM = Math.random(); // ❌
|
|
17
|
+
* const MY_LOCAL_FLAG = true; // ❌
|
|
18
|
+
* };
|
|
19
|
+
*
|
|
20
|
+
* const MY_FUNCTION = () => {}; // ❌ function name must be camelCase
|
|
21
|
+
*
|
|
22
|
+
* GOOD:
|
|
23
|
+
* const MY_GLOBAL = 'value'; // ✅ module level
|
|
24
|
+
*
|
|
25
|
+
* const myFn = () => {
|
|
26
|
+
* const random = Math.random(); // ✅
|
|
27
|
+
* const myLocalFlag = true; // ✅
|
|
28
|
+
* };
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Matches ALL-CAPS identifiers: at least one char, all uppercase letters, digits, underscores */
|
|
32
|
+
const SCREAMING_CASE_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns true if the given node is inside a function body (arrow or regular).
|
|
36
|
+
* @param {import('eslint').Rule.Node} node
|
|
37
|
+
*/
|
|
38
|
+
const isInsideFunction = (node) => {
|
|
39
|
+
let current = node.parent;
|
|
40
|
+
while (current) {
|
|
41
|
+
if (
|
|
42
|
+
current.type === 'ArrowFunctionExpression' ||
|
|
43
|
+
current.type === 'FunctionExpression' ||
|
|
44
|
+
current.type === 'FunctionDeclaration'
|
|
45
|
+
) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
current = current.parent;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
54
|
+
const noScreamingCaseLocal = {
|
|
55
|
+
meta: {
|
|
56
|
+
type: 'suggestion',
|
|
57
|
+
docs: {
|
|
58
|
+
description:
|
|
59
|
+
'Disallow SCREAMING_CASE variable names inside function bodies. Use camelCase for all local variables.',
|
|
60
|
+
recommended: true,
|
|
61
|
+
},
|
|
62
|
+
messages: {
|
|
63
|
+
noScreamingCaseLocal: "'{{name}}' uses SCREAMING_CASE inside a function. Use camelCase for local variables.",
|
|
64
|
+
noScreamingCaseFunctionName:
|
|
65
|
+
"'{{name}}' uses SCREAMING_CASE for a function name. Use camelCase for function names.",
|
|
66
|
+
},
|
|
67
|
+
schema: [],
|
|
68
|
+
},
|
|
69
|
+
create(context) {
|
|
70
|
+
return {
|
|
71
|
+
VariableDeclarator(node) {
|
|
72
|
+
// Only flag simple identifier bindings (not destructuring)
|
|
73
|
+
if (node.id.type !== 'Identifier') return;
|
|
74
|
+
|
|
75
|
+
const { name } = node.id;
|
|
76
|
+
|
|
77
|
+
// Must match the SCREAMING_CASE pattern
|
|
78
|
+
if (!SCREAMING_CASE_RE.test(name)) return;
|
|
79
|
+
|
|
80
|
+
// Only flag inside function bodies
|
|
81
|
+
if (!isInsideFunction(node)) return;
|
|
82
|
+
|
|
83
|
+
context.report({
|
|
84
|
+
node: node.id,
|
|
85
|
+
messageId: 'noScreamingCaseLocal',
|
|
86
|
+
data: { name },
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// Catch SCREAMING_CASE names for function-valued variables at any scope:
|
|
91
|
+
// const MY_FUNCTION = () => {} ❌
|
|
92
|
+
// const MY_FUNCTION = function() {} ❌
|
|
93
|
+
/** @param {import('estree').VariableDeclarator & import('eslint').Rule.NodeParentExtension} node */
|
|
94
|
+
'VariableDeclarator[init.type="ArrowFunctionExpression"], VariableDeclarator[init.type="FunctionExpression"]'(
|
|
95
|
+
node,
|
|
96
|
+
) {
|
|
97
|
+
if (node.id.type !== 'Identifier') return;
|
|
98
|
+
|
|
99
|
+
const { name } = /** @type {import('estree').Identifier} */ (node.id);
|
|
100
|
+
|
|
101
|
+
if (!SCREAMING_CASE_RE.test(name)) return;
|
|
102
|
+
|
|
103
|
+
context.report({
|
|
104
|
+
node: /** @type {import('eslint').Rule.Node} */ (node.id),
|
|
105
|
+
messageId: 'noScreamingCaseFunctionName',
|
|
106
|
+
data: { name },
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
module.exports = noScreamingCaseLocal;
|