zod-compiler 1.0.6 → 1.0.8

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.
@@ -1,4 +1,4 @@
1
- import { parseExpressionAt } from "acorn";
1
+ import { Parser } from "acorn";
2
2
  /**
3
3
  * Hoist Zod schema construction out of functions to module scope —
4
4
  * equivalent of babel-plugin-zod-hoist.
@@ -16,14 +16,27 @@ import { parseExpressionAt } from "acorn";
16
16
  * return _zh_94b7f5c1; // built once
17
17
  * }
18
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.
19
+ * Safety rules (babel-plugin-zod-hoist's `canSafelyHoist`, hardened for
20
+ * lexical analysis):
21
+ * - A free identifier must be an import or a KNOWN_GLOBALS member, and must
22
+ * never be bound anywhere in the file (function params, locals, catch
23
+ * clauses, class names, module-level const/let/var — hoisting above those
24
+ * would change meaning or hit the TDZ). The babel plugin additionally
25
+ * allows arbitrary unbound identifiers because it has real scope
26
+ * information; this port's binding collector is lexical, so an unknown
27
+ * bare name is treated as a possibly-missed binding rather than a global
28
+ * (a wrong guess crashes at module load with a ReferenceError).
29
+ * `this`/`super` disqualify anywhere. Eager `await`/`yield` also
30
+ * disqualify (stricter than the babel plugin, which never encounters
31
+ * them: hoisting one would emit top-level await / orphaned yield).
24
32
  * - Eligible roots: any binding imported from zod, an imported identifier
25
33
  * matching /ZodSchema$/, or an imported identifier whose chain contains
26
34
  * an inline z.* reference (e.g. `Base.extend({ a: z.string() })`).
35
+ * - Nesting (babel's `isNestedInZodCall`): the interior of a zod-rooted
36
+ * chain never hoists separately — it goes with the outer schema or not at
37
+ * all. Chains rooted elsewhere (`sql.type(...)`, `api.get(...)`) do NOT
38
+ * suppress their arguments: when the outer chain is rejected, an inner
39
+ * `z.object({...})` still hoists on its own.
27
40
  * - Declarations are inserted at the top of the module (after shebang and
28
41
  * directive prologue). Imports are initialized before module code runs,
29
42
  * so referencing them from above their textual position is safe.
@@ -47,21 +60,23 @@ const ZOD_MODULES = new Set(["zod", "zod/v4", "zod/mini", "zod/v4/mini"]);
47
60
  export function collectImportBindings(code) {
48
61
  const all = new Set();
49
62
  const zod = new Set();
63
+ const details = new Map();
50
64
  const importPattern = /import\s+(type\s+)?([^'";]+?)\s+from\s*["']([^"']+)["']/g;
51
65
  for (const match of code.matchAll(importPattern)) {
52
66
  const [, typeOnly, clause, specifier] = match;
53
67
  if (typeOnly || clause === undefined || specifier === undefined)
54
68
  continue;
55
69
  const isZod = ZOD_MODULES.has(specifier);
56
- for (const name of parseImportClause(clause)) {
57
- all.add(name);
70
+ for (const { local, imported } of parseImportClause(clause)) {
71
+ all.add(local);
58
72
  if (isZod)
59
- zod.add(name);
73
+ zod.add(local);
74
+ details.set(local, { specifier, imported });
60
75
  }
61
76
  }
62
- return { all, zod };
77
+ return { all, zod, details };
63
78
  }
64
- /** Extract local binding names from an import clause. */
79
+ /** Extract local binding names (with their source export) from an import clause. */
65
80
  function parseImportClause(clause) {
66
81
  const names = [];
67
82
  const namedStart = clause.indexOf("{");
@@ -73,10 +88,10 @@ function parseImportClause(clause) {
73
88
  continue;
74
89
  const ns = trimmed.match(/^\*\s*as\s+([A-Za-z_$][\w$]*)$/);
75
90
  if (ns?.[1]) {
76
- names.push(ns[1]);
91
+ names.push({ local: ns[1], imported: "*" });
77
92
  }
78
93
  else if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
79
- names.push(trimmed);
94
+ names.push({ local: trimmed, imported: "default" });
80
95
  }
81
96
  }
