@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,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;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows trivial wrapper methods that do nothing but forward all their
|
|
6
|
+
* arguments to another call.
|
|
7
|
+
*
|
|
8
|
+
* These methods add an extra layer of indirection without adding any value.
|
|
9
|
+
* Call sites should invoke the target directly.
|
|
10
|
+
*
|
|
11
|
+
* BAD:
|
|
12
|
+
* setValue(val: string) { this.value.set(val); } // just delegates
|
|
13
|
+
* getItem(key: string) { return this.store.get(key); } // just delegates
|
|
14
|
+
* doThing(a: string, b: number) { this.impl.doThing(a, b); }
|
|
15
|
+
*
|
|
16
|
+
* GOOD:
|
|
17
|
+
* // Call this.value.set(val) directly at the call sites.
|
|
18
|
+
* // If the method wraps something non-trivially (transforms args, adds logic), it is fine.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns true when the param is a plain identifier (no default, no destructuring, no rest).
|
|
23
|
+
* @param {import('@typescript-eslint/types').TSESTree.Parameter} param
|
|
24
|
+
*/
|
|
25
|
+
const isSimpleParam = (param) => param.type === 'Identifier';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the single CallExpression in the method body when the method is a
|
|
29
|
+
* trivial all-args-forwarding wrapper, or null otherwise.
|
|
30
|
+
*
|
|
31
|
+
* Matches both void wrappers:
|
|
32
|
+
* setValue(val) { this.value.set(val); }
|
|
33
|
+
* and return wrappers:
|
|
34
|
+
* get(key) { return this.store.get(key); }
|
|
35
|
+
*
|
|
36
|
+
* @param {import('eslint').Rule.Node} methodNode MethodDefinition node
|
|
37
|
+
* @returns {import('eslint').Rule.Node | null}
|
|
38
|
+
*/
|
|
39
|
+
const getTrivialCallExpr = (methodNode) => {
|
|
40
|
+
const fnValue = methodNode.value;
|
|
41
|
+
if (!fnValue) return null;
|
|
42
|
+
|
|
43
|
+
const params = fnValue.params;
|
|
44
|
+
// Require at least one parameter — zero-param delegate methods are deliberate API surface.
|
|
45
|
+
if (params.length === 0) return null;
|
|
46
|
+
// All params must be simple identifiers (no destructuring, defaults, or rest).
|
|
47
|
+
if (!params.every(isSimpleParam)) return null;
|
|
48
|
+
|
|
49
|
+
const body = fnValue.body;
|
|
50
|
+
if (!body || body.type !== 'BlockStatement') return null;
|
|
51
|
+
if (body.body.length !== 1) return null;
|
|
52
|
+
|
|
53
|
+
const stmt = body.body[0];
|
|
54
|
+
|
|
55
|
+
let callExpr = null;
|
|
56
|
+
if (stmt.type === 'ExpressionStatement') {
|
|
57
|
+
callExpr = stmt.expression;
|
|
58
|
+
} else if (stmt.type === 'ReturnStatement' && stmt.argument) {
|
|
59
|
+
callExpr = stmt.argument;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!callExpr || callExpr.type !== 'CallExpression') return null;
|
|
63
|
+
if (callExpr.arguments.length !== params.length) return null;
|
|
64
|
+
|
|
65
|
+
// All arguments must be the method's own param names in the same order.
|
|
66
|
+
const allMatch = params.every((param, i) => {
|
|
67
|
+
const arg = callExpr.arguments[i];
|
|
68
|
+
return arg.type === 'Identifier' && arg.name === param.name;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return allMatch ? callExpr : null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
75
|
+
const noTrivialWrapperMethod = {
|
|
76
|
+
meta: {
|
|
77
|
+
type: 'suggestion',
|
|
78
|
+
docs: {
|
|
79
|
+
description: 'Disallow trivial wrapper methods that only forward all arguments to another call.',
|
|
80
|
+
recommended: true,
|
|
81
|
+
},
|
|
82
|
+
messages: {
|
|
83
|
+
noTrivialWrapperMethod:
|
|
84
|
+
"'{{name}}' is a trivial wrapper — it only forwards all its arguments to '{{target}}'. Remove it and call '{{target}}' directly at each call site.",
|
|
85
|
+
},
|
|
86
|
+
schema: [],
|
|
87
|
+
},
|
|
88
|
+
create(context) {
|
|
89
|
+
return {
|
|
90
|
+
MethodDefinition(node) {
|
|
91
|
+
// Skip constructors, getters, and setters — they commonly delegate for good reasons.
|
|
92
|
+
if (node.kind === 'constructor' || node.kind === 'get' || node.kind === 'set') return;
|
|
93
|
+
|
|
94
|
+
const callExpr = getTrivialCallExpr(node);
|
|
95
|
+
if (!callExpr) return;
|
|
96
|
+
|
|
97
|
+
const methodName = node.key.type === 'Identifier' ? node.key.name : null;
|
|
98
|
+
if (!methodName) return;
|
|
99
|
+
|
|
100
|
+
const callee = callExpr.callee;
|
|
101
|
+
const targetName =
|
|
102
|
+
callee.type === 'MemberExpression'
|
|
103
|
+
? context.sourceCode.getText(callee)
|
|
104
|
+
: callee.type === 'Identifier'
|
|
105
|
+
? callee.name
|
|
106
|
+
: null;
|
|
107
|
+
|
|
108
|
+
if (!targetName) return;
|
|
109
|
+
|
|
110
|
+
context.report({
|
|
111
|
+
node,
|
|
112
|
+
messageId: 'noTrivialWrapperMethod',
|
|
113
|
+
data: { name: methodName, target: targetName },
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
module.exports = noTrivialWrapperMethod;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
5
|
+
const noTypeOnlyImport = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'suggestion',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Disallow `import type { Foo }` and `import { type Foo }` — use a regular value import instead.',
|
|
10
|
+
recommended: true,
|
|
11
|
+
},
|
|
12
|
+
fixable: 'code',
|
|
13
|
+
messages: {
|
|
14
|
+
noTypeImportDeclaration:
|
|
15
|
+
"Do not use `import type`. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
|
|
16
|
+
noInlineTypeSpecifier:
|
|
17
|
+
"Do not use inline `type` on import specifiers. Use a regular import instead: `import { {{specifiers}} } from '{{source}}';`.",
|
|
18
|
+
},
|
|
19
|
+
schema: [],
|
|
20
|
+
},
|
|
21
|
+
create(context) {
|
|
22
|
+
return {
|
|
23
|
+
ImportDeclaration(node) {
|
|
24
|
+
const decl = /** @type {any} */ (node);
|
|
25
|
+
if (decl.importKind === 'type') {
|
|
26
|
+
const source = /** @type {string} */ (node.source.value);
|
|
27
|
+
const specifiers = node.specifiers.map((s) => s.local.name).join(', ');
|
|
28
|
+
|
|
29
|
+
context.report({
|
|
30
|
+
node,
|
|
31
|
+
messageId: 'noTypeImportDeclaration',
|
|
32
|
+
data: { specifiers, source },
|
|
33
|
+
fix(fixer) {
|
|
34
|
+
const specifierText = node.specifiers.map((s) => s.local.name).join(', ');
|
|
35
|
+
return fixer.replaceText(node, `import { ${specifierText} } from '${source}';`);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const specifier of node.specifiers) {
|
|
43
|
+
const spec = /** @type {any} */ (specifier);
|
|
44
|
+
if (specifier.type === 'ImportSpecifier' && spec.importKind === 'type') {
|
|
45
|
+
const source = /** @type {string} */ (node.source.value);
|
|
46
|
+
const allSpecifiers = node.specifiers
|
|
47
|
+
.map((s) => {
|
|
48
|
+
if (s.type === 'ImportSpecifier') {
|
|
49
|
+
const imported = s.imported.type === 'Identifier' ? s.imported.name : s.imported.value;
|
|
50
|
+
return s.local.name === imported ? imported : `${imported} as ${s.local.name}`;
|
|
51
|
+
}
|
|
52
|
+
return s.local.name;
|
|
53
|
+
})
|
|
54
|
+
.join(', ');
|
|
55
|
+
|
|
56
|
+
context.report({
|
|
57
|
+
node: specifier,
|
|
58
|
+
messageId: 'noInlineTypeSpecifier',
|
|
59
|
+
data: { specifiers: allSpecifiers, source },
|
|
60
|
+
fix(fixer) {
|
|
61
|
+
const sourceCode = context.sourceCode;
|
|
62
|
+
const tokenBefore = sourceCode.getTokenBefore(specifier.imported ?? specifier.local);
|
|
63
|
+
if (tokenBefore && tokenBefore.value === 'type') {
|
|
64
|
+
const tokenAfter = sourceCode.getTokenAfter(tokenBefore);
|
|
65
|
+
if (tokenAfter) {
|
|
66
|
+
return fixer.removeRange([tokenBefore.range[0], tokenAfter.range[0]]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
module.exports = noTypeOnlyImport;
|