@antithrow/eslint-plugin 0.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 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
- // ... your other configs
28
- antithrow.configs.recommended,
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
- plugins: {
40
- "@antithrow": antithrow,
41
- },
42
- rules: {
43
- "@antithrow/no-unused-result": "error",
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
  },
@@ -1 +1,2 @@
1
+ export { noUnsafeUnwrap } from "./no-unsafe-unwrap.js";
1
2
  export { noUnusedResult } from "./no-unused-result.js";
@@ -1 +1,2 @@
1
+ export { noUnsafeUnwrap } from "./no-unsafe-unwrap.js";
1
2
  export { noUnusedResult } from "./no-unused-result.js";
@@ -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
- export declare const noUnusedResult: ESLintUtils.RuleModule<"unusedResult", [], import("../create-rule.js").AntithrowPluginDocs, ESLintUtils.RuleListener> & {
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
- const RESULT_TYPE_NAMES = new Set(["Ok", "Err", "ResultAsync"]);
5
- function isResultType(type) {
6
- if (type.isUnion()) {
7
- return type.types.some((t) => isResultType(t));
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
- unusedResult: "This Result must be used. Handle the error case or explicitly discard it with `void`.",
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
- break;
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: "unusedResult",
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,3 @@
1
+ import { RuleTester } from "@typescript-eslint/rule-tester";
2
+ export declare const ruleTester: RuleTester;
3
+ export declare function createCodeHelper(preamble: string): (strings: TemplateStringsArray, ...values: unknown[]) => string;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antithrow/eslint-plugin",
3
- "version": "0.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "ESLint plugin for antithrow Result types",
5
5
  "license": "MIT",
6
6
  "author": "Jack Weilage",