82
97
  if (namedStart !== -1) {
@@ -87,10 +102,13 @@ function parseImportClause(clause) {
87
102
  if (!spec || spec.startsWith("type "))
88
103
  continue;
89
104
  // `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);
105
+ const asMatch = spec.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
106
+ if (asMatch?.[1] && asMatch[2]) {
107
+ names.push({ local: asMatch[2], imported: asMatch[1] });
108
+ }
109
+ else if (/^[A-Za-z_$][\w$]*$/.test(spec)) {
110
+ names.push({ local: spec, imported: spec });
111
+ }
94
112
  }
95
113
  }
96
114
  return names;
@@ -301,9 +319,23 @@ function skipRegexLiteral(code, start) {
301
319
  }
302
320
  return code.length;
303
321
  }
322
+ /**
323
+ * Parse a single assignment-level expression at `pos` — the same entry point
324
+ * acorn's own parseExpressionAt uses, one precedence level down. At
325
+ * expression level a trailing comma in an argument position
326
+ * (`sql.type(z.object({...}),\n)`) starts a sequence-expression parse that
327
+ * throws on the surrounding `)`; assignment level treats the comma as
328
+ * trailing garbage and stops cleanly. parseMaybeAssign is the entry point
329
+ * the acorn plugin ecosystem overrides — stable across versions.
330
+ */
331
+ function parseAssignmentAt(code, pos) {
332
+ const parser = new Parser({ ecmaVersion: "latest", sourceType: "module" }, code, pos);
333
+ parser.nextToken();
334
+ return parser.parseMaybeAssign();
335
+ }
304
336
  /**
305
337
  * Narrow a parsed expression to the largest call chain starting at `start`.
306
- * parseExpressionAt can overshoot into surrounding operators
338
+ * The parse can overshoot into surrounding operators
307
339
  * (`z.string().min(1) || fallback` parses as a LogicalExpression) — descend
308
340
  * through same-start children until a CallExpression is found.
309
341
  */
@@ -361,9 +393,34 @@ function describeChain(node) {
361
393
  }
362
394
  /**
363
395
  * Combinators eligible on non-z chain bases (babel-plugin-zod-hoist's
364
- * documented set) — `Base.extend({...})`, `Base.pick({...})`, ...
396
+ * SCHEMA_COMBINATOR_METHODS, verbatim) — `Base.extend({...})`,
397
+ * `Base.pick({...})`, `UserZodSchema.optional()`, ...
365
398
  */
366
- const COMBINATOR_METHODS = new Set(["extend", "pick", "omit", "merge", "partial"]);
399
+ const COMBINATOR_METHODS = new Set([
400
+ "and",
401
+ "array",
402
+ "brand",
403
+ "catchall",
404
+ "deepPartial",
405
+ "describe",
406
+ "extend",
407
+ "merge",
408
+ "nullable",
409
+ "nullish",
410
+ "omit",
411
+ "optional",
412
+ "or",
413
+ "partial",
414
+ "passthrough",
415
+ "pick",
416
+ "readonly",
417
+ "refine",
418
+ "required",
419
+ "strict",
420
+ "strip",
421
+ "superRefine",
422
+ "transform",
423
+ ]);
367
424
  /**
368
425
  * Methods that evaluate data rather than construct schemas. Hoisting one
369
426
  * would move the evaluation (and any throw) to module load.
@@ -379,12 +436,19 @@ const PARSE_METHODS = new Set([
379
436
  "encodeAsync",
380
437
  ]);
381
438
  /**
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.
439
+ * Standard globals a hoisted expression may reference (eager or deferred —
440
+ * babel-parity behaviors like hoisting `z.date().default(new Date())` rely
441
+ * on Date/Math being recognized).
442
+ *
443
+ * The babel plugin allows ANY unbound identifier because it has real scope
444
+ * information; this port's binding collector is a lexical approximation, so
445
+ * an unknown bare name must be assumed to be a binding the collector missed
446
+ * — hoisting it would crash at module load with
447
+ * `ReferenceError: <name> is not defined`. A fixed allowlist converts that
448
+ * failure mode into a missed optimization.
386
449
  */
387
- const SAFE_DEFERRED_GLOBALS = new Set([
450
+ const KNOWN_GLOBALS = new Set([
451
+ "globalThis",
388
452
  "NaN",
389
453
  "Infinity",
390
454
  "Math",
@@ -400,11 +464,21 @@ const SAFE_DEFERRED_GLOBALS = new Set([
400
464
  "Symbol",
401
465
  "Map",
402
466
  "Set",
467
+ "WeakMap",
468
+ "WeakSet",
403
469
  "Promise",
470
+ "Proxy",
471
+ "Reflect",
472
+ "Intl",
404
473
  "Error",
405
474
  "TypeError",
406
475
  "RangeError",
407
476
  "SyntaxError",
477
+ "EvalError",
478
+ "URIError",
479
+ "AggregateError",
480
+ "ArrayBuffer",
481
+ "Uint8Array",
408
482
  "parseInt",
409
483
  "parseFloat",
410
484
  "isNaN",
@@ -414,6 +488,16 @@ const SAFE_DEFERRED_GLOBALS = new Set([
414
488
  "encodeURIComponent",
415
489
  "decodeURIComponent",
416
490
  "structuredClone",
491
+ "URL",
492
+ "URLSearchParams",
493
+ "TextEncoder",
494
+ "TextDecoder",
495
+ "atob",
496
+ "btoa",
497
+ "crypto",
498
+ "console",
499
+ "process",
500
+ "Buffer",
417
501
  ]);
418
502
  /**
419
503
  * Scope-aware free-variable analysis of an expression. Nested function
@@ -507,11 +591,10 @@ function analyzeCaptures(expr) {
507
591
  return;
508
592
  case "AwaitExpression":
509
593
  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.
594
+ // Eager occurrences cannot be moved to module scope: hoisting would
595
+ // emit top-level await (broken in CJS output) or an orphaned yield.
596
+ // (Stricter than the babel plugin, whose suite never exercises
597
+ // these.) Deferred occurrences run per call — fine to move.
515
598
  if (!deferred) {
516
599
  impure = true;
517
600
  return;
@@ -594,64 +677,208 @@ const NON_BINDING_KEYWORDS = "if|for|while|switch|return|typeof|await|yield|new|
594
677
  /**
595
678
  * Conservative shadow detection: collect every identifier appearing in a
596
679
  * 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.
680
+ * clauses, class names, const/let/var declarator patterns. The scanner has
681
+ * no scope information, so an import referenced from a hoisted expression
682
+ * must resolve to that import everywhere; a name that is ever re-bound
683
+ * cannot be trusted and disqualifies hoists referencing it.
684
+ *
685
+ * Collection is depth-aware and multiline (balanced delimiter scanning, not
686
+ * line-bounded regexes): multiline destructuring declarations and parameter
687
+ * defaults containing calls are real production patterns whose bindings a
688
+ * line-based collector misses — and a missed binding here used to mean a
689
+ * hoist referencing it crashed at module load (`ReferenceError`).
690
+ * Over-collection (type names, destructuring default values, for-of
691
+ * iterables) is deliberate and safe: it can only suppress a hoist.
604
692
  */
605
693
  function collectBoundNames(code) {
606
694
  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`).
695
+ // Per top-level-comma piece, keep only the binding side: names before any
696
+ // top-level `:` (type annotation) or `=` (default/initializer). Inside
697
+ // destructuring patterns the `:`/`=` sit at depth > 0, so the whole
698
+ // pattern is collected (renames and default values over-collect — safe).
609
699
  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)) {
700
+ let depth = 0;
701
+ let pieceStart = 0;
702
+ let cut = -1;
703
+ const flush = (end) => {
704
+ const prefix = segment.slice(pieceStart, cut === -1 ? end : cut);
705
+ for (const id of prefix.matchAll(/[A-Za-z_$][\w$]*/g)) {
619
706
  bound.add(id[0]);
620
707
  }
708
+ cut = -1;
709
+ };
710
+ for (let i = 0; i < segment.length; i++) {
711
+ const ch = segment[i];
712
+ if (ch === "(" || ch === "[" || ch === "{") {
713
+ depth++;
714
+ }
715
+ else if (ch === ")" || ch === "]" || ch === "}") {
716
+ depth--;
717
+ }
718
+ else if (depth === 0 && ch === ",") {
719
+ flush(i);
720
+ pieceStart = i + 1;
721
+ }
722
+ else if (depth === 0 && cut === -1 && (ch === ":" || ch === "=")) {
723
+ cut = i;
724
+ }
725
+ }
726
+ flush(segment.length);
727
+ };
728
+ /** Balanced `( ... )` span starting at `open` (must point at `(`). */
729
+ const parenSpan = (open) => {
730
+ let depth = 0;
731
+ for (let i = open; i < code.length; i++) {
732
+ const ch = code[i];
733
+ if (ch === "(")
734
+ depth++;
735
+ else if (ch === ")") {
736
+ depth--;
737
+ if (depth === 0)
738
+ return { inner: code.slice(open + 1, i), end: i };
739
+ }
621
740
  }
741
+ return null;
622
742
  };
623
- // function f(a, b) / function (a, b)
624
- for (const m of code.matchAll(/\bfunction\b[^(]*\(([^()]*)\)/g)) {
625
- addBindingSegment(m[1] ?? "");
743
+ // function f(a, b) / function (a = call()) — balanced + multiline
744
+ for (const m of code.matchAll(/\bfunction\b/g)) {
745
+ let i = m.index + m[0].length;
746
+ while (i < code.length && code[i] !== "(" && code[i] !== "{" && code[i] !== ";")
747
+ i++;
748
+ if (code[i] !== "(")
749
+ continue;
750
+ const span = parenSpan(i);
751
+ if (span)
752
+ addBindingSegment(span.inner);
626
753
  }
627
- // (a, b) => / (a, b): Ret =>
628
- for (const m of code.matchAll(/\(([^()]*)\)\s*(?::[^=>{};()]*)?=>/g)) {
629
- addBindingSegment(m[1] ?? "");
754
+ // (a, b) => / (a = call()): Ret => — balanced params located from the arrow
755
+ for (const m of code.matchAll(/=>/g)) {
756
+ let i = m.index - 1;
757
+ while (i >= 0 && /\s/.test(code[i]))
758
+ i--;
759
+ if (i < 0)
760
+ continue;
761
+ let closeAt = -1;
762
+ if (code[i] === ")") {
763
+ closeAt = i;
764
+ }
765
+ else {
766
+ // Possible return-type annotation between `)` and `=>`:
767
+ // `(a): Promise<T> =>`. Find the nearest `)` whose gap to the arrow
768
+ // looks like a type annotation; give up otherwise (over-approximation
769
+ // elsewhere keeps this safe).
770
+ const before = code.slice(0, m.index);
771
+ const lastClose = before.lastIndexOf(")");
772
+ if (lastClose !== -1 && /^\s*:[^(){};]*$/.test(before.slice(lastClose + 1))) {
773
+ closeAt = lastClose;
774
+ }
775
+ }
776
+ if (closeAt === -1)
777
+ continue;
778
+ // walk back to the matching `(`
779
+ let depth = 0;
780
+ for (let j = closeAt; j >= 0; j--) {
781
+ const ch = code[j];
782
+ if (ch === ")")
783
+ depth++;
784
+ else if (ch === "(") {
785
+ depth--;
786
+ if (depth === 0) {
787
+ addBindingSegment(code.slice(j + 1, closeAt));
788
+ break;
789
+ }
790
+ }
791
+ }
630
792
  }
631
793
  // bare arrow param: a =>
632
794
  for (const m of code.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) {
633
795
  bound.add(m[1] ?? "");
634
796
  }
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] ?? "");
797
+ // method(a, b) { / method(a = call()): Ret { — excluding control flow.
798
+ // The balanced span must be directly followed by `{` (after an optional
799
+ // return type), which call expressions essentially never are.
800
+ const methodAnchor = new RegExp(String.raw `(?<![.\w$])(?!(?:${NON_BINDING_KEYWORDS})\b)[A-Za-z_$][\w$]*\s*\(`, "g");
801
+ for (const m of code.matchAll(methodAnchor)) {
802
+ const span = parenSpan(m.index + m[0].length - 1);
803
+ if (!span)
804
+ continue;
805
+ const after = code.slice(span.end + 1);
806
+ if (/^\s*(?::[^{};()]*)?\{/.test(after))
807
+ addBindingSegment(span.inner);
639
808
  }
640
809
  // catch (e)
641
- for (const m of code.matchAll(/\bcatch\s*\(([^()]*)\)/g)) {
642
- addBindingSegment(m[1] ?? "");
810
+ for (const m of code.matchAll(/\bcatch\s*\(/g)) {
811
+ const span = parenSpan(m.index + m[0].length - 1);
812
+ if (span)
813
+ addBindingSegment(span.inner);
814
+ }
815
+ // class names are TDZ bindings (function declarations hoist, classes don't)
816
+ for (const m of code.matchAll(/\bclass\s+([A-Za-z_$][\w$]*)/g)) {
817
+ bound.add(m[1] ?? "");
643
818
  }
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] ?? "");
819
+ // const/let/var declarator patterns — balanced + multiline. The span runs
820
+ // to the first top-level `;` (or an unbalanced closer: for-headers), so
821
+ // `const {\n inputSchema,\n} = getSchemas();` collects inputSchema.
822
+ for (const m of code.matchAll(/\b(?:const|let|var)\b/g)) {
823
+ const start = m.index + m[0].length;
824
+ let depth = 0;
825
+ let end = code.length;
826
+ for (let i = start; i < code.length; i++) {
827
+ const ch = code[i];
828
+ if (ch === "(" || ch === "[" || ch === "{") {
829
+ depth++;
830
+ }
831
+ else if (ch === ")" || ch === "]" || ch === "}") {
832
+ depth--;
833
+ if (depth < 0) {
834
+ end = i;
835
+ break;
836
+ }
837
+ }
838
+ else if (depth === 0 && ch === ";") {
839
+ end = i;
840
+ break;
841
+ }
842
+ }
843
+ addBindingSegment(code.slice(start, end));
647
844
  }
648
845
  return bound;
649
846
  }
847
+ /**
848
+ * Free-variable analysis of a hoisted expression's source text, for the
849
+ * build-time compile step. Returns null when the text does not parse (it
850
+ * always should — it was extracted by this module).
851
+ */
852
+ export function analyzeHoistedExpression(text) {
853
+ try {
854
+ const parsed = parseAssignmentAt(text, 0);
855
+ const { eagerFree, deferredFree, impure } = analyzeCaptures(parsed);
856
+ if (impure)
857
+ return null;
858
+ return { eagerFree, deferredFree };
859
+ }
860
+ catch {
861
+ return null;
862
+ }
863
+ }
650
864
  /**
651
865
  * Hoist eligible Zod schema expressions to module scope.
652
866
  * Returns the rewritten source, or null when nothing was hoisted.
653
867
  */
654
868
  export function hoistZodSchemas(code, options) {
869
+ return hoistZodSchemasMeta(code, options)?.code ?? null;
870
+ }
871
+ /**
872
+ * hoistZodSchemas + metadata about each hoisted declaration, so the
873
+ * transform can compile the hoisted schemas into optimized validators.
874
+ */
875
+ export function hoistZodSchemasMeta(code, options) {
876
+ // Idempotency: a file carrying hoisted declarations IS this pass's output.
877
+ // Plain hoists are naturally inert on re-runs (depth-0 masking), but a
878
+ // compiled hoisted decl embeds its original z.* expression inside the IIFE
879
+ // (depth > 0) — re-hoisting it would emit a duplicate _zh_ declaration.
880
+ if (/\bconst _zh_[0-9a-f]{8} = /.test(code))
881
+ return null;
655
882
  const imports = collectImportBindings(code);
656
883
  if (imports.all.size === 0)
657
884
  return null;
@@ -694,10 +921,7 @@ export function hoistZodSchemas(code, options) {
694
921
  continue;
695
922
  let parsed;
696
923
  try {
697
- parsed = parseExpressionAt(code, candidate.index, {
698
- ecmaVersion: "latest",
699
- sourceType: "module",
700
- });
924
+ parsed = parseAssignmentAt(code, candidate.index);
701
925
  }
702
926
  catch {
703
927
  continue;
@@ -705,14 +929,25 @@ export function hoistZodSchemas(code, options) {
705
929
  let chain = narrowToCallChain(parsed, candidate.index);
706
930
  if (!chain)
707
931
  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)
932
+ const rootIsZod = imports.zod.has(candidate.name);
933
+ // Masking (babel-plugin-zod-hoist nesting semantics):
934
+ // - Depth-0 chains are top-level statements/initializers — already
935
+ // evaluated once per module load, nothing to gain — and their interior
936
+ // must not hoist separately either. Exception: concise arrow bodies
937
+ // (`const make = () => z.object(...)`) re-run per call.
938
+ // - Zod-rooted chains mask their interior REGARDLESS of eligibility (an
939
+ // inner z.string() of `z.object({ a: z.string(), b: local })` belongs
940
+ // to the outer schema — babel's isNestedInZodCall).
941
+ // - Chains rooted elsewhere (`sql.type(...)`, `api.get(...)`,
942
+ // `Base.extend(...)`) mask their interior only when actually hoisted:
943
+ // a rejected outer chain leaves its arguments free, so the inner
944
+ // `z.object({...})` of `sql.type(z.object({...}))` hoists on its own.
945
+ if (candidate.depth === 0 && !candidate.afterArrow) {
946
+ consumedUntil = chain.end;
715
947
  continue;
948
+ }
949
+ if (rootIsZod)
950
+ consumedUntil = chain.end;
716
951
  // Peel trailing parse calls: for `z.object({...}).safeParse(input)`,
717
952
  // hoist the construction and leave `.safeParse(input)` — with its
718
953
  // local-variable arguments — at the call site.
@@ -728,11 +963,16 @@ export function hoistZodSchemas(code, options) {
728
963
  }
729
964
  if (chain === null || described === null)
730
965
  continue;
966
+ // Tighten the zod-rooted mask to the peeled construction extent: the
967
+ // arguments of a peeled `.parse(...)` are not part of the schema, so
968
+ // candidates inside them stay free (babel traverses execution-method
969
+ // arguments normally).
970
+ if (rootIsZod)
971
+ consumedUntil = chain.end;
731
972
  if (described.root !== candidate.name)
732
973
  continue;
733
974
  if (described.methods.some((m) => PARSE_METHODS.has(m)))
734
975
  continue;
735
- const rootIsZod = imports.zod.has(candidate.name);
736
976
  const rootMatchesPattern = namePattern?.test(candidate.name) === true;
737
977
  // Non-z bases must look like schema derivation: the chain must START
738
978
  // with a combinator (`Base.extend({...}).optional()` qualifies via
@@ -746,24 +986,25 @@ export function hoistZodSchemas(code, options) {
746
986
  const { eagerFree, deferredFree, impure } = analyzeCaptures(chain);
747
987
  if (impure)
748
988
  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.
989
+ // Capture rule (babel-plugin-zod-hoist's canSafelyHoist, hardened): a
990
+ // free name is safe only when it is an import or a recognized standard
991
+ // global, and is never re-bound anywhere in the file (no scope info, so
992
+ // a name bound in ANY function cannot be trusted to mean the import —
993
+ // over-rejection only costs a missed hoist). The babel plugin also
994
+ // allows arbitrary unbound identifiers, but it has real scope analysis;
995
+ // here an unknown bare name is more likely a binding the lexical
996
+ // collector missed than a genuine global, and hoisting it would crash
997
+ // at module load (`ReferenceError: <name> is not defined`).
756
998
  let eligible = true;
757
999
  for (const name of eagerFree) {
758
- if (!imports.all.has(name) || isShadowed(name)) {
1000
+ if ((!imports.all.has(name) && !KNOWN_GLOBALS.has(name)) || isShadowed(name)) {
759
1001
  eligible = false;
760
1002
  break;
761
1003
  }
762
1004
  }
763
1005
  if (eligible) {
764
1006
  for (const name of deferredFree) {
765
- const allowed = imports.all.has(name) || SAFE_DEFERRED_GLOBALS.has(name);
766
- if (!allowed || isShadowed(name)) {
1007
+ if ((!imports.all.has(name) && !KNOWN_GLOBALS.has(name)) || isShadowed(name)) {
767
1008
  eligible = false;
768
1009
  break;
769
1010
  }
@@ -797,6 +1038,9 @@ export function hoistZodSchemas(code, options) {
797
1038
  declByText.set(text, declName);
798
1039
  }
799
1040
  hoists.push({ start: candidate.index, end: chain.end, name: declName });
1041
+ // Non-zod-rooted chains mask their interior only on success — the inner
1042
+ // parts are consumed by this hoist's replacement.
1043
+ consumedUntil = Math.max(consumedUntil, chain.end);
800
1044
  }
801
1045
  if (hoists.length === 0)
802
1046
  return null;
@@ -810,6 +1054,9 @@ export function hoistZodSchemas(code, options) {
810
1054
  .map(([text, name]) => `const ${name} = ${text};`)
811
1055
  .join("\n");
812
1056
  const offset = insertionOffset(result);
813
- return `${result.slice(0, offset)}${decls}\n${result.slice(offset)}`;
1057
+ return {
1058
+ code: `${result.slice(0, offset)}${decls}\n${result.slice(offset)}`,
1059
+ schemas: [...declByText.entries()].map(([text, name]) => ({ name, text })),
1060
+ };
814
1061
  }
815
1062
  //# sourceMappingURL=hoist.js.map