Skip to content

fix(hir): give an imported constructor's instanceof its value - #10596

Closed
proggeramlug wants to merge 2 commits into
mainfrom
wip/10477-instanceof-imported-fn-ctor
Closed

proggeramlug wants to merge 2 commits into
mainfrom
wip/10477-instanceof-imported-fn-ctor

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

x instanceof F always returned false when F was an imported non-class constructor function
(a plain ES5-style function, a factory-built function, or a CJS module.exports = F). Named import,
default import, and CJS forms were all affected; ns.F, a local alias of the import, and the same
check written inside the module that defines F all worked correctly.

Root cause

crates/perry-hir/src/lower/lower_expr/arm_bin.rs (lower_bin_expr's instanceof arm, ~line 148)
only attached a lowered runtime value (ty_expr) to an identifier RHS when the name resolved via
ctx.lookup_local, ctx.lookup_func, or ctx.lookup_native_module. An imported binding matches
none of those — ctx.lookup_imported_func was never consulted — so ty_expr stayed None.
Codegen (crates/perry-codegen/src/expr/instance_misc1.rs, the Expr::InstanceOf arm of lower)
then resolved the bare name through the static class-id path. A function constructor has no class id
there, so the check folded to js_instanceof(v, 0), which is always false. Imported classes were
unaffected because they do have a class id (populated from opts.imported_classes into
ctx.class_ids).

The fix

  1. arm_bin.rs: add || ctx.lookup_imported_func(name).is_some() to the condition that decides
    whether an identifier RHS gets its value lowered. An imported binding now takes the same dynamic
    js_instanceof_dynamic path a local/module function constructor already did.
  2. instance_misc1.rs: a new helper imported_instanceof_rhs_is_static(ctx, ty, ty_expr) gates
    whether the static js_instanceof(v, <class id>) path is still used despite the RHS now
    carrying a value, so the previously-correct cases stay byte-for-byte unchanged:
    • an imported class — its value is an ExternFuncRef INT32 class-ref immediate that the
      dynamic helper would only unpack back to the same id (mirrors how ExternFuncRef value-lowering
      already special-cases this in dyn_extern_i18n.rs);
    • a binding that is not a compiled source-module import (V8-fallback, node-submodule, an FFI
      declare function, or an unresolved name) — its value form is a placeholder, so the
      reserved-builtin-id mapping must stay.

Net effect: a function-constructor import now resolves its value and walks the real prototype chain;
an imported class and every non-compiled-source import emit the identical LLVM IR they did before
(asserted by the codegen unit tests via literal js_instanceof(/js_instanceof_dynamic( IR-string
checks).

Tests added

  • test-files/test_gap_10477_instanceof_imported_function_ctor.ts (+ fixtures under
    test-files/fixtures/issue_10477_fn_ctor/) — covers named/default/CJS import forms, prototype
    reassignment (the bignumber.js/decimal.js shape), ns.F, a local alias, the in-module check,
    util.inherits/setPrototypeOf hierarchies, Symbol.hasInstance, factory-built constructors,
    live rebinding, and imported-class controls.
  • crates/perry-hir/src/lower/tests/instanceof_rhs.rs (3 tests) and
    crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs (3 tests) — unit-level coverage of
    the lowering condition and the static/dynamic codegen gate, including literal IR-string assertions
    that an imported class and an unresolved import keep the exact static form.

Proof the gap test fails on the pre-fix baseline, passes on this fix: ran both against a pristine
origin/main build (9df5075fbe) and against this branch, PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_10477:

  • baseline: PARITY_FAILNode.js: plain importer-made true / Perry: plain importer-made false
    (Parity Rate 0.0%)
  • this branch: PASS (Parity Rate 100.0%)

Validation

  • cargo test --release -p perry-hir -p perry-codegen --tests: all green, including the 6 new tests
    (instanceof_rhs::*, instanceof_imported_rhs_tests::*).

  • python3 scripts/check_test_registration.py: OK, 335 files checked against 4 registries.

  • Lint (rustup run stable cargo fmt --all -- --check then SKIP_COMPILE_GATES=1 ./scripts/run_lint_gates.sh): 76 of 77 script gates passed, compile tier not run (known red on
    this host per project docs). The one failure, "Public benchmark evidence freshness", is known-red
    on main and unrelated to this change.

  • Gap suite: ran only test_gap_10477_* locally (see above); did not run the full local suite (the
    change is scoped to the instanceof identifier-RHS path, not a hot lowering/runtime path used by
    most programs) — leaving the full sweep to CI's gap-suite shards.

  • Performance (perf stat -e instructions,task-clock, 3 runs each, baseline vs this branch, 10M
    iterations):

    workload baseline instructions (median) fix instructions (median) Δ
    instanceof ImportedClass (already-correct fast path — regression check) 1,029,960,429 1,030,020,295 +0.006% (noise)
    instanceof ImportedFn (this fix's behavior) 419,653,148 27,296,563,858 not comparable¹

    ¹ Behavior changed: pre-fix, the check always folded to false so the loop body (count++) never
    ran; post-fix it does the real prototype-chain work and increments 10M times — the instruction
    count reflects doing the correct work, not a regression on an unchanged code path. The
    already-correct class-instanceof path (the actual regression risk, since
    imported_instanceof_rhs_is_static's extra branching runs on every instanceof with an identifier
    RHS) shows no measurable change. Node wall time for context: ~90-96ms for either 10M-iteration
    workload (this shared host's wall time is not a reliable A/B metric; instructions is).

Side findings (not fixed here, filed separately)

Not verified

Note on provenance

This fix was implemented and validated on a build host that was destroyed mid-session before it
could be pushed. It was recovered from a session mirror/transcript and has been fully re-applied,
rebuilt, and re-validated from scratch on a different host against current main for this PR
(all numbers above are from this re-validation run, not carried over from the lost session).

Fixes #10477

Summary by CodeRabbit

  • Bug Fixes
    • Fixed instanceof checks for imported function-style constructors.
    • Support now covers named, default, namespace, renamed, and CommonJS imports.
    • Preserved correct behavior for imported classes, inheritance, custom Symbol.hasInstance, and live bindings.
    • Non-callable imported values now correctly raise a TypeError instead of incorrectly returning false.
  • Tests
    • Added comprehensive coverage for imported constructors and related instanceof scenarios.

x instanceof F was always false when F was an imported non-class
constructor function. Lowering only attached a runtime value to an
identifier RHS for a local, a module function or a native module, so an
imported binding reached codegen as a bare name, resolved to no class id
and folded to js_instanceof(v, 0) - false for every instance. Every
import form was affected (named, default, CJS module.exports/exports.F),
while ns.F, a local alias and the check inside the defining module all
worked.

Imported bindings now lower to their value and take the prototype-chain
path. Codegen keeps the static class-id check for an imported class, and
for a binding that is not a compiled source-module import (its value form
is a placeholder), so the class fast path and the reserved builtin ids are
unchanged.
@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: add0cbda-74d4-474d-a00b-176eda7ad8ef

📥 Commits

Reviewing files that changed from the base of the PR and between 9df5075 and 82b94ab.

📒 Files selected for processing (13)
  • changelog.d/10596-instanceof-imported-fn-ctor.md
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-hir/src/lower/lower_expr/arm_bin.rs
  • crates/perry-hir/src/lower/tests.rs
  • crates/perry-hir/src/lower/tests/instanceof_rhs.rs
  • test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs
  • test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs
  • test-files/fixtures/issue_10477_fn_ctor/default_fn.ts
  • test-files/fixtures/issue_10477_fn_ctor/default_var.ts
  • test-files/fixtures/issue_10477_fn_ctor/lib.ts
  • test-files/test_gap_10477_instanceof_imported_function_ctor.ts

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


📝 Walkthrough

Walkthrough

This fix preserves runtime values for imported function constructors in instanceof lowering. Codegen now uses dynamic prototype checks for those imports and retains static class-id checks for imported classes and unresolved built-ins. Tests cover ES modules, CommonJS, factories, inheritance, live bindings, and errors.

Changes

Imported instanceof RHS handling

Layer / File(s) Summary
HIR imported RHS lowering
crates/perry-hir/src/lower/lower_expr/arm_bin.rs, crates/perry-hir/src/lower/tests/*
Imported function bindings now retain an ExternFuncRef runtime value for instanceof. Lowering tests verify imported, builtin, and local constructor behavior.
Codegen dispatch and IR tests
crates/perry-codegen/src/expr/instance_misc1.rs, crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs, crates/perry-codegen/src/expr/mod.rs, changelog.d/10596-instanceof-imported-fn-ctor.md
Codegen sends imported function constructors to js_instanceof_dynamic. Imported classes and unresolved built-ins retain static js_instanceof checks.
Imported constructor fixtures and execution coverage
test-files/fixtures/issue_10477_fn_ctor/*, test-files/test_gap_10477_instanceof_imported_function_ctor.ts
Tests cover import forms, prototype replacement, inheritance, Symbol.hasInstance, reserved-name collisions, live bindings, and non-callable imports.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 82b94

The imported function-constructor behavior is covered across named, default, CommonJS, inheritance, live-binding, and error cases, with controls for existing class and builtin behavior. No actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.24% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 12 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 identifies the main change: providing the runtime value for imported constructors used with instanceof.
Description check ✅ Passed The description is complete and directly related to the change. It explains the issue, root cause, fix, tests, validation, related issue, performance impact, and known limitations. It does not copy ev…
Linked Issues check ✅ Passed Issue #10477 requires imported non-class constructors to use their runtime prototype chain for instanceof. crates/perry-hir/src/lower/lower_expr/arm_bin.rs now lowers imported bindings through `lo…
Out of Scope Changes check ✅ Passed The changes stay within Issue #10477. The fixtures and control cases support the required import forms, prototype-chain behavior, class preservation, and TypeError behavior. The HIR and codegen tests …
Full details: Docstring Coverage

Explanation

Docstring coverage is 43.24% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 12 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

CodeRabbit review triage: "No actionable comments were generated in the recent review." The only
flagged item is the automated "Docstring Coverage" pre-merge check (43.24% vs. an 80% threshold,
scoped to functions touched by this diff) — not applicable: it's a CodeRabbit soft check, not a
project CI gate, and most of the functions dragging the percentage down are #[test] fns and small
match-arm helpers, which this codebase does not doc-comment individually (consistent with the
existing style in the files touched). Not treating this as an actionable finding.

CI: the only failing check is lint's "Public benchmark evidence freshness" step, which is
known-red on main (documented project-wide, unrelated to this change) — confirmed by checking the
failing step directly (benchmarks/ci_public_baseline_check.py, the same gate noted in this PR's own
description). All other checks (check, cargo-test, warnings, e2e-scoped,
gap-suite-build/gc-stress-build) are green; the gap-suite shards were still queued at the time of
this check.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10631 (v0.5.1595). 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