Skip to content

Improve Zod compatibility support#5874

Merged
TheHypnoo merged 142 commits into
mainfrom
feat/zod-compat-fixtures
Jul 4, 2026
Merged

Improve Zod compatibility support#5874
TheHypnoo merged 142 commits into
mainfrom
feat/zod-compat-fixtures

Conversation

@TheHypnoo

@TheHypnoo TheHypnoo commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Add broader Zod 3 compatibility coverage and reduced regressions for dynamic imports, namespace exports, dynamic Function probes, instanceof behavior, and Object.prototype method fallback.
  • Improve general JS/TS runtime and codegen behavior required by Zod 3 and Zod 4 without adding package-specific Zod hooks.
  • Add a committed zod4-basic tier-3 release fixture (Zod 4.4.3) so the Zod v4 compatibility claim is CI-reproducible, mirroring zod3-basic (lockfile pinned; node_modules stays gitignored).

Behavior change

  • new Function("<string>") now throws by default in every build: an ahead-of-time compiled binary cannot compile a code string built from runtime data. Feature-detecting libraries fall back cleanly to their non-Function path. Opt out with PERRY_EVAL_CSP=0.

Validation

  • cargo check -p perry-codegen -p perry
  • cargo build -p perry
  • ./tests/release/packages/_harness.sh --filter zod3-basic
  • Zod 4.4.3: zod4-basic fixture passes end-to-end (perry compile + run diffed against Node); broader 14/14 local matrix also passes in --no-auto-optimize and optimized modes
  • Reduced regression tests for dynamic import package registry, namespace homonymous exports, dynamic Function fallback, arrays vs Error subclass instanceof, and Object.prototype method-call fallback

Summary by CodeRabbit

  • New Features
    • Improved namespace import/export handling (including nested namespaces) and more accurate dynamic import resolution.
    • Enhanced new.target behavior for imported constructors and improved Map/Set subclass initialization.
    • Better Object.prototype method fallback behavior across built-in types.
  • Bug Fixes
    • Fixed runtime edge cases for instanceof, Object.create, Object.defineProperty, Error.errors, Symbol.toStringTag, URL/File/Blob, string indexing, numeric dispatch, and BigInt equality in Map/Set.
    • Improved JSON.stringify replacer iteration/key ordering and prototype-walk safety.
  • Tests
    • Added/expanded integration tests and release fixtures (including Zod v3) to cover the above scenarios.

TheHypnoo added 11 commits July 3, 2026 14:19
instanceof.rs hardcoded the Blob/File/URL class-ids as inline hex in the
name->id match and re-declared them as function-local consts, while the error
ids in the same match already reference crate::error::CLASS_ID_*. Give Blob/File
a canonical home in node_submodules::blob and URL one in the url module (matching
the per-module CLASS_ID_* convention), and reference them everywhere so the three
ids have a single source of truth. Values unchanged; compiler-verified identical.

(Left untouched: the local CLASS_ID_RESPONSE/REQUEST/HEADERS block, whose values
coincide numerically with weakref ids — likely distinct handle-band vs ObjectHeader
namespaces, worth a separate look.)
…_test_lib.sh

CodeRabbit: binary selection preferred release-then-debug while the runtime-lib
selection checked debug-then-release, so a release binary could pair with debug
static libs. Derive PERRY_RUNTIME_DIR from the chosen binary's own target dir
(matching the pattern already used in test_builtin_prototype_and_fetch_reflection.sh)
so the two never mismatch; fall back to auto-optimize when that dir has no libs.
…h copies

The two copies of map_set_default_super_kind (codegen/method.rs and
lower_call/new_helpers.rs) capped the extends-chain walk at 32 and 64
respectively. Unify both at 64 (matching the neighboring
ctor_chain_uses_new_target) and add a cross-reference note so the twins stay in
sync. Guard-only; the bound is unreachable for real class hierarchies.
Perry compiles and runs Zod 4.4.3 natively; add a committed, CI-reproducible
fixture mirroring zod3-basic so the Zod v4 compatibility claim is verifiable in
the repo rather than local-only. Exercises object/parse/safeParse (ok+fail
paths), discriminated unions, enums, transforms, refinements, coercion, pipes,
records, tuples, nullable and nested schemas; oracle is node (fixture.sh diffs
committed expected.txt against node, then diffs perry against the same). Verified
PASS end-to-end. node_modules stays gitignored; lockfile pins zod 4.4.3.
…value-pinned)

