@unthrown/oxlint 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benoit Travers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @unthrown/oxlint
2
+
3
+ > An [oxlint](https://oxc.rs/docs/guide/usage/linter) plugin that enforces
4
+ > [unthrown](https://github.com/btravstack/unthrown)'s conventions.
5
+
6
+ 📖 **[Documentation](https://btravstack.github.io/unthrown/guide/linting)**
7
+
8
+ ```sh
9
+ pnpm add -D @unthrown/oxlint oxlint
10
+ ```
11
+
12
+ A small set of lint rules that keep unthrown code honest — turning two of the
13
+ library's theses into automated checks.
14
+
15
+ ## Rules
16
+
17
+ | Rule | What it enforces |
18
+ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | `unthrown/no-ambiguous-error-type` | The `E` in `Result<T, E>` / `AsyncResult<T, E>` must name a concrete domain error — no `unknown`, `any`, `Error`, `object`, bare `{}`, or primitives. (`never` is allowed.) This is [Thesis #1](https://btravstack.github.io/unthrown/guide/why-unthrown): `E` is only the _anticipated_ failures. |
20
+ | `unthrown/prefer-async-result` | Prefer `AsyncResult<T, E>` over `Promise<Result<T, E>>` — a raw `Promise<Result>` can still reject. Autofixable. |
21
+
22
+ Both rules resolve the import source via scope analysis, so they only fire on
23
+ unthrown's own `Result` / `AsyncResult` — a `Result` from another library is left
24
+ alone.
25
+
26
+ ## Usage
27
+
28
+ Register the plugin and enable its rules in your `.oxlintrc.json`:
29
+
30
+ ```json
31
+ {
32
+ "jsPlugins": [{ "name": "unthrown", "specifier": "@unthrown/oxlint" }],
33
+ "rules": {
34
+ "unthrown/no-ambiguous-error-type": "error",
35
+ "unthrown/prefer-async-result": "error"
36
+ }
37
+ }
38
+ ```
39
+
40
+ The package's default export also exposes a `recommended` preset (an oxlint
41
+ config that registers the plugin and turns both rules on) for setups that build
42
+ their config programmatically:
43
+
44
+ ```ts
45
+ import unthrown from "@unthrown/oxlint";
46
+ // unthrown.recommended → { jsPlugins: [...], rules: { "unthrown/...": "error" } }
47
+ ```
48
+
49
+ `oxlint` is a peer dependency. JS plugins require a recent oxlint (≥ 1.69).
50
+
51
+ ## License
52
+
53
+ [MIT](../../LICENSE) © Benoit TRAVERS
package/dist/index.cjs ADDED
@@ -0,0 +1,176 @@
1
+ let _oxlint_plugins = require("@oxlint/plugins");
2
+ let oxlint = require("oxlint");
3
+ //#region src/helpers/get-import-source.ts
4
+ /**
5
+ * Resolve the module a given identifier was imported from, via scope analysis —
6
+ * e.g. the `Result` in `Result<T, E>` resolves to `"unthrown"` when it was
7
+ * `import type { Result } from "unthrown"`. Returns `undefined` if it cannot be
8
+ * resolved to an import (so callers stay conservative — no false positives).
9
+ */
10
+ const getImportSource = (scope, target) => {
11
+ const node = (scope.references.find((ref) => ref.identifier === target)?.resolved)?.defs[0]?.parent;
12
+ if (node?.type !== "ImportDeclaration") return void 0;
13
+ return node.source.value;
14
+ };
15
+ //#endregion
16
+ //#region src/helpers/has-type-arguments.ts
17
+ /**
18
+ * Narrow a node to one carrying exactly `count` type arguments
19
+ * (`node.typeArguments.params` of that length).
20
+ */
21
+ const hasTypeArguments = (node, count) => {
22
+ return "typeArguments" in node && node.typeArguments != null && node.typeArguments.params.length === count;
23
+ };
24
+ //#endregion
25
+ //#region src/helpers/is-identifier-type-name.ts
26
+ /**
27
+ * Narrow a `TSTypeReference` to one whose `typeName` is a bare `Identifier`,
28
+ * optionally one of `names`. unthrown's `Result` / `AsyncResult` are used as
29
+ * bare identifiers (not namespaced), so this is how we spot them.
30
+ */
31
+ const isIdentifierTypeName = (node, names) => {
32
+ return node.typeName.type === "Identifier" && (!names || names.includes(node.typeName.name));
33
+ };
34
+ //#endregion
35
+ //#region src/rules/no-ambiguous-error-type.ts
36
+ const MODULE$1 = "unthrown";
37
+ const RESULT_TYPES = ["Result", "AsyncResult"];
38
+ const AMBIGUOUS_KEYWORDS = /* @__PURE__ */ new Set([
39
+ "TSUnknownKeyword",
40
+ "TSAnyKeyword",
41
+ "TSStringKeyword",
42
+ "TSNumberKeyword",
43
+ "TSBooleanKeyword",
44
+ "TSBigIntKeyword",
45
+ "TSSymbolKeyword",
46
+ "TSObjectKeyword",
47
+ "TSNullKeyword",
48
+ "TSUndefinedKeyword"
49
+ ]);
50
+ /**
51
+ * Disallow a non-specific error type in the `E` position of `Result<T, E>` /
52
+ * `AsyncResult<T, E>`: `unknown`, `any`, `Error`, bare `{}` / `object`, and the
53
+ * primitive keywords. `E` should name the *anticipated* domain failures — a
54
+ * tagged error, a union of them, a literal — not "anything went wrong". `never`
55
+ * (an intentionally error-free result) is allowed.
56
+ */
57
+ const noAmbiguousErrorType = (0, _oxlint_plugins.defineRule)({
58
+ meta: {
59
+ type: "problem",
60
+ docs: {
61
+ description: "Disallow non-specific error types (`unknown`, `any`, `Error`, `object`, `{}`, primitives) in the error position of `Result` / `AsyncResult`",
62
+ recommended: true
63
+ },
64
+ messages: { noAmbiguousErrorType: "Specify a concrete domain error instead of `{{ type }}` in `{{ result }}`." }
65
+ },
66
+ createOnce: (context) => {
67
+ return { TSTypeReference: (node) => {
68
+ if (!isIdentifierTypeName(node, RESULT_TYPES)) return;
69
+ if (!hasTypeArguments(node, 2)) return;
70
+ if (getImportSource(context.sourceCode.getScope(node), node.typeName) !== MODULE$1) return;
71
+ const errorNode = node.typeArguments.params[1];
72
+ if (!isAmbiguousType(errorNode)) return;
73
+ context.report({
74
+ node: errorNode,
75
+ messageId: "noAmbiguousErrorType",
76
+ data: {
77
+ type: context.sourceCode.getText(errorNode),
78
+ result: context.sourceCode.getText(node)
79
+ }
80
+ });
81
+ } };
82
+ }
83
+ });
84
+ /**
85
+ * Whether an error-position type is non-specific. Recurses into unions and
86
+ * intersections, so `MyError | unknown` and `Error | MyError` are flagged too —
87
+ * one ambiguous member taints the whole type.
88
+ */
89
+ function isAmbiguousType(node) {
90
+ if (node.type === "TSUnionType" || node.type === "TSIntersectionType") return node.types.some(isAmbiguousType);
91
+ if (node.type === "TSTypeLiteral") return node.members.length === 0;
92
+ if (node.type === "TSTypeReference") return node.typeName.type === "Identifier" && node.typeName.name === "Error";
93
+ return AMBIGUOUS_KEYWORDS.has(node.type);
94
+ }
95
+ //#endregion
96
+ //#region src/helpers/has-named-import.ts
97
+ /**
98
+ * Whether `name` is in scope as a named import from `module` (e.g. is
99
+ * `AsyncResult` imported from `"unthrown"`). Used to decide whether an autofix
100
+ * can safely reference it. Walks up the scope chain.
101
+ */
102
+ const hasNamedImport = (scope, name, module) => {
103
+ for (let current = scope; current; current = current.upper) {
104
+ const variable = current.variables.find((v) => v.name === name);
105
+ if (variable) {
106
+ const parent = variable.defs[0]?.parent;
107
+ return parent?.type === "ImportDeclaration" && parent.source.value === module;
108
+ }
109
+ }
110
+ return false;
111
+ };
112
+ //#endregion
113
+ //#region src/rules/prefer-async-result.ts
114
+ const MODULE = "unthrown";
115
+ /**
116
+ * Prefer unthrown's `AsyncResult<T, E>` over `Promise<Result<T, E>>`. A raw
117
+ * `Promise<Result>` can *reject*, reintroducing the throw channel `AsyncResult`
118
+ * is designed to eliminate — so the wrapper is both shorter and stronger.
119
+ *
120
+ * Autofixable — but the fix is only offered when `AsyncResult` is already
121
+ * imported from `unthrown`, so it can't rewrite to an undefined name.
122
+ */
123
+ const preferAsyncResult = (0, _oxlint_plugins.defineRule)({
124
+ meta: {
125
+ type: "suggestion",
126
+ docs: {
127
+ description: "Prefer `AsyncResult<T, E>` over `Promise<Result<T, E>>`",
128
+ recommended: true
129
+ },
130
+ messages: { preferAsyncResult: "Use `AsyncResult<T, E>` instead of `Promise<Result<T, E>>`." },
131
+ fixable: "code"
132
+ },
133
+ createOnce: (context) => {
134
+ return { TSTypeReference: (node) => {
135
+ if (!isIdentifierTypeName(node, ["Promise"])) return;
136
+ if (!hasTypeArguments(node, 1)) return;
137
+ const inner = node.typeArguments.params[0];
138
+ if (inner.type !== "TSTypeReference") return;
139
+ if (!isIdentifierTypeName(inner, ["Result"])) return;
140
+ if (!hasTypeArguments(inner, 2)) return;
141
+ const scope = context.sourceCode.getScope(node);
142
+ if (getImportSource(scope, inner.typeName) !== MODULE) return;
143
+ const canFix = hasNamedImport(scope, "AsyncResult", MODULE);
144
+ context.report({
145
+ node,
146
+ messageId: "preferAsyncResult",
147
+ ...canFix && { fix: (fixer) => {
148
+ const value = context.sourceCode.getText(inner.typeArguments.params[0]);
149
+ const error = context.sourceCode.getText(inner.typeArguments.params[1]);
150
+ return fixer.replaceText(node, `AsyncResult<${value}, ${error}>`);
151
+ } }
152
+ });
153
+ } };
154
+ }
155
+ });
156
+ //#endregion
157
+ //#region src/index.ts
158
+ const plugin = (0, _oxlint_plugins.eslintCompatPlugin)({
159
+ meta: { name: "unthrown" },
160
+ rules: {
161
+ "no-ambiguous-error-type": noAmbiguousErrorType,
162
+ "prefer-async-result": preferAsyncResult
163
+ }
164
+ });
165
+ plugin.recommended = (0, oxlint.defineConfig)({
166
+ jsPlugins: [{
167
+ name: "unthrown",
168
+ specifier: "@unthrown/oxlint"
169
+ }],
170
+ rules: {
171
+ "unthrown/no-ambiguous-error-type": "error",
172
+ "unthrown/prefer-async-result": "error"
173
+ }
174
+ });
175
+ //#endregion
176
+ module.exports = plugin;
@@ -0,0 +1,10 @@
1
+ import { Plugin } from "@oxlint/plugins";
2
+ import { OxlintConfig } from "oxlint";
3
+
4
+ //#region src/index.d.ts
5
+ type UnthrownPlugin = Plugin & {
6
+ recommended: OxlintConfig;
7
+ };
8
+ declare const plugin: UnthrownPlugin;
9
+ export = plugin;
10
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/index.ts"],"mappings":";;;;KAmBK,cAAA,GAAiB,MAAA;EAAW,WAAA,EAAa,YAAY;AAAA;AAAA,cAEpD,MAAA,EAMA,cAAc;AAAA"}
@@ -0,0 +1,11 @@
1
+ import { Plugin } from "@oxlint/plugins";
2
+ import { OxlintConfig } from "oxlint";
3
+
4
+ //#region src/index.d.ts
5
+ type UnthrownPlugin = Plugin & {
6
+ recommended: OxlintConfig;
7
+ };
8
+ declare const plugin: UnthrownPlugin;
9
+ //#endregion
10
+ export { plugin as default };
11
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;KAmBK,cAAA,GAAiB,MAAA;EAAW,WAAA,EAAa,YAAY;AAAA;AAAA,cAEpD,MAAA,EAMA,cAAc"}
package/dist/index.mjs ADDED
@@ -0,0 +1,169 @@
1
+ import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
2
+ import { defineConfig } from "oxlint";
3
+ //#region src/helpers/get-import-source.ts
4
+ /**
5
+ * Resolve the module a given identifier was imported from, via scope analysis —
6
+ * e.g. the `Result` in `Result<T, E>` resolves to `"unthrown"` when it was
7
+ * `import type { Result } from "unthrown"`. Returns `undefined` if it cannot be
8
+ * resolved to an import (so callers stay conservative — no false positives).
9
+ */
10
+ const getImportSource = (scope, target) => {
11
+ const node = (scope.references.find((ref) => ref.identifier === target)?.resolved)?.defs[0]?.parent;
12
+ if (node?.type !== "ImportDeclaration") return void 0;
13
+ return node.source.value;
14
+ };
15
+ //#endregion
16
+ //#region src/helpers/has-type-arguments.ts
17
+ /**
18
+ * Narrow a node to one carrying exactly `count` type arguments
19
+ * (`node.typeArguments.params` of that length).
20
+ */
21
+ const hasTypeArguments = (node, count) => {
22
+ return "typeArguments" in node && node.typeArguments != null && node.typeArguments.params.length === count;
23
+ };
24
+ //#endregion
25
+ //#region src/helpers/is-identifier-type-name.ts
26
+ /**
27
+ * Narrow a `TSTypeReference` to one whose `typeName` is a bare `Identifier`,
28
+ * optionally one of `names`. unthrown's `Result` / `AsyncResult` are used as
29
+ * bare identifiers (not namespaced), so this is how we spot them.
30
+ */
31
+ const isIdentifierTypeName = (node, names) => {
32
+ return node.typeName.type === "Identifier" && (!names || names.includes(node.typeName.name));
33
+ };
34
+ //#endregion
35
+ //#region src/rules/no-ambiguous-error-type.ts
36
+ const MODULE$1 = "unthrown";
37
+ const RESULT_TYPES = ["Result", "AsyncResult"];
38
+ const AMBIGUOUS_KEYWORDS = /* @__PURE__ */ new Set([
39
+ "TSUnknownKeyword",
40
+ "TSAnyKeyword",
41
+ "TSStringKeyword",
42
+ "TSNumberKeyword",
43
+ "TSBooleanKeyword",
44
+ "TSBigIntKeyword",
45
+ "TSSymbolKeyword",
46
+ "TSObjectKeyword",
47
+ "TSNullKeyword",
48
+ "TSUndefinedKeyword"
49
+ ]);
50
+ /**
51
+ * Disallow a non-specific error type in the `E` position of `Result<T, E>` /
52
+ * `AsyncResult<T, E>`: `unknown`, `any`, `Error`, bare `{}` / `object`, and the
53
+ * primitive keywords. `E` should name the *anticipated* domain failures — a
54
+ * tagged error, a union of them, a literal — not "anything went wrong". `never`
55
+ * (an intentionally error-free result) is allowed.
56
+ */
57
+ const noAmbiguousErrorType = defineRule({
58
+ meta: {
59
+ type: "problem",
60
+ docs: {
61
+ description: "Disallow non-specific error types (`unknown`, `any`, `Error`, `object`, `{}`, primitives) in the error position of `Result` / `AsyncResult`",
62
+ recommended: true
63
+ },
64
+ messages: { noAmbiguousErrorType: "Specify a concrete domain error instead of `{{ type }}` in `{{ result }}`." }
65
+ },
66
+ createOnce: (context) => {
67
+ return { TSTypeReference: (node) => {
68
+ if (!isIdentifierTypeName(node, RESULT_TYPES)) return;
69
+ if (!hasTypeArguments(node, 2)) return;
70
+ if (getImportSource(context.sourceCode.getScope(node), node.typeName) !== MODULE$1) return;
71
+ const errorNode = node.typeArguments.params[1];
72
+ if (!isAmbiguousType(errorNode)) return;
73
+ context.report({
74
+ node: errorNode,
75
+ messageId: "noAmbiguousErrorType",
76
+ data: {
77
+ type: context.sourceCode.getText(errorNode),
78
+ result: context.sourceCode.getText(node)
79
+ }
80
+ });
81
+ } };
82
+ }
83
+ });
84
+ /**
85
+ * Whether an error-position type is non-specific. Recurses into unions and
86
+ * intersections, so `MyError | unknown` and `Error | MyError` are flagged too —
87
+ * one ambiguous member taints the whole type.
88
+ */
89
+ function isAmbiguousType(node) {
90
+ if (node.type === "TSUnionType" || node.type === "TSIntersectionType") return node.types.some(isAmbiguousType);
91
+ if (node.type === "TSTypeLiteral") return node.members.length === 0;
92
+ if (node.type === "TSTypeReference") return node.typeName.type === "Identifier" && node.typeName.name === "Error";
93
+ return AMBIGUOUS_KEYWORDS.has(node.type);
94
+ }
95
+ //#endregion
96
+ //#region src/helpers/has-named-import.ts
97
+ /**
98
+ * Whether `name` is in scope as a named import from `module` (e.g. is
99
+ * `AsyncResult` imported from `"unthrown"`). Used to decide whether an autofix
100
+ * can safely reference it. Walks up the scope chain.
101
+ */
102
+ const hasNamedImport = (scope, name, module) => {
103
+ for (let current = scope; current; current = current.upper) {
104
+ const variable = current.variables.find((v) => v.name === name);
105
+ if (variable) {
106
+ const parent = variable.defs[0]?.parent;
107
+ return parent?.type === "ImportDeclaration" && parent.source.value === module;
108
+ }
109
+ }
110
+ return false;
111
+ };
112
+ //#endregion
113
+ //#region src/rules/prefer-async-result.ts
114
+ const MODULE = "unthrown";
115
+ //#endregion
116
+ //#region src/index.ts
117
+ const plugin = eslintCompatPlugin({
118
+ meta: { name: "unthrown" },
119
+ rules: {
120
+ "no-ambiguous-error-type": noAmbiguousErrorType,
121
+ "prefer-async-result": defineRule({
122
+ meta: {
123
+ type: "suggestion",
124
+ docs: {
125
+ description: "Prefer `AsyncResult<T, E>` over `Promise<Result<T, E>>`",
126
+ recommended: true
127
+ },
128
+ messages: { preferAsyncResult: "Use `AsyncResult<T, E>` instead of `Promise<Result<T, E>>`." },
129
+ fixable: "code"
130
+ },
131
+ createOnce: (context) => {
132
+ return { TSTypeReference: (node) => {
133
+ if (!isIdentifierTypeName(node, ["Promise"])) return;
134
+ if (!hasTypeArguments(node, 1)) return;
135
+ const inner = node.typeArguments.params[0];
136
+ if (inner.type !== "TSTypeReference") return;
137
+ if (!isIdentifierTypeName(inner, ["Result"])) return;
138
+ if (!hasTypeArguments(inner, 2)) return;
139
+ const scope = context.sourceCode.getScope(node);
140
+ if (getImportSource(scope, inner.typeName) !== MODULE) return;
141
+ const canFix = hasNamedImport(scope, "AsyncResult", MODULE);
142
+ context.report({
143
+ node,
144
+ messageId: "preferAsyncResult",
145
+ ...canFix && { fix: (fixer) => {
146
+ const value = context.sourceCode.getText(inner.typeArguments.params[0]);
147
+ const error = context.sourceCode.getText(inner.typeArguments.params[1]);
148
+ return fixer.replaceText(node, `AsyncResult<${value}, ${error}>`);
149
+ } }
150
+ });
151
+ } };
152
+ }
153
+ })
154
+ }
155
+ });
156
+ plugin.recommended = defineConfig({
157
+ jsPlugins: [{
158
+ name: "unthrown",
159
+ specifier: "@unthrown/oxlint"
160
+ }],
161
+ rules: {
162
+ "unthrown/no-ambiguous-error-type": "error",
163
+ "unthrown/prefer-async-result": "error"
164
+ }
165
+ });
166
+ //#endregion
167
+ export { plugin as default };
168
+
169
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["MODULE"],"sources":["../src/helpers/get-import-source.ts","../src/helpers/has-type-arguments.ts","../src/helpers/is-identifier-type-name.ts","../src/rules/no-ambiguous-error-type.ts","../src/helpers/has-named-import.ts","../src/rules/prefer-async-result.ts","../src/index.ts"],"sourcesContent":["import type { ESTree, Scope } from \"@oxlint/plugins\";\n\n/**\n * Resolve the module a given identifier was imported from, via scope analysis —\n * e.g. the `Result` in `Result<T, E>` resolves to `\"unthrown\"` when it was\n * `import type { Result } from \"unthrown\"`. Returns `undefined` if it cannot be\n * resolved to an import (so callers stay conservative — no false positives).\n */\nexport const getImportSource = (scope: Scope, target: ESTree.Node): string | undefined => {\n const variable = scope.references.find((ref) => ref.identifier === target)?.resolved;\n const node = variable?.defs[0]?.parent;\n if (node?.type !== \"ImportDeclaration\") return undefined;\n return node.source.value;\n};\n","import type { ESTree } from \"@oxlint/plugins\";\n\ntype Tuple<T, N extends number, R extends T[] = []> = R[\"length\"] extends N\n ? R\n : Tuple<T, N, [...R, T]>;\n\n/**\n * Narrow a node to one carrying exactly `count` type arguments\n * (`node.typeArguments.params` of that length).\n */\nexport const hasTypeArguments = <N extends number>(\n node: ESTree.Node,\n count: N,\n): node is ESTree.Node & { typeArguments: { params: Tuple<ESTree.TSType, N> } } => {\n return (\n \"typeArguments\" in node &&\n node.typeArguments != null &&\n node.typeArguments.params.length === count\n );\n};\n","import type { ESTree } from \"@oxlint/plugins\";\n\n/**\n * Narrow a `TSTypeReference` to one whose `typeName` is a bare `Identifier`,\n * optionally one of `names`. unthrown's `Result` / `AsyncResult` are used as\n * bare identifiers (not namespaced), so this is how we spot them.\n */\nexport const isIdentifierTypeName = (\n node: ESTree.TSTypeReference,\n names?: readonly string[],\n): node is ESTree.TSTypeReference & { typeName: ESTree.IdentifierReference } => {\n return node.typeName.type === \"Identifier\" && (!names || names.includes(node.typeName.name));\n};\n","import { defineRule } from \"@oxlint/plugins\";\n\nimport { getImportSource } from \"../helpers/get-import-source.js\";\nimport { hasTypeArguments } from \"../helpers/has-type-arguments.js\";\nimport { isIdentifierTypeName } from \"../helpers/is-identifier-type-name.js\";\n\nimport type { ESTree } from \"@oxlint/plugins\";\n\nconst MODULE = \"unthrown\";\nconst RESULT_TYPES = [\"Result\", \"AsyncResult\"] as const;\n\n// Keyword type nodes that say nothing about the domain — they make `E` a\n// catch-all, which is exactly what Thesis #1 forbids.\nconst AMBIGUOUS_KEYWORDS: ReadonlySet<string> = new Set([\n \"TSUnknownKeyword\",\n \"TSAnyKeyword\",\n \"TSStringKeyword\",\n \"TSNumberKeyword\",\n \"TSBooleanKeyword\",\n \"TSBigIntKeyword\",\n \"TSSymbolKeyword\",\n \"TSObjectKeyword\",\n \"TSNullKeyword\",\n \"TSUndefinedKeyword\",\n]);\n\n/**\n * Disallow a non-specific error type in the `E` position of `Result<T, E>` /\n * `AsyncResult<T, E>`: `unknown`, `any`, `Error`, bare `{}` / `object`, and the\n * primitive keywords. `E` should name the *anticipated* domain failures — a\n * tagged error, a union of them, a literal — not \"anything went wrong\". `never`\n * (an intentionally error-free result) is allowed.\n */\nexport const noAmbiguousErrorType = defineRule({\n meta: {\n type: \"problem\",\n docs: {\n description:\n \"Disallow non-specific error types (`unknown`, `any`, `Error`, `object`, `{}`, primitives) in the error position of `Result` / `AsyncResult`\",\n recommended: true,\n },\n messages: {\n noAmbiguousErrorType:\n \"Specify a concrete domain error instead of `{{ type }}` in `{{ result }}`.\",\n },\n },\n createOnce: (context) => {\n return {\n TSTypeReference: (node) => {\n if (!isIdentifierTypeName(node, RESULT_TYPES)) return;\n if (!hasTypeArguments(node, 2)) return;\n\n const importSource = getImportSource(context.sourceCode.getScope(node), node.typeName);\n if (importSource !== MODULE) return;\n\n const errorNode: ESTree.TSType = node.typeArguments.params[1];\n if (!isAmbiguousType(errorNode)) return;\n\n context.report({\n node: errorNode,\n messageId: \"noAmbiguousErrorType\",\n data: {\n type: context.sourceCode.getText(errorNode),\n result: context.sourceCode.getText(node),\n },\n });\n },\n };\n },\n});\n\n/**\n * Whether an error-position type is non-specific. Recurses into unions and\n * intersections, so `MyError | unknown` and `Error | MyError` are flagged too —\n * one ambiguous member taints the whole type.\n */\nfunction isAmbiguousType(node: ESTree.TSType): boolean {\n if (node.type === \"TSUnionType\" || node.type === \"TSIntersectionType\") {\n return node.types.some(isAmbiguousType);\n }\n // The *empty* object literal `{}` is ambiguous; `{ code: number }` is fine.\n if (node.type === \"TSTypeLiteral\") return node.members.length === 0;\n // The bare `Error` class is too generic; a `MyError` is fine.\n if (node.type === \"TSTypeReference\") {\n return node.typeName.type === \"Identifier\" && node.typeName.name === \"Error\";\n }\n return AMBIGUOUS_KEYWORDS.has(node.type);\n}\n","import type { Scope } from \"@oxlint/plugins\";\n\n/**\n * Whether `name` is in scope as a named import from `module` (e.g. is\n * `AsyncResult` imported from `\"unthrown\"`). Used to decide whether an autofix\n * can safely reference it. Walks up the scope chain.\n */\nexport const hasNamedImport = (scope: Scope, name: string, module: string): boolean => {\n for (let current: Scope | null = scope; current; current = current.upper) {\n const variable = current.variables.find((v) => v.name === name);\n if (variable) {\n const parent = variable.defs[0]?.parent;\n return parent?.type === \"ImportDeclaration\" && parent.source.value === module;\n }\n }\n return false;\n};\n","import { defineRule } from \"@oxlint/plugins\";\n\nimport { getImportSource } from \"../helpers/get-import-source.js\";\nimport { hasNamedImport } from \"../helpers/has-named-import.js\";\nimport { hasTypeArguments } from \"../helpers/has-type-arguments.js\";\nimport { isIdentifierTypeName } from \"../helpers/is-identifier-type-name.js\";\n\nconst MODULE = \"unthrown\";\n\n/**\n * Prefer unthrown's `AsyncResult<T, E>` over `Promise<Result<T, E>>`. A raw\n * `Promise<Result>` can *reject*, reintroducing the throw channel `AsyncResult`\n * is designed to eliminate — so the wrapper is both shorter and stronger.\n *\n * Autofixable — but the fix is only offered when `AsyncResult` is already\n * imported from `unthrown`, so it can't rewrite to an undefined name.\n */\nexport const preferAsyncResult = defineRule({\n meta: {\n type: \"suggestion\",\n docs: {\n description: \"Prefer `AsyncResult<T, E>` over `Promise<Result<T, E>>`\",\n recommended: true,\n },\n messages: {\n preferAsyncResult: \"Use `AsyncResult<T, E>` instead of `Promise<Result<T, E>>`.\",\n },\n fixable: \"code\",\n },\n createOnce: (context) => {\n return {\n TSTypeReference: (node) => {\n if (!isIdentifierTypeName(node, [\"Promise\"])) return;\n if (!hasTypeArguments(node, 1)) return;\n\n const inner = node.typeArguments.params[0];\n if (inner.type !== \"TSTypeReference\") return;\n if (!isIdentifierTypeName(inner, [\"Result\"])) return;\n if (!hasTypeArguments(inner, 2)) return;\n\n const scope = context.sourceCode.getScope(node);\n if (getImportSource(scope, inner.typeName) !== MODULE) return;\n\n // Only autofix when `AsyncResult` is already importable — otherwise the\n // rewrite would reference an undefined name. Report without a fix instead.\n const canFix = hasNamedImport(scope, \"AsyncResult\", MODULE);\n\n context.report({\n node,\n messageId: \"preferAsyncResult\",\n ...(canFix && {\n fix: (fixer) => {\n const value = context.sourceCode.getText(inner.typeArguments.params[0]);\n const error = context.sourceCode.getText(inner.typeArguments.params[1]);\n return fixer.replaceText(node, `AsyncResult<${value}, ${error}>`);\n },\n }),\n });\n },\n };\n },\n});\n","// @unthrown/oxlint — an oxlint (JS) plugin that enforces unthrown's conventions\n// at lint time. Two rules:\n//\n// unthrown/no-ambiguous-error-type — keep `E` a concrete domain error\n// (no unknown/any/Error/{}), i.e. Thesis #1.\n// unthrown/prefer-async-result — use AsyncResult<T,E> over Promise<Result<T,E>>.\n//\n// Enable the bundled `recommended` preset, or wire the rules by hand. See the\n// package README.\n\nimport { eslintCompatPlugin } from \"@oxlint/plugins\";\nimport { defineConfig } from \"oxlint\";\n\nimport { noAmbiguousErrorType } from \"./rules/no-ambiguous-error-type.js\";\nimport { preferAsyncResult } from \"./rules/prefer-async-result.js\";\n\nimport type { Plugin } from \"@oxlint/plugins\";\nimport type { OxlintConfig } from \"oxlint\";\n\ntype UnthrownPlugin = Plugin & { recommended: OxlintConfig };\n\nconst plugin = eslintCompatPlugin({\n meta: { name: \"unthrown\" },\n rules: {\n \"no-ambiguous-error-type\": noAmbiguousErrorType,\n \"prefer-async-result\": preferAsyncResult,\n },\n}) as UnthrownPlugin;\n\nplugin.recommended = defineConfig({\n jsPlugins: [{ name: \"unthrown\", specifier: \"@unthrown/oxlint\" }],\n rules: {\n \"unthrown/no-ambiguous-error-type\": \"error\",\n \"unthrown/prefer-async-result\": \"error\",\n },\n});\n\nexport default plugin;\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@unthrown/oxlint",
3
+ "version": "0.2.0",
4
+ "description": "oxlint plugin enforcing unthrown's conventions",
5
+ "keywords": [
6
+ "errors-as-values",
7
+ "lint",
8
+ "oxlint",
9
+ "plugin",
10
+ "result",
11
+ "typescript",
12
+ "unthrown"
13
+ ],
14
+ "homepage": "https://github.com/btravstack/unthrown#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/btravstack/unthrown/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/btravstack/unthrown.git",
23
+ "directory": "packages/oxlint"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.mjs",
32
+ "types": "./dist/index.d.mts",
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./dist/index.d.mts",
37
+ "default": "./dist/index.mjs"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "dependencies": {
47
+ "@oxlint/plugins": "1.69.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "24.13.2",
51
+ "@vitest/coverage-v8": "4.1.8",
52
+ "oxlint": "1.69.0",
53
+ "tsdown": "0.22.2",
54
+ "typescript": "6.0.3",
55
+ "vitest": "4.1.8",
56
+ "@unthrown/tsconfig": "0.1.0"
57
+ },
58
+ "peerDependencies": {
59
+ "oxlint": "^1"
60
+ },
61
+ "engines": {
62
+ "node": ">=22.19"
63
+ },
64
+ "scripts": {
65
+ "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
66
+ "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
67
+ "test": "vitest run",
68
+ "typecheck": "tsc --noEmit"
69
+ }
70
+ }