@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 +21 -0
- package/README.md +53 -0
- package/dist/index.cjs +176 -0
- package/dist/index.d.cts +10 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +169 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +70 -0
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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"}
|
package/dist/index.d.mts
ADDED
|
@@ -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":";;;;;;;;;AAQA,MAAa,mBAAmB,OAAc,WAA4C;CAExF,MAAM,QADW,MAAM,WAAW,MAAM,QAAQ,IAAI,eAAe,MAAM,CAAC,EAAE,SAAA,EACrD,KAAK,EAAE,EAAE;CAChC,IAAI,MAAM,SAAS,qBAAqB,OAAO,KAAA;CAC/C,OAAO,KAAK,OAAO;AACrB;;;;;;;ACHA,MAAa,oBACX,MACA,UACiF;CACjF,OACE,mBAAmB,QACnB,KAAK,iBAAiB,QACtB,KAAK,cAAc,OAAO,WAAW;AAEzC;;;;;;;;ACZA,MAAa,wBACX,MACA,UAC8E;CAC9E,OAAO,KAAK,SAAS,SAAS,iBAAiB,CAAC,SAAS,MAAM,SAAS,KAAK,SAAS,IAAI;AAC5F;;;ACJA,MAAMA,WAAS;AACf,MAAM,eAAe,CAAC,UAAU,aAAa;AAI7C,MAAM,qCAA0C,IAAI,IAAI;CACtD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;AASD,MAAa,uBAAuB,WAAW;CAC7C,MAAM;EACJ,MAAM;EACN,MAAM;GACJ,aACE;GACF,aAAa;EACf;EACA,UAAU,EACR,sBACE,6EACJ;CACF;CACA,aAAa,YAAY;EACvB,OAAO,EACL,kBAAkB,SAAS;GACzB,IAAI,CAAC,qBAAqB,MAAM,YAAY,GAAG;GAC/C,IAAI,CAAC,iBAAiB,MAAM,CAAC,GAAG;GAGhC,IADqB,gBAAgB,QAAQ,WAAW,SAAS,IAAI,GAAG,KAAK,QAC9D,MAAMA,UAAQ;GAE7B,MAAM,YAA2B,KAAK,cAAc,OAAO;GAC3D,IAAI,CAAC,gBAAgB,SAAS,GAAG;GAEjC,QAAQ,OAAO;IACb,MAAM;IACN,WAAW;IACX,MAAM;KACJ,MAAM,QAAQ,WAAW,QAAQ,SAAS;KAC1C,QAAQ,QAAQ,WAAW,QAAQ,IAAI;IACzC;GACF,CAAC;EACH,EACF;CACF;AACF,CAAC;;;;;;AAOD,SAAS,gBAAgB,MAA8B;CACrD,IAAI,KAAK,SAAS,iBAAiB,KAAK,SAAS,sBAC/C,OAAO,KAAK,MAAM,KAAK,eAAe;CAGxC,IAAI,KAAK,SAAS,iBAAiB,OAAO,KAAK,QAAQ,WAAW;CAElE,IAAI,KAAK,SAAS,mBAChB,OAAO,KAAK,SAAS,SAAS,gBAAgB,KAAK,SAAS,SAAS;CAEvE,OAAO,mBAAmB,IAAI,KAAK,IAAI;AACzC;;;;;;;;AChFA,MAAa,kBAAkB,OAAc,MAAc,WAA4B;CACrF,KAAK,IAAI,UAAwB,OAAO,SAAS,UAAU,QAAQ,OAAO;EACxE,MAAM,WAAW,QAAQ,UAAU,MAAM,MAAM,EAAE,SAAS,IAAI;EAC9D,IAAI,UAAU;GACZ,MAAM,SAAS,SAAS,KAAK,EAAE,EAAE;GACjC,OAAO,QAAQ,SAAS,uBAAuB,OAAO,OAAO,UAAU;EACzE;CACF;CACA,OAAO;AACT;;;ACTA,MAAM,SAAS;;;ACcf,MAAM,SAAS,mBAAmB;CAChC,MAAM,EAAE,MAAM,WAAW;CACzB,OAAO;EACL,2BAA2B;EAC3B,uBDR6B,WAAW;GAC1C,MAAM;IACJ,MAAM;IACN,MAAM;KACJ,aAAa;KACb,aAAa;IACf;IACA,UAAU,EACR,mBAAmB,8DACrB;IACA,SAAS;GACX;GACA,aAAa,YAAY;IACvB,OAAO,EACL,kBAAkB,SAAS;KACzB,IAAI,CAAC,qBAAqB,MAAM,CAAC,SAAS,CAAC,GAAG;KAC9C,IAAI,CAAC,iBAAiB,MAAM,CAAC,GAAG;KAEhC,MAAM,QAAQ,KAAK,cAAc,OAAO;KACxC,IAAI,MAAM,SAAS,mBAAmB;KACtC,IAAI,CAAC,qBAAqB,OAAO,CAAC,QAAQ,CAAC,GAAG;KAC9C,IAAI,CAAC,iBAAiB,OAAO,CAAC,GAAG;KAEjC,MAAM,QAAQ,QAAQ,WAAW,SAAS,IAAI;KAC9C,IAAI,gBAAgB,OAAO,MAAM,QAAQ,MAAM,QAAQ;KAIvD,MAAM,SAAS,eAAe,OAAO,eAAe,MAAM;KAE1D,QAAQ,OAAO;MACb;MACA,WAAW;MACX,GAAI,UAAU,EACZ,MAAM,UAAU;OACd,MAAM,QAAQ,QAAQ,WAAW,QAAQ,MAAM,cAAc,OAAO,EAAE;OACtE,MAAM,QAAQ,QAAQ,WAAW,QAAQ,MAAM,cAAc,OAAO,EAAE;OACtE,OAAO,MAAM,YAAY,MAAM,eAAe,MAAM,IAAI,MAAM,EAAE;MAClE,EACF;KACF,CAAC;IACH,EACF;GACF;EACF,CCpC2B;CACzB;AACF,CAAC;AAED,OAAO,cAAc,aAAa;CAChC,WAAW,CAAC;EAAE,MAAM;EAAY,WAAW;CAAmB,CAAC;CAC/D,OAAO;EACL,oCAAoC;EACpC,gCAAgC;CAClC;AACF,CAAC"}
|
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
|
+
}
|