zod-compiler 1.0.1 → 1.0.3
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 +59 -22
- package/dist/core/codegen/context.d.ts +27 -8
- package/dist/core/codegen/context.d.ts.map +1 -1
- package/dist/core/codegen/context.js +29 -14
- package/dist/core/codegen/context.js.map +1 -1
- package/dist/core/codegen/index.d.ts.map +1 -1
- package/dist/core/codegen/index.js +19 -1
- package/dist/core/codegen/index.js.map +1 -1
- package/dist/core/codegen/schemas/array.js +2 -2
- package/dist/core/codegen/schemas/array.js.map +1 -1
- package/dist/core/codegen/schemas/discriminated-union.js +2 -2
- package/dist/core/codegen/schemas/discriminated-union.js.map +1 -1
- package/dist/core/codegen/schemas/map.js +3 -3
- package/dist/core/codegen/schemas/map.js.map +1 -1
- package/dist/core/codegen/schemas/number.d.ts.map +1 -1
- package/dist/core/codegen/schemas/number.js +15 -8
- package/dist/core/codegen/schemas/number.js.map +1 -1
- package/dist/core/codegen/schemas/record.d.ts.map +1 -1
- package/dist/core/codegen/schemas/record.js +13 -5
- package/dist/core/codegen/schemas/record.js.map +1 -1
- package/dist/core/codegen/schemas/recursive-ref.d.ts +1 -1
- package/dist/core/codegen/schemas/recursive-ref.d.ts.map +1 -1
- package/dist/core/codegen/schemas/recursive-ref.js +9 -3
- package/dist/core/codegen/schemas/recursive-ref.js.map +1 -1
- package/dist/core/codegen/schemas/set.js +2 -2
- package/dist/core/codegen/schemas/set.js.map +1 -1
- package/dist/core/codegen/schemas/tuple.js +2 -2
- package/dist/core/codegen/schemas/tuple.js.map +1 -1
- package/dist/core/diagnostic.js +1 -1
- package/dist/core/diagnostic.js.map +1 -1
- package/dist/core/iife.d.ts +9 -1
- package/dist/core/iife.d.ts.map +1 -1
- package/dist/core/iife.js +9 -1
- package/dist/core/iife.js.map +1 -1
- package/dist/unplugin/hoist.d.ts +28 -0
- package/dist/unplugin/hoist.d.ts.map +1 -0
- package/dist/unplugin/hoist.js +815 -0
- package/dist/unplugin/hoist.js.map +1 -0
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +12 -5
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/transform.d.ts.map +1 -1
- package/dist/unplugin/transform.js +29 -12
- package/dist/unplugin/transform.js.map +1 -1
- package/dist/unplugin/types.d.ts +42 -7
- package/dist/unplugin/types.d.ts.map +1 -1
- package/dist/unplugin/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import { parseExpressionAt } from "acorn";
|
|
2
|
+
/**
|
|
3
|
+
* Hoist Zod schema construction out of functions to module scope —
|
|
4
|
+
* equivalent of babel-plugin-zod-hoist.
|
|
5
|
+
*
|
|
6
|
+
* Schemas built inside function bodies are re-constructed on every call:
|
|
7
|
+
*
|
|
8
|
+
* function getSchema() {
|
|
9
|
+
* return z.object({ name: z.string() }); // rebuilt per call
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* becomes
|
|
13
|
+
*
|
|
14
|
+
* const _zh_94b7f5c1 = z.object({ name: z.string() });
|
|
15
|
+
* function getSchema() {
|
|
16
|
+
* return _zh_94b7f5c1; // built once
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Safety rules (mirroring babel-plugin-zod-hoist):
|
|
20
|
+
* - Only expressions built solely from **imported bindings and literals**
|
|
21
|
+
* are hoisted. Local variables, module-level const/let/var, `this`,
|
|
22
|
+
* `await`/`yield`, and globals (`new Date()`, `Math.random()`) all
|
|
23
|
+
* disqualify — hoisting would freeze their evaluation at module load.
|
|
24
|
+
* - Eligible roots: any binding imported from zod, an imported identifier
|
|
25
|
+
* matching /ZodSchema$/, or an imported identifier whose chain contains
|
|
26
|
+
* an inline z.* reference (e.g. `Base.extend({ a: z.string() })`).
|
|
27
|
+
* - Declarations are inserted at the top of the module (after shebang and
|
|
28
|
+
* directive prologue). Imports are initialized before module code runs,
|
|
29
|
+
* so referencing them from above their textual position is safe.
|
|
30
|
+
* - Names are content-hashed, so identical schemas dedupe to one binding.
|
|
31
|
+
*
|
|
32
|
+
* The source is TypeScript, which acorn cannot fully parse — candidates are
|
|
33
|
+
* located with a string/comment/depth-aware scanner and extracted with
|
|
34
|
+
* parseExpressionAt (the same technique as the autoDiscover rewrite).
|
|
35
|
+
* Anything unparseable (TS generics, `as` casts) is skipped: a miss leaves
|
|
36
|
+
* the schema unhoisted, never breaks the code.
|
|
37
|
+
*/
|
|
38
|
+
/** Imported identifiers matching this pattern are treated as schema roots. */
|
|
39
|
+
const SCHEMA_NAME_PATTERN = /ZodSchema$/;
|
|
40
|
+
/** Module specifiers whose bindings count as the zod namespace. */
|
|
41
|
+
const ZOD_MODULES = new Set(["zod", "zod/v4", "zod/mini", "zod/v4/mini"]);
|
|
42
|
+
/**
|
|
43
|
+
* Collect runtime import bindings with a regex over import statements.
|
|
44
|
+
* Type-only imports and `type` specifiers are excluded — they cannot be
|
|
45
|
+
* referenced at runtime, so excluding them keeps the capture rule sound.
|
|
46
|
+
*/
|
|
47
|
+
export function collectImportBindings(code) {
|
|
48
|
+
const all = new Set();
|
|
49
|
+
const zod = new Set();
|
|
50
|
+
const importPattern = /import\s+(type\s+)?([^'";]+?)\s+from\s*["']([^"']+)["']/g;
|
|
51
|
+
for (const match of code.matchAll(importPattern)) {
|
|
52
|
+
const [, typeOnly, clause, specifier] = match;
|
|
53
|
+
if (typeOnly || clause === undefined || specifier === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
const isZod = ZOD_MODULES.has(specifier);
|
|
56
|
+
for (const name of parseImportClause(clause)) {
|
|
57
|
+
all.add(name);
|
|
58
|
+
if (isZod)
|
|
59
|
+
zod.add(name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { all, zod };
|
|
63
|
+
}
|
|
64
|
+
/** Extract local binding names from an import clause. */
|
|
65
|
+
function parseImportClause(clause) {
|
|
66
|
+
const names = [];
|
|
67
|
+
const namedStart = clause.indexOf("{");
|
|
68
|
+
// Default import and/or namespace import before the named group
|
|
69
|
+
const head = namedStart === -1 ? clause : clause.slice(0, namedStart);
|
|
70
|
+
for (const part of head.split(",")) {
|
|
71
|
+
const trimmed = part.trim();
|
|
72
|
+
if (!trimmed)
|
|
73
|
+
continue;
|
|
74
|
+
const ns = trimmed.match(/^\*\s*as\s+([A-Za-z_$][\w$]*)$/);
|
|
75
|
+
if (ns?.[1]) {
|
|
76
|
+
names.push(ns[1]);
|
|
77
|
+
}
|
|
78
|
+
else if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
|
|
79
|
+
names.push(trimmed);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (namedStart !== -1) {
|
|
83
|
+
const namedEnd = clause.indexOf("}", namedStart);
|
|
84
|
+
const inner = clause.slice(namedStart + 1, namedEnd === -1 ? undefined : namedEnd);
|
|
85
|
+
for (const part of inner.split(",")) {
|
|
86
|
+
const spec = part.trim();
|
|
87
|
+
if (!spec || spec.startsWith("type "))
|
|
88
|
+
continue;
|
|
89
|
+
// `a as b` binds b; plain `a` binds a
|
|
90
|
+
const asMatch = spec.match(/^[A-Za-z_$][\w$]*\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
91
|
+
const name = asMatch?.[1] ?? (/^[A-Za-z_$][\w$]*$/.test(spec) ? spec : null);
|
|
92
|
+
if (name)
|
|
93
|
+
names.push(name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return names;
|
|
97
|
+
}
|
|
98
|
+
/** Deterministic FNV-1a 32-bit hash, hex-encoded. */
|
|
99
|
+
function fnv1a(text) {
|
|
100
|
+
let hash = 0x811c9dc5;
|
|
101
|
+
for (let i = 0; i < text.length; i++) {
|
|
102
|
+
hash ^= text.charCodeAt(i);
|
|
103
|
+
hash = Math.imul(hash, 0x01000193);
|
|
104
|
+
}
|
|
105
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
106
|
+
}
|
|
107
|
+
const IDENT_START = /[A-Za-z_$]/;
|
|
108
|
+
const IDENT_PART = /[\w$]/;
|
|
109
|
+
/** After these tokens a `/` starts a regex literal, not division. */
|
|
110
|
+
const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
111
|
+
"return",
|
|
112
|
+
"typeof",
|
|
113
|
+
"instanceof",
|
|
114
|
+
"in",
|
|
115
|
+
"of",
|
|
116
|
+
"new",
|
|
117
|
+
"delete",
|
|
118
|
+
"void",
|
|
119
|
+
"throw",
|
|
120
|
+
"do",
|
|
121
|
+
"else",
|
|
122
|
+
"case",
|
|
123
|
+
"yield",
|
|
124
|
+
"await",
|
|
125
|
+
]);
|
|
126
|
+
/** Is `/` after this token division (true) or a regex literal (false)? */
|
|
127
|
+
function isDivisionContext(lastToken) {
|
|
128
|
+
if (lastToken === ")" || lastToken === "]")
|
|
129
|
+
return true;
|
|
130
|
+
if (/^["'`]$/.test(lastToken))
|
|
131
|
+
return true;
|
|
132
|
+
if (/^[\w$]+$/.test(lastToken))
|
|
133
|
+
return !REGEX_PRECEDING_KEYWORDS.has(lastToken);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Scan the source for candidate root identifiers (`z`, `FooZodSchema`, ...)
|
|
138
|
+
* followed by a `.`, tracking string/template/comment state and nesting
|
|
139
|
+
* depth. Depth 0 candidates are top-level initializers — already evaluated
|
|
140
|
+
* once — and are recorded only so their extents mask nested candidates.
|
|
141
|
+
*/
|
|
142
|
+
function scanSource(code, roots) {
|
|
143
|
+
const candidates = [];
|
|
144
|
+
const out = code.split("");
|
|
145
|
+
const mask = (from, to) => {
|
|
146
|
+
for (let p = from; p < to; p++) {
|
|
147
|
+
if (out[p] !== "\n")
|
|
148
|
+
out[p] = " ";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
let depth = 0;
|
|
152
|
+
// Template literals interleave string and expression states; the stack
|
|
153
|
+
// records the brace depth at which each `${` opened so the matching `}`
|
|
154
|
+
// resumes string state.
|
|
155
|
+
const templateStack = [];
|
|
156
|
+
let lastToken = "";
|
|
157
|
+
let i = 0;
|
|
158
|
+
while (i < code.length) {
|
|
159
|
+
const ch = code[i];
|
|
160
|
+
const next = code[i + 1];
|
|
161
|
+
// Comments
|
|
162
|
+
if (ch === "/" && next === "/") {
|
|
163
|
+
const nl = code.indexOf("\n", i);
|
|
164
|
+
const end = nl === -1 ? code.length : nl + 1;
|
|
165
|
+
mask(i, end);
|
|
166
|
+
i = end;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (ch === "/" && next === "*") {
|
|
170
|
+
const close = code.indexOf("*/", i + 2);
|
|
171
|
+
const end = close === -1 ? code.length : close + 2;
|
|
172
|
+
mask(i, end);
|
|
173
|
+
i = end;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (ch === "/") {
|
|
177
|
+
if (!isDivisionContext(lastToken)) {
|
|
178
|
+
const end = skipRegexLiteral(code, i);
|
|
179
|
+
mask(i, end);
|
|
180
|
+
i = end;
|
|
181
|
+
lastToken = ")"; // a regex literal is an operand
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
lastToken = ch;
|
|
185
|
+
i++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// Strings
|
|
189
|
+
if (ch === '"' || ch === "'") {
|
|
190
|
+
const end = skipString(code, i, ch);
|
|
191
|
+
mask(i, end);
|
|
192
|
+
i = end;
|
|
193
|
+
lastToken = ch;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// Template literals
|
|
197
|
+
if (ch === "`") {
|
|
198
|
+
const end = skipTemplateChunk(code, i + 1, templateStack, depth);
|
|
199
|
+
mask(i, end);
|
|
200
|
+
i = end;
|
|
201
|
+
lastToken = "`";
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (templateStack.length > 0 &&
|
|
205
|
+
ch === "}" &&
|
|
206
|
+
depth === templateStack[templateStack.length - 1]) {
|
|
207
|
+
// End of a ${ } expression — back into template string state
|
|
208
|
+
templateStack.pop();
|
|
209
|
+
const end = skipTemplateChunk(code, i + 1, templateStack, depth);
|
|
210
|
+
mask(i, end);
|
|
211
|
+
i = end;
|
|
212
|
+
lastToken = "`";
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
216
|
+
depth++;
|
|
217
|
+
lastToken = ch;
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (ch === ")" || ch === "]" || ch === "}") {
|
|
222
|
+
depth--;
|
|
223
|
+
lastToken = ch;
|
|
224
|
+
i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (IDENT_START.test(ch) && !IDENT_PART.test(code[i - 1] ?? "")) {
|
|
228
|
+
let j = i + 1;
|
|
229
|
+
while (j < code.length && IDENT_PART.test(code[j]))
|
|
230
|
+
j++;
|
|
231
|
+
const word = code.slice(i, j);
|
|
232
|
+
// Skip whitespace to check for the member access
|
|
233
|
+
let k = j;
|
|
234
|
+
while (k < code.length && /\s/.test(code[k]))
|
|
235
|
+
k++;
|
|
236
|
+
if (roots.has(word) && code[k] === "." && lastToken !== ".") {
|
|
237
|
+
candidates.push({ index: i, depth, name: word, afterArrow: lastToken === "=>" });
|
|
238
|
+
}
|
|
239
|
+
lastToken = word;
|
|
240
|
+
i = j;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!/\s/.test(ch)) {
|
|
244
|
+
lastToken = ch === ">" && lastToken === "=" ? "=>" : ch;
|
|
245
|
+
}
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
return { candidates, stripped: out.join("") };
|
|
249
|
+
}
|
|
250
|
+
function skipString(code, start, quote) {
|
|
251
|
+
for (let i = start + 1; i < code.length; i++) {
|
|
252
|
+
if (code[i] === "\\") {
|
|
253
|
+
i++;
|
|
254
|
+
}
|
|
255
|
+
else if (code[i] === quote || code[i] === "\n") {
|
|
256
|
+
return i + 1;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return code.length;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Skip a template string chunk; returns the offset after the closing
|
|
263
|
+
* `` ` `` or after a `${`, recording the current depth so the scanner can
|
|
264
|
+
* recognize the matching `}` later.
|
|
265
|
+
*/
|
|
266
|
+
function skipTemplateChunk(code, start, templateStack, depth) {
|
|
267
|
+
for (let i = start; i < code.length; i++) {
|
|
268
|
+
if (code[i] === "\\") {
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
else if (code[i] === "`") {
|
|
272
|
+
return i + 1;
|
|
273
|
+
}
|
|
274
|
+
else if (code[i] === "$" && code[i + 1] === "{") {
|
|
275
|
+
templateStack.push(depth);
|
|
276
|
+
return i + 2;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return code.length;
|
|
280
|
+
}
|
|
281
|
+
function skipRegexLiteral(code, start) {
|
|
282
|
+
let inClass = false;
|
|
283
|
+
for (let i = start + 1; i < code.length; i++) {
|
|
284
|
+
const ch = code[i];
|
|
285
|
+
if (ch === "\\") {
|
|
286
|
+
i++;
|
|
287
|
+
}
|
|
288
|
+
else if (ch === "[") {
|
|
289
|
+
inClass = true;
|
|
290
|
+
}
|
|
291
|
+
else if (ch === "]") {
|
|
292
|
+
inClass = false;
|
|
293
|
+
}
|
|
294
|
+
else if (ch === "/" && !inClass) {
|
|
295
|
+
return i + 1;
|
|
296
|
+
}
|
|
297
|
+
else if (ch === "\n") {
|
|
298
|
+
// Not a regex after all (unterminated) — treat as division
|
|
299
|
+
return start + 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return code.length;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Narrow a parsed expression to the largest call chain starting at `start`.
|
|
306
|
+
* parseExpressionAt can overshoot into surrounding operators
|
|
307
|
+
* (`z.string().min(1) || fallback` parses as a LogicalExpression) — descend
|
|
308
|
+
* through same-start children until a CallExpression is found.
|
|
309
|
+
*/
|
|
310
|
+
function narrowToCallChain(node, start) {
|
|
311
|
+
let current = node;
|
|
312
|
+
while (current.start === start) {
|
|
313
|
+
if (current.type === "CallExpression")
|
|
314
|
+
return current;
|
|
315
|
+
const child = sameStartChild(current, start);
|
|
316
|
+
if (!child)
|
|
317
|
+
return null;
|
|
318
|
+
current = child;
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
function sameStartChild(node, start) {
|
|
323
|
+
const record = node;
|
|
324
|
+
for (const key of ["left", "object", "callee", "test", "tag", "expression", "expressions"]) {
|
|
325
|
+
const value = record[key];
|
|
326
|
+
const child = Array.isArray(value) ? value[0] : value;
|
|
327
|
+
if (typeof child === "object" &&
|
|
328
|
+
child !== null &&
|
|
329
|
+
child.start === start &&
|
|
330
|
+
typeof child.type === "string") {
|
|
331
|
+
return child;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Walk a member/call chain down to its base identifier, collecting the
|
|
338
|
+
* non-computed method names along the spine. Computed members
|
|
339
|
+
* (`base[name]()`) are unanalyzable — the chain is rejected.
|
|
340
|
+
*/
|
|
341
|
+
function describeChain(node) {
|
|
342
|
+
const methods = [];
|
|
343
|
+
let current = node;
|
|
344
|
+
while (true) {
|
|
345
|
+
if (current.type === "CallExpression") {
|
|
346
|
+
current = current.callee;
|
|
347
|
+
}
|
|
348
|
+
else if (current.type === "MemberExpression") {
|
|
349
|
+
if (current.computed || current.property.type !== "Identifier")
|
|
350
|
+
return null;
|
|
351
|
+
methods.push(current.property.name);
|
|
352
|
+
current = current.object;
|
|
353
|
+
}
|
|
354
|
+
else if (current.type === "Identifier") {
|
|
355
|
+
return { root: current.name, methods };
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Combinators eligible on non-z chain bases (babel-plugin-zod-hoist's
|
|
364
|
+
* documented set) — `Base.extend({...})`, `Base.pick({...})`, ...
|
|
365
|
+
*/
|
|
366
|
+
const COMBINATOR_METHODS = new Set(["extend", "pick", "omit", "merge", "partial"]);
|
|
367
|
+
/**
|
|
368
|
+
* Methods that evaluate data rather than construct schemas. Hoisting one
|
|
369
|
+
* would move the evaluation (and any throw) to module load.
|
|
370
|
+
*/
|
|
371
|
+
const PARSE_METHODS = new Set([
|
|
372
|
+
"parse",
|
|
373
|
+
"safeParse",
|
|
374
|
+
"parseAsync",
|
|
375
|
+
"safeParseAsync",
|
|
376
|
+
"decode",
|
|
377
|
+
"encode",
|
|
378
|
+
"decodeAsync",
|
|
379
|
+
"encodeAsync",
|
|
380
|
+
]);
|
|
381
|
+
/**
|
|
382
|
+
* Globals that are safe to reference from a DEFERRED position (inside a
|
|
383
|
+
* callback): the callback still runs per parse, so hoisting cannot change
|
|
384
|
+
* when these evaluate. Eager positions stay imports-only — an eager
|
|
385
|
+
* `Math.random()` / `Date.now()` would freeze at module load.
|
|
386
|
+
*/
|
|
387
|
+
const SAFE_DEFERRED_GLOBALS = new Set([
|
|
388
|
+
"NaN",
|
|
389
|
+
"Infinity",
|
|
390
|
+
"Math",
|
|
391
|
+
"Number",
|
|
392
|
+
"String",
|
|
393
|
+
"Boolean",
|
|
394
|
+
"Array",
|
|
395
|
+
"Object",
|
|
396
|
+
"JSON",
|
|
397
|
+
"Date",
|
|
398
|
+
"RegExp",
|
|
399
|
+
"BigInt",
|
|
400
|
+
"Symbol",
|
|
401
|
+
"Map",
|
|
402
|
+
"Set",
|
|
403
|
+
"Promise",
|
|
404
|
+
"Error",
|
|
405
|
+
"TypeError",
|
|
406
|
+
"RangeError",
|
|
407
|
+
"SyntaxError",
|
|
408
|
+
"parseInt",
|
|
409
|
+
"parseFloat",
|
|
410
|
+
"isNaN",
|
|
411
|
+
"isFinite",
|
|
412
|
+
"encodeURI",
|
|
413
|
+
"decodeURI",
|
|
414
|
+
"encodeURIComponent",
|
|
415
|
+
"decodeURIComponent",
|
|
416
|
+
"structuredClone",
|
|
417
|
+
]);
|
|
418
|
+
/**
|
|
419
|
+
* Scope-aware free-variable analysis of an expression. Nested function
|
|
420
|
+
* params and local declarations are bound names, not captures — so
|
|
421
|
+
* `z.string().refine((v) => v.length > 0)` has no free variables beyond `z`.
|
|
422
|
+
* References inside nested function bodies are tracked separately: they
|
|
423
|
+
* evaluate per call even after hoisting, so safe globals are allowed there.
|
|
424
|
+
*/
|
|
425
|
+
function analyzeCaptures(expr) {
|
|
426
|
+
const eagerFree = new Set();
|
|
427
|
+
const deferredFree = new Set();
|
|
428
|
+
let impure = false;
|
|
429
|
+
function patternNames(node, into) {
|
|
430
|
+
switch (node.type) {
|
|
431
|
+
case "Identifier":
|
|
432
|
+
into.add(node.name);
|
|
433
|
+
return;
|
|
434
|
+
case "ObjectPattern":
|
|
435
|
+
for (const prop of node.properties) {
|
|
436
|
+
if (prop.type === "Property")
|
|
437
|
+
patternNames(prop.value, into);
|
|
438
|
+
else
|
|
439
|
+
patternNames(prop.argument, into);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
case "ArrayPattern":
|
|
443
|
+
for (const el of node.elements) {
|
|
444
|
+
if (el)
|
|
445
|
+
patternNames(el, into);
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
case "AssignmentPattern":
|
|
449
|
+
patternNames(node.left, into);
|
|
450
|
+
return;
|
|
451
|
+
case "RestElement":
|
|
452
|
+
patternNames(node.argument, into);
|
|
453
|
+
return;
|
|
454
|
+
default:
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function collectFunctionScope(body, into) {
|
|
459
|
+
// Flatten block scoping into the function scope — over-approximating
|
|
460
|
+
// bound names can only suppress a hoist's free set, and the failure
|
|
461
|
+
// mode is a loud ReferenceError, never silent misvalidation.
|
|
462
|
+
const stack = [body];
|
|
463
|
+
while (stack.length > 0) {
|
|
464
|
+
const node = stack.pop();
|
|
465
|
+
if (!node || typeof node !== "object")
|
|
466
|
+
continue;
|
|
467
|
+
if (node.type === "VariableDeclaration") {
|
|
468
|
+
for (const decl of node.declarations)
|
|
469
|
+
patternNames(decl.id, into);
|
|
470
|
+
}
|
|
471
|
+
else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") {
|
|
472
|
+
if (node.id)
|
|
473
|
+
into.add(node.id.name);
|
|
474
|
+
continue; // do not descend into nested declarations' bodies here
|
|
475
|
+
}
|
|
476
|
+
else if (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
477
|
+
continue; // nested functions get their own scope in visit()
|
|
478
|
+
}
|
|
479
|
+
for (const value of Object.values(node)) {
|
|
480
|
+
if (Array.isArray(value)) {
|
|
481
|
+
for (const item of value) {
|
|
482
|
+
if (typeof item === "object" && item !== null && "type" in item) {
|
|
483
|
+
stack.push(item);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
else if (typeof value === "object" && value !== null && "type" in value) {
|
|
488
|
+
stack.push(value);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function visit(node, scopes, deferred) {
|
|
494
|
+
switch (node.type) {
|
|
495
|
+
case "Identifier": {
|
|
496
|
+
const name = node.name;
|
|
497
|
+
if (name !== "undefined" && !scopes.some((s) => s.has(name))) {
|
|
498
|
+
(deferred ? deferredFree : eagerFree).add(name);
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
case "ThisExpression":
|
|
503
|
+
case "Super":
|
|
504
|
+
// Arrows bind `this` lexically — hoisting changes it even when the
|
|
505
|
+
// reference sits inside a callback. Always disqualifying.
|
|
506
|
+
impure = true;
|
|
507
|
+
return;
|
|
508
|
+
case "AwaitExpression":
|
|
509
|
+
case "YieldExpression":
|
|
510
|
+
case "NewExpression":
|
|
511
|
+
case "TaggedTemplateExpression":
|
|
512
|
+
case "MetaProperty":
|
|
513
|
+
// Deferred occurrences run per call (`refine(d => d < new Date())`)
|
|
514
|
+
// — hoisting does not change when they evaluate.
|
|
515
|
+
if (!deferred) {
|
|
516
|
+
impure = true;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
case "MemberExpression":
|
|
521
|
+
visit(node.object, scopes, deferred);
|
|
522
|
+
if (node.computed)
|
|
523
|
+
visit(node.property, scopes, deferred);
|
|
524
|
+
return;
|
|
525
|
+
case "Property":
|
|
526
|
+
if (node.computed)
|
|
527
|
+
visit(node.key, scopes, deferred);
|
|
528
|
+
visit(node.value, scopes, deferred);
|
|
529
|
+
return;
|
|
530
|
+
case "FunctionExpression":
|
|
531
|
+
case "ArrowFunctionExpression": {
|
|
532
|
+
const scope = new Set();
|
|
533
|
+
for (const param of node.params)
|
|
534
|
+
patternNames(param, scope);
|
|
535
|
+
if (node.type === "FunctionExpression" && node.id)
|
|
536
|
+
scope.add(node.id.name);
|
|
537
|
+
collectFunctionScope(node.body, scope);
|
|
538
|
+
visit(node.body, [...scopes, scope], true);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
default:
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
for (const value of Object.values(node)) {
|
|
545
|
+
if (Array.isArray(value)) {
|
|
546
|
+
for (const item of value) {
|
|
547
|
+
if (typeof item === "object" && item !== null && "type" in item) {
|
|
548
|
+
visit(item, scopes, deferred);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else if (typeof value === "object" && value !== null && "type" in value) {
|
|
553
|
+
visit(value, scopes, deferred);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
visit(expr, [], false);
|
|
558
|
+
return { eagerFree, deferredFree, impure };
|
|
559
|
+
}
|
|
560
|
+
/** Offset after the shebang and directive prologue ("use client", ...). */
|
|
561
|
+
function insertionOffset(code) {
|
|
562
|
+
let i = 0;
|
|
563
|
+
if (code.startsWith("#!")) {
|
|
564
|
+
const nl = code.indexOf("\n");
|
|
565
|
+
i = nl === -1 ? code.length : nl + 1;
|
|
566
|
+
}
|
|
567
|
+
while (true) {
|
|
568
|
+
// Skip whitespace and comments between directives
|
|
569
|
+
while (i < code.length) {
|
|
570
|
+
const ch = code[i];
|
|
571
|
+
if (/\s/.test(ch)) {
|
|
572
|
+
i++;
|
|
573
|
+
}
|
|
574
|
+
else if (ch === "/" && code[i + 1] === "/") {
|
|
575
|
+
const nl = code.indexOf("\n", i);
|
|
576
|
+
i = nl === -1 ? code.length : nl + 1;
|
|
577
|
+
}
|
|
578
|
+
else if (ch === "/" && code[i + 1] === "*") {
|
|
579
|
+
const end = code.indexOf("*/", i + 2);
|
|
580
|
+
i = end === -1 ? code.length : end + 2;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const directive = code.slice(i).match(/^(["'])use [^"'\n]*\1\s*;?[^\S\n]*\n?/);
|
|
587
|
+
if (!directive)
|
|
588
|
+
return i;
|
|
589
|
+
i += directive[0].length;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/** Keywords that precede `( ... ) {` without binding anything. */
|
|
593
|
+
const NON_BINDING_KEYWORDS = "if|for|while|switch|return|typeof|await|yield|new|do|else|case|in|of|delete|void";
|
|
594
|
+
/**
|
|
595
|
+
* Conservative shadow detection: collect every identifier appearing in a
|
|
596
|
+
* binding-like position — function/method/arrow parameter lists, catch
|
|
597
|
+
* clauses, const/let/var declarator names. The scanner has no scope
|
|
598
|
+
* information, so an import referenced from a hoisted expression must
|
|
599
|
+
* resolve to that import everywhere; a name that is ever re-bound cannot be
|
|
600
|
+
* trusted and disqualifies hoists referencing it. Over-collection only
|
|
601
|
+
* costs missed hoists; the identifiers extracted from each binding segment
|
|
602
|
+
* are cut at `:` and `=` so TypeScript annotations and initializers
|
|
603
|
+
* (`schema: z.ZodType`, `x = z.object()`) never poison the set.
|
|
604
|
+
*/
|
|
605
|
+
function collectBoundNames(code) {
|
|
606
|
+
const bound = new Set();
|
|
607
|
+
// Per comma-separated piece, keep only the binding side: names before any
|
|
608
|
+
// type annotation (`a: T`) or default/initializer (`a = v`).
|
|
609
|
+
const addBindingSegment = (segment) => {
|
|
610
|
+
for (const piece of segment.split(",")) {
|
|
611
|
+
let cut = piece;
|
|
612
|
+
const colon = cut.indexOf(":");
|
|
613
|
+
if (colon !== -1)
|
|
614
|
+
cut = cut.slice(0, colon);
|
|
615
|
+
const eq = cut.indexOf("=");
|
|
616
|
+
if (eq !== -1)
|
|
617
|
+
cut = cut.slice(0, eq);
|
|
618
|
+
for (const id of cut.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
|
619
|
+
bound.add(id[0]);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
// function f(a, b) / function (a, b)
|
|
624
|
+
for (const m of code.matchAll(/\bfunction\b[^(]*\(([^()]*)\)/g)) {
|
|
625
|
+
addBindingSegment(m[1] ?? "");
|
|
626
|
+
}
|
|
627
|
+
// (a, b) => / (a, b): Ret =>
|
|
628
|
+
for (const m of code.matchAll(/\(([^()]*)\)\s*(?::[^=>{};()]*)?=>/g)) {
|
|
629
|
+
addBindingSegment(m[1] ?? "");
|
|
630
|
+
}
|
|
631
|
+
// bare arrow param: a =>
|
|
632
|
+
for (const m of code.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) {
|
|
633
|
+
bound.add(m[1] ?? "");
|
|
634
|
+
}
|
|
635
|
+
// method(a, b) { / method(a): Ret { — excluding control flow (`if (x) {`)
|
|
636
|
+
const methodPattern = new RegExp(String.raw `(?<![.\w$])(?!(?:${NON_BINDING_KEYWORDS})\b)[A-Za-z_$][\w$]*\s*\(([^()]*)\)\s*(?::[^{};()]*)?\{`, "g");
|
|
637
|
+
for (const m of code.matchAll(methodPattern)) {
|
|
638
|
+
addBindingSegment(m[1] ?? "");
|
|
639
|
+
}
|
|
640
|
+
// catch (e)
|
|
641
|
+
for (const m of code.matchAll(/\bcatch\s*\(([^()]*)\)/g)) {
|
|
642
|
+
addBindingSegment(m[1] ?? "");
|
|
643
|
+
}
|
|
644
|
+
// const/let/var declarator names (cut per comma at `:` / `=`)
|
|
645
|
+
for (const m of code.matchAll(/\b(?:const|let|var)\s+([^;\n]*)/g)) {
|
|
646
|
+
addBindingSegment(m[1] ?? "");
|
|
647
|
+
}
|
|
648
|
+
return bound;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Hoist eligible Zod schema expressions to module scope.
|
|
652
|
+
* Returns the rewritten source, or null when nothing was hoisted.
|
|
653
|
+
*/
|
|
654
|
+
export function hoistZodSchemas(code, options) {
|
|
655
|
+
const imports = collectImportBindings(code);
|
|
656
|
+
if (imports.all.size === 0)
|
|
657
|
+
return null;
|
|
658
|
+
const rawPattern = options?.schemaNamePattern;
|
|
659
|
+
const namePattern = rawPattern === null
|
|
660
|
+
? null
|
|
661
|
+
: rawPattern === undefined
|
|
662
|
+
? SCHEMA_NAME_PATTERN
|
|
663
|
+
: typeof rawPattern === "string"
|
|
664
|
+
? new RegExp(rawPattern)
|
|
665
|
+
: rawPattern;
|
|
666
|
+
const roots = new Set(imports.zod);
|
|
667
|
+
if (namePattern) {
|
|
668
|
+
for (const name of imports.all) {
|
|
669
|
+
if (namePattern.test(name))
|
|
670
|
+
roots.add(name);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Imported roots that only qualify via an inline z.* reference in the
|
|
674
|
+
// chain are validated per-expression below; scan them as candidates too.
|
|
675
|
+
if (imports.zod.size > 0) {
|
|
676
|
+
for (const name of imports.all)
|
|
677
|
+
roots.add(name);
|
|
678
|
+
}
|
|
679
|
+
if (roots.size === 0)
|
|
680
|
+
return null;
|
|
681
|
+
const { candidates, stripped } = scanSource(code, roots);
|
|
682
|
+
if (candidates.length === 0)
|
|
683
|
+
return null;
|
|
684
|
+
let boundNames;
|
|
685
|
+
function isShadowed(name) {
|
|
686
|
+
boundNames ??= collectBoundNames(stripped);
|
|
687
|
+
return boundNames.has(name);
|
|
688
|
+
}
|
|
689
|
+
const hoists = [];
|
|
690
|
+
const declByText = new Map();
|
|
691
|
+
let consumedUntil = 0;
|
|
692
|
+
for (const candidate of candidates) {
|
|
693
|
+
if (candidate.index < consumedUntil)
|
|
694
|
+
continue;
|
|
695
|
+
let parsed;
|
|
696
|
+
try {
|
|
697
|
+
parsed = parseExpressionAt(code, candidate.index, {
|
|
698
|
+
ecmaVersion: "latest",
|
|
699
|
+
sourceType: "module",
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
let chain = narrowToCallChain(parsed, candidate.index);
|
|
706
|
+
if (!chain)
|
|
707
|
+
continue;
|
|
708
|
+
// Mask nested candidates inside this extent regardless of eligibility —
|
|
709
|
+
// an inner z.string() of a top-level schema must not hoist on its own.
|
|
710
|
+
consumedUntil = chain.end;
|
|
711
|
+
// Depth 0 expressions are top-level statements/initializers: already
|
|
712
|
+
// evaluated once per module load, nothing to gain. Exception: concise
|
|
713
|
+
// arrow bodies (`const make = () => z.object(...)`) re-run per call.
|
|
714
|
+
if (candidate.depth === 0 && !candidate.afterArrow)
|
|
715
|
+
continue;
|
|
716
|
+
// Peel trailing parse calls: for `z.object({...}).safeParse(input)`,
|
|
717
|
+
// hoist the construction and leave `.safeParse(input)` — with its
|
|
718
|
+
// local-variable arguments — at the call site.
|
|
719
|
+
let described = describeChain(chain);
|
|
720
|
+
while (chain !== null &&
|
|
721
|
+
described !== null &&
|
|
722
|
+
described.methods.length > 0 &&
|
|
723
|
+
PARSE_METHODS.has(described.methods[0])) {
|
|
724
|
+
const callee = chain.type === "CallExpression" ? chain.callee : null;
|
|
725
|
+
const inner = callee !== null && callee.type === "MemberExpression" ? callee.object : null;
|
|
726
|
+
chain = inner !== null && inner.type === "CallExpression" ? inner : null;
|
|
727
|
+
described = chain === null ? null : describeChain(chain);
|
|
728
|
+
}
|
|
729
|
+
if (chain === null || described === null)
|
|
730
|
+
continue;
|
|
731
|
+
if (described.root !== candidate.name)
|
|
732
|
+
continue;
|
|
733
|
+
if (described.methods.some((m) => PARSE_METHODS.has(m)))
|
|
734
|
+
continue;
|
|
735
|
+
const rootIsZod = imports.zod.has(candidate.name);
|
|
736
|
+
const rootMatchesPattern = namePattern?.test(candidate.name) === true;
|
|
737
|
+
// Non-z bases must look like schema derivation: the chain must START
|
|
738
|
+
// with a combinator (`Base.extend({...}).optional()` qualifies via
|
|
739
|
+
// extend), so an arbitrary imported object with a z-mentioning argument
|
|
740
|
+
// (`api.get(z.string())`) is never hoisted. describeChain records
|
|
741
|
+
// methods outermost-first — the deepest method is last.
|
|
742
|
+
const deepestMethod = described.methods[described.methods.length - 1];
|
|
743
|
+
if (!rootIsZod && (deepestMethod === undefined || !COMBINATOR_METHODS.has(deepestMethod))) {
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const { eagerFree, deferredFree, impure } = analyzeCaptures(chain);
|
|
747
|
+
if (impure)
|
|
748
|
+
continue;
|
|
749
|
+
// Imports-and-literals rule (babel-plugin-zod-hoist): eager positions
|
|
750
|
+
// may reference only imports — locals, module-level bindings, and
|
|
751
|
+
// globals all disqualify. Deferred positions (callback bodies) run per
|
|
752
|
+
// call regardless, so safe globals are additionally allowed there.
|
|
753
|
+
// Every referenced import must also be shadow-free: without scope
|
|
754
|
+
// information, a name that is ever re-bound (function parameter, local)
|
|
755
|
+
// cannot be trusted to mean the import.
|
|
756
|
+
let eligible = true;
|
|
757
|
+
for (const name of eagerFree) {
|
|
758
|
+
if (!imports.all.has(name) || isShadowed(name)) {
|
|
759
|
+
eligible = false;
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (eligible) {
|
|
764
|
+
for (const name of deferredFree) {
|
|
765
|
+
const allowed = imports.all.has(name) || SAFE_DEFERRED_GLOBALS.has(name);
|
|
766
|
+
if (!allowed || isShadowed(name)) {
|
|
767
|
+
eligible = false;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (!eligible)
|
|
773
|
+
continue;
|
|
774
|
+
// Non-zod, non-pattern roots qualify only when the chain itself
|
|
775
|
+
// references a zod binding (`Base.extend({ a: z.string() })`).
|
|
776
|
+
if (!rootIsZod && !rootMatchesPattern) {
|
|
777
|
+
let referencesZod = false;
|
|
778
|
+
for (const name of eagerFree) {
|
|
779
|
+
if (imports.zod.has(name)) {
|
|
780
|
+
referencesZod = true;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
for (const name of deferredFree) {
|
|
785
|
+
if (imports.zod.has(name)) {
|
|
786
|
+
referencesZod = true;
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (!referencesZod)
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const text = code.slice(candidate.index, chain.end);
|
|
794
|
+
let declName = declByText.get(text);
|
|
795
|
+
if (!declName) {
|
|
796
|
+
declName = `_zh_${fnv1a(text)}`;
|
|
797
|
+
declByText.set(text, declName);
|
|
798
|
+
}
|
|
799
|
+
hoists.push({ start: candidate.index, end: chain.end, name: declName });
|
|
800
|
+
}
|
|
801
|
+
if (hoists.length === 0)
|
|
802
|
+
return null;
|
|
803
|
+
// Apply replacements from the end so earlier offsets stay valid.
|
|
804
|
+
let result = code;
|
|
805
|
+
for (let i = hoists.length - 1; i >= 0; i--) {
|
|
806
|
+
const hoist = hoists[i];
|
|
807
|
+
result = result.slice(0, hoist.start) + hoist.name + result.slice(hoist.end);
|
|
808
|
+
}
|
|
809
|
+
const decls = [...declByText.entries()]
|
|
810
|
+
.map(([text, name]) => `const ${name} = ${text};`)
|
|
811
|
+
.join("\n");
|
|
812
|
+
const offset = insertionOffset(result);
|
|
813
|
+
return `${result.slice(0, offset)}${decls}\n${result.slice(offset)}`;
|
|
814
|
+
}
|
|
815
|
+
//# sourceMappingURL=hoist.js.map
|