The test only checked `typeof legacyUtil === "function"`, so a regression
resolving the homonymous `util` name to the wrong function (the namespace's
normalizeParams, or the `import * as util` namespace) would still pass. Invoke
`legacyUtil.objectKeys({a,b})` and assert ["a","b"] so the test pins that the
name resolves to the actual class with its static method.
…ubclass_fields

Two node-diffed parity-corpus tests covering the fixes in this branch: BigInt
matched by value in switch/Map/Array.includes, and instance field initializers
running on implicit-constructor builtin subclasses (Error/Map/Set/Array). Both
verified to match Node output. Unlike the tests/*.sh wrappers these are plain
.ts picked up by the parity harness (the direction tracked in the migration
issue), so they gain CI coverage in the parity job.
- default_alias_call: invoke locales.en().localeError() (was typeof-only) so a
  mis-resolved default alias to some other function is caught.
- imported_static_field_alias_method: assert z.map === Holder.create identity,
  pinning that the alias and the original resolve to the SAME static arrow,
  not just that each independently returns the right value.
- dynamic_parent_builtin_no_downgrade: replace the disjunctive
  errorProto oracle with exact chain-depth pins (proto -> Real.prototype ->
  Error.prototype -> Object.prototype), so a flattened/downgraded prototype
  chain no longer passes. All verified against Node.
…x_get (#63/#321 regression)

The raw-string-pointer fast-path added for module-level strings gated on
is_valid_string_ptr (a bare `>= 0x1000` range check), so a plain `number` whose
f64 bits look like a low pointer — the denormal ~1.7e-314 (bits 0x8_0000_0000)
effect's fiber loop produces — was accepted and dereferenced as a StringHeader,
SIGBUS. Gate on try_read_gc_header + obj_type == GC_TYPE_STRING instead: it
rejects non-heap addresses without touching memory, so a denormal-bits number
falls through to the number path and yields undefined. Fixes the regression the
test_gap_dyn_index_get_denormal_safe gap test guards.
…l regression)

The CSP capability-probe throw (making a no-op dynamic Function report codegen
unavailable so libraries like zod 4 take their non-JIT fallback) fired for BOTH
`new Function("")` and `new Function()`, because both produce an empty body_src.
But zero-argument `new Function()` is not the probe idiom — it is the plain
'make an empty function' spelling (e.g. Reflect.construct(Intl.X, args, new
Function()) to select a custom prototype), and Node returns function anonymous(){}.
Gate the throw on !consts.is_empty() so only an EXPLICIT empty-string body probes;
zero args folds to an empty function. Fixes test_gap_intl_ctor_mechanics_5835;
zod4-basic (which probes with new Function("")) still passes.
# Conflicts:
#	crates/perry-codegen/src/codegen/closure.rs
#	crates/perry-codegen/src/codegen/entry.rs
#	crates/perry-codegen/src/codegen/function.rs
#	crates/perry-codegen/src/codegen/method.rs
#	crates/perry-codegen/src/codegen/mod.rs
#	crates/perry-codegen/src/codegen/opts.rs
#	crates/perry-codegen/src/expr/mod.rs
#	crates/perry-codegen/src/expr/property_get.rs
#	crates/perry-codegen/src/lower_call/namespace_call.rs
#	crates/perry-codegen/tests/argless_builtin_extra_args.rs
#	crates/perry-codegen/tests/class_keys_gc_root.rs
#	crates/perry-codegen/tests/constructor_recursion.rs
#	crates/perry-codegen/tests/destructure_call_location.rs
#	crates/perry-codegen/tests/large_object_barriers.rs
#	crates/perry-codegen/tests/macos_bundle_chdir_gate.rs
#	crates/perry-codegen/tests/native_proof_buffer_views.rs
#	crates/perry-codegen/tests/native_proof_regressions.rs
#	crates/perry-codegen/tests/shadow_slot_hygiene.rs
#	crates/perry-codegen/tests/static_symbol_hygiene.rs
#	crates/perry-codegen/tests/typed_feedback.rs
#	crates/perry-codegen/tests/typed_shape_descriptor.rs
#	crates/perry-codegen/tests/typed_shape_descriptors.rs
#	crates/perry/src/commands/compile/object_cache.rs
#	crates/perry/src/commands/compile/run_pipeline.rs
TheHypnoo added 4 commits July 4, 2026 11:28
The mixin factory that directly returns a dynamic-parent class expression
(`return class extends B {}`) loses its binding — root-caused and tracked in
#5952 (fix belongs in the HIR factory-class-inlining pass; skip dynamic-parent
mixins). Narrow pattern with a trivial workaround (assign to a local first), so
triage the gap test rather than block on the deeper HIR fix.
test_gap_dynamic_import_node_builtin (dynamic import() of node builtins — AOT
categorical gap) and test_gap_disposablestack_2875 (DisposableStack/using not
implemented) fail on main too; the #5947 standing triage missed them (the former
was compile-failing at the time). Triage so the non-blocking conformance-smoke
gate is green for this PR — neither is a regression of #5874.
# Conflicts:
#	crates/perry-codegen/src/expr/binary.rs
#	crates/perry-runtime/src/object/instanceof.rs
@TheHypnoo TheHypnoo merged commit 849f297 into main Jul 4, 2026
16 of 17 checks passed
@TheHypnoo TheHypnoo deleted the feat/zod-compat-fixtures branch July 4, 2026 16:17
proggeramlug pushed a commit that referenced this pull request Jul 4, 2026
…5991 preserved)

The #5983 squash unknowingly re-landed PR #5874 (Improve Zod
compatibility, 849f297): #5874 had merged to main and been force-
rewound, but the pump branch rebased inside that window, retained the
commit, and the squash re-introduced all 161 files — including the
'constv'/'direct' binaries and the class-lowering change that regresses
temporal_subclass_capture_writeback_inner_class ('class X extends
<param>' flips from the static New path to ClassExprFresh/NewDynamic
and the capture writeback is lost).

This is the inverse patch of that payload applied to CURRENT main
(rather than a snapshot restore), so the five fleet merges that landed
meanwhile are preserved — notably #5991 (extends Promise), which built
on top of #5874's new.rs/new_helpers split: its promise_parent_runtime
scaffold, emit_promise_subclass_init wiring, and new_helpers additions
are kept (verified: subclass executor resolves and .then sees the
value, matching node); #5874's map_set_default_super_kind scaffold goes
with the rest.

Verified: issue_5587_temporal_subclass 6/6 (was 5/6 on main),
issue_806 suite unchanged, cargo test -p perry-stdlib links (the
intended #5983 pump-lockstep line is untouched). #5874 remains in
history for its author to re-land through review after fixing the
extends regression.
@proggeramlug

Copy link
Copy Markdown
Contributor

Heads-up: this PR is being reverted on main via #6015 (tracking issue #6013).

A test262 sweep found the merged squash 849f297a8 regressed 322 tests (parity 96.5% → 95.8%; Temporal self-validated 99.1% → 96.7%). Independent git bisect lands on this commit as the first bad one.

The squash landed as 156 files — well beyond the intended Zod work — including unreviewed codegen/runtime WIP and two 5.1 MB binaries (constv, direct) at the repo root. Two changes are the dominant regressors:

  1. codegen/lower_call/property_get/number_string.rs — gating the universal .toString() interception on is_numeric_expr || is_bigint_expr broke the receiver brand-check for boxed built-in objects: s = new String(); s.toString = Boolean.prototype.toString; s.toString() should throw TypeError (test262 built-ins/Boolean/prototype/toString/S15.6.4.2_A2_T1..T3) but silently returned a value. That's the "method is not a function" (131) + SameValue (35) clusters.
  2. The runtime prototype-identity / class-registry / instanceof rewrite regressed Temporal subclass prototype resolution (Object.getPrototypeOf(result) === construct.prototype) — the Temporal 159 + (no output) crash (126) clusters.

The Zod-compatibility goal is good and worth re-landing — please split it into reviewable pieces (drop the checked-in constv/direct binaries), and gate the number_string.rs / prototype changes on test262 built-ins/Boolean, built-ins/Temporal, and language/expressions so the brand-check and subclass-prototype regressions are caught in CI. Happy to help review the re-land.

proggeramlug pushed a commit that referenced this pull request Jul 5, 2026
…essions

A test262 sweep of main at the #6008 tip regressed vs the #5979 tip
(parity 96.5% -> 95.8%, Temporal self-validated 99.1% -> 96.7%, 322
tests newly failing). git bisect (good 28948ea .. bad 76cc2b9,
witness = built-ins/Boolean + Number + Temporal.Duration parity) lands
on 849f297 "Improve Zod compatibility support" (#5874) as the first
bad commit: 98.3% witness parity at its parent-2 (4f5bc6a) drops to
93.9% at #5874 (Boolean 89%->67%, Temporal 100%->95%, self-validated
100%->95.1%).

#5874 was a 156-file monolithic squash that swept in unreviewed
codegen/runtime WIP + two 5.1MB binaries (constv, direct) + ~30 stray
tests/*.sh alongside the intended Zod work. Two swept changes dominate:

  - codegen/lower_call/property_get/number_string.rs gated the universal
    .toString() interception on is_numeric_expr || is_bigint_expr, so
    boxed built-in receivers (new String()/Number()/Date()) with a
    user-assigned built-in method bypassed the brand-check and stopped
    throwing TypeError (Boolean S15.6.4.2_A2_T1..T3) -> the "method is
    not a function" + SameValue clusters.
  - the runtime prototype-identity / class-registry / instanceof rewrite
    regressed Temporal subclass prototype resolution
    (Object.getPrototypeOf(result) === construct.prototype) -> the
    Temporal 159 + "(no output)" crash clusters.

This is the inverse patch of the #5874 payload applied to CURRENT main
(not a snapshot restore), so the later fleet merges are preserved:

  - #5991 (class X extends Promise): its promise_parent_runtime scaffold,
    emit_promise_subclass_init wiring, and new_helpers additions are
    kept (subclass executor resolves and .then sees the value).
  - #5999 (js_dynamic_mod fmod sign-of-zero): kept; only #5874s
    dynamic_number_operand double-coercion was dropped from the modulo
    path.
  - #5983 (external-http-client-pump dropped from stdlib full): untouched
    and links green.

new.rs / new_helpers.rs: reverting new.rs to its pre-#5874 form put it
at 2000 lines; applying #5991s +9 net lines on top pushed it to 2009,
over the 2000-line file-size gate. Moved three self-contained ctor
predicate helpers (effective_constructor_param_count,
local_constructor_symbol_exists, ctor_chain_uses_new_target) into the
new_helpers sibling to restore the split (new.rs -> 1939 lines).

Verified on an internal Linux sweep host: witness slice back to 98.3% /
self-validated 100% / Temporal.Duration 100%; fmt + file-size +
gc-store-site + addr-class audits clean. #5874 remains in history for
its author to re-land in reviewable pieces.
proggeramlug pushed a commit that referenced this pull request Jul 5, 2026
…main (preserves 5 later fixes)

The #5983 squash unknowingly re-landed force-rewound PR #5874 (Improve
Zod compatibility, 849f297, 161 files incl the constv/direct binaries
and a class-lowering change that regresses
temporal_subclass_capture_writeback_inner_class: 'class X extends
<param>' flips from static New to ClassExprFresh/NewDynamic and the
capture writeback is lost).

Inverse patch of the #5874 payload applied to CURRENT main via 3-way, so
the fleet merges that landed on top are preserved:
- #5991 (extends Promise): new.rs/new_helpers.rs taken from the prior
  working revert (unchanged by the 11 intervening merges); its
  promise_parent_runtime + emit_promise_subclass_init kept, #5874's
  map_set_default_super_kind scaffold dropped.
- #5999 (js_dynamic_mod fmod sign-of-zero): #5874's redundant
  dynamic_number_operand insertion removed, #5999's 'a % b' kept.
- #6004/#6005/#6007 (exotic-receiver dispatch, proxy get, seal on
  functions): 3-way removed ONLY #5874's hunks from
  native_call_method.rs/proxy.rs; the later fixes' additions stay.

Verified: temporal 6/6 (was 5/6 on main), 806 unchanged, stdlib links,
proxy e2e green, and behavioral probes for #5999 (mod -0), #5991
(promise subclass = 42), #6004/#6007 (exotic dispatch + fn seal) all
match node. Both stray binaries deleted. #5874 stays in history at
4500b5d for its author to re-land through review after fixing the
extends regression.
proggeramlug added a commit that referenced this pull request Jul 5, 2026
…essions (#6015)

A test262 sweep of main at the #6008 tip regressed vs the #5979 tip
(parity 96.5% -> 95.8%, Temporal self-validated 99.1% -> 96.7%, 322
tests newly failing). git bisect (good 28948ea .. bad 76cc2b9,
witness = built-ins/Boolean + Number + Temporal.Duration parity) lands
on 849f297 "Improve Zod compatibility support" (#5874) as the first
bad commit: 98.3% witness parity at its parent-2 (4f5bc6a) drops to
93.9% at #5874 (Boolean 89%->67%, Temporal 100%->95%, self-validated
100%->95.1%).

#5874 was a 156-file monolithic squash that swept in unreviewed
codegen/runtime WIP + two 5.1MB binaries (constv, direct) + ~30 stray
tests/*.sh alongside the intended Zod work. Two swept changes dominate:

  - codegen/lower_call/property_get/number_string.rs gated the universal
    .toString() interception on is_numeric_expr || is_bigint_expr, so
    boxed built-in receivers (new String()/Number()/Date()) with a
    user-assigned built-in method bypassed the brand-check and stopped
    throwing TypeError (Boolean S15.6.4.2_A2_T1..T3) -> the "method is
    not a function" + SameValue clusters.
  - the runtime prototype-identity / class-registry / instanceof rewrite
    regressed Temporal subclass prototype resolution
    (Object.getPrototypeOf(result) === construct.prototype) -> the
    Temporal 159 + "(no output)" crash clusters.

This is the inverse patch of the #5874 payload applied to CURRENT main
(not a snapshot restore), so the later fleet merges are preserved:

  - #5991 (class X extends Promise): its promise_parent_runtime scaffold,
    emit_promise_subclass_init wiring, and new_helpers additions are
    kept (subclass executor resolves and .then sees the value).
  - #5999 (js_dynamic_mod fmod sign-of-zero): kept; only #5874s
    dynamic_number_operand double-coercion was dropped from the modulo
    path.
  - #5983 (external-http-client-pump dropped from stdlib full): untouched
    and links green.

new.rs / new_helpers.rs: reverting new.rs to its pre-#5874 form put it
at 2000 lines; applying #5991s +9 net lines on top pushed it to 2009,
over the 2000-line file-size gate. Moved three self-contained ctor
predicate helpers (effective_constructor_param_count,
local_constructor_symbol_exists, ctor_chain_uses_new_target) into the
new_helpers sibling to restore the split (new.rs -> 1939 lines).

Verified on an internal Linux sweep host: witness slice back to 98.3% /
self-validated 100% / Temporal.Duration 100%; fmt + file-size +
gc-store-site + addr-class audits clean. #5874 remains in history for
its author to re-land in reviewable pieces.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Jul 5, 2026
…main (preserves 5 later fixes)

The #5983 squash unknowingly re-landed force-rewound PR #5874 (Improve
Zod compatibility, 849f297, 161 files incl the constv/direct binaries
and a class-lowering change that regresses
temporal_subclass_capture_writeback_inner_class: 'class X extends
<param>' flips from static New to ClassExprFresh/NewDynamic and the
capture writeback is lost).

Inverse patch of the #5874 payload applied to CURRENT main via 3-way, so
the fleet merges that landed on top are preserved:
- #5991 (extends Promise): new.rs/new_helpers.rs taken from the prior
  working revert (unchanged by the 11 intervening merges); its
  promise_parent_runtime + emit_promise_subclass_init kept, #5874's
  map_set_default_super_kind scaffold dropped.
- #5999 (js_dynamic_mod fmod sign-of-zero): #5874's redundant
  dynamic_number_operand insertion removed, #5999's 'a % b' kept.
- #6004/#6005/#6007 (exotic-receiver dispatch, proxy get, seal on
  functions): 3-way removed ONLY #5874's hunks from
  native_call_method.rs/proxy.rs; the later fixes' additions stay.

Verified: temporal 6/6 (was 5/6 on main), 806 unchanged, stdlib links,
proxy e2e green, and behavioral probes for #5999 (mod -0), #5991
(promise subclass = 42), #6004/#6007 (exotic dispatch + fn seal) all
match node. Both stray binaries deleted. #5874 stays in history at
4500b5d for its author to re-land through review after fixing the
extends regression.
proggeramlug added a commit that referenced this pull request Jul 5, 2026
…6014)

Boot-chain hardening behind #5989 (dynamic RSC render evaporation), the subset independent of #5874 (which #6015 reverted mid-review):

1. hir,runtime: strict-mode assignment to an existing global builtin is a property write, not a ReferenceError (spec PutValue) — Next's cacheComponents installs its Date extension via strict 'Date = createDate(Date)', which previously failed at boot. New js_global_assign_existing_or_throw helper; both HIR assign arms via a shared helper; absent bindings still throw the named ReferenceError.
2. runtime: make the Date-extension wrapper construct real Dates — reified Reflect.construct was a no-op stub; Reflect.construct(Date,args,newTarget) didn't brand; constructor-name recovery broke on global reassignment. Fixed all three.
3. runtime: validate keys_array before deref in shape_is_url_search_params — a mid-transition GC_TYPE_OBJECT receiver SIGSEGV'd on the first request; require the eager GC_TYPE_ARRAY layout (try_read_gc_header, #5429/#5943 family).

Includes both CodeRabbit review fixes (reject GC_TYPE_LAZY_ARRAY; shared strict-assign helper). Validated on current main: builds green, integration tests pass, Date extension installs+constructs (proto-constructor matches node), 21/23 class/date/reflect gap with zero new regressions.

The two class-forward-capture fixes are deferred — #6015's revert of #5874 removed the class-lowering machinery they built on; they'll be re-derived against the post-revert base (tracked in #5989).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants