Skip to content

fix(codegen): stop new(X) picking a builtin over a same-named imported function ctor - #10608

Closed
proggeramlug wants to merge 3 commits into
mainfrom
wip/10589-new-cast-builtin-shadow
Closed

proggeramlug wants to merge 3 commits into
mainfrom
wip/10589-new-cast-builtin-shadow

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

new (X as any)() (and, as this PR's testing found, plain new X() too) on an
imported binding whose name collides with a Perry builtin constructor
(Headers, confirmed; any other name matching an unconditional
lower_builtin_new arm is the same class of bug) constructed the builtin
instead of the user's own function/class of the same name.

Root cause

lower_new_impl_inner (crates/perry-codegen/src/lower_call/new.rs:279)
called into the unconditional builtin-constructor dispatch table
(lower_builtin_new) for any class_name absent from ctx.classes, before
ever checking ctx.import_function_prefixes — the table where an imported
plain function constructor is tracked. Imported classes are already
registered in ctx.classes for the importing module (via the
opts.imported_classes stub-building pass in codegen/mod.rs), so they
always skipped the builtin block. An imported plain function never lands in
ctx.classes, so any builtin arm not gated by the existing required_sources
provenance check (used for the genuinely ambiguous Client/Pool/Database/
Redis/MongoClient/Decimal/RateLimiterMemory/CronJob names) fired
unconditionally.

The as any cast is not the trigger. HIR's peel_new_callee
(crates/perry-hir/src/lower/expr_new/helpers.rs) strips Paren/TsAs/
TsTypeAssertion/TsNonNull/TsConstAssertion from the callee before
lower_new ever branches on its shape, so new X() and new (X as any)()
lower to the byte-identical Expr::New { class_name: "X", .. }. I verified
this empirically: on this base (9df5075fbe3), both new Headers(1) and
new (Headers as any)(1) threw the real fetch-API Headers's "init is not
iterable" error identically. The issue's "alias works, cast doesn't" framing
describes a real, reproducible symptom, but the actual discriminator is
local-variable alias vs. bare imported identifier, not cast vs. no-cast —
a local alias takes a completely different codegen path (NewDynamic over
LocalGet, resolved as a runtime value via js_new_function_construct,
never touching the by-name builtin table at all).

I also found the bug's actual scope is narrower than the issue's three
named examples: on this base, only Headers (declared as a plain function)
reproduces. EventEmitter (declared as a class in the repro) already
worked, because imported classes skip the builtin block as described above.
Stream already worked too, because lower_builtin_new has no "Stream"
arm at all (nothing to collide with). The gap test below still exercises all
three names, in both function- and class-declaration form, because the
underlying mechanism (an unconditional builtin arm + a same-named imported
plain function) applies to any of Perry's ~50 unconditional builtin
constructor names, not just Headers.

Fix

Added a user_owns_construction guard — mirroring the existing
required_sources gate pattern — to the same condition that already lets
imported classes skip the builtin block:

let user_owns_construction = ctx.import_function_prefixes.contains_key(class_name)
    && !ctx.import_function_v8_specifiers.contains_key(class_name);
if !ctx.classes.contains_key(class_name) && !user_owns_construction {
    // ... Crypto/CryptoKey/SubtleCrypto, readline_promises, lower_builtin_new,
    // aliased-builtin-import recovery — all now correctly skipped when the
    // name resolves to the user's own imported function.
}

When skipped, control falls through to the pre-existing import_function_prefixes
arm below ctx.classes.get(class_name) (added for #4698), which constructs the
value via js_new_function_construct — the same runtime helper every other
imported-function-constructor call already uses. No new runtime code.

Tests

Gap test: test-files/test_gap_10589_new_cast_builtin_shadow.ts +
test-files/_helpers/new_cast_builtin_shadow_10589/ (18 helper files). Covers
Headers/EventEmitter/Stream × function-declaration/class-declaration ×
named-import/default-import, each probing new X() (plain), new (X as any)()
(cast), and new alias() for a local-variable alias (the control that already
worked — a regression there is caught by the same assertion). Each of the 12
scenarios lives in its own tiny helper module because a single module can only
bind one top-level identifier per reserved name. Discriminators are
own-property checks (__mark === "user"), not instanceof#10477
(imported non-class constructor instanceof) is not fixed on this base yet,
and using instanceof here would conflate the two bugs.

Proof the test fails on baseline, passes on the fix (both via
PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_10589):

  • Baseline (crates/perry-codegen/src/lower_call/new.rs at 9df5075fbe3): PARITY_FAILheaders fn named: plain=THROW:Headers constructor: init is not iterable (received 0x3ff0000000000000) cast=THROW:... alias=true vs. Node's plain=true cast=true alias=true.
  • Fixed: PASS, 100% parity, all 12 scenarios match Node 26.5.1 exactly.

Unit tests: crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs
(4 tests, direct Expr::New + CompileOptions harness, asserting on emitted
LLVM IR):

  • an imported function constructor shadows the builtin arm (call ... @js_new_function_construct present, call ... @js_headers_new absent);
  • an unshadowed builtin name still builds the builtin (regression guard for the common case);
  • a V8-fallback import of the same name still falls through to the builtin (it isn't a compiled-source value this fix can construct);
  • an imported class of the same name already shadowed the builtin before this fix (regression guard for the pre-existing mechanism).

Validation

  • cargo test --profile perry-dev -p perry-codegen --tests: all pass
    (36 test result: ok blocks across the crate, 0 failed, including the 4 new
    tests and everything pre-existing).

  • Lint (SKIP_COMPILE_GATES=1 ./scripts/run_lint_gates.sh, compile tier not
    run per this campaign's known-red-on-Linux note): 76/77 script gates
    passed
    ; the one failure, Public benchmark evidence freshness, is
    documented pre-existing-red on main (this campaign's FIX_BRIEF and the
    project's own testing docs both call it out).

  • Gap suite, targeted (PERRY_SKIP_BUILD=1 PERRY_NO_AUTO_OPTIMIZE=1 ./run_parity_tests.sh --filter <name>): the new test plus 18 adjacent
    shadowing/native-base-construction/EventEmitter/Stream/Headers gap tests
    (new_local_shadows_class, module_const_local_shadow,
    new_globalthis_shadowed_10359 (fix(hir,codegen): new globalThis.X() constructs the global when a binding shadows X (#10359) #10375's fix), 9466_shadowed_class_identity,
    proto_write_local_shadows_class, 5437_class_ref_shadows_captured_local,
    buffer_own_prop_shadow_intrinsic_6405, 6316/6326/6336/6343_native_base_*,
    class_expr_dynamic_parent_ctor, events_import_4995, fetch_response,
    node_events_console_3072_3081_3080, process_emitter_3047_3046_3050,
    10430_stream_module_constructor) — all pass. (An early pass showed
    8 spurious COMPILE_FAILs from a stale auto-optimize runtime-archive cache
    left over from my own iterative rebuilds on this box — commit-stamp
    mismatch between compiler and a cached auto-built runtime archive, not a
    code issue; rebuilding + clearing target/perry-auto-* resolved it and all
    18 passed cleanly on rerun.)

  • Performance: lower_new_impl_inner runs at Perry's own compile time
    (codegen), not in the compiled program's runtime, so this fix cannot regress
    the runtime instruction count of any new call whose emitted IR is
    unchanged — which is everything except the specific collision case. Verified
    directly: --trace llvm IR for a representative unaffected new call
    (imported plain-function constructor, non-colliding name) is byte-identical
    between baseline and fixed compilers (0-line diff, 3567 lines each). Backed
    that with perf stat -e instructions,task-clock, release builds
    (CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16, 3 runs each):

    workload baseline instructions (avg of 3) fixed instructions (avg of 3) delta
    3M-iteration loop, imported non-colliding function ctor (new Widget(i)) 35,850,287,697 35,824,149,961 -0.07% (noise; fixed skips a couple of now-provably-unreachable no-op checks)
    benchmarks/bench_histogram_numarray.ts (new Array(BUCKETS), unconditional builtin arm, unaffected) 6,449,504,691 6,449,428,599 -0.001% (noise)

    Both well within the ±1% noise floor; no regression. Node wall time on the
    loop workload for context: 0.076s (Perry's ~2.7-3.3s wall / ~35.8B
    instructions for 3M allocating iterations is a pre-existing characteristic
    of js_new_function_construct-routed construction, identical on both
    binaries — out of scope for this PR, not introduced or affected by it).

  • Package check: the issue does not name a specific npm package (found during
    general package-audit validation of x instanceof F is always false when F is an imported non-class constructor (ES5 function, factory-made function, CJS module.exports = F); namespace access and local aliases work #10477), so this step is not applicable.

Not verified

  • The full (non-filtered) gap suite was not run locally per this campaign's
    default (targeted filters only; CI's gap-suite shards are the full gate).
  • cargo test --workspace was not run; only -p perry-codegen (the only
    crate touched).

Related

Fixes #10589

Summary by CodeRabbit

  • Bug Fixes

    • Fixed construction of imported functions and classes whose names overlap with built-in constructors.
    • Direct calls, as any casts, and aliases now consistently instantiate the imported user-defined constructor instead of the built-in.
    • Preserved built-in behavior for unshadowed names and supported fallback cases.
  • Tests

    • Added regression coverage for named and default imports, functions and classes, and multiple construction styles.

Ralph Küpper added 2 commits September 18, 2026 08:15
…d function ctor

lower_new_impl_inner called lower_builtin_new for any class_name absent from
ctx.classes before checking import_function_prefixes. Imported classes
already land in ctx.classes and skip the builtin block; an imported plain
function constructor (Headers, EventEmitter-shaped, ...) never does, so any
unconditional builtin arm (not gated by required_sources) fired regardless
of whether the callee was a bare identifier or wrapped in (X as any) --
peel_new_callee strips that cast before lower_new branches on callee shape.
…(X as any)()

Gap test: Headers/EventEmitter/Stream x function/class declaration x
named/default import x plain-new/cast-new, plus a local-alias control that
already worked. Property-based discriminators, not instanceof -- #10477
(imported non-class instanceof) is not yet fixed on this base and would
conflate the two bugs.

Unit tests: direct Expr::New harness asserting js_new_function_construct
fires (not the builtin arm) once a name resolves to an imported function
constructor, the builtin still fires when unshadowed, a V8-fallback import
of the same name still falls to the builtin, and an imported CLASS of the
same name already shadowed the builtin before this fix (regression guard).
@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b0a4510b-6710-4f33-81fb-f70499ef6ccd

📥 Commits

Reviewing files that changed from the base of the PR and between 9df5075 and 651906b.

📒 Files selected for processing (23)
  • changelog.d/10608-new-cast-builtin-shadow.md
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts
  • test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts
  • test-files/test_gap_10589_new_cast_builtin_shadow.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The fix prevents imported function constructors from being replaced by same-named builtins during new lowering. Compiler tests and integration fixtures cover function and class imports, named and default exports, direct calls, casts, and aliases.

Changes

Builtin constructor shadowing

Layer / File(s) Summary
Constructor dispatch and IR tests
crates/perry-codegen/src/lower_call/new.rs, crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs, crates/perry-codegen/src/lower_call/mod.rs, changelog.d/...
Builtin handling now skips imported function constructors unless they are V8 fallback specifiers. Tests verify shadowed, unshadowed, V8-fallback, and imported-class cases.
Imported constructor fixtures and probes
test-files/_helpers/new_cast_builtin_shadow_10589/*
Fixtures define Headers, EventEmitter, and Stream as functions and classes. Helper modules test direct, cast, and aliased construction with user markers and error capture.
Integration gap coverage
test-files/test_gap_10589_new_cast_builtin_shadow.ts
The integration test runs twelve named and default import combinations across the three builtin names and logs each result.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 65190

The constructor-shadowing change is covered by compiler tests and parity checks for the affected import forms, with no actionable risk remaining.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.76% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 34 functions across 22 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary codegen fix: preventing new(X) from selecting a builtin instead of a same-named imported function constructor.
Description check ✅ Passed The description is complete and directly related to the change. It explains the root cause, fix, affected behavior, related issue, tests, validation results, and known limits. It does not use every te…
Linked Issues check ✅ Passed Issue #10589 requires imported bindings named like Perry builtins to construct the user binding for direct and cast-wrapped calls. lower_new_impl_inner now detects imported function bindings with `u…
Out of Scope Changes check ✅ Passed The changed code, regression tests, test fixtures, and changelog entry all support issue #10589. The changes preserve unshadowed builtin construction and V8-fallback behavior. No unrelated change is d…
Full details: Docstring Coverage

Explanation

Docstring coverage is 11.76% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 34 functions across 22 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10652 (v0.5.1596). All source commits preserve authorship; merged main matches the validated train exactly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings

Projects

None yet

1 participant