From c95500f54f67d19fef16fd25c053e2b60201397c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 06:30:23 +0200 Subject: [PATCH] feat(compile): defer non-resolvable dynamic import() to a throw-on-reach runtime error by default (#5230) A dynamic import(spec) whose specifier is runtime-computed (non-resolvable) previously hard-failed the build ("path argument is not statically resolvable"). Apply the same defer/notice/strict policy as #5206's eval deferral: - Default (defer): compile the site to a rejected Promise carrying a descriptive Error ("dynamic import() of a runtime-computed path cannot run in an ahead-of-time compiled binary (:)"), so await import(spec) throws only if reached. The build succeeds and the app's core runs. - Notice: the site is recorded in #5206's shared sink and surfaces in the SAME end-of-compile notice (generalized header: "N ahead-of-time-unsupported site(s)"), with kind import(...) + file:line. - Strict: restores the historical hard compile error. Unifies with #5206 by generalizing DeferredEvalSite into a shared ahead-of-time-unsupported-site concept (new perry_hir::record_deferred_aot_site) that both eval/new Function and dynamic import push into; one notice prints all. Strict scheme: perry.strict=true covers both eval and dynamic import. Dedicated knobs scoped to imports: --strict-dynamic-import flag and perry.dynamicImport="defer"|"error" (overrides the broad perry.strict for import sites). PERRY_ALLOW_EVAL=1 is the shared escape hatch (forces defer for both). Resolvable forms (string literals, ternaries, template-with-const-locals, finite union-typed params, globs) compile + load unchanged. Tests: new issue_5230_deferred_dynamic_import.rs (defer+notice+run+throw, strict flag, perry.strict, perry.dynamicImport=error, defer-override). #5206 tests stay green (assertions updated for the generalized notice header). --- .../perry-codegen/src/expr/dyn_extern_i18n.rs | 26 +- crates/perry-hir/src/eval_classifier.rs | 35 ++- crates/perry-hir/src/ir/expr.rs | 14 + crates/perry-hir/src/lib.rs | 5 +- crates/perry-hir/src/lower/expr_call/mod.rs | 2 + crates/perry-hir/src/stable_hash/expr.rs | 2 +- crates/perry/src/commands/compile.rs | 28 +- .../src/commands/compile/collect_modules.rs | 85 +++++- .../perry/src/commands/compile/host_config.rs | 34 ++- crates/perry/src/commands/compile/types.rs | 22 ++ crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + .../issue_5206_deferred_eval_runtime_error.rs | 8 +- .../issue_5230_deferred_dynamic_import.rs | 273 ++++++++++++++++++ docs/src/cli/flags.md | 1 + docs/src/language/limitations.md | 40 ++- 16 files changed, 531 insertions(+), 46 deletions(-) create mode 100644 crates/perry/tests/issue_5230_deferred_dynamic_import.rs diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index ca9dbd4af9..f4c567e441 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -113,7 +113,31 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &entry_ptr), (DOUBLE, &options_val)], )) } - Expr::DynamicImport { paths, arg } => { + Expr::DynamicImport { + paths, + arg, + deferred_error, + .. + } => { + // #5230: a non-resolvable (runtime-computed) specifier was + // *deferred* (the default, non-strict policy — analog of #5206's + // eval deferral). Evaluate the arg for its side effects, then + // reject the promise with a descriptive `Error` so + // `await import(spec)` throws only if this site is actually + // reached, instead of failing the whole build. + if let Some(msg) = deferred_error { + let _ = lower_expr(ctx, arg)?; + // Build the `Error(msg)` value the same way `new Error()` + // does (see `Expr::ErrorNew`): intern the message as a string + // literal handle, then `js_error_new_from_value`. + let msg_val = lower_expr(ctx, &Expr::String(msg.clone()))?; + let blk = ctx.block(); + let err_ptr = blk.call(I64, "js_error_new_from_value", &[(DOUBLE, &msg_val)]); + let err_box = nanbox_pointer_inline(blk, &err_ptr); + let p = blk.call(I64, "js_promise_rejected", &[(DOUBLE, &err_box)]); + return Ok(nanbox_pointer_inline(blk, &p)); + } + // Defensive: an empty `paths` list means the resolver pass // failed to populate this node, which `collect_modules` // should have raised as a compile error. Fall through to a diff --git a/crates/perry-hir/src/eval_classifier.rs b/crates/perry-hir/src/eval_classifier.rs index dbb87a5d39..617fbff0b0 100644 --- a/crates/perry-hir/src/eval_classifier.rs +++ b/crates/perry-hir/src/eval_classifier.rs @@ -323,11 +323,17 @@ thread_local! { /// module lowered more than once isn't counted twice. static EVAL_DEFERRED_SITES: Mutex> = Mutex::new(Vec::new()); -/// A bucket-3 site that was compiled to a deferred runtime error. Reported in -/// the end-of-compile notice (#5206). +/// A site that was compiled to a deferred throw-on-reach runtime error +/// instead of failing the build. Reported in the end-of-compile notice. +/// +/// #5206 introduced this for runtime-unknown `eval(...)` / `new Function(...)` +/// sites; #5230 generalized it to any ahead-of-time-unsupported surface +/// (currently also a non-resolvable dynamic `import(...)`). The `kind` string +/// distinguishes the surface (`eval(...)`, `new Function(...)`, `import(...)`) +/// so a single notice can list every degraded site of every kind together. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeferredEvalSite { - /// Display label of the call shape, e.g. `new Function(...)`. + /// Display label of the surface, e.g. `new Function(...)` or `import(...)`. pub kind: String, /// `file:line` of the site. pub location: String, @@ -346,12 +352,17 @@ pub fn eval_strict_mode() -> bool { EVAL_STRICT_MODE.with(|s| *s.borrow()) } -/// Record a deferred bucket-3 site for the end-of-compile notice. Idempotent -/// per `(kind, location)`. -fn record_deferred_site(classification: &EvalClassification) { +/// Record a deferred ahead-of-time-unsupported site for the end-of-compile +/// notice. Shared sink for every degraded surface (#5206 eval / `new Function`, +/// #5230 dynamic `import(...)`). Idempotent per `(kind, location)` so the same +/// site lowered more than once (rayon re-lower, two passes) isn't double-counted. +/// +/// `kind` is the display label of the surface (e.g. `import(...)`); `location` +/// is `file:line`. +pub fn record_deferred_aot_site(kind: impl Into, location: impl Into) { let site = DeferredEvalSite { - kind: classification.surface.label().to_string(), - location: classification.location(), + kind: kind.into(), + location: location.into(), }; if let Ok(mut v) = EVAL_DEFERRED_SITES.lock() { if !v.contains(&site) { @@ -360,6 +371,14 @@ fn record_deferred_site(classification: &EvalClassification) { } } +/// Record a deferred bucket-3 eval/Function site for the end-of-compile notice. +fn record_deferred_site(classification: &EvalClassification) { + record_deferred_aot_site( + classification.surface.label().to_string(), + classification.location(), + ); +} + /// Drain and return every deferred bucket-3 site recorded so far this /// compile. Called by the driver to render the end-of-compile notice. pub fn take_deferred_eval_sites() -> Vec { diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 37c0da8b3e..ba85f4e136 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -2518,6 +2518,20 @@ pub enum Expr { DynamicImport { paths: Vec, arg: Box, + /// Byte offset (`span.lo.0`) of the `import(...)` call in its module's + /// source, captured at lowering time. Used by the driver to resolve a + /// `file:line` for the #5230 deferred-site notice (HIR `Expr` carries no + /// span otherwise). `0` when unknown. + byte_offset: u32, + /// #5230: when `Some(msg)`, the path argument was non-resolvable + /// (runtime-computed) and this site was *deferred* (the default, + /// non-strict policy — the analog of #5206's eval deferral). Codegen + /// lowers it to a rejected `Promise` carrying an `Error(msg)`, so + /// `await import(spec)` throws a descriptive error *only if reached* + /// rather than failing the whole build. `None` is the normal case + /// (`paths` resolved to a finite set). In strict mode such a site is a + /// compile error instead and never produces a node with this set. + deferred_error: Option, }, /// Compile-time-resolved `new Worker(filename, options?)` from /// `node:worker_threads`. The filename expression follows the same diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index 91dc6e07dc..217b2c1a75 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -40,8 +40,9 @@ pub use dynamic_import::{ pub use egress::{audit_module_egress, EgressRefusalReason, EgressViolation}; pub use enums::fix_imported_enums; pub use eval_classifier::{ - classify as classify_eval_surface, set_eval_strict_mode, take_deferred_eval_sites, - DeferredEvalSite, EvalBucket, EvalClassification, EvalDecision, EvalSurface, + classify as classify_eval_surface, record_deferred_aot_site, set_eval_strict_mode, + take_deferred_eval_sites, DeferredEvalSite, EvalBucket, EvalClassification, EvalDecision, + EvalSurface, }; pub use ir::*; pub use js_transform::{ diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index dccecdb9b7..97bb2d5cb9 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -649,6 +649,8 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result { tag(h, 12054); module.as_ref().hash(h); name.as_ref().hash(h); } Expr::WebAssemblyInstantiate(bytes) => { tag(h, 12028); bytes.as_ref().hash(h); } Expr::WebAssemblyCallExport { instance, name, args, } => { tag(h, 12029); instance.as_ref().hash(h); name.as_ref().hash(h); args.hash(h); } - Expr::DynamicImport { paths, arg } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); } + Expr::DynamicImport { paths, arg, byte_offset, deferred_error } => { tag(h, 12030); for p in paths { p.hash(h); } arg.as_ref().hash(h); byte_offset.hash(h); deferred_error.hash(h); } Expr::WorkerNew { paths, filename, options } => { tag(h, 12055); for p in paths { p.hash(h); } diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index a84c6cefa2..ac7e6d4b07 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -5761,12 +5761,14 @@ pub fn run_with_parse_cache( cleanup_intermediates(args.keep_intermediates, &obj_cleanup_paths); - // #5206: visible end-of-compile notice listing every runtime-eval site - // (`eval(...)` / `new Function()`) that was compiled to a - // deferred runtime error instead of blocking the build. Strict-eval mode - // (`--strict-eval` / `perry.eval = "error"`) never reaches here for such - // a site — it fails the build earlier. Text format only (JSON consumers - // get a clean machine-readable result on stdout). + // #5206 / #5230: visible end-of-compile notice listing every + // ahead-of-time-unsupported site that was compiled to a deferred runtime + // error instead of blocking the build — runtime-unknown `eval(...)` / + // `new Function()` and non-resolvable dynamic `import(...)`. + // Strict mode (`--strict-eval` / `--strict-dynamic-import` / `perry.eval = + // "error"` / `perry.dynamicImport = "error"` / `perry.strict`) never reaches + // here for a covered site — it fails the build earlier. Text format only + // (JSON consumers get a clean machine-readable result on stdout). print_deferred_eval_notice(format); let final_output_path = result_app_dir.unwrap_or(exe_path); @@ -5783,10 +5785,12 @@ pub fn run_with_parse_cache( }) } -/// #5206: print the end-of-compile notice for runtime-eval sites that were -/// compiled to deferred runtime errors. Drains the process-global sink (so -/// re-running a compile in the same process starts fresh) and prints a single -/// stand-out block. No-op when there are no such sites or for JSON output. +/// #5206 / #5230: print the end-of-compile notice for ahead-of-time-unsupported +/// sites (runtime-unknown `eval(...)` / `new Function(...)`, and non-resolvable +/// dynamic `import(...)`) that were compiled to deferred runtime errors. Drains +/// the shared process-global sink (so re-running a compile in the same process +/// starts fresh) and prints a single stand-out block. No-op when there are no +/// such sites or for JSON output. fn print_deferred_eval_notice(format: OutputFormat) { let sites = perry_hir::take_deferred_eval_sites(); if sites.is_empty() || !matches!(format, OutputFormat::Text) { @@ -5807,7 +5811,7 @@ fn print_deferred_eval_notice(format: OutputFormat) { }; eprintln!(); eprintln!( - "{y}{b}notice:{r}{y} {n} runtime-eval {plural} compiled to a deferred runtime error (throws only if reached):{r}" + "{y}{b}notice:{r}{y} {n} ahead-of-time-unsupported {plural} compiled to a deferred runtime error (throws only if reached):{r}" ); // Align the locations into a column for readability. let kind_width = sites.iter().map(|s| s.kind.len()).max().unwrap_or(0); @@ -5820,7 +5824,7 @@ fn print_deferred_eval_notice(format: OutputFormat) { ); } eprintln!( - " Pass {b}--strict-eval{r} (or set {b}perry.eval = \"error\"{r}) to make these a compile-time error instead." + " Pass {b}--strict-eval{r}/{b}--strict-dynamic-import{r} (or set {b}perry.strict = true{r}) to make these a compile-time error instead." ); eprintln!(); } diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index a442fbe132..f0119a25fb 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -818,9 +818,28 @@ fn collect_module_one( &module_const_locals, &dynamic_param_literals, ); - let mut dynamic_path_sets: Vec> = Vec::new(); + // #5230: re-install this module's source (cleared after the lower above) so + // `current_module_line_at` can resolve a `file:line` for a deferred dynamic + // import's notice/runtime-error message. Cleared again after the fill pass. + perry_hir::set_current_module_source(source.clone()); + // Per-site outcome, aligned 1:1 with the `for_each_dynamic_import` + // traversal order so the mutable fill pass below can apply them. + // `Resolved(set)` populates `paths`; `Deferred(msg)` (#5230) leaves + // `paths` empty and sets `deferred_error` so codegen lowers the site to a + // rejected promise that throws `msg` only if reached. + enum DynImportOutcome { + Resolved(Vec), + Deferred(String), + } + let mut dynamic_path_sets: Vec = Vec::new(); perry_hir::for_each_dynamic_import(&hir_module, &mut |expr| { - if let perry_hir::Expr::DynamicImport { paths, arg } = expr { + if let perry_hir::Expr::DynamicImport { + paths, + arg, + byte_offset, + .. + } = expr + { if !paths.is_empty() { // Already resolved (e.g. a second pass on the same module). return; @@ -851,7 +870,7 @@ fn collect_module_one( new_dyn_imports.push(p.clone()); } } - dynamic_path_sets.push(set); + dynamic_path_sets.push(DynImportOutcome::Resolved(set)); } perry_hir::Resolution::Unresolved(reason) => { // #1674 sub-part B: a non-resolvable template specifier with @@ -886,16 +905,43 @@ fn collect_module_one( new_dyn_imports.push(p.clone()); } } - dynamic_path_sets.push(matches); + dynamic_path_sets.push(DynImportOutcome::Resolved(matches)); return; } } - dyn_errors.push(format!( - "dynamic import() in module {} ({}): {}", - module_name, - canonical.display(), - reason - )); + // #5230: a genuinely runtime-computed specifier. This is the + // analog of #5206's runtime-unknown eval bucket. Strict mode + // (`--strict-dynamic-import` / `perry.dynamicImport = "error"` + // / `perry.strict`) restores the historical hard compile + // error. The default policy *defers* it: compile the site to + // a rejected promise that throws a descriptive Error only if + // reached, record it for the shared end-of-compile notice, + // and keep building so plugin-loader apps compile + run their + // core. `PERRY_ALLOW_EVAL=1` forces defer (shared AOT escape + // hatch). + if ctx.strict_dynamic_import + && !perry_hir::eval_classifier::eval_override_enabled() + { + dyn_errors.push(format!( + "dynamic import() in module {} ({}): {}", + module_name, + canonical.display(), + reason + )); + } else { + let line = perry_hir::current_module_line_at(*byte_offset) + .filter(|&l| l != 0); + let loc = match line { + Some(l) => format!("{}:{}", source_file_path, l), + None => source_file_path.clone(), + }; + let msg = format!( + "dynamic import() of a runtime-computed path cannot run in an \ + ahead-of-time compiled binary ({loc})" + ); + perry_hir::record_deferred_aot_site("import(...)", loc); + dynamic_path_sets.push(DynImportOutcome::Deferred(msg)); + } } } } @@ -965,14 +1011,22 @@ fn collect_module_one( drop(dynamic_local_literals); drop(module_const_locals); if !dyn_errors.is_empty() { + perry_hir::clear_current_module_source(); return Err(anyhow!("{}", dyn_errors.join("\n"))); } let mut dynamic_path_sets = dynamic_path_sets.into_iter(); perry_hir::for_each_dynamic_import_mut(&mut hir_module, &mut |expr| { - if let perry_hir::Expr::DynamicImport { paths, .. } = expr { - if paths.is_empty() { - if let Some(set) = dynamic_path_sets.next() { - *paths = set; + if let perry_hir::Expr::DynamicImport { + paths, + deferred_error, + .. + } = expr + { + if paths.is_empty() && deferred_error.is_none() { + match dynamic_path_sets.next() { + Some(DynImportOutcome::Resolved(set)) => *paths = set, + Some(DynImportOutcome::Deferred(msg)) => *deferred_error = Some(msg), + None => {} } } } @@ -987,6 +1041,9 @@ fn collect_module_one( } } }); + // #5230: done with the dynamic-import line resolution; don't leak this + // module's source onto unrelated work on this (possibly rayon-worker) thread. + perry_hir::clear_current_module_source(); for source in new_dyn_imports { // A dynamic edge to the same source as a static import is folded // into the existing static edge: that edge already gives us full diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs index 134da22eb9..e5a444858c 100644 --- a/crates/perry/src/commands/compile/host_config.rs +++ b/crates/perry/src/commands/compile/host_config.rs @@ -356,6 +356,8 @@ pub(super) fn apply_pkg_and_toml_config( .and_then(|v| v.as_bool()) { ctx.strict_eval = strict; + // #5230: the broad `perry.strict` covers dynamic imports too. + ctx.strict_dynamic_import = strict; } if let Some(mode) = pkg .get("perry") @@ -368,6 +370,19 @@ pub(super) fn apply_pkg_and_toml_config( _ => {} } } + // #5230: dedicated `perry.dynamicImport = "defer" | "error"` + // overrides the broad `perry.strict` for dynamic-import sites. + if let Some(mode) = pkg + .get("perry") + .and_then(|p| p.get("dynamicImport")) + .and_then(|v| v.as_str()) + { + match mode { + "error" | "strict" => ctx.strict_dynamic_import = true, + "defer" => ctx.strict_dynamic_import = false, + _ => {} + } + } // #502: perry.allowedHosts — compile-time URL/host // egress allowlist. Patterns: exact host, "*.foo.com" // subdomain wildcard, "https://host/prefix*" URL @@ -523,6 +538,8 @@ pub(super) fn apply_pkg_and_toml_config( if let Some(perry_tbl) = table.get("perry").and_then(|v| v.as_table()) { if let Some(strict) = perry_tbl.get("strict").and_then(|v| v.as_bool()) { ctx.strict_eval = strict; + // #5230: broad `perry.strict` covers dynamic imports too. + ctx.strict_dynamic_import = strict; } if let Some(mode) = perry_tbl.get("eval").and_then(|v| v.as_str()) { match mode { @@ -531,17 +548,30 @@ pub(super) fn apply_pkg_and_toml_config( _ => {} } } + // #5230: dedicated `perry.dynamicImport` overrides for imports. + if let Some(mode) = perry_tbl.get("dynamicImport").and_then(|v| v.as_str()) { + match mode { + "error" | "strict" => ctx.strict_dynamic_import = true, + "defer" => ctx.strict_dynamic_import = false, + _ => {} + } + } } } } - // CLI flag opts in. + // CLI flags opt in. if args.strict_eval { ctx.strict_eval = true; } + if args.strict_dynamic_import { + ctx.strict_dynamic_import = true; + } // Back-compat: `PERRY_ALLOW_EVAL` forces non-strict for a one-off build, - // overriding any strict flag/config. + // overriding any strict flag/config — for both eval and dynamic import + // (shared AOT escape hatch, #5230). if perry_hir::eval_classifier::eval_override_enabled() { ctx.strict_eval = false; + ctx.strict_dynamic_import = false; } // Install into the HIR thread-local before any lowering begins (re-applied // per rayon worker in collect_modules.rs, mirroring the dynamic-stdlib diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 7512081755..8fa71770b1 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -292,6 +292,17 @@ pub struct CompileArgs { #[arg(long)] pub strict_eval: bool, + /// #5230 — strict dynamic-import mode. Fail the build at compile time if a + /// dynamic `import(...)` has a runtime-computed (non-resolvable) specifier + /// (the historical behavior). By default such a site is instead compiled to + /// a rejected `Promise` that throws a descriptive `Error` only if reached, + /// and is listed in the same end-of-build notice as deferred eval sites. + /// Also settable via `"perry": { "dynamicImport": "error" }`; the broad + /// `"perry": { "strict": true }` covers it too. `PERRY_ALLOW_EVAL=1` forces + /// this off. + #[arg(long)] + pub strict_dynamic_import: bool, + /// Minimum Windows version the compiled executable must run on. /// Accepted values: `7`, `8`, `10` (default `10`). Ignored on every /// non-Windows target. @@ -672,6 +683,16 @@ pub struct CompilationContext { /// package.json or perry.toml → CLI `--strict-eval`. `PERRY_ALLOW_EVAL=1` /// always forces this off (back-compat escape hatch). pub strict_eval: bool, + /// #5230: strict mode for non-resolvable (runtime-computed) dynamic + /// `import(...)` specifiers. When true, such a site is a hard compile error + /// (the historical behavior). When false (the default), it is deferred to a + /// rejected `Promise` that throws a descriptive `Error` only if reached, and + /// recorded in the shared end-of-compile notice. Defaults to `strict_eval` + /// so the broad `perry.strict = true` covers both; overridden by the + /// dedicated `perry.dynamicImport = "defer" | "error"` config and the + /// `--strict-dynamic-import` CLI flag. `PERRY_ALLOW_EVAL=1` forces it off + /// (shared AOT escape hatch). + pub strict_dynamic_import: bool, /// #503: package names whose modules may legitimately use dynamic /// stdlib dispatch (`perry.allowDynamicStdlibDispatch: [...]`). /// Consulted per-module during HIR lowering; ignored when @@ -873,6 +894,7 @@ impl CompilationContext { extra_stdlib_features: BTreeSet::new(), refuse_dynamic_stdlib_dispatch: true, strict_eval: false, + strict_dynamic_import: false, allow_dynamic_stdlib_packages: HashSet::new(), js_runtime_importers: Vec::new(), permissions: std::collections::BTreeMap::new(), diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 9dc550ae51..642920443b 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -306,6 +306,7 @@ fn build_once( emit_sandbox: false, lockdown: false, strict_eval: false, + strict_dynamic_import: false, min_windows_version: "10".to_string(), windows_subsystem: "auto".to_string(), // Phase 2 v7: harmonyos signing flags. `perry dev` is the watch diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index bc3fd7ed26..02a37a5329 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -237,6 +237,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> emit_sandbox: false, lockdown: false, strict_eval: false, + strict_dynamic_import: false, min_windows_version: "10".to_string(), windows_subsystem: "auto".to_string(), // Phase 2 v7: harmonyos signing flags. `perry run` is for local diff --git a/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs b/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs index 8827eecb8b..ac4e0d6213 100644 --- a/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs +++ b/crates/perry/tests/issue_5206_deferred_eval_runtime_error.rs @@ -119,9 +119,11 @@ fn default_defer_compiles_prints_notice_and_runs() { "default compile must succeed; stderr:\n{stderr}" ); - // The visible end-of-compile notice: count + kind + file:line. + // The visible end-of-compile notice: count + kind + file:line. #5230 + // generalized the headline ("ahead-of-time-unsupported site(s)") so eval and + // dynamic-import deferrals share one notice. assert!( - stderr.contains("runtime-eval site"), + stderr.contains("ahead-of-time-unsupported site"), "expected the deferred-eval notice; stderr:\n{stderr}" ); assert!( @@ -234,7 +236,7 @@ fn allow_eval_env_overrides_strict_config() { "PERRY_ALLOW_EVAL=1 must override a strict config and build; stderr:\n{stderr}" ); assert!( - stderr.contains("runtime-eval site"), + stderr.contains("ahead-of-time-unsupported site"), "back-compat override must still print the deferred notice; stderr:\n{stderr}" ); } diff --git a/crates/perry/tests/issue_5230_deferred_dynamic_import.rs b/crates/perry/tests/issue_5230_deferred_dynamic_import.rs new file mode 100644 index 0000000000..052695b9f9 --- /dev/null +++ b/crates/perry/tests/issue_5230_deferred_dynamic_import.rs @@ -0,0 +1,273 @@ +//! Regression test for #5230: a non-resolvable (runtime-computed) dynamic +//! `import(...)` no longer blocks the build by default — it applies the same +//! defer/notice/strict policy as #5206's eval deferral. +//! +//! Default (non-strict) behavior: +//! 1. compilation SUCCEEDS even with a runtime-computed `import(spec)` in a +//! cold (never-taken) branch, +//! 2. a visible end-of-compile NOTICE lists the degraded site under the SAME +//! header as deferred eval sites (count + kind `import(...)` + `file:line`), +//! 3. the binary runs fine when the import path is never reached, including a +//! sibling RESOLVABLE literal `import("./real.js")` that still loads, and +//! 4. if the deferred import IS reached it throws a descriptive, catchable +//! `Error` (not a crash/segfault, not a silent no-op). +//! +//! Strict mode (`--strict-dynamic-import`, `perry.dynamicImport = "error"`, or +//! the broad `perry.strict = true`) restores the historical hard compile-time +//! refusal ("not statically resolvable"). + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +/// Build `libperry_runtime.a` once so the compiled binaries can link (mirrors +/// the #5206 test; the CI `cargo-test` job doesn't pre-build the staticlib). +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime") + .output() + .expect("run cargo build -p perry-runtime"); + assert!( + build.status.success(), + "cargo build -p perry-runtime failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +fn runtime_dir() -> PathBuf { + ensure_runtime_archive(); + target_debug_dir() +} + +/// A resolvable sibling module — the literal `import("./real.js")` must still +/// compile and load it (regression guard for the resolvable forms). +const REAL_MODULE: &str = r#"export const greeting = "hello from real module"; +"#; + +/// The cold-path fixture: +/// - `import("./real.js")` is a string literal → resolvable → still loads. +/// - `import(p + ".js")` is runtime-computed → non-resolvable → deferred. +/// The deferred site is only reached behind `--force-import`. +const COLD_FIXTURE: &str = r#" +async function loadReal() { + const m = await import("./real.js"); + console.log("REAL:" + m.greeting); +} + +async function loadPlugin(name: string) { + const p = name; + const m = await import(p + ".js"); + return m; +} + +await loadReal(); + +if (process.argv.indexOf("--force-import") !== -1) { + try { + await loadPlugin("./real"); + console.log("NO_THROW"); + } catch (e: any) { + console.log("CAUGHT:" + (e && e.message)); + } +} +"#; + +fn write_fixture(root: &std::path::Path) { + std::fs::write(root.join("real.js"), REAL_MODULE).expect("write real.js"); + std::fs::write(root.join("main.ts"), COLD_FIXTURE).expect("write main.ts"); +} + +fn compile(root: &std::path::Path, extra_args: &[&str]) -> std::process::Output { + let entry = root.join("main.ts"); + let output = root.join("main_bin"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache"); + for a in extra_args { + cmd.arg(a); + } + cmd.env("PERRY_NO_AUTO_OPTIMIZE", "1"); + cmd.env("PERRY_RUNTIME_DIR", runtime_dir()); + cmd.output().expect("run perry compile") +} + +#[test] +fn default_defer_compiles_prints_notice_runs_and_throws_on_reach() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + + let out = compile(root, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "default compile must succeed; stderr:\n{stderr}" + ); + + // The shared end-of-compile notice (same header as deferred eval) names the + // dynamic-import kind and a `file:line`. + assert!( + stderr.contains("ahead-of-time-unsupported site"), + "expected the shared deferred-site notice; stderr:\n{stderr}" + ); + assert!( + stderr.contains("import(...)"), + "notice must name the dynamic-import kind; stderr:\n{stderr}" + ); + assert!( + stderr.contains("main.ts:9"), + "notice must name the site location (file:line); stderr:\n{stderr}" + ); + + // The resolvable literal import loads; the deferred site is never reached. + let bin = root.join("main_bin"); + let run = Command::new(&bin).output().expect("run compiled binary"); + assert!(run.status.success(), "binary must run the resolvable path"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + stdout.contains("REAL:hello from real module"), + "resolvable literal import must still load; got:\n{stdout}" + ); + + // Forcing the deferred path throws a descriptive, catchable Error. + let run2 = Command::new(&bin) + .arg("--force-import") + .output() + .expect("run compiled binary --force-import"); + let stdout2 = String::from_utf8_lossy(&run2.stdout); + assert!( + run2.status.success(), + "the binary must not crash when the deferred import is reached" + ); + assert!( + stdout2.contains("CAUGHT:"), + "the reached deferred import must throw a catchable Error; got:\n{stdout2}" + ); + assert!( + stdout2.contains("runtime-computed path cannot run in an ahead-of-time compiled binary"), + "the thrown Error must be descriptive; got:\n{stdout2}" + ); + assert!( + !stdout2.contains("NO_THROW"), + "the reached deferred import must NOT silently no-op; got:\n{stdout2}" + ); +} + +#[test] +fn strict_dynamic_import_flag_refuses_at_compile_time() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + + let out = compile(root, &["--strict-dynamic-import"]); + assert!( + !out.status.success(), + "--strict-dynamic-import must fail the build for a runtime-computed import" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not statically resolvable"), + "strict mode must print the not-resolvable refusal; stderr:\n{stderr}" + ); +} + +#[test] +fn perry_strict_config_covers_dynamic_import() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + // The broad `perry.strict = true` must cover dynamic imports too. + std::fs::write( + root.join("package.json"), + r#"{ "name": "strict-dynimport-cfg", "perry": { "strict": true } }"#, + ) + .expect("write package.json"); + + let out = compile(root, &[]); + assert!( + !out.status.success(), + "perry.strict = true must fail the build for a runtime-computed import" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not statically resolvable"), + "perry.strict must restore the refusal; stderr:\n{stderr}" + ); +} + +#[test] +fn perry_dynamic_import_error_config_refuses() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + std::fs::write( + root.join("package.json"), + r#"{ "name": "dynimport-error-cfg", "perry": { "dynamicImport": "error" } }"#, + ) + .expect("write package.json"); + + let out = compile(root, &[]); + assert!( + !out.status.success(), + "perry.dynamicImport = \"error\" must fail the build" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not statically resolvable"), + "dynamicImport=error must restore the refusal; stderr:\n{stderr}" + ); +} + +#[test] +fn perry_dynamic_import_defer_overrides_broad_strict() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + // Dedicated knob (defer) wins over the broad strict for dynamic imports. + std::fs::write( + root.join("package.json"), + r#"{ "name": "dynimport-override", "perry": { "strict": true, "dynamicImport": "defer" } }"#, + ) + .expect("write package.json"); + + let out = compile(root, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "perry.dynamicImport=\"defer\" must override perry.strict for imports; stderr:\n{stderr}" + ); + assert!( + stderr.contains("ahead-of-time-unsupported site"), + "override must still defer + notice; stderr:\n{stderr}" + ); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index b5d410bfbf..2cd0e6ec13 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -87,6 +87,7 @@ Minification strips comments, collapses whitespace, and mangles local variable/p | `--enable-wasm-runtime` | Force-link the wasmi WebAssembly host runtime (auto-detected when `WebAssembly.*` is referenced; needed only when loading via dlopen / FFI without a static reference) | | `--type-check` | Enable type checking via tsgo IPC | | `--strict-eval` | Fail the build if any runtime-unknown `eval(...)` / `new Function()` site is reachable. By default such a site is compiled to a deferred runtime error (throws only if reached) and a compile-time notice is printed. Also settable via `perry.eval = "error"` / `perry.strict = true` (package.json or perry.toml). `PERRY_ALLOW_EVAL=1` forces it off. See [Limitations](../language/limitations.md#no-eval-or-dynamic-code). | +| `--strict-dynamic-import` | Fail the build if a dynamic `import(...)` has a runtime-computed (non-resolvable) specifier. By default such a site is compiled to a rejected `Promise` that throws a descriptive `Error` only if reached, and is listed in the same end-of-build notice as deferred eval sites. Also settable via `perry.dynamicImport = "error"` / `perry.strict = true` (package.json or perry.toml). `PERRY_ALLOW_EVAL=1` forces it off. Resolvable forms (string literals, ternaries of resolvable arms, template literals over const locals, finite union-typed params, glob) are unaffected. See [Limitations](../language/limitations.md#no-eval-or-dynamic-code). | ## Environment Variables diff --git a/docs/src/language/limitations.md b/docs/src/language/limitations.md index 9e3fc88b80..8e4ae2b619 100644 --- a/docs/src/language/limitations.md +++ b/docs/src/language/limitations.md @@ -43,15 +43,41 @@ evaluated; a `new Function(...)` returns a function that throws when called), and prints a single end-of-compile notice listing every degraded site: ```text -notice: 2 runtime-eval site(s) compiled to a deferred runtime error (throws only if reached): - - new Function(...) src/cli/cmd/debug/agent.handler.ts:41 +notice: 3 ahead-of-time-unsupported site(s) compiled to a deferred runtime error (throws only if reached): - eval(...) src/foo.ts:12 - Pass --strict-eval (or set perry.eval = "error") to make these a compile-time error instead. + - import(...) src/plugins/loader.ts:88 + - new Function(...) src/cli/cmd/debug/agent.handler.ts:41 + Pass --strict-eval/--strict-dynamic-import (or set perry.strict = true) to make these a compile-time error instead. ``` This lets a single such call in a cold path ship without aborting the whole build, while still failing loudly (and catchably) if that path runs. +### Dynamic `import()` with a runtime-computed specifier (#5230) + +A dynamic `import(spec)` whose `spec` is only known at runtime (a plugin loader +building a path from a variable) is subject to the **same defer/notice/strict +policy** as `eval`. By default it compiles to a rejected `Promise` carrying a +descriptive `Error` (so `await import(spec)` throws *only if reached*), is +listed in the shared notice above under the `import(...)` kind, and does **not** +abort the build. This lets an app with a plugin-loader path compile and run its +core, with only the plugin-load path throwing if exercised. + +Resolvable specifiers are unaffected and still compile + load: string literals +(`import("./mod.js")`), ternaries of resolvable arms, template literals over +`const` locals (`` import(`./${KIND}.js`) ``), finite string-literal-union +parameters, and directory globs. + +```ts +// Resolvable → compiled + loaded as today. +const real = await import("./real.js"); + +// Runtime-computed → deferred (default): throws only if this line runs. +async function loadPlugin(name: string) { + return await import(name + ".js"); +} +``` + ### Strict mode: refuse at compile time To make every runtime-unknown site a hard compile-time error instead, opt into @@ -67,6 +93,14 @@ package.json/perry.toml config → `--strict-eval` (opts in). The legacy `PERRY_ALLOW_EVAL=1` environment variable still works: it forces non-strict (defer) mode for a one-off build, overriding any strict flag/config. +The same strict controls apply to runtime-computed dynamic `import()` (#5230). +The broad `perry.strict = true` covers both eval and dynamic import. For a knob +scoped to dynamic imports only, use `--strict-dynamic-import` or +`"perry": { "dynamicImport": "error" }` (accepts `"defer"` (default) or +`"error"`); the dedicated knob overrides the broad `perry.strict` for import +sites. `PERRY_ALLOW_EVAL=1` is the shared AOT escape hatch — it forces defer for +both eval and dynamic import. + Test262 rows that only observe parsing or executing a code string remain intentional AOT exclusions, not runtime dynamic-code work. This includes the `language/white-space/comment-{multi,single}-{form-feed,horizontal-tab,nbsp,space,vertical-tab}.js`