@antithrow/eslint-plugin 1.1.0 → 1.2.1
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 +8 -5
- package/dist/create-rule.js +1 -1
- 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-throwing-call.d.ts +9 -0
- package/dist/rules/no-throwing-call.js +192 -0
- package/dist/rules/no-unsafe-unwrap.js +2 -2
- package/dist/rules/utils/result-type.js +1 -1
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<h1>@antithrow/eslint-plugin</h1>
|
|
3
3
|
<p>
|
|
4
|
-
ESLint rules for <a href="https://github.com/
|
|
4
|
+
ESLint rules for <a href="https://github.com/antithrow/antithrow">antithrow</a> Result types
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|

|
|
9
|
+
|
|
9
10
|
</div>
|
|
10
11
|
|
|
11
12
|
## Installation
|
|
@@ -40,6 +41,7 @@ export default [
|
|
|
40
41
|
"@antithrow": antithrow,
|
|
41
42
|
},
|
|
42
43
|
rules: {
|
|
44
|
+
"@antithrow/no-throwing-call": "warn",
|
|
43
45
|
"@antithrow/no-unsafe-unwrap": "warn",
|
|
44
46
|
"@antithrow/no-unused-result": "error",
|
|
45
47
|
},
|
|
@@ -49,7 +51,8 @@ export default [
|
|
|
49
51
|
|
|
50
52
|
## Rules
|
|
51
53
|
|
|
52
|
-
| Rule
|
|
53
|
-
|
|
|
54
|
-
| [`no-
|
|
55
|
-
| [`no-
|
|
54
|
+
| Rule | Description | Recommended |
|
|
55
|
+
| ------------------------------------------------------ | --------------------------------------------------------------------------- | ----------- |
|
|
56
|
+
| [`no-throwing-call`](https://antithrow.dev/docs/api/eslint-plugin/no-throwing-call) | Disallow calls to throwing built-in APIs with `@antithrow/std` replacements | `warn` |
|
|
57
|
+
| [`no-unsafe-unwrap`](https://antithrow.dev/docs/api/eslint-plugin/no-unsafe-unwrap) | Disallow `unwrap`/`expect` APIs on antithrow `Result` values | `warn` |
|
|
58
|
+
| [`no-unused-result`](https://antithrow.dev/docs/api/eslint-plugin/no-unused-result) | Require `Result` and `ResultAsync` values to be used | `error` |
|
package/dist/create-rule.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
-
export const createRule = ESLintUtils.RuleCreator((name) => `https://
|
|
2
|
+
export const createRule = ESLintUtils.RuleCreator((name) => `https://antithrow.dev/docs/api/eslint-plugin/${name}`);
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import packageJson from "../package.json" with { type: "json" };
|
|
2
|
-
import { noUnsafeUnwrap, noUnusedResult } from "./rules/index.js";
|
|
2
|
+
import { noThrowingCall, 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-throwing-call": noThrowingCall,
|
|
9
10
|
"no-unsafe-unwrap": noUnsafeUnwrap,
|
|
10
11
|
"no-unused-result": noUnusedResult,
|
|
11
12
|
},
|
|
@@ -18,6 +19,7 @@ plugin.configs = {
|
|
|
18
19
|
"@antithrow": plugin,
|
|
19
20
|
},
|
|
20
21
|
rules: {
|
|
22
|
+
"@antithrow/no-throwing-call": "warn",
|
|
21
23
|
"@antithrow/no-unsafe-unwrap": "warn",
|
|
22
24
|
"@antithrow/no-unused-result": "error",
|
|
23
25
|
},
|
package/dist/rules/index.d.ts
CHANGED
package/dist/rules/index.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
/** @lintignore */
|
|
3
|
+
export declare const MessageId: {
|
|
4
|
+
readonly THROWING_CALL: "throwingCall";
|
|
5
|
+
};
|
|
6
|
+
export type MessageId = (typeof MessageId)[keyof typeof MessageId];
|
|
7
|
+
export declare const noThrowingCall: ESLintUtils.RuleModule<"throwingCall", [], import("../create-rule.js").AntithrowPluginDocs, ESLintUtils.RuleListener> & {
|
|
8
|
+
name: string;
|
|
9
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { createRule } from "../create-rule.js";
|
|
4
|
+
/** @lintignore */
|
|
5
|
+
export const MessageId = {
|
|
6
|
+
THROWING_CALL: "throwingCall",
|
|
7
|
+
};
|
|
8
|
+
const BANNED_GLOBAL_CALLS = new Set([
|
|
9
|
+
"fetch",
|
|
10
|
+
"atob",
|
|
11
|
+
"btoa",
|
|
12
|
+
"structuredClone",
|
|
13
|
+
"decodeURI",
|
|
14
|
+
"decodeURIComponent",
|
|
15
|
+
"encodeURI",
|
|
16
|
+
"encodeURIComponent",
|
|
17
|
+
"JSON.parse",
|
|
18
|
+
"JSON.stringify",
|
|
19
|
+
]);
|
|
20
|
+
const RESPONSE_BODY_METHODS = new Set(["json", "text", "arrayBuffer", "blob", "formData"]);
|
|
21
|
+
const GLOBAL_OBJECTS = new Set(["globalThis", "window", "self"]);
|
|
22
|
+
const NULLISH_TYPE_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void;
|
|
23
|
+
/**
|
|
24
|
+
* Extracts the property name from a `MemberExpression` when it can be
|
|
25
|
+
* statically determined. Handles `obj.prop`, `obj["prop"]`, and
|
|
26
|
+
* `` obj[`prop`] `` (template literals with no interpolations).
|
|
27
|
+
* Returns `null` for dynamic access like `obj[variable]`.
|
|
28
|
+
*/
|
|
29
|
+
function getStaticMemberName(node) {
|
|
30
|
+
if (!node.computed && node.property.type === "Identifier") {
|
|
31
|
+
return node.property.name;
|
|
32
|
+
}
|
|
33
|
+
if (node.computed &&
|
|
34
|
+
node.property.type === "Literal" &&
|
|
35
|
+
typeof node.property.value === "string") {
|
|
36
|
+
return node.property.value;
|
|
37
|
+
}
|
|
38
|
+
if (node.computed &&
|
|
39
|
+
node.property.type === "TemplateLiteral" &&
|
|
40
|
+
node.property.expressions.length === 0) {
|
|
41
|
+
const [quasi] = node.property.quasis;
|
|
42
|
+
return quasi?.value.cooked ?? null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function isImplicitGlobal(name, scope) {
|
|
47
|
+
let current = scope;
|
|
48
|
+
while (current) {
|
|
49
|
+
const variable = current.set.get(name);
|
|
50
|
+
if (variable) {
|
|
51
|
+
return variable.defs.length === 0;
|
|
52
|
+
}
|
|
53
|
+
current = current.upper;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
function collectStaticMemberPath(node) {
|
|
58
|
+
const propertyName = getStaticMemberName(node);
|
|
59
|
+
if (!propertyName) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (node.object.type === "Identifier") {
|
|
63
|
+
return {
|
|
64
|
+
segments: [node.object.name, propertyName],
|
|
65
|
+
rootIdentifier: node.object,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (node.object.type !== "MemberExpression") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const parentPath = collectStaticMemberPath(node.object);
|
|
72
|
+
if (!parentPath) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
segments: [...parentPath.segments, propertyName],
|
|
77
|
+
rootIdentifier: parentPath.rootIdentifier,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function getStaticCalleePath(callee) {
|
|
81
|
+
if (callee.type === "Identifier") {
|
|
82
|
+
return {
|
|
83
|
+
segments: [callee.name],
|
|
84
|
+
rootIdentifier: callee,
|
|
85
|
+
memberExpression: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (callee.type !== "MemberExpression") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const memberPath = collectStaticMemberPath(callee);
|
|
92
|
+
if (!memberPath) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
segments: memberPath.segments,
|
|
97
|
+
rootIdentifier: memberPath.rootIdentifier,
|
|
98
|
+
memberExpression: callee,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function normalizeGlobalPath(calleePath, scope) {
|
|
102
|
+
// Invariant: getStaticCalleePath always returns at least one segment.
|
|
103
|
+
const [root = "", ...rest] = calleePath.segments;
|
|
104
|
+
if (GLOBAL_OBJECTS.has(root)) {
|
|
105
|
+
if (!isImplicitGlobal(root, scope)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return rest.length > 0 ? rest : null;
|
|
109
|
+
}
|
|
110
|
+
if (!isImplicitGlobal(root, scope)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return calleePath.segments;
|
|
114
|
+
}
|
|
115
|
+
function containsGlobalResponseType(type, globalResponseType, checker) {
|
|
116
|
+
if (type.isUnionOrIntersection()) {
|
|
117
|
+
return type.types.some((member) => containsGlobalResponseType(member, globalResponseType, checker));
|
|
118
|
+
}
|
|
119
|
+
const flags = type.getFlags();
|
|
120
|
+
if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never | NULLISH_TYPE_FLAGS)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (checker.isTypeAssignableTo(type, globalResponseType)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const constraint = checker.getBaseConstraintOfType(type);
|
|
127
|
+
if (constraint && constraint !== type) {
|
|
128
|
+
return containsGlobalResponseType(constraint, globalResponseType, checker);
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
export const noThrowingCall = createRule({
|
|
133
|
+
name: "no-throwing-call",
|
|
134
|
+
meta: {
|
|
135
|
+
type: "problem",
|
|
136
|
+
docs: {
|
|
137
|
+
description: "Disallow calls to known throwing built-in APIs that have @antithrow/std replacements.",
|
|
138
|
+
recommended: true,
|
|
139
|
+
requiresTypeChecking: true,
|
|
140
|
+
},
|
|
141
|
+
messages: {
|
|
142
|
+
[MessageId.THROWING_CALL]: "`{{ api }}` can throw. A non-throwing wrapper is available from `@antithrow/std`.",
|
|
143
|
+
},
|
|
144
|
+
schema: [],
|
|
145
|
+
},
|
|
146
|
+
defaultOptions: [],
|
|
147
|
+
create(context) {
|
|
148
|
+
const services = ESLintUtils.getParserServices(context);
|
|
149
|
+
const checker = services.program.getTypeChecker();
|
|
150
|
+
const globalResponseSymbol = checker.resolveName("Response", undefined, ts.SymbolFlags.Type, false);
|
|
151
|
+
const globalResponseType = globalResponseSymbol
|
|
152
|
+
? checker.getDeclaredTypeOfSymbol(globalResponseSymbol)
|
|
153
|
+
: null;
|
|
154
|
+
return {
|
|
155
|
+
CallExpression(node) {
|
|
156
|
+
const calleePath = getStaticCalleePath(node.callee);
|
|
157
|
+
if (!calleePath) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const rootScope = context.sourceCode.getScope(calleePath.rootIdentifier);
|
|
161
|
+
const normalizedGlobalPath = normalizeGlobalPath(calleePath, rootScope);
|
|
162
|
+
if (normalizedGlobalPath) {
|
|
163
|
+
const api = normalizedGlobalPath.join(".");
|
|
164
|
+
if (BANNED_GLOBAL_CALLS.has(api)) {
|
|
165
|
+
context.report({
|
|
166
|
+
node,
|
|
167
|
+
messageId: MessageId.THROWING_CALL,
|
|
168
|
+
data: { api },
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const methodName = calleePath.segments[calleePath.segments.length - 1];
|
|
174
|
+
const { memberExpression } = calleePath;
|
|
175
|
+
if (methodName &&
|
|
176
|
+
memberExpression &&
|
|
177
|
+
RESPONSE_BODY_METHODS.has(methodName) &&
|
|
178
|
+
globalResponseType) {
|
|
179
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(memberExpression.object);
|
|
180
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
181
|
+
if (containsGlobalResponseType(type, globalResponseType, checker)) {
|
|
182
|
+
context.report({
|
|
183
|
+
node,
|
|
184
|
+
messageId: MessageId.THROWING_CALL,
|
|
185
|
+
data: { api: `response.${methodName}` },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
|
@@ -100,7 +100,7 @@ export const noUnsafeUnwrap = createRule({
|
|
|
100
100
|
return {
|
|
101
101
|
MemberExpression(node) {
|
|
102
102
|
const method = getStaticMemberName(node);
|
|
103
|
-
if (!method
|
|
103
|
+
if (!(method && BANNED_METHOD_NAMES.has(method))) {
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
const tsNode = services.esTreeNodeToTSNodeMap.get(node.object);
|
|
@@ -153,7 +153,7 @@ export const noUnsafeUnwrap = createRule({
|
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
155
|
const method = getDestructuredPropertyName(node);
|
|
156
|
-
if (!method
|
|
156
|
+
if (!(method && BANNED_METHOD_NAMES.has(method))) {
|
|
157
157
|
return;
|
|
158
158
|
}
|
|
159
159
|
// Resolve the node whose type represents the value being destructured.
|
|
@@ -31,7 +31,7 @@ function collectResultTypes(type, collection) {
|
|
|
31
31
|
return;
|
|
32
32
|
}
|
|
33
33
|
const symbol = type.getSymbol();
|
|
34
|
-
if (!symbol
|
|
34
|
+
if (!(symbol && isAntithrowResultTypeSymbol(symbol))) {
|
|
35
35
|
collection.hasNonResultMembers = true;
|
|
36
36
|
return;
|
|
37
37
|
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antithrow/eslint-plugin",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "ESLint plugin for antithrow Result types",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jack Weilage",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/
|
|
9
|
+
"url": "git+https://github.com/antithrow/antithrow.git",
|
|
10
10
|
"directory": "packages/eslint-plugin"
|
|
11
11
|
},
|
|
12
|
+
"homepage": "https://antithrow.dev",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/antithrow/antithrow/issues"
|
|
15
|
+
},
|
|
12
16
|
"keywords": [
|
|
17
|
+
"antithrow",
|
|
13
18
|
"eslint",
|
|
14
19
|
"eslintplugin",
|
|
20
|
+
"eslint-plugin",
|
|
15
21
|
"result",
|
|
16
22
|
"error-handling",
|
|
17
23
|
"typescript"
|
|
18
24
|
],
|
|
25
|
+
"sideEffects": false,
|
|
19
26
|
"type": "module",
|
|
20
27
|
"exports": {
|
|
21
28
|
".": {
|
|
@@ -41,7 +48,7 @@
|
|
|
41
48
|
"devDependencies": {
|
|
42
49
|
"@typescript-eslint/parser": "8.54.0",
|
|
43
50
|
"@typescript-eslint/rule-tester": "8.54.0",
|
|
44
|
-
"antithrow": "workspace
|
|
51
|
+
"antithrow": "workspace:^",
|
|
45
52
|
"eslint": "9.39.2",
|
|
46
53
|
"typescript": "5.9.3"
|
|
47
54
|
},
|