@antithrow/eslint-plugin 1.0.0 → 1.1.0
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/README.md +12 -10
- package/dist/index.js +3 -1
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.js +1 -0
- package/dist/rules/no-unsafe-unwrap.d.ts +11 -0
- package/dist/rules/no-unsafe-unwrap.js +182 -0
- package/dist/rules/no-unused-result.d.ts +7 -1
- package/dist/rules/no-unused-result.js +45 -30
- package/dist/rules/utils/result-type.d.ts +19 -0
- package/dist/rules/utils/result-type.js +77 -0
- package/dist/rules/utils/test-utils.d.ts +3 -0
- package/dist/rules/utils/test-utils.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ Add the recommended config to your `eslint.config.ts`:
|
|
|
24
24
|
import antithrow from "@antithrow/eslint-plugin";
|
|
25
25
|
|
|
26
26
|
export default [
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// ... your other configs
|
|
28
|
+
antithrow.configs.recommended,
|
|
29
29
|
];
|
|
30
30
|
```
|
|
31
31
|
|
|
@@ -35,14 +35,15 @@ Or configure rules individually:
|
|
|
35
35
|
import antithrow from "@antithrow/eslint-plugin";
|
|
36
36
|
|
|
37
37
|
export default [
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
{
|
|
39
|
+
plugins: {
|
|
40
|
+
"@antithrow": antithrow,
|
|
41
|
+
},
|
|
42
|
+
rules: {
|
|
43
|
+
"@antithrow/no-unsafe-unwrap": "warn",
|
|
44
|
+
"@antithrow/no-unused-result": "error",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
46
47
|
];
|
|
47
48
|
```
|
|
48
49
|
|
|
@@ -50,4 +51,5 @@ export default [
|
|
|
50
51
|
|
|
51
52
|
| Rule | Description | Recommended |
|
|
52
53
|
| --- | --- | --- |
|
|
54
|
+
| [`no-unsafe-unwrap`](./docs/rules/no-unsafe-unwrap.md) | Disallow `unwrap`/`expect` APIs on antithrow `Result` values | `warn` |
|
|
53
55
|
| [`no-unused-result`](./docs/rules/no-unused-result.md) | Require `Result` and `ResultAsync` values to be used | `error` |
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import packageJson from "../package.json" with { type: "json" };
|
|
2
|
-
import { noUnusedResult } from "./rules/index.js";
|
|
2
|
+
import { noUnsafeUnwrap, noUnusedResult } from "./rules/index.js";
|
|
3
3
|
const plugin = {
|
|
4
4
|
meta: {
|
|
5
5
|
name: packageJson.name,
|
|
6
6
|
version: packageJson.version,
|
|
7
7
|
},
|
|
8
8
|
rules: {
|
|
9
|
+
"no-unsafe-unwrap": noUnsafeUnwrap,
|
|
9
10
|
"no-unused-result": noUnusedResult,
|
|
10
11
|
},
|
|
11
12
|
configs: {},
|
|
@@ -17,6 +18,7 @@ plugin.configs = {
|
|
|
17
18
|
"@antithrow": plugin,
|
|
18
19
|
},
|
|
19
20
|
rules: {
|
|
21
|
+
"@antithrow/no-unsafe-unwrap": "warn",
|
|
20
22
|
"@antithrow/no-unused-result": "error",
|
|
21
23
|
},
|
|
22
24
|
},
|
package/dist/rules/index.d.ts
CHANGED
package/dist/rules/index.js
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
/** @lintignore */
|
|
3
|
+
export declare const MessageId: {
|
|
4
|
+
readonly UNSAFE_UNWRAP: "unsafeUnwrap";
|
|
5
|
+
readonly UNWRAP_OK_VALUE: "unwrapOkValue";
|
|
6
|
+
readonly UNWRAP_ERR_ERROR: "unwrapErrError";
|
|
7
|
+
};
|
|
8
|
+
export type MessageId = (typeof MessageId)[keyof typeof MessageId];
|
|
9
|
+
export declare const noUnsafeUnwrap: ESLintUtils.RuleModule<MessageId, [], import("../create-rule.js").AntithrowPluginDocs, ESLintUtils.RuleListener> & {
|
|
10
|
+
name: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
import { createRule } from "../create-rule.js";
|
|
3
|
+
import { getResultVariant, isResultType, ResultVariant } from "./utils/result-type.js";
|
|
4
|
+
/** @lintignore */
|
|
5
|
+
export const MessageId = {
|
|
6
|
+
UNSAFE_UNWRAP: "unsafeUnwrap",
|
|
7
|
+
UNWRAP_OK_VALUE: "unwrapOkValue",
|
|
8
|
+
UNWRAP_ERR_ERROR: "unwrapErrError",
|
|
9
|
+
};
|
|
10
|
+
const BANNED_METHOD_NAMES = new Set(["unwrap", "unwrapErr", "expect", "expectErr"]);
|
|
11
|
+
const FIXABLE_OK_METHOD_NAMES = new Set(["unwrap"]);
|
|
12
|
+
const FIXABLE_ERR_METHOD_NAMES = new Set(["unwrapErr"]);
|
|
13
|
+
/**
|
|
14
|
+
* Extracts the property name from a `MemberExpression` when it can be
|
|
15
|
+
* statically determined. Handles `obj.prop`, `obj["prop"]`, and
|
|
16
|
+
* `` obj[`prop`] `` (template literals with no interpolations).
|
|
17
|
+
* Returns `null` for dynamic access like `obj[variable]`.
|
|
18
|
+
*/
|
|
19
|
+
function getStaticMemberName(node) {
|
|
20
|
+
if (!node.computed && node.property.type === "Identifier") {
|
|
21
|
+
return node.property.name;
|
|
22
|
+
}
|
|
23
|
+
if (node.computed &&
|
|
24
|
+
node.property.type === "Literal" &&
|
|
25
|
+
typeof node.property.value === "string") {
|
|
26
|
+
return node.property.value;
|
|
27
|
+
}
|
|
28
|
+
if (node.computed &&
|
|
29
|
+
node.property.type === "TemplateLiteral" &&
|
|
30
|
+
node.property.expressions.length === 0) {
|
|
31
|
+
const [quasi] = node.property.quasis;
|
|
32
|
+
return quasi?.value.cooked ?? null;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Like {@link getStaticMemberName} but for destructuring patterns.
|
|
38
|
+
* Extracts the key name from `{ key: value }` in an `ObjectPattern`.
|
|
39
|
+
* Handles identifier keys, string literal keys, and static template
|
|
40
|
+
* literal keys. Returns `null` for computed keys with dynamic expressions.
|
|
41
|
+
*/
|
|
42
|
+
function getDestructuredPropertyName(node) {
|
|
43
|
+
if (node.computed) {
|
|
44
|
+
if (node.key.type === "Literal" && typeof node.key.value === "string") {
|
|
45
|
+
return node.key.value;
|
|
46
|
+
}
|
|
47
|
+
if (node.key.type === "TemplateLiteral" && node.key.expressions.length === 0) {
|
|
48
|
+
const [quasi] = node.key.quasis;
|
|
49
|
+
return quasi?.value.cooked ?? null;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (node.key.type === "Identifier") {
|
|
54
|
+
return node.key.name;
|
|
55
|
+
}
|
|
56
|
+
if (node.key.type === "Literal" && typeof node.key.value === "string") {
|
|
57
|
+
return node.key.value;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
function getCallExpression(node) {
|
|
62
|
+
if (node.parent.type === "CallExpression" && node.parent.callee === node) {
|
|
63
|
+
return node.parent;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function getFixedMemberExpressionText(node, method, propertyName, sourceCode) {
|
|
68
|
+
const memberText = sourceCode.getText(node);
|
|
69
|
+
const propertyText = sourceCode.getText(node.property);
|
|
70
|
+
const oldSuffix = node.computed
|
|
71
|
+
? `${node.optional ? "?.[" : "["}${propertyText}]`
|
|
72
|
+
: `${node.optional ? "?." : "."}${method}`;
|
|
73
|
+
if (!memberText.endsWith(oldSuffix)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const baseText = memberText.slice(0, memberText.length - oldSuffix.length);
|
|
77
|
+
return `${baseText}${node.optional ? "?." : "."}${propertyName}`;
|
|
78
|
+
}
|
|
79
|
+
export const noUnsafeUnwrap = createRule({
|
|
80
|
+
name: "no-unsafe-unwrap",
|
|
81
|
+
meta: {
|
|
82
|
+
type: "problem",
|
|
83
|
+
fixable: "code",
|
|
84
|
+
docs: {
|
|
85
|
+
description: "Disallow unsafe unwrap APIs on Result and ResultAsync values to prevent unexpected throws.",
|
|
86
|
+
recommended: true,
|
|
87
|
+
requiresTypeChecking: true,
|
|
88
|
+
},
|
|
89
|
+
messages: {
|
|
90
|
+
[MessageId.UNSAFE_UNWRAP]: "Avoid `{{ method }}` on Result values. Handle both branches explicitly instead.",
|
|
91
|
+
[MessageId.UNWRAP_OK_VALUE]: "`{{ method }}` on `Ok` is unnecessary. Use `.value` instead.",
|
|
92
|
+
[MessageId.UNWRAP_ERR_ERROR]: "`{{ method }}` on `Err` is unnecessary. Use `.error` instead.",
|
|
93
|
+
},
|
|
94
|
+
schema: [],
|
|
95
|
+
},
|
|
96
|
+
defaultOptions: [],
|
|
97
|
+
create(context) {
|
|
98
|
+
const services = ESLintUtils.getParserServices(context);
|
|
99
|
+
const checker = services.program.getTypeChecker();
|
|
100
|
+
return {
|
|
101
|
+
MemberExpression(node) {
|
|
102
|
+
const method = getStaticMemberName(node);
|
|
103
|
+
if (!method || !BANNED_METHOD_NAMES.has(method)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(node.object);
|
|
107
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
108
|
+
if (!isResultType(type)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const callExpression = getCallExpression(node);
|
|
112
|
+
const variant = getResultVariant(type);
|
|
113
|
+
if (callExpression && variant === ResultVariant.OK && FIXABLE_OK_METHOD_NAMES.has(method)) {
|
|
114
|
+
context.report({
|
|
115
|
+
node,
|
|
116
|
+
messageId: MessageId.UNWRAP_OK_VALUE,
|
|
117
|
+
data: { method },
|
|
118
|
+
fix(fixer) {
|
|
119
|
+
const fixedText = getFixedMemberExpressionText(node, method, "value", context.sourceCode);
|
|
120
|
+
if (!fixedText) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return fixer.replaceText(callExpression, fixedText);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (callExpression &&
|
|
129
|
+
variant === ResultVariant.ERR &&
|
|
130
|
+
FIXABLE_ERR_METHOD_NAMES.has(method)) {
|
|
131
|
+
context.report({
|
|
132
|
+
node,
|
|
133
|
+
messageId: MessageId.UNWRAP_ERR_ERROR,
|
|
134
|
+
data: { method },
|
|
135
|
+
fix(fixer) {
|
|
136
|
+
const fixedText = getFixedMemberExpressionText(node, method, "error", context.sourceCode);
|
|
137
|
+
if (!fixedText) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return fixer.replaceText(callExpression, fixedText);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
context.report({
|
|
146
|
+
node,
|
|
147
|
+
messageId: MessageId.UNSAFE_UNWRAP,
|
|
148
|
+
data: { method },
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
Property(node) {
|
|
152
|
+
if (node.parent.type !== "ObjectPattern") {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const method = getDestructuredPropertyName(node);
|
|
156
|
+
if (!method || !BANNED_METHOD_NAMES.has(method)) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Resolve the node whose type represents the value being destructured.
|
|
160
|
+
// For most contexts (variable declarations, function parameters, for-of
|
|
161
|
+
// loops), the ObjectPattern itself carries the correct type. However, in
|
|
162
|
+
// assignment destructuring (`({ unwrap } = result)`), the pattern's type
|
|
163
|
+
// reflects the target bindings, not the source, so we use the RHS instead.
|
|
164
|
+
const pattern = node.parent;
|
|
165
|
+
let typeSourceNode = pattern;
|
|
166
|
+
if (pattern.parent.type === "AssignmentExpression" && pattern.parent.left === pattern) {
|
|
167
|
+
typeSourceNode = pattern.parent.right;
|
|
168
|
+
}
|
|
169
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(typeSourceNode);
|
|
170
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
171
|
+
if (!isResultType(type)) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
context.report({
|
|
175
|
+
node,
|
|
176
|
+
messageId: MessageId.UNSAFE_UNWRAP,
|
|
177
|
+
data: { method },
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
-
|
|
2
|
+
/** @lintignore */
|
|
3
|
+
export declare const MessageId: {
|
|
4
|
+
readonly UNUSED_RESULT: "unusedResult";
|
|
5
|
+
readonly ADD_VOID: "addVoid";
|
|
6
|
+
};
|
|
7
|
+
export type MessageId = (typeof MessageId)[keyof typeof MessageId];
|
|
8
|
+
export declare const noUnusedResult: ESLintUtils.RuleModule<MessageId, [], import("../create-rule.js").AntithrowPluginDocs, ESLintUtils.RuleListener> & {
|
|
3
9
|
name: string;
|
|
4
10
|
};
|
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
-
import ts from "typescript";
|
|
3
2
|
import { createRule } from "../create-rule.js";
|
|
4
|
-
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { isResultType } from "./utils/result-type.js";
|
|
4
|
+
function needsParentheses(node) {
|
|
5
|
+
switch (node.type) {
|
|
6
|
+
case "SequenceExpression":
|
|
7
|
+
case "AssignmentExpression":
|
|
8
|
+
case "ConditionalExpression":
|
|
9
|
+
case "LogicalExpression":
|
|
10
|
+
case "BinaryExpression":
|
|
11
|
+
case "TSAsExpression":
|
|
12
|
+
case "TSSatisfiesExpression":
|
|
13
|
+
return true;
|
|
14
|
+
default:
|
|
15
|
+
return false;
|
|
8
16
|
}
|
|
9
|
-
const flags = type.getFlags();
|
|
10
|
-
if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) {
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
const symbol = type.getSymbol();
|
|
14
|
-
if (!symbol || !RESULT_TYPE_NAMES.has(symbol.getName())) {
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
const declarations = symbol.getDeclarations() ?? [];
|
|
18
|
-
return declarations.some((decl) => {
|
|
19
|
-
const sourceFile = decl.getSourceFile();
|
|
20
|
-
return sourceFile.fileName.includes("antithrow");
|
|
21
|
-
});
|
|
22
17
|
}
|
|
18
|
+
/** @lintignore */
|
|
19
|
+
export const MessageId = {
|
|
20
|
+
UNUSED_RESULT: "unusedResult",
|
|
21
|
+
ADD_VOID: "addVoid",
|
|
22
|
+
};
|
|
23
23
|
export const noUnusedResult = createRule({
|
|
24
24
|
name: "no-unused-result",
|
|
25
25
|
meta: {
|
|
26
26
|
type: "problem",
|
|
27
|
+
hasSuggestions: true,
|
|
27
28
|
docs: {
|
|
28
29
|
description: "Require Result and ResultAsync values to be used, preventing silently ignored errors.",
|
|
29
30
|
recommended: true,
|
|
30
31
|
requiresTypeChecking: true,
|
|
31
32
|
},
|
|
32
33
|
messages: {
|
|
33
|
-
|
|
34
|
+
[MessageId.UNUSED_RESULT]: "This Result must be used. Handle the error case or explicitly discard it with `void`.",
|
|
35
|
+
[MessageId.ADD_VOID]: "Explicitly discard the Result with `void`.",
|
|
34
36
|
},
|
|
35
37
|
schema: [],
|
|
36
38
|
},
|
|
@@ -38,32 +40,33 @@ export const noUnusedResult = createRule({
|
|
|
38
40
|
create(context) {
|
|
39
41
|
const services = ESLintUtils.getParserServices(context);
|
|
40
42
|
const checker = services.program.getTypeChecker();
|
|
41
|
-
function checkExpression(node) {
|
|
43
|
+
function checkExpression(node, statement) {
|
|
42
44
|
switch (node.type) {
|
|
43
45
|
case "UnaryExpression":
|
|
44
46
|
if (node.operator === "void") {
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
|
-
|
|
49
|
+
checkExpression(node.argument, statement);
|
|
50
|
+
return;
|
|
48
51
|
case "ConditionalExpression":
|
|
49
|
-
checkExpression(node.consequent);
|
|
50
|
-
checkExpression(node.alternate);
|
|
52
|
+
checkExpression(node.consequent, statement);
|
|
53
|
+
checkExpression(node.alternate, statement);
|
|
51
54
|
return;
|
|
52
55
|
case "LogicalExpression":
|
|
53
|
-
checkExpression(node.left);
|
|
54
|
-
checkExpression(node.right);
|
|
56
|
+
checkExpression(node.left, statement);
|
|
57
|
+
checkExpression(node.right, statement);
|
|
55
58
|
return;
|
|
56
59
|
case "SequenceExpression":
|
|
57
60
|
for (const expr of node.expressions) {
|
|
58
|
-
checkExpression(expr);
|
|
61
|
+
checkExpression(expr, statement);
|
|
59
62
|
}
|
|
60
63
|
return;
|
|
61
64
|
case "TSAsExpression":
|
|
62
65
|
case "TSNonNullExpression":
|
|
63
|
-
checkExpression(node.expression);
|
|
66
|
+
checkExpression(node.expression, statement);
|
|
64
67
|
return;
|
|
65
68
|
case "ChainExpression":
|
|
66
|
-
checkExpression(node.expression);
|
|
69
|
+
checkExpression(node.expression, statement);
|
|
67
70
|
return;
|
|
68
71
|
}
|
|
69
72
|
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
|
@@ -73,12 +76,24 @@ export const noUnusedResult = createRule({
|
|
|
73
76
|
}
|
|
74
77
|
context.report({
|
|
75
78
|
node,
|
|
76
|
-
messageId:
|
|
79
|
+
messageId: MessageId.UNUSED_RESULT,
|
|
80
|
+
suggest: [
|
|
81
|
+
{
|
|
82
|
+
messageId: MessageId.ADD_VOID,
|
|
83
|
+
fix(fixer) {
|
|
84
|
+
const expr = statement.expression;
|
|
85
|
+
if (needsParentheses(expr)) {
|
|
86
|
+
return [fixer.insertTextBefore(expr, "void ("), fixer.insertTextAfter(expr, ")")];
|
|
87
|
+
}
|
|
88
|
+
return fixer.insertTextBefore(expr, "void ");
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
77
92
|
});
|
|
78
93
|
}
|
|
79
94
|
return {
|
|
80
95
|
ExpressionStatement(node) {
|
|
81
|
-
checkExpression(node.expression);
|
|
96
|
+
checkExpression(node.expression, node);
|
|
82
97
|
},
|
|
83
98
|
};
|
|
84
99
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export declare const ResultVariant: {
|
|
3
|
+
readonly NONE: "none";
|
|
4
|
+
readonly OK: "ok";
|
|
5
|
+
readonly ERR: "err";
|
|
6
|
+
readonly MIXED: "mixed";
|
|
7
|
+
};
|
|
8
|
+
export type ResultVariant = (typeof ResultVariant)[keyof typeof ResultVariant];
|
|
9
|
+
export declare function getResultVariant(type: ts.Type): ResultVariant;
|
|
10
|
+
/**
|
|
11
|
+
* Determines whether a type originates from antithrow's `Result` family.
|
|
12
|
+
*
|
|
13
|
+
* `Result<T, E>` is a union of `Ok<T, E> | Err<T, E>`, so we recurse into
|
|
14
|
+
* union members. We also guard against `any`/`unknown`/`never` to avoid
|
|
15
|
+
* false positives on untyped code. Finally, we verify the symbol's
|
|
16
|
+
* declaration lives inside the `antithrow` package so that unrelated types
|
|
17
|
+
* with the same names (e.g. a user-defined `Ok` class) are not flagged.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isResultType(type: ts.Type): boolean;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
const RESULT_TYPE_NAMES = new Set(["Ok", "Err", "ResultAsync"]);
|
|
3
|
+
const FIXABLE_OK_TYPE_NAMES = new Set(["Ok"]);
|
|
4
|
+
const FIXABLE_ERR_TYPE_NAMES = new Set(["Err"]);
|
|
5
|
+
const NULLISH_TYPE_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void;
|
|
6
|
+
export const ResultVariant = {
|
|
7
|
+
NONE: "none",
|
|
8
|
+
OK: "ok",
|
|
9
|
+
ERR: "err",
|
|
10
|
+
MIXED: "mixed",
|
|
11
|
+
};
|
|
12
|
+
function isAntithrowResultTypeSymbol(symbol) {
|
|
13
|
+
if (!RESULT_TYPE_NAMES.has(symbol.getName())) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const declarations = symbol.getDeclarations() ?? [];
|
|
17
|
+
return declarations.some((decl) => {
|
|
18
|
+
const sourceFile = decl.getSourceFile();
|
|
19
|
+
return sourceFile.fileName.includes("antithrow");
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function collectResultTypes(type, collection) {
|
|
23
|
+
if (type.isUnion()) {
|
|
24
|
+
for (const member of type.types) {
|
|
25
|
+
collectResultTypes(member, collection);
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const flags = type.getFlags();
|
|
30
|
+
if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never | NULLISH_TYPE_FLAGS)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const symbol = type.getSymbol();
|
|
34
|
+
if (!symbol || !isAntithrowResultTypeSymbol(symbol)) {
|
|
35
|
+
collection.hasNonResultMembers = true;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
collection.names.add(symbol.getName());
|
|
39
|
+
}
|
|
40
|
+
export function getResultVariant(type) {
|
|
41
|
+
const collection = {
|
|
42
|
+
hasNonResultMembers: false,
|
|
43
|
+
names: new Set(),
|
|
44
|
+
};
|
|
45
|
+
collectResultTypes(type, collection);
|
|
46
|
+
const { names } = collection;
|
|
47
|
+
if (names.size === 0) {
|
|
48
|
+
return ResultVariant.NONE;
|
|
49
|
+
}
|
|
50
|
+
if (collection.hasNonResultMembers) {
|
|
51
|
+
return ResultVariant.MIXED;
|
|
52
|
+
}
|
|
53
|
+
if (names.has("ResultAsync")) {
|
|
54
|
+
return ResultVariant.MIXED;
|
|
55
|
+
}
|
|
56
|
+
const isOnlyOk = [...names].every((name) => FIXABLE_OK_TYPE_NAMES.has(name));
|
|
57
|
+
if (isOnlyOk) {
|
|
58
|
+
return ResultVariant.OK;
|
|
59
|
+
}
|
|
60
|
+
const isOnlyErr = [...names].every((name) => FIXABLE_ERR_TYPE_NAMES.has(name));
|
|
61
|
+
if (isOnlyErr) {
|
|
62
|
+
return ResultVariant.ERR;
|
|
63
|
+
}
|
|
64
|
+
return ResultVariant.MIXED;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Determines whether a type originates from antithrow's `Result` family.
|
|
68
|
+
*
|
|
69
|
+
* `Result<T, E>` is a union of `Ok<T, E> | Err<T, E>`, so we recurse into
|
|
70
|
+
* union members. We also guard against `any`/`unknown`/`never` to avoid
|
|
71
|
+
* false positives on untyped code. Finally, we verify the symbol's
|
|
72
|
+
* declaration lives inside the `antithrow` package so that unrelated types
|
|
73
|
+
* with the same names (e.g. a user-defined `Ok` class) are not flagged.
|
|
74
|
+
*/
|
|
75
|
+
export function isResultType(type) {
|
|
76
|
+
return getResultVariant(type) !== ResultVariant.NONE;
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { afterAll, describe, test } from "bun:test";
|
|
2
|
+
import { RuleTester } from "@typescript-eslint/rule-tester";
|
|
3
|
+
RuleTester.describe = describe;
|
|
4
|
+
RuleTester.describeSkip = describe.skip;
|
|
5
|
+
RuleTester.it = test;
|
|
6
|
+
RuleTester.itSkip = test.skip;
|
|
7
|
+
RuleTester.afterAll = afterAll;
|
|
8
|
+
export const ruleTester = new RuleTester({
|
|
9
|
+
languageOptions: {
|
|
10
|
+
parserOptions: {
|
|
11
|
+
projectService: {
|
|
12
|
+
allowDefaultProject: ["*.ts"],
|
|
13
|
+
},
|
|
14
|
+
tsconfigRootDir: import.meta.dirname,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
export function createCodeHelper(preamble) {
|
|
19
|
+
return (strings, ...values) => {
|
|
20
|
+
let body = strings[0] ?? "";
|
|
21
|
+
for (const [index, value] of values.entries()) {
|
|
22
|
+
body += `${String(value)}${strings[index + 1] ?? ""}`;
|
|
23
|
+
}
|
|
24
|
+
return `${preamble}${body}`;
|
|
25
|
+
};
|
|
26
|
+
}
|