Tracks #5373. A bare/computed require(expr) in a compilePackages-compiled module throws ReferenceError: require is not defined because no ambient require binding is emitted. Only a literal require('pkg') is rewritten to an import at compile time; a non-literal callee falls through the HIR ident-read path to js_global_get_or_throw_unresolved("require").
This issue scopes the fix into two tiers. Tier 1 closes #5373 as filed. Tier 2 extends coverage to const-foldable computed specifiers and is explicitly bounded by the AOT model.
Background
Three independent layers rewrite literal require/createRequire to imports at compile time, so the runtime closure is never consulted for them:
- HIR
try_require_literal — crates/perry-hir/src/lower/expr_call/intrinsics.rs:40-111 (bails on a non-literal arg, returns Ok(None)).
- var-decl destructuring —
crates/perry-hir/src/destructuring/var_decl_sources.rs.
- source-text createRequire transform —
crates/perry/src/commands/compile/collect_modules/create_require_transform.rs.
The runtime require closure that createRequire(...) produces (crates/perry-runtime/src/module_require.rs, require_thunk) only supports Node builtins — any package/relative specifier throws ERR_PERRY_UNSUPPORTED_CREATE_REQUIRE ("supports built-in modules only"). So "createRequire works" in #5373 is true only because the repro's computed specifier resolves to the builtin node:os.
No ambient global require binding exists: require is absent from is_known_global_identifier_name (crates/perry-hir/src/lower/lower_expr.rs:57-96), so a bare require(expr) lowers to the throwing js_global_get_or_throw_unresolved("require") (lower_expr.rs:666-689).
The hard architectural boundary
A require(expr) in an ahead-of-time native binary can only resolve to a module that was discovered and linked at compile time — a runtime-computed string cannot pull in code that isn't in the executable. Perry already enforces exactly this for dynamic import(expr): a specifier the const-folder/globber can't reduce to a finite literal set is rejected (strict mode: compile error; default mode: a deferred throw at the call site — "dynamic import() of a runtime-computed path cannot run in an ahead-of-time compiled binary"; see crates/perry/src/commands/compile/collect_modules.rs:698-841 and crates/perry-codegen/src/expr/dyn_extern_i18n.rs:116-369).
Therefore "dynamic require" can never mean "resolve arbitrary runtime strings." Both tiers respect this boundary.
Tier 1 — ambient require backed by createRequire (closes #5373)
Stop emitting ReferenceError. In the HIR ident-read fall-through (crates/perry-hir/src/lower/lower_expr.rs:666-689), add an arm so a bare unshadowed require lowers to a real createRequire-backed binding instead of js_global_get_or_throw_unresolved("require").
Effects:
Scope guard: gate to external/compilePackages modules (ctx.is_external_module). In user source, the bare-require compile-time error is deliberate ("Closes #668", points users at static imports) and must not regress into a silent runtime path.
Tests: crates/perry/tests/create_require_package.rs, crates/perry/tests/module_import_forms.rs:526-586 (currently asserts the "unsupported interop" diagnostic).
Tier 2 — const-foldable / globbable computed require (follow-on)
Extend coverage from builtins to statically-resolvable package/relative computed specifiers — e.g. require(\./plugins/${name}`), require(cond ? 'a' : 'b'). Recommended approach reuses the proven dynamic-import()` pipeline:
- const-fold/glob the arg to a finite specifier set at compile time (machinery exists:
collect_modules.rs:698-841, expand_dynamic_import_glob, 64-target cap),
- add each target to the module graph as a dynamic edge (
per_module_dyn_import_targets),
- emit a
js_string_equals dispatch chain → <prefix>__init() + load @__perry_ns_<prefix>,
- synchronous + CJS namespace shape (require returns the value directly, not a promise; the one real divergence from
import() — needs care around module.exports vs ESM namespace),
- genuinely-computed specifiers fall through to
require_thunk → builtin-or-throw (unchanged graceful behavior).
Out of scope (by design): genuinely runtime-computed package strings (e.g. ['n','o',…].join('') resolving to a package, network/dynamic specifiers). Same boundary as dynamic import().
Rejected alternative: a net-new runtime spec→{init_fn, ns_global} registry looked up in require_thunk. It resolves the same set of modules as the approach above (the AOT limit is unchanged), at the cost of net-new infra in artifacts.rs/compile.rs/module_require.rs. Only revisit if require.cache identity semantics force a single canonical namespace store.
Plan
Tracks #5373. A bare/computed
require(expr)in acompilePackages-compiled module throwsReferenceError: require is not definedbecause no ambientrequirebinding is emitted. Only a literalrequire('pkg')is rewritten to an import at compile time; a non-literal callee falls through the HIR ident-read path tojs_global_get_or_throw_unresolved("require").This issue scopes the fix into two tiers. Tier 1 closes #5373 as filed. Tier 2 extends coverage to const-foldable computed specifiers and is explicitly bounded by the AOT model.
Background
Three independent layers rewrite literal require/createRequire to imports at compile time, so the runtime closure is never consulted for them:
try_require_literal—crates/perry-hir/src/lower/expr_call/intrinsics.rs:40-111(bails on a non-literal arg, returnsOk(None)).crates/perry-hir/src/destructuring/var_decl_sources.rs.crates/perry/src/commands/compile/collect_modules/create_require_transform.rs.The runtime
requireclosure thatcreateRequire(...)produces (crates/perry-runtime/src/module_require.rs,require_thunk) only supports Node builtins — any package/relative specifier throwsERR_PERRY_UNSUPPORTED_CREATE_REQUIRE("supports built-in modules only"). So "createRequire works" in #5373 is true only because the repro's computed specifier resolves to the builtinnode:os.No ambient global
requirebinding exists:requireis absent fromis_known_global_identifier_name(crates/perry-hir/src/lower/lower_expr.rs:57-96), so a barerequire(expr)lowers to the throwingjs_global_get_or_throw_unresolved("require")(lower_expr.rs:666-689).The hard architectural boundary
A
require(expr)in an ahead-of-time native binary can only resolve to a module that was discovered and linked at compile time — a runtime-computed string cannot pull in code that isn't in the executable. Perry already enforces exactly this for dynamicimport(expr): a specifier the const-folder/globber can't reduce to a finite literal set is rejected (strict mode: compile error; default mode: a deferred throw at the call site — "dynamic import() of a runtime-computed path cannot run in an ahead-of-time compiled binary"; seecrates/perry/src/commands/compile/collect_modules.rs:698-841andcrates/perry-codegen/src/expr/dyn_extern_i18n.rs:116-369).Therefore "dynamic require" can never mean "resolve arbitrary runtime strings." Both tiers respect this boundary.
Tier 1 — ambient
requirebacked bycreateRequire(closes #5373)Stop emitting
ReferenceError. In the HIR ident-read fall-through (crates/perry-hir/src/lower/lower_expr.rs:666-689), add an arm so a bare unshadowedrequirelowers to a realcreateRequire-backed binding instead ofjs_global_get_or_throw_unresolved("require").Effects:
require(builtin)(e.g. thenode:osrepro in compilePackages: computed require(expr) throws ReferenceError (createRequire works) #5373) works out of the box —require_thunkalready resolves builtins by string.require(package)changes failure mode from a confusingReferenceErrorto the existing descriptiveERR_PERRY_UNSUPPORTED_CREATE_REQUIRE.module_require.rs); no new infra.Scope guard: gate to external/compilePackages modules (
ctx.is_external_module). In user source, the bare-requirecompile-time error is deliberate ("Closes #668", points users at static imports) and must not regress into a silent runtime path.Tests:
crates/perry/tests/create_require_package.rs,crates/perry/tests/module_import_forms.rs:526-586(currently asserts the "unsupported interop" diagnostic).Tier 2 — const-foldable / globbable computed require (follow-on)
Extend coverage from builtins to statically-resolvable package/relative computed specifiers — e.g.
require(\./plugins/${name}`),require(cond ? 'a' : 'b'). Recommended approach reuses the proven dynamic-import()` pipeline:collect_modules.rs:698-841,expand_dynamic_import_glob, 64-target cap),per_module_dyn_import_targets),js_string_equalsdispatch chain →<prefix>__init()+ load@__perry_ns_<prefix>,import()— needs care aroundmodule.exportsvs ESM namespace),require_thunk→ builtin-or-throw (unchanged graceful behavior).Out of scope (by design): genuinely runtime-computed package strings (e.g.
['n','o',…].join('')resolving to a package, network/dynamic specifiers). Same boundary as dynamicimport().Rejected alternative: a net-new runtime spec→
{init_fn, ns_global}registry looked up inrequire_thunk. It resolves the same set of modules as the approach above (the AOT limit is unchanged), at the cost of net-new infra inartifacts.rs/compile.rs/module_require.rs. Only revisit ifrequire.cacheidentity semantics force a single canonical namespace store.Plan
require→ createRequire-backed closure, gated tois_external_module(closes compilePackages: computed require(expr) throws ReferenceError (createRequire works) #5373)