typespec-http-zod 0.25.1 → 0.27.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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
3
+ *
4
+ * **`HttpOperation.uriTemplate`, never `HttpOperation.path`.** `path` drops every operator, so
5
+ * `array{.param*}`, `array{;param}` and `array{/param}` all become `array{param}`, and a route mounted
6
+ * from it answers 404 to the URL its own contract declares. Measured on `@typespec/http-specs`
7
+ * `routes`: 30 of its mock server's URIs, plus the query-continuation six.
8
+ *
9
+ * `parseUriTemplate` is copied from `@typespec/http` 1.16.0 (`dist/src/uri-template.js`, MIT), which
10
+ * implements it but does not export it, so the template is split exactly as the library that built
11
+ * it splits it.
12
+ */
13
+ const OPERATORS = ["+", "#", ".", "/", ";", "?", "&"];
14
+ function parseUriTemplate(template) {
15
+ const parts = [];
16
+ for (const [, rawExpression, literal] of template.matchAll(/\{([^{}]+)\}|([^{}]+)/g)) {
17
+ if (rawExpression === undefined) {
18
+ parts.push(literal ?? "");
19
+ continue;
20
+ }
21
+ let expression = rawExpression;
22
+ let operator;
23
+ const first = expression[0];
24
+ if (first !== undefined && OPERATORS.includes(first)) {
25
+ operator = first;
26
+ expression = expression.slice(1);
27
+ }
28
+ for (const item of expression.split(",")) {
29
+ const match = /([^:*]*)(?::(\d+)|(\*))?/.exec(item);
30
+ parts.push({ name: match?.[1] ?? item, operator, explode: match?.[3] !== undefined });
31
+ }
32
+ }
33
+ return parts;
34
+ }
35
+ /** A route template as the segments a server mounts, and the literal query pairs it carries. */
36
+ export function routeTemplateOf(uriTemplate, parameters) {
37
+ const facts = new Map(parameters.map((parameter) => [parameter.name, parameter]));
38
+ const raw = [];
39
+ const literalQuery = [];
40
+ let current;
41
+ let inQuery = false;
42
+ const open = () => {
43
+ current = [];
44
+ raw.push(current);
45
+ return current;
46
+ };
47
+ for (const part of parseUriTemplate(uriTemplate)) {
48
+ if (typeof part === "string") {
49
+ let text = part;
50
+ if (inQuery) {
51
+ literalQuery.push(...pairsOf(text));
52
+ continue;
53
+ }
54
+ if (text.includes("?")) {
55
+ inQuery = true;
56
+ literalQuery.push(...pairsOf(text.slice(text.indexOf("?") + 1)));
57
+ text = text.slice(0, text.indexOf("?"));
58
+ }
59
+ text.split("/").forEach((piece, index) => {
60
+ const target = index === 0 ? (current ?? (piece === "" ? undefined : open())) : open();
61
+ if (piece !== "" && target !== undefined)
62
+ target.push(piece);
63
+ });
64
+ continue;
65
+ }
66
+ // `{?x}` and `{&x}` are query parameters, which a query validator reads; they are not path.
67
+ if (part.operator === "?" || part.operator === "&")
68
+ continue;
69
+ if (part.operator === "/")
70
+ open().push(part);
71
+ else
72
+ (current ?? open()).push(part);
73
+ }
74
+ const segments = raw
75
+ .filter((parts) => parts.length > 0)
76
+ .map((parts) => {
77
+ const expressions = parts.filter((part) => typeof part !== "string");
78
+ const text = (from) => from.filter((part) => typeof part === "string").join("");
79
+ const [only] = expressions;
80
+ if (only === undefined)
81
+ return { kind: "literal", text: text(parts) };
82
+ if (expressions.length > 1) {
83
+ return {
84
+ kind: "unsupported",
85
+ text: parts
86
+ .map((part) => typeof part === "string" ? part : `{${part.operator ?? ""}${part.name}}`)
87
+ .join(""),
88
+ };
89
+ }
90
+ const at = parts.indexOf(only);
91
+ const fact = facts.get(only.name);
92
+ return {
93
+ kind: "expression",
94
+ parameter: only.name,
95
+ prefix: text(parts.slice(0, at)),
96
+ suffix: text(parts.slice(at + 1)),
97
+ operator: only.operator === "?" || only.operator === "&" ? "" : (only.operator ?? ""),
98
+ explode: only.explode,
99
+ optional: fact?.optional ?? false,
100
+ reserved: only.operator === "+" || only.operator === "#" || fact?.reserved === true,
101
+ };
102
+ });
103
+ return { segments, literalQuery };
104
+ }
105
+ /** `a=1&b=2` -> the pairs it spells; a piece with no `=` is a name with an empty value. */
106
+ function pairsOf(text) {
107
+ return text
108
+ .split("&")
109
+ .filter((piece) => piece !== "")
110
+ .map((piece) => {
111
+ const at = piece.indexOf("=");
112
+ return at === -1 ? [piece, ""] : [piece.slice(0, at), piece.slice(at + 1)];
113
+ });
114
+ }
115
+ /**
116
+ * Whether a path value needs decoding at all. **An ordinary whole-segment parameter does not**, and
117
+ * leaving its validator untouched keeps every spec without an RFC 6570 form byte-identical.
118
+ */
119
+ export function needsExpansionDecoding(segment, shape) {
120
+ return (segment.prefix !== "" ||
121
+ segment.suffix !== "" ||
122
+ segment.operator === "." ||
123
+ segment.operator === ";" ||
124
+ shape !== "scalar");
125
+ }
126
+ /**
127
+ * The call that decodes one expansion, as a `z.preprocess` argument. The decoder itself is
128
+ * {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
129
+ */
130
+ export function uriExpansionCall(spec) {
131
+ return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${JSON.stringify(spec.prefix)}, suffix: ${JSON.stringify(spec.suffix)}, operator: ${JSON.stringify(spec.operator)}, name: ${JSON.stringify(spec.name)}, explode: ${spec.explode}, shape: ${JSON.stringify(spec.shape)}, values: ${JSON.stringify(spec.values)} })`;
132
+ }
133
+ /**
134
+ * **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
135
+ * sits in (or, for an exploding `/` expression, every segment after its literal), and this undoes
136
+ * the expansion: the literal around it, the operator's leading character, a matrix parameter's
137
+ * `name=`, then the joiner the operator and explode modifier chose.
138
+ *
139
+ * Text that is not the expansion the route declares is REFUSED with an issue of its own, not passed
140
+ * on: a string parameter's schema would accept `primitive;other=a` as the string it is, so leaving
141
+ * the refusal to the schema admitted a request the route does not match. A list's element values
142
+ * are decoded by the element decoder inside, as a query list's are; a record's values are decoded
143
+ * here, because no schema expression reaches them one by one.
144
+ */
145
+ export const URI_EXPANSION_SOURCE = `
146
+ /** RFC 6570 section 3.2 in reverse: one expression's expansion, decoded into the value it expanded. */
147
+ function uriExpansion(
148
+ raw: unknown,
149
+ ctx: { addIssue(issue: { code: "custom"; message: string }): void },
150
+ spec: {
151
+ readonly prefix: string;
152
+ readonly suffix: string;
153
+ readonly operator: string;
154
+ readonly name: string;
155
+ readonly explode: boolean;
156
+ readonly shape: "scalar" | "list" | "record";
157
+ readonly values: "number" | "boolean" | "";
158
+ },
159
+ ): unknown {
160
+ if (typeof raw !== "string") return raw;
161
+ const refuse = (): unknown => {
162
+ ctx.addIssue({ code: "custom", message: \`Not the expansion of {\${spec.operator}\${spec.name}\${spec.explode ? "*" : ""}} this route declares\` });
163
+ return z.NEVER;
164
+ };
165
+ if (raw.length < spec.prefix.length + spec.suffix.length) return refuse();
166
+ if (!raw.startsWith(spec.prefix) || !raw.endsWith(spec.suffix)) return refuse();
167
+ let text = raw.slice(spec.prefix.length, raw.length - spec.suffix.length);
168
+ if (spec.operator === "." || spec.operator === ";") {
169
+ if (!text.startsWith(spec.operator)) return refuse();
170
+ text = text.slice(1);
171
+ }
172
+ if (spec.operator === ";" && !(spec.shape === "record" && spec.explode)) {
173
+ const named = text
174
+ .split(";")
175
+ .map((part) => (part === spec.name ? "" : part.startsWith(\`\${spec.name}=\`) ? part.slice(spec.name.length + 1) : undefined));
176
+ if (named.some((part) => part === undefined)) return refuse();
177
+ if (spec.shape === "list" && spec.explode) return named;
178
+ if (named.length !== 1) return refuse();
179
+ text = named[0] ?? "";
180
+ }
181
+ if (spec.shape === "scalar") return text;
182
+ const joiner = spec.explode && (spec.operator === "." || spec.operator === "/" || spec.operator === ";") ? spec.operator : ",";
183
+ if (spec.shape === "list") return text === "" ? [] : text.split(joiner);
184
+ const value = (item: string): unknown =>
185
+ spec.values === "number" && item.trim() !== "" && Number.isFinite(Number(item))
186
+ ? Number(item)
187
+ : spec.values === "boolean" && (item === "true" || item === "false")
188
+ ? item === "true"
189
+ : item;
190
+ const entries: [string, unknown][] = [];
191
+ if (spec.explode) {
192
+ for (const pair of text === "" ? [] : text.split(joiner)) {
193
+ const at = pair.indexOf("=");
194
+ if (at === -1) return refuse();
195
+ entries.push([pair.slice(0, at), value(pair.slice(at + 1))]);
196
+ }
197
+ } else {
198
+ const items = text === "" ? [] : text.split(",");
199
+ if (items.length % 2 !== 0) return refuse();
200
+ for (let index = 0; index < items.length; index += 2) {
201
+ entries.push([items[index] ?? "", value(items[index + 1] ?? "")]);
202
+ }
203
+ }
204
+ return Object.fromEntries(entries);
205
+ }
206
+ `;
207
+ /**
208
+ * The call that gathers a form-exploded record or model back under its parameter name. The
209
+ * gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
210
+ */
211
+ export function explodedQueryCall(spec) {
212
+ return `(raw) => explodedQuery(raw, { name: ${JSON.stringify(spec.name)}, others: ${JSON.stringify(spec.others)}, keys: ${JSON.stringify(spec.keys ?? null)}, numbers: ${JSON.stringify(spec.numbers)}, booleans: ${JSON.stringify(spec.booleans)}, values: ${JSON.stringify(spec.values)} })`;
213
+ }
214
+ /**
215
+ * **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
216
+ * `?field=status&value=active`, so there is no `param` key for a query validator to find. This
217
+ * gathers those keys back under the parameter's name before the query object is validated: a model
218
+ * takes exactly its declared properties, a record every key no other query parameter claims.
219
+ */
220
+ export const EXPLODED_QUERY_SOURCE = `
221
+ /** RFC 6570 form expansion of an exploded record or model, gathered back under its parameter. */
222
+ function explodedQuery(
223
+ raw: unknown,
224
+ spec: {
225
+ readonly name: string;
226
+ readonly others: readonly string[];
227
+ readonly keys: readonly string[] | null;
228
+ readonly numbers: readonly string[];
229
+ readonly booleans: readonly string[];
230
+ readonly values: "number" | "boolean" | "";
231
+ },
232
+ ): unknown {
233
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw) || spec.name in raw) return raw;
234
+ const gathered: Record<string, unknown> = {};
235
+ const rest: Record<string, unknown> = {};
236
+ for (const [key, value] of Object.entries(raw)) {
237
+ const own = spec.keys === null ? !spec.others.includes(key) : spec.keys.includes(key);
238
+ if (!own) {
239
+ rest[key] = value;
240
+ continue;
241
+ }
242
+ const kind = spec.values !== "" ? spec.values : spec.numbers.includes(key) ? "number" : spec.booleans.includes(key) ? "boolean" : "";
243
+ gathered[key] =
244
+ typeof value !== "string"
245
+ ? value
246
+ : kind === "number" && value.trim() !== "" && Number.isFinite(Number(value))
247
+ ? Number(value)
248
+ : kind === "boolean" && (value === "true" || value === "false")
249
+ ? value === "true"
250
+ : value;
251
+ }
252
+ return Object.keys(gathered).length === 0 ? raw : { ...rest, [spec.name]: gathered };
253
+ }
254
+ `;
package/dist/src/zod.d.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  import { type Enum, type Model, type ModelProperty, type Program, type Type } from "@typespec/compiler";
2
2
  import { Visibility } from "@typespec/http";
3
+ /**
4
+ * What a walk returns after reporting a refusal.
5
+ *
6
+ * **Reporting does not unwind, which is the point.** A throw abandoned the compile at the first
7
+ * unrepresentable construct; reporting lets the walk carry on and name every one of them in a single
8
+ * run. The walk still has to return a `string`, and `z.never()` is the honest placeholder: valid
9
+ * Zod, rejects everything, and unreachable in practice because the error diagnostic has already
10
+ * failed the compile that would have written it.
11
+ */
12
+ export declare const UNREPRESENTABLE = "z.never()";
13
+ export declare function withDeferredAnnotator<T>(annotator: (property: ModelProperty) => string | undefined, run: () => T): T;
3
14
  export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
4
15
  export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
5
16
  /** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
@@ -97,23 +108,6 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
97
108
  * for a real subtype - the same rule that decides whether a base may be sealed.
98
109
  */
99
110
  export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
100
- /**
101
- * **What JSON kind a value of this type arrives as, for a parameter carried in text.**
102
- *
103
- * A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
104
- * to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
105
- * inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
106
- * and fails silently the moment the expression is spelled any other way.
107
- *
108
- * It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
109
- * neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
110
- * that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
111
- * caller got a 400, and no document comparison could see it, because both sides agree the parameter
112
- * is a number - the disagreement is with the wire.
113
- *
114
- * Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
115
- * single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
116
- */
117
111
  export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
118
112
  /**
119
113
  * A name as an object key - quoted when it is not a legal JavaScript identifier.
package/dist/src/zod.js CHANGED
@@ -11,7 +11,26 @@ import { reportDiagnostic } from "./lib.js";
11
11
  * Zod, rejects everything, and unreachable in practice because the error diagnostic has already
12
12
  * failed the compile that would have written it.
13
13
  */
14
- const UNREPRESENTABLE = "z.never()";
14
+ export const UNREPRESENTABLE = "z.never()";
15
+ /**
16
+ * **Installed by the registry, which is the only place that knows what a declared type is CALLED.**
17
+ *
18
+ * A property emitted as a getter needs its Zod type written out - see the getter docblock in
19
+ * `modelToZod`. Naming the type is the registry's job (it resolves a named type to its declaration,
20
+ * versioned name included), and `types.ts` already imports this module, so the annotation is handed
21
+ * in rather than computed here.
22
+ */
23
+ let annotateDeferred = () => undefined;
24
+ export function withDeferredAnnotator(annotator, run) {
25
+ const previous = annotateDeferred;
26
+ annotateDeferred = annotator;
27
+ try {
28
+ return run();
29
+ }
30
+ finally {
31
+ annotateDeferred = previous;
32
+ }
33
+ }
15
34
  /**
16
35
  * TypeSpec type -> Zod source text.
17
36
  *
@@ -320,16 +339,36 @@ const SCALARS = {
320
339
  string: "z.string()",
321
340
  boolean: "z.boolean()",
322
341
  bytes: "z.string()",
323
- int8: "z.number().int()",
324
- int16: "z.number().int()",
325
- int32: "z.number().int()",
326
- int64: "z.number().int()",
327
- safeint: "z.number().int()",
328
- uint8: "z.number().int()",
329
- uint16: "z.number().int()",
330
- uint32: "z.number().int()",
331
- uint64: "z.number().int()",
332
- integer: "z.number().int()",
342
+ /**
343
+ * **A declared WIDTH is a claim about the value too, and ten of them used to share one check.**
344
+ *
345
+ * Every integer scalar emitted `z.number().int()`, which is "an integer within the safe range" and
346
+ * nothing more. Measured at the wire on `workerd`, against a document publishing the widths below:
347
+ * a `uint8` accepted `-5` and `100000`, an `int32` accepted `3000000000` and `-3000000000`.
348
+ *
349
+ * The differential oracle cannot see it. JSON Schema 2020-12 treats `format` as an annotation, so
350
+ * Ajv on the published document answers VALID for all of those and agrees with the server while
351
+ * both are wrong. Ajv says so out loud: `unknown format "uint8" ignored`.
352
+ *
353
+ * Zod 4 has the named formats for the two widths that have them, and they emit better JSON Schema
354
+ * than a bare bound would. The rest take explicit bounds.
355
+ *
356
+ * **`int64` and `uint64` keep the safe-integer ceiling, and that is a limit rather than a
357
+ * choice.** `JSON.parse` has already lost a value above 2^53 before any validator runs, so the
358
+ * alternatives are to refuse it or to accept a number that is not the one the caller sent.
359
+ * Refusing is the honest one. `z.int64()` exists but is a `bigint`, which a JSON body cannot
360
+ * carry. What changes here is only that `uint64` stops accepting negatives.
361
+ */
362
+ int8: "z.int().min(-128).max(127)",
363
+ int16: "z.int().min(-32768).max(32767)",
364
+ int32: "z.int32()",
365
+ int64: "z.int()",
366
+ safeint: "z.int()",
367
+ uint8: "z.int().min(0).max(255)",
368
+ uint16: "z.int().min(0).max(65535)",
369
+ uint32: "z.uint32()",
370
+ uint64: "z.int().min(0)",
371
+ integer: "z.int()",
333
372
  float: "z.number()",
334
373
  float32: "z.number()",
335
374
  float64: "z.number()",
@@ -762,9 +801,26 @@ function modelToZod(program, model) {
762
801
  .map((property) => {
763
802
  const key = propertyKey(program, property);
764
803
  const { value, deferred } = captureBackEdges(() => withDeclarationSite(property, () => propertyToZod(program, property)));
804
+ /**
805
+ * **A getter without a return type infers `unknown`, and the collapse is SILENT.**
806
+ *
807
+ * The docblock above claims the getter keeps `z.infer` inferring the recursive type. It does
808
+ * for the declaration itself, and not for a schema that WRAPS it: measured on the conformance
809
+ * corpus, `z.record(z.string(), innerModelSchema)` - what `@body body: Record<InnerModel>`
810
+ * emits - infers `children?: unknown`, and `.parse()` on it returns `unknown` outright. The
811
+ * generated server then failed to compile with `TS2322`, in `type/dictionary`, which is how
812
+ * this was found; nothing said the emitted TYPE was weaker than the document.
813
+ *
814
+ * Annotating the GETTER rather than the declaration is what keeps `.shape` and `.extend`,
815
+ * which inheritance and every group spread depend on. `: z.ZodType<T, T>` names both of Zod's
816
+ * parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
817
+ */
818
+ const annotation = deferred ? (annotateDeferred(property) ?? "") : "";
765
819
  return {
766
820
  deferred,
767
- text: deferred ? `\tget ${key}() {\n\t\treturn ${value};\n\t},` : `\t${key}: ${value},`,
821
+ text: deferred
822
+ ? `\tget ${key}()${annotation} {\n\t\treturn ${value};\n\t},`
823
+ : `\t${key}: ${value},`,
768
824
  };
769
825
  });
770
826
  const entries = members.map((member) => member.text);
@@ -823,8 +879,25 @@ function modelToZod(program, model) {
823
879
  * is a number - the disagreement is with the wire.
824
880
  *
825
881
  * Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
826
- * single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
882
+ * single list of what becomes a number.
883
+ *
884
+ * **It still inspects the emitted expression to decide WHICH kind, and that did drift once more.**
885
+ * The test was `startsWith("z.number()")`, and giving each integer width its declared bounds spelled
886
+ * nine of them `z.int(`, `z.int32(` and `z.uint32(` instead. Every one silently lost its wire
887
+ * decoder: 22 arms went red at once, which is the only reason it was not shipped. The prefixes live
888
+ * in one named constant now.
889
+ *
890
+ * **That claim named a `scalars.test.ts` which did not exist, and nothing graded the list.**
891
+ * Measured: dropping `z.uint32(` from it, and separately `z.int(`, left all 468 arms in this
892
+ * package green, because no fixture had ever declared an integer width as a PARAMETER - the only
893
+ * place the decoder is attached. `test/widths/` now declares all ten as query parameters and sends
894
+ * each as text, so a spelling this list forgets fails here rather than at a consumer.
895
+ */
896
+ /**
897
+ * Every emitted expression that denotes a JSON number. Named once, because `SCALARS` and this list
898
+ * have to agree and nothing about the two being in the same file makes them.
827
899
  */
900
+ const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
828
901
  export function wireKindOf(program, type) {
829
902
  if (type.kind === "Scalar") {
830
903
  const encoded = encodedTypeOf(program, type);
@@ -834,7 +907,7 @@ export function wireKindOf(program, type) {
834
907
  const mapped = SCALARS[current.name];
835
908
  if (mapped === undefined)
836
909
  continue;
837
- if (mapped.startsWith("z.number()"))
910
+ if (NUMERIC_EXPRESSIONS.some((prefix) => mapped.startsWith(prefix)))
838
911
  return "number";
839
912
  if (mapped === "z.boolean()")
840
913
  return "boolean";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",
@@ -42,6 +42,9 @@
42
42
  "access": "public",
43
43
  "provenance": true
44
44
  },
45
+ "dependencies": {
46
+ "redos-detector": "^6.1.4"
47
+ },
45
48
  "devDependencies": {
46
49
  "@types/node": "^26.5.1",
47
50
  "@typespec/compiler": "1.16.0",
@@ -62,7 +65,7 @@
62
65
  "typespec-http-zod": "link:.",
63
66
  "vitest": "^5.0.0",
64
67
  "yaml": "^2.9.1",
65
- "zod": "^4.5.2"
68
+ "zod": "^4.6.5"
66
69
  },
67
70
  "peerDependencies": {
68
71
  "@typespec/compiler": "^1.15.0",
@@ -85,7 +88,7 @@
85
88
  },
86
89
  "tspMain": "lib/main.tsp",
87
90
  "scripts": {
88
- "build": "tsc -p tsconfig.build.json",
91
+ "build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
89
92
  "test": "tsc -p tsconfig.build.json && vitest run",
90
93
  "typecheck": "tsc -p tsconfig.json",
91
94
  "lint": "oxlint src/ test/",