@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 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/jack-weilage/antithrow">antithrow</a> Result types
4
+ ESLint rules for <a href="https://github.com/antithrow/antithrow">antithrow</a> Result types
5
5
  </p>
6
6
 
7
7
  ![NPM Version](https://img.shields.io/npm/v/@antithrow/eslint-plugin)
8
8
  ![NPM License](https://img.shields.io/npm/l/@antithrow/eslint-plugin)
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 | Description | Recommended |
53
- | --- | --- | --- |
54
- | [`no-unsafe-unwrap`](./docs/rules/no-unsafe-unwrap.md) | Disallow `unwrap`/`expect` APIs on antithrow `Result` values | `warn` |
55
- | [`no-unused-result`](./docs/rules/no-unused-result.md) | Require `Result` and `ResultAsync` values to be used | `error` |
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` |
@@ -1,2 +1,2 @@
1
1
  import { ESLintUtils } from "@typescript-eslint/utils";
2
- export const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/jack-weilage/antithrow/blob/main/packages/eslint-plugin/docs/rules/${name}.md`);
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
  },
@@ -1,2 +1,3 @@
1
+ export { noThrowingCall } from "./no-throwing-call.js";
1
2
  export { noUnsafeUnwrap } from "./no-unsafe-unwrap.js";
2
3
  export { noUnusedResult } from "./no-unused-result.js";
@@ -1,2 +1,3 @@
1
+ export { noThrowingCall } from "./no-throwing-call.js";
1
2
  export { noUnsafeUnwrap } from "./no-unsafe-unwrap.js";
2
3
  export { noUnusedResult } from "./no-unused-result.js";
@@ -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 || !BANNED_METHOD_NAMES.has(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 || !BANNED_METHOD_NAMES.has(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 || !isAntithrowResultTypeSymbol(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.0",
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/jack-weilage/antithrow.git",
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
  },