@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,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;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @typedef {'Component' | 'Directive' | 'Pipe'} TDecoratorName */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('estree').Property['key']} key
|
|
8
|
+
*/
|
|
9
|
+
const getPropertyName = (key) => {
|
|
10
|
+
if (key.type === 'Identifier') return key.name;
|
|
11
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {import('eslint').Rule.Node} node
|
|
17
|
+
* @returns {TDecoratorName | null}
|
|
18
|
+
*/
|
|
19
|
+
const getDecoratorName = (node) => {
|
|
20
|
+
const decorator = /** @type {any} */ (node);
|
|
21
|
+
if (decorator.type !== 'Decorator') return null;
|
|
22
|
+
|
|
23
|
+
const expression = decorator.expression;
|
|
24
|
+
if (expression.type === 'CallExpression') {
|
|
25
|
+
return expression.callee.type === 'Identifier' ? expression.callee.name : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return expression.type === 'Identifier' ? expression.name : null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
33
|
+
* @param {any} metadata
|
|
34
|
+
* @param {any} property
|
|
35
|
+
*/
|
|
36
|
+
const getRemovalRange = (sourceCode, metadata, property) => {
|
|
37
|
+
const properties = metadata.properties.filter((entry) => entry.type === 'Property');
|
|
38
|
+
const propertyIndex = properties.indexOf(property);
|
|
39
|
+
const openingBrace = sourceCode.getFirstToken(metadata);
|
|
40
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
41
|
+
|
|
42
|
+
if (!openingBrace || !closingBrace) return property.range;
|
|
43
|
+
if (properties.length === 1) return [openingBrace.range[1], closingBrace.range[0]];
|
|
44
|
+
if (propertyIndex < properties.length - 1) return [property.range[0], properties[propertyIndex + 1].range[0]];
|
|
45
|
+
|
|
46
|
+
return [properties[propertyIndex - 1].range[1], property.range[1]];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
50
|
+
const noStandaloneFlag = {
|
|
51
|
+
meta: {
|
|
52
|
+
type: 'suggestion',
|
|
53
|
+
docs: {
|
|
54
|
+
description:
|
|
55
|
+
'Disallow standalone metadata on Angular decorators because standalone is the default and should be omitted.',
|
|
56
|
+
},
|
|
57
|
+
fixable: 'code',
|
|
58
|
+
schema: [],
|
|
59
|
+
messages: {
|
|
60
|
+
noStandalone: 'Remove standalone from Angular metadata. Standalone is implicit and should not be declared.',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
create(context) {
|
|
64
|
+
const sourceCode = context.sourceCode;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
Decorator(node) {
|
|
68
|
+
const decoratorName = getDecoratorName(node);
|
|
69
|
+
if (decoratorName !== 'Component' && decoratorName !== 'Directive' && decoratorName !== 'Pipe') return;
|
|
70
|
+
|
|
71
|
+
const decorator = /** @type {any} */ (node);
|
|
72
|
+
const expression = decorator.expression;
|
|
73
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
74
|
+
|
|
75
|
+
const metadata = expression.arguments[0];
|
|
76
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
77
|
+
|
|
78
|
+
for (const property of metadata.properties) {
|
|
79
|
+
if (property.type !== 'Property') continue;
|
|
80
|
+
if (getPropertyName(property.key) !== 'standalone') continue;
|
|
81
|
+
|
|
82
|
+
context.report({
|
|
83
|
+
node: property,
|
|
84
|
+
messageId: 'noStandalone',
|
|
85
|
+
fix(fixer) {
|
|
86
|
+
return fixer.replaceTextRange(getRemovalRange(sourceCode, metadata, property), '');
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
module.exports = noStandaloneFlag;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows calling .subscribe() inside a .pipe() callback.
|
|
6
|
+
*
|
|
7
|
+
* BAD:
|
|
8
|
+
* obs$.pipe(
|
|
9
|
+
* tap(() => {
|
|
10
|
+
* other$.subscribe(); // ❌ creates an unmanaged subscription
|
|
11
|
+
* }),
|
|
12
|
+
* ).subscribe();
|
|
13
|
+
*
|
|
14
|
+
* GOOD:
|
|
15
|
+
* obs$.pipe(
|
|
16
|
+
* switchMap(() => other$), // ✅ compose instead
|
|
17
|
+
* ).subscribe();
|
|
18
|
+
*
|
|
19
|
+
* obs$.pipe(...operators).subscribe(); // ✅ subscribe on the result of pipe
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
23
|
+
const noSubscribeInPipe = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'problem',
|
|
26
|
+
docs: {
|
|
27
|
+
description: 'Disallow .subscribe() calls inside .pipe() callbacks.',
|
|
28
|
+
recommended: true,
|
|
29
|
+
},
|
|
30
|
+
messages: {
|
|
31
|
+
noSubscribeInPipe:
|
|
32
|
+
'Avoid calling .subscribe() inside a .pipe() callback. Compose with operators (switchMap, mergeMap, etc.) instead.',
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
/**
|
|
38
|
+
* Returns true if the given node is nested inside a callback function that
|
|
39
|
+
* is itself (directly or indirectly) an argument to a .pipe() call.
|
|
40
|
+
* @param {import('eslint').Rule.Node} node
|
|
41
|
+
*/
|
|
42
|
+
const isInsidePipeCallback = (node) => {
|
|
43
|
+
let crossedFunctionBoundary = false;
|
|
44
|
+
let current = node.parent;
|
|
45
|
+
while (current) {
|
|
46
|
+
// A `new Observable(factory)` is its own subscription boundary — subscribing
|
|
47
|
+
// inside it is intentional (inner subscription management) and should not
|
|
48
|
+
// be flagged even if the Observable itself is passed into a .pipe() operator.
|
|
49
|
+
if (
|
|
50
|
+
current.type === 'NewExpression' &&
|
|
51
|
+
current.callee.type === 'Identifier' &&
|
|
52
|
+
current.callee.name === 'Observable'
|
|
53
|
+
) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
|
|
57
|
+
crossedFunctionBoundary = true;
|
|
58
|
+
}
|
|
59
|
+
if (
|
|
60
|
+
crossedFunctionBoundary &&
|
|
61
|
+
current.type === 'CallExpression' &&
|
|
62
|
+
current.callee.type === 'MemberExpression' &&
|
|
63
|
+
current.callee.property.type === 'Identifier' &&
|
|
64
|
+
current.callee.property.name === 'pipe'
|
|
65
|
+
) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
current = current.parent;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
CallExpression(node) {
|
|
75
|
+
const { callee } = node;
|
|
76
|
+
if (
|
|
77
|
+
callee.type === 'MemberExpression' &&
|
|
78
|
+
callee.property.type === 'Identifier' &&
|
|
79
|
+
callee.property.name === 'subscribe' &&
|
|
80
|
+
isInsidePipeCallback(node)
|
|
81
|
+
) {
|
|
82
|
+
context.report({ node, messageId: 'noSubscribeInPipe' });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
module.exports = noSubscribeInPipe;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows non-empty function bodies inside `.subscribe()` calls.
|
|
6
|
+
* Side effects must be moved into a `tap()` operator inside the pipe instead.
|
|
7
|
+
*
|
|
8
|
+
* ❌ someObs$.subscribe((res) => console.log(res));
|
|
9
|
+
* ❌ someObs$.subscribe((res) => { doSomething(res); });
|
|
10
|
+
* ❌ someObs$.subscribe({ next: (res) => doSomething(res) });
|
|
11
|
+
*
|
|
12
|
+
* ✅ someObs$.pipe(tap((res) => console.log(res))).subscribe();
|
|
13
|
+
* ✅ someObs$.subscribe();
|
|
14
|
+
* ✅ someObs$.subscribe(() => {}); // empty body is fine
|
|
15
|
+
*
|
|
16
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
17
|
+
*/
|
|
18
|
+
const rule = {
|
|
19
|
+
meta: {
|
|
20
|
+
type: 'suggestion',
|
|
21
|
+
docs: {
|
|
22
|
+
description: 'Require subscribe() to be called with an empty body. Move side effects into tap() inside the pipe.',
|
|
23
|
+
},
|
|
24
|
+
schema: [],
|
|
25
|
+
messages: {
|
|
26
|
+
noSubscribeBody: 'Keep subscribe() empty. Move side effects into a tap() operator inside the pipe.',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
/**
|
|
31
|
+
* Returns true if the function node has a non-empty body.
|
|
32
|
+
* @param {import('eslint').Rule.Node} fn
|
|
33
|
+
*/
|
|
34
|
+
const hasBody = (fn) => {
|
|
35
|
+
const node = /** @type {any} */ (fn);
|
|
36
|
+
if (node.type === 'ArrowFunctionExpression') {
|
|
37
|
+
// Concise arrow: `(x) => x.foo` — always has a value expression
|
|
38
|
+
if (node.body.type !== 'BlockStatement') return true;
|
|
39
|
+
return node.body.body.length > 0;
|
|
40
|
+
}
|
|
41
|
+
if (node.type === 'FunctionExpression') {
|
|
42
|
+
return node.body.body.length > 0;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
const callee = /** @type {any} */ (node.callee);
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
callee.type !== 'MemberExpression' ||
|
|
53
|
+
callee.property.type !== 'Identifier' ||
|
|
54
|
+
callee.property.name !== 'subscribe'
|
|
55
|
+
) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const args = /** @type {any[]} */ (node.arguments);
|
|
60
|
+
|
|
61
|
+
// Skip non-RxJS subscribe patterns where the first argument is a string
|
|
62
|
+
// literal (e.g. Facebook SDK: player.subscribe('eventName', callback)).
|
|
63
|
+
// RxJS .subscribe() never takes a string as its first argument.
|
|
64
|
+
if (args.length > 0 && args[0].type === 'Literal' && typeof args[0].value === 'string') {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const arg of args) {
|
|
69
|
+
// Direct function argument: .subscribe((res) => ...)
|
|
70
|
+
if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
|
|
71
|
+
if (hasBody(arg)) {
|
|
72
|
+
context.report({ node, messageId: 'noSubscribeBody' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Observer object: .subscribe({ next: (res) => ..., error: ..., complete: ... })
|
|
79
|
+
if (arg.type === 'ObjectExpression') {
|
|
80
|
+
for (const prop of arg.properties) {
|
|
81
|
+
if (prop.type !== 'Property') continue;
|
|
82
|
+
const val = /** @type {any} */ (prop.value);
|
|
83
|
+
if ((val.type === 'ArrowFunctionExpression' || val.type === 'FunctionExpression') && hasBody(val)) {
|
|
84
|
+
context.report({ node, messageId: 'noSubscribeBody' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
module.exports = rule;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows trivially-inferable explicit return type annotations on function
|
|
6
|
+
* implementations. TypeScript infers `void`, `boolean`, `string`, `number`,
|
|
7
|
+
* `undefined`, and `null` without help; writing them out adds noise.
|
|
8
|
+
*
|
|
9
|
+
* BAD: const go = (): void => { ... }
|
|
10
|
+
* GOOD: const go = () => { ... }
|
|
11
|
+
*
|
|
12
|
+
* ALSO GOOD (non-trivial, keep it):
|
|
13
|
+
* const parse = (): Date => new Date(value)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const TRIVIAL_TYPES = new Set([
|
|
17
|
+
'TSVoidKeyword',
|
|
18
|
+
'TSBooleanKeyword',
|
|
19
|
+
'TSStringKeyword',
|
|
20
|
+
'TSNumberKeyword',
|
|
21
|
+
'TSUndefinedKeyword',
|
|
22
|
+
'TSNullKeyword',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const TRIVIAL_LABELS = {
|
|
26
|
+
TSVoidKeyword: 'void',
|
|
27
|
+
TSBooleanKeyword: 'boolean',
|
|
28
|
+
TSStringKeyword: 'string',
|
|
29
|
+
TSNumberKeyword: 'number',
|
|
30
|
+
TSUndefinedKeyword: 'undefined',
|
|
31
|
+
TSNullKeyword: 'null',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
35
|
+
const noTrivialReturnType = {
|
|
36
|
+
meta: {
|
|
37
|
+
type: 'suggestion',
|
|
38
|
+
fixable: 'code',
|
|
39
|
+
docs: {
|
|
40
|
+
description:
|
|
41
|
+
'Disallow explicit return type annotations that TypeScript can infer (void, boolean, string, number, undefined, null).',
|
|
42
|
+
recommended: true,
|
|
43
|
+
},
|
|
44
|
+
messages: {
|
|
45
|
+
trivialReturnType: "Omit the explicit ': {{type}}' return type — TypeScript infers it.",
|
|
46
|
+
},
|
|
47
|
+
schema: [],
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
/**
|
|
51
|
+
* Walk up the AST to detect type-level contexts (type aliases, interface bodies,
|
|
52
|
+
* abstract method signatures) where return type annotations are required / expected.
|
|
53
|
+
* @param {import('eslint').Rule.Node} node
|
|
54
|
+
*/
|
|
55
|
+
const isInsideTypeContext = (node) => {
|
|
56
|
+
let current = node.parent;
|
|
57
|
+
while (current) {
|
|
58
|
+
const t = current.type;
|
|
59
|
+
// Type-level signatures — not implementations
|
|
60
|
+
if (t === 'TSTypeLiteral' || t === 'TSInterfaceBody' || t === 'TSTypeAliasDeclaration') {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
// Abstract method declarations (separate AST node via @typescript-eslint/parser)
|
|
64
|
+
if (t === 'TSAbstractMethodDefinition' || t === 'TSMethodSignature') {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
current = current.parent;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {import('eslint').Rule.Node} node
|
|
74
|
+
*/
|
|
75
|
+
const checkReturnType = (node) => {
|
|
76
|
+
// @typescript-eslint/parser adds `returnType` to function nodes at runtime
|
|
77
|
+
// even though the base ESTree type doesn't declare it.
|
|
78
|
+
const fn = /** @type {any} */ (node);
|
|
79
|
+
if (!fn.returnType) return;
|
|
80
|
+
if (isInsideTypeContext(node)) return;
|
|
81
|
+
|
|
82
|
+
const annotation = fn.returnType.typeAnnotation;
|
|
83
|
+
if (!TRIVIAL_TYPES.has(annotation.type)) return;
|
|
84
|
+
|
|
85
|
+
const label = TRIVIAL_LABELS[annotation.type] ?? annotation.type;
|
|
86
|
+
|
|
87
|
+
context.report({
|
|
88
|
+
node: fn.returnType,
|
|
89
|
+
messageId: 'trivialReturnType',
|
|
90
|
+
data: { type: label },
|
|
91
|
+
fix(fixer) {
|
|
92
|
+
return fixer.remove(fn.returnType);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ArrowFunctionExpression: checkReturnType,
|
|
99
|
+
FunctionExpression: checkReturnType,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
module.exports = noTrivialReturnType;
|