Skip to content

fix(cjs): evaluate a conditional require whatever its target exports - #10756

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/10754-conditional-require-shapes
Closed

proggeramlug wants to merge 2 commits into
mainfrom
fix/10754-conditional-require-shapes

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

What

A top-level CommonJS require() whose target has no CommonJS export marker
was silently never evaluated when the call site was conditional. The module did
not load even when the branch was taken; Node loads it. No crash, no
diagnostic — the dependency's side effects simply never happened.

Closes #10754

The measurement that matters

The issue reports the failure as syntactic: if (cond) require(...) works,
&& / ?: / try / switch / loop-body do not. That is a confound in the
reproducer — its if case required a module with module.exports = 1, the
other five required side-effect-only modules. Crossing the two axes on a
release build of main @ 91a566c (perry 0.5.1605) shows the real
discriminator is the target's export shape, not the call-site shape:

shape side-effect-only target module.exports target
if (go) require(t) never loads loads
go && require(t) never loads loads
go ? require(t) : 0 never loads loads
try { if (go) require(t) } never loads loads
switch (go) { case true: require(t) } never loads loads
for (…; go && …;) require(t) never loads loads

(Branch-not-taken is correct in all twelve cells on main#10674's deferral
holds. Node loads in all twelve taken cells.)

Why

#10674 correctly defers a conditional require: the target stays
ModuleInitKind::Deferred and the CJS shim returns the _lazyreq_N import
binding, with codegen firing <S>__init() at the binding read. That init call
is gated on the binding being a known imported function
(ctx.import_function_prefixes). A target with no default export has no such
entry, so nothing fires and the module body never runs. The if-shape cases
that "worked" worked because their targets had a default export to read.

The fix

This is PR #10285's implementation, rebased onto current main, plus one
addition (below). It:

Addition on top of #10285. Routing through the registry regressed the
value of a deferred require whose target is not CJS-wrapped at all: a file
with no CommonJS marker (console.log('x') and nothing else) registers an
initializer but never any exports, so the registry ran its body and handed back
undefined where Node hands back {}. The runtime-record arm now falls back to
the import binding on a genuine registry miss, using __perry_has_path_module
as the miss-vs-undefined-export discriminator — the same guard the generic
runtime-require(path) arm in the same wrapper already uses. Without it
const v = cond ? require('./side-effect-only.cjs') : 0 yields undefined.

Relationship to #10285

This supersedes #10285. Same implementation, rebased (it cherry-picks cleanly),
plus the registry-miss fallback above, plus the gap fixture. #10285 stalled
because it looked superseded by #10674; it is not — #10674 fixed the
classification, #10285 fixes the evaluation site, and only together do all
twelve cells pass.

Both directions, or it isn't a fix

#10437 was the opposite bug (eager hoist: the module loaded when the branch was
not taken). A fix that loads unconditionally is that bug again, so
test-files/test_gap_10754_cjs_conditional_require_shapes.cts asserts every
shape in both directions and in three cells per shape:

  • on_sfx_* — taken branch, side-effect-only target → must load;
  • on_exp_* — taken branch, value-returning target → must load and the
    shim must hand back the target's exports;
  • off_* — branch not taken → must not load.

The existing #10437 fixture covers the not-taken direction with
value-returning targets only; the side-effect-only half is what this adds.

Validation

Two release arms, each built in one invocation
(cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static)
into its own target dir, PERRY_RUNTIME_DIR pinned to its own archives, both
stamping perry 0.5.1605:

  • before — worktree at 91a566c8af (unfixed main);
  • after — this branch.
check before after
6 shapes x {side-effect-only, value-returning} x {taken, not taken} 6 of 24 cells wrong (every taken/side-effect-only cell) 24 of 24 match Node 26.5.1
test_gap_10754_cjs_conditional_require_shapes.cts vs Node 12 lines differ byte-for-byte match
the same fixture through run_parity_tests.sh parity_fail (harness exit 1) PASS
cargo test -p perry --test conditional_require_init 11 failed 11 passed

No collateral in the CJS-adjacent gap slices (fast mode, PERRY_RUN_TIMEOUT=30,
prebuilt compiler), 45 tests, all green:

--filter test_gap_ --filter … pass fail compile-fail crash skip
cjs 6 0 0 0 0
require 5 0 0 0 0
module 14 0 0 0 0
import 20 0 0 0 0

test_gap_cjs_conditional_require_deferred (#10437's own fixture, the
eager-hoist direction) is in the cjs slice and passes.

Out of scope, noticed while measuring

JSON.stringify(require('./side-effect-only.cjs')) returns "" where Node
returns {}. Pre-existing on unfixed main on the eager path as well, so it is
not this change; filed here only so it is on the record.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed conditional CommonJS require() calls so side-effect-only modules execute when their branch is taken.
    • Preserved correct behavior for value-returning modules, skipped branches, and repeated requires.
    • Improved handling across conditionals, short-circuit expressions, loops, switches, and try blocks.
    • Ensured deferred modules initialize at the call site, execute only when reached, and propagate errors correctly.
  • Tests

    • Added coverage for conditional loading, module caching, exports, control-flow cases, and CommonJS/ESM interoperability.

@coderabbitai

coderabbitai Bot commented Sep 19, 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: 21a5dc2d-2c7b-49fc-ae60-878ebc99beba

📥 Commits

Reviewing files that changed from the base of the PR and between 91a566c and 8226c74.

📒 Files selected for processing (26)
  • changelog.d/10756-conditional-require-target-export-shape.md
  • crates/perry-codegen/src/expr/dyn_extern_i18n.rs
  • crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs
  • crates/perry/src/commands/compile/cjs_wrap/mod.rs
  • crates/perry/src/commands/compile/cjs_wrap/tests.rs
  • crates/perry/src/commands/compile/cjs_wrap/wrap.rs
  • crates/perry/tests/conditional_require_init.rs
  • test-files/_helpers/gap10754_off_and.cjs
  • test-files/_helpers/gap10754_off_for.cjs
  • test-files/_helpers/gap10754_off_if.cjs
  • test-files/_helpers/gap10754_off_switch.cjs
  • test-files/_helpers/gap10754_off_tern.cjs
  • test-files/_helpers/gap10754_off_try.cjs
  • test-files/_helpers/gap10754_on_exp_and.cjs
  • test-files/_helpers/gap10754_on_exp_for.cjs
  • test-files/_helpers/gap10754_on_exp_if.cjs
  • test-files/_helpers/gap10754_on_exp_switch.cjs
  • test-files/_helpers/gap10754_on_exp_tern.cjs
  • test-files/_helpers/gap10754_on_exp_try.cjs
  • test-files/_helpers/gap10754_on_sfx_and.cjs
  • test-files/_helpers/gap10754_on_sfx_for.cjs
  • test-files/_helpers/gap10754_on_sfx_if.cjs
  • test-files/_helpers/gap10754_on_sfx_switch.cjs
  • test-files/_helpers/gap10754_on_sfx_tern.cjs
  • test-files/_helpers/gap10754_on_sfx_try.cjs
  • test-files/test_gap_10754_cjs_conditional_require_shapes.cts

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


📝 Walkthrough

Walkthrough

The CommonJS wrapper now detects conditional require() calls with an AST visitor, defers their execution, resolves targets through the path registry, caches results, and initializes deferred modules before fast paths. Tests cover control-flow shapes, export forms, caching, exceptions, and module cycles.

Changes

Conditional require evaluation

Layer / File(s) Summary
AST-based deferred-require classification
crates/perry/src/commands/compile/cjs_wrap/deferred_requires.rs, crates/perry/src/commands/compile/cjs_wrap/mod.rs
An AST visitor classifies require() calls in conditional and function-local scopes. Tests cover control-flow boundaries, eager occurrences, ignored call forms, and emitted lazy wrapper shapes.
Deferred runtime require wiring
crates/perry/src/commands/compile/cjs_wrap/wrap.rs, crates/perry-codegen/src/expr/dyn_extern_i18n.rs, crates/perry/src/commands/compile/cjs_wrap/tests.rs, changelog.d/10756-conditional-require-target-export-shape.md
Deferred specs use runtime-record resolution, per-module cache slots, registry-miss fallback, and lazy alias handling. Deferred initialization runs before class, namespace, and submodule fast paths.
Conditional require behavior coverage
crates/perry/tests/conditional_require_init.rs, test-files/test_gap_10754_cjs_conditional_require_shapes.cts, test-files/_helpers/*
Tests cover skipped and reached branches, side-effect-only and value-returning targets, caching, exceptions, class state, ESM and CommonJS cycles, and loop behavior.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant CJSWrapper
  participant DeferredInit
  participant PathRegistry
  participant RequiredModule
  CJSWrapper->>DeferredInit: evaluate conditional require
  DeferredInit->>PathRegistry: resolve deferred specifier
  PathRegistry->>RequiredModule: initialize target
  RequiredModule-->>PathRegistry: return exports
  PathRegistry-->>CJSWrapper: return cached exports
Loading

Merge Risk: ⚪ Minimal · up to 8226c

No concrete merge-blocking risk remains in the conditional require changes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 25 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 identifies the primary change: fixing conditional CommonJS require evaluation regardless of the target's export shape.
Description check ✅ Passed The description provides a detailed summary, related issue, implementation rationale, scope, and extensive validation results. It does not use the template headings or include the checklist, but it co…
Linked Issues check ✅ Passed The PR satisfies the coding requirements in [#10754] and [#10437]. deferred_requires.rs uses AST traversal for conditional, short-circuit, ternary, try, switch, loop, logical-assignment, arrow, …
Out of Scope Changes check ✅ Passed The changes stay within the linked issue scope. The compiler changes preserve conditional evaluation boundaries, prevent eager loading, support targets without CommonJS export markers, and preserve re…
Full details: Docstring Coverage

Explanation

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

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • 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 pushed a commit that referenced this pull request Sep 19, 2026
Ralph Küpper added 2 commits September 19, 2026 22:55
A top-level CommonJS `require()` in a conditional position never ran its
target when that target carried no CommonJS export marker. The branch was
taken, the shim was reached, and the module body never executed — silently,
with no crash and no diagnostic.

#10754 reports this as a call-site-shape problem (`if` works, `&&` / `?:` /
`try` / `switch` / loop-body do not). That is a confound in the reproducer:
its `if` case required a module with `module.exports = 1` while the other five
required side-effect-only modules. Crossing the two axes against Node 26.5.1
on a release build of main @ 91a566c shows the discriminator is the
TARGET's export shape — all six shapes fail with a side-effect-only target,
all six pass with a value-returning one.

#10674 defers a conditional require correctly: the target stays
`ModuleInitKind::Deferred` and the shim returns the `_lazyreq_N` import
binding, with codegen firing `<S>__init()` at the binding read. That init call
is gated on the binding being a known imported FUNCTION
(`ctx.import_function_prefixes`), which a target with no default export never
is — so nothing fires.

- `cjs_wrap/deferred_requires.rs`: an AST visitor replaces the text-scanning
  deferral classifier, which missed `if (cond) x = require('S')` (still
  eagerly hoisted on main, a residual #10437 shape), concise arrows, the
  ternary ALTERNATE arm, both halves of a `do`/`while`, and `&&=`/`||=`/`??=`.
  `extract_requires::function_local_specs` stays as the parse-failure fallback.
- `cjs_wrap/wrap.rs`: a deferred specifier resolves through the path registry
  rather than its import binding, so initialization no longer depends on the
  target's export shape; the registry record is memoized per call site once
  `loaded === true`, because re-entering the registry on every call measured
  3.4x on a hot require.
- `cjs_wrap/wrap.rs`: the registry only holds EXPORTS for a target that
  publishes them, which is every CJS-wrapped module and no other. A file with
  no CommonJS marker is not CJS-wrapped, so it registers an initializer and
  never any exports, and the registry returned `undefined` where Node returns
  `{}`. The arm falls back to the import binding on a genuine registry miss,
  discriminated by `__perry_has_path_module` — the same guard the generic
  runtime-`require(path)` arm in the same wrapper already uses.
- `perry-codegen/src/expr/dyn_extern_i18n.rs`: fire the deferred `__init()`
  before the imported-class and namespace fast paths, which can themselves
  depend on module initialization.

The implementation is PR #10285's, rebased onto current main; the
registry-miss fallback and the gap fixture are new here.

`test_gap_10754_cjs_conditional_require_shapes.cts` crosses all six shapes
with three cells each — taken/side-effect-only, taken/value-returning and
not-taken — because a fix that loads the module unconditionally is #10437
again, not a fix. On unfixed main it differs from the Node oracle on 12 lines
(six side-effect-only targets never load; three value-returning targets load
before the program's first statement instead of at their call site) and the
harness reports parity_fail; with this change it matches Node byte-for-byte
and the harness reports PASS.

Closes #10754
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed in merge train 228 (#10764), released as v0.5.1607 — main is now 2f9dc8e692.

Closing rather than merging is how trains work here: both PRs were cherry-picked onto one tree, validated together, and landed under the train's own commit, so GitHub cannot mark this one merged even though your change is on main.

Counts re-derived on the assembled tree, not carried: workspace 76/27/44, ledger 376/326, unrooted 578. Worth recording that workspace-architecture.json came through the rebase as M rather than UU — git merged it with zero conflict markers while preserving a stale value, and only workspace_architecture.py --check caught it.

Validation: all nine cheap gates, cargo check --workspace --all-targets under -D warnings, the release build of all five pinned artifacts, every unit suite, and a 6-area gap sweep with zero unexplained regressions, each area asserted to have run a non-zero number of tests, at PERRY_RUN_TIMEOUT=30.

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.

1 participant