@ethlete/eslint-plugin 0.1.0-beta.5
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 +39 -0
- package/README.md +25 -0
- package/package.json +14 -0
- package/src/configs/recommended.js +403 -0
- package/src/index.js +136 -0
- package/src/rules/angular-decorator-property-order.js +205 -0
- package/src/rules/class-constant-property.js +344 -0
- package/src/rules/class-member-order.js +431 -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 +93 -0
- package/src/rules/internals/angular-member-visibility.js +233 -0
- package/src/rules/internals/implemented-contract-members.js +294 -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 +85 -0
- package/src/rules/no-readonly-signal.js +119 -0
- package/src/rules/no-redundant-internal.js +94 -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-typed-injected-element-ref.js +88 -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
- package/src/rules/template-member-accessibility.js +190 -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,85 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getMemberName, isReferencedFromTemplateOrHost } = require('./internals/angular-member-visibility');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {any} node
|
|
8
|
+
*/
|
|
9
|
+
const isInjectCall = (node) => {
|
|
10
|
+
if (!node || node.type !== 'CallExpression') return false;
|
|
11
|
+
return node.callee.type === 'Identifier' && node.callee.name === 'inject';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
15
|
+
const noPublicProperty = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'problem',
|
|
18
|
+
docs: {
|
|
19
|
+
description:
|
|
20
|
+
'Disallow explicit public on class properties, except for injected members that intentionally expose an external API.',
|
|
21
|
+
recommended: true,
|
|
22
|
+
},
|
|
23
|
+
fixable: 'code',
|
|
24
|
+
schema: [],
|
|
25
|
+
messages: {
|
|
26
|
+
noPublicProperty:
|
|
27
|
+
'No public keyword on class properties. The only exception is an injected member that intentionally exposes an external API.',
|
|
28
|
+
shouldBeProtected:
|
|
29
|
+
'Member `{{name}}` should be protected instead of public because it is referenced from a template or host binding.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
create(context) {
|
|
33
|
+
/**
|
|
34
|
+
* @param {any} node
|
|
35
|
+
*/
|
|
36
|
+
const removePublicFix = (node) => {
|
|
37
|
+
const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
|
|
38
|
+
const nextToken = publicToken ? context.sourceCode.getTokenAfter(publicToken) : null;
|
|
39
|
+
if (!publicToken || !nextToken) return null;
|
|
40
|
+
|
|
41
|
+
return (fixer) => fixer.removeRange([publicToken.range[0], nextToken.range[0]]);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {any} node
|
|
46
|
+
*/
|
|
47
|
+
const replacePublicWithProtectedFix = (node) => {
|
|
48
|
+
const publicToken = context.sourceCode.getFirstToken(node, (token) => token.value === 'public');
|
|
49
|
+
if (!publicToken) return null;
|
|
50
|
+
|
|
51
|
+
return (fixer) => fixer.replaceText(publicToken, 'protected');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
PropertyDefinition(node) {
|
|
56
|
+
if (node.accessibility !== 'public') return;
|
|
57
|
+
if (isInjectCall(node.value)) return;
|
|
58
|
+
|
|
59
|
+
const classNode = node.parent && node.parent.type === 'ClassBody' ? node.parent.parent : null;
|
|
60
|
+
const memberName = getMemberName(node);
|
|
61
|
+
const isTemplateVisible =
|
|
62
|
+
classNode && memberName ? isReferencedFromTemplateOrHost(classNode, memberName, context) : false;
|
|
63
|
+
|
|
64
|
+
if (isTemplateVisible) {
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: 'shouldBeProtected',
|
|
68
|
+
data: { name: memberName },
|
|
69
|
+
fix: replacePublicWithProtectedFix(node),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
context.report({
|
|
76
|
+
node,
|
|
77
|
+
messageId: 'noPublicProperty',
|
|
78
|
+
fix: removePublicFix(node),
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
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,94 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
6
|
+
* @param {import('eslint').Rule.Node} node
|
|
7
|
+
*/
|
|
8
|
+
const getAttachedInternalComment = (sourceCode, node) => {
|
|
9
|
+
const comments = sourceCode.getCommentsBefore(node);
|
|
10
|
+
const attachedComment = comments[comments.length - 1];
|
|
11
|
+
|
|
12
|
+
if (!attachedComment || attachedComment.type !== 'Block') {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const textBetween = sourceCode.text.slice(attachedComment.range[1], node.range[0]);
|
|
17
|
+
|
|
18
|
+
if (textBetween.trim() !== '') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return /@internal\b/u.test(attachedComment.value) ? attachedComment : null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {any} node
|
|
27
|
+
*/
|
|
28
|
+
const getMemberName = (node) => {
|
|
29
|
+
if (node.key?.type === 'Identifier') return node.key.name;
|
|
30
|
+
if (node.key?.type === 'Literal' && typeof node.key.value === 'string') return node.key.value;
|
|
31
|
+
|
|
32
|
+
return 'member';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {any} node
|
|
37
|
+
*/
|
|
38
|
+
const getMemberKind = (node) => {
|
|
39
|
+
if (node.type === 'MethodDefinition') return 'method';
|
|
40
|
+
return 'property';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
44
|
+
const noRedundantInternal = {
|
|
45
|
+
meta: {
|
|
46
|
+
type: 'suggestion',
|
|
47
|
+
docs: {
|
|
48
|
+
description: 'Disallow redundant @internal usage on class members that are already hidden by accessibility.',
|
|
49
|
+
recommended: true,
|
|
50
|
+
},
|
|
51
|
+
fixable: 'code',
|
|
52
|
+
schema: [],
|
|
53
|
+
messages: {
|
|
54
|
+
redundantInternal:
|
|
55
|
+
'`@internal` is redundant on {{kind}} `{{name}}` because {{accessibility}} members are already hidden from consumers.',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
create(context) {
|
|
59
|
+
const sourceCode = context.sourceCode;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {any} node
|
|
63
|
+
*/
|
|
64
|
+
const checkMember = (node) => {
|
|
65
|
+
const internalComment = getAttachedInternalComment(sourceCode, node);
|
|
66
|
+
|
|
67
|
+
if (!internalComment) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (node.accessibility !== 'private' && node.accessibility !== 'protected') {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
context.report({
|
|
76
|
+
node: internalComment,
|
|
77
|
+
messageId: 'redundantInternal',
|
|
78
|
+
data: {
|
|
79
|
+
accessibility: node.accessibility,
|
|
80
|
+
kind: getMemberKind(node),
|
|
81
|
+
name: getMemberName(node),
|
|
82
|
+
},
|
|
83
|
+
fix: (fixer) => fixer.removeRange([internalComment.range[0], node.range[0]]),
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
PropertyDefinition: checkMember,
|
|
89
|
+
MethodDefinition: checkMember,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
module.exports = noRedundantInternal;
|