From c8552cb3f18d0ea776448b949f5ff2b4d9bfb5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 25 May 2026 11:59:36 +0200 Subject: [PATCH] feat(hir): classify new Function/eval call sites + refusal diagnostic (#1678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of #1677 (AOT-first eval/new Function strategy). Establishes the single decision point every later phase builds on: a classifier that buckets each `new Function` / `Function(...)` / `eval(...)` site into 1. const-foldable — literal/substitution-free body (→ #1679) 2. known-library-codegen — from fast-json-stringify / ajv / find-my-way (the Fastify JIT trio; → #1680/#1681/#1682) 3. runtime-unknown — genuinely runtime-dynamic code string Only the runtime-unknown bucket is refused, with a precise diagnostic that names the surface, file:line, and originating package (or "user source"). Buckets 1 and 2 keep their existing placeholder lowering so the phases that own them can swap it in without a behaviour change here — Phase 0 is pure analysis + reporting, it never compiles, folds, or evaluates anything. Before this, both shapes silently fell through to broken lowerings (a bare `Function`/`eval` ident → GlobalGet(0) sentinel → runtime TypeError, and `new Function(...)` → an unknown-class class_id=0 empty-object placeholder) with no indication of why. New module crates/perry-hir/src/eval_classifier.rs (pure classification + diagnostic + instrumentation), hooked at the two Function-shape lowering sites (expr_new for `new Function`, expr_call for `Function(...)`/`eval`). The `Function('return this')()` globalThis fold (#957/#959) runs first and short-circuits, so it is unaffected. Instrumentation: PERRY_EVAL_DIAG=1 logs every classified site (surface, file:line, package, bucket, body preview) to stderr. Escape hatch: PERRY_ALLOW_EVAL=1 downgrades the bucket-3 refusal to the legacy fall-through for a one-off build (mirrors #503's PERRY_ALLOW_DYNAMIC_STDLIB). Tests: 9 unit tests covering each bucket + provenance + line resolution + preview truncation. Verified end-to-end on direct `new Function`/`eval` samples (refused, with file:line + provenance), a const-foldable sample (passes through), an ajv-path sample (known-library bucket), and the existing `Function('return this')()` fold (still works). --- crates/perry-hir/src/eval_classifier.rs | 439 ++++++++++++++++++ crates/perry-hir/src/ir/constants.rs | 20 + crates/perry-hir/src/ir/mod.rs | 19 +- crates/perry-hir/src/lib.rs | 4 + .../src/lower/expr_call/intrinsics.rs | 46 ++ crates/perry-hir/src/lower/expr_call/mod.rs | 8 +- crates/perry-hir/src/lower/expr_new.rs | 27 ++ 7 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 crates/perry-hir/src/eval_classifier.rs diff --git a/crates/perry-hir/src/eval_classifier.rs b/crates/perry-hir/src/eval_classifier.rs new file mode 100644 index 0000000000..1fe7c4f0b0 --- /dev/null +++ b/crates/perry-hir/src/eval_classifier.rs @@ -0,0 +1,439 @@ +//! #1678 (Phase 0 of #1677) — classify `new Function` / `Function(...)` / +//! `eval(...)` call sites and emit a precise refusal diagnostic. +//! +//! Perry is an ahead-of-time compiler: it never executes a code string at +//! runtime. Before this module, the `Function`/`eval` shapes silently fell +//! through to a broken lowering — a bare `Function`/`eval` ident lowers to +//! the `GlobalGet(0)` sentinel (→ runtime `TypeError: value is not a +//! function`) and `new Function(...)` to an unknown-class `Expr::New` +//! (→ a class_id=0 empty-object placeholder). Neither named *why* the call +//! couldn't compile, and there was no single decision point every later +//! phase of #1677 could build on. +//! +//! This module is that decision point. It buckets each call site into: +//! +//! 1. [`EvalBucket::ConstFoldable`] — the body argument is a compile-time +//! constant string (string literal / substitution-free template, or no +//! body at all). Phase 1 (#1679) will compile these to native functions. +//! 2. [`EvalBucket::KnownLibraryCodegen`] — the call originates from a +//! recognized code-generating library (`fast-json-stringify`, `ajv`, +//! `find-my-way`). Phases 2–4 (#1680/#1681/#1682) move these to build +//! time. +//! 3. [`EvalBucket::RuntimeUnknown`] — none of the above; a genuinely +//! runtime-dynamic code string. This is the only bucket Phase 0 refuses. +//! +//! Phase 0 is pure analysis + reporting: it does **not** compile, fold, or +//! evaluate anything. Buckets 1 and 2 keep their existing (placeholder) +//! lowering so the future phases that own them can swap it out without a +//! behaviour change here; only bucket 3 turns into a hard compile error. +//! +//! `PERRY_EVAL_DIAG=1` logs every classified site (package + `file:line` + +//! bucket) to stderr, so a single compile reveals which dependencies hit +//! each bucket. `PERRY_ALLOW_EVAL=1` downgrades the bucket-3 refusal back +//! to the legacy (non-functional) fall-through for a one-off build — an +//! escape hatch mirroring `#503`'s `PERRY_ALLOW_DYNAMIC_STDLIB`. + +use swc_ecma_ast as ast; + +/// Which arbitrary-code-execution surface a classified site is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvalSurface { + /// `eval(code)`. + Eval, + /// `Function(params..., body)` called without `new`. + FunctionCall, + /// `new Function(params..., body)`. + NewFunction, +} + +impl EvalSurface { + /// Human-readable call shape for diagnostics. + pub fn label(self) -> &'static str { + match self { + EvalSurface::Eval => "eval(...)", + EvalSurface::FunctionCall => "Function(...)", + EvalSurface::NewFunction => "new Function(...)", + } + } +} + +/// The classification bucket — see the module docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvalBucket { + /// Body is a compile-time-constant string (or absent). → #1679. + ConstFoldable, + /// Originates from a recognized codegen library. → #1680/#1681/#1682. + KnownLibraryCodegen, + /// Genuinely runtime-dynamic. Refused by Phase 0. + RuntimeUnknown, +} + +impl EvalBucket { + /// Short tag used in `--diag` log lines. + pub fn tag(self) -> &'static str { + match self { + EvalBucket::ConstFoldable => "const-foldable", + EvalBucket::KnownLibraryCodegen => "known-library-codegen", + EvalBucket::RuntimeUnknown => "runtime-unknown", + } + } +} + +/// npm packages whose `new Function`/`Function(...)`/`eval(...)` calls are +/// recognized as build-time-knowable code generation (the Fastify JIT +/// trio, see #1677). A call from one of these lands in +/// [`EvalBucket::KnownLibraryCodegen`] even when its body is a runtime +/// value, because the *input* to the codegen (a schema, a route table) is +/// build-time-knowable — later phases evaluate them at build time. +pub const KNOWN_CODEGEN_PACKAGES: &[&str] = &["fast-json-stringify", "ajv", "find-my-way"]; + +/// A classified `eval`/`Function` call site plus its provenance. Pure data +/// — the lowering site decides whether to refuse based on [`Self::bucket`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvalClassification { + /// Which surface (`eval` / `Function` / `new Function`). + pub surface: EvalSurface, + /// Which bucket the body argument put this site in. + pub bucket: EvalBucket, + /// Originating npm package name, or `None` for user/host source. + pub package: Option, + /// Source file the call appears in. + pub file: String, + /// 1-based line of the call, or 0 when the source line is unknown. + pub line: usize, + /// For const-foldable sites, a short preview of the body string (used + /// only in `--diag` output). `None` for the other buckets. + pub body_preview: Option, +} + +impl EvalClassification { + /// Phase 0 refuses exactly the runtime-unknown bucket. + pub fn is_refused(&self) -> bool { + self.bucket == EvalBucket::RuntimeUnknown + } + + /// `file:line` (line omitted when unknown). Built from the call's byte + /// offset against the currently-installed module source. + pub fn location(&self) -> String { + if self.line == 0 { + self.file.clone() + } else { + format!("{}:{}", self.file, self.line) + } + } + + /// `(in package `pkg`)` / `(user source)` provenance label. + pub fn provenance(&self) -> String { + match &self.package { + Some(pkg) => format!("in package `{}`", pkg), + None => "user source".to_string(), + } + } + + /// The bucket-3 refusal message: names the surface, `file:line`, the + /// originating package, and the available remedies. Includes the + /// location inline so it surfaces regardless of which command renders + /// the error (the span is also attached by `lower_bail!` for `perry + /// check`'s snippet emitter). + pub fn refusal_message(&self) -> String { + format!( + "`{surface}` is refused at compile time: {loc} ({prov}). Perry is an \ + ahead-of-time compiler — it cannot evaluate a code string built from \ + runtime data. (#1677)\n\ + \n\ + Options:\n\ + - Replace the generated function with an ordinary function or closure.\n\ + - If the body is a build-time constant string, a future release will \ + compile it natively (#1679).\n\ + - If this comes from a code-generating library, only \ + `fast-json-stringify`, `ajv`, and `find-my-way` are recognized so far \ + (#1680/#1681/#1682) — file an issue against #1677 naming the package.\n\ + - Set `PERRY_ALLOW_EVAL=1` to restore the legacy (non-functional) \ + behavior for a one-off build.", + surface = self.surface.label(), + loc = self.location(), + prov = self.provenance(), + ) + } + + /// One `--diag` log line: surface, `file:line`, provenance, bucket, and + /// (for const-foldable sites) a body preview. + pub fn diag_line(&self) -> String { + let preview = match &self.body_preview { + Some(b) => format!(" body={:?}", b), + None => String::new(), + }; + format!( + "[perry-eval-diag] {surface} @ {loc} ({prov}) -> {bucket}{preview}", + surface = self.surface.label(), + loc = self.location(), + prov = self.provenance(), + bucket = self.bucket.tag(), + ) + } +} + +/// Peel parens and return the constant string value of `expr` if it is a +/// string literal or a substitution-free template literal. `None` for any +/// other shape (a variable, concatenation, call result, …). +fn const_string_of(expr: &ast::Expr) -> Option { + let mut e = expr; + while let ast::Expr::Paren(p) = e { + e = p.expr.as_ref(); + } + match e { + ast::Expr::Lit(ast::Lit::Str(s)) => Some(s.value.as_str().unwrap_or("").to_string()), + ast::Expr::Tpl(tpl) if tpl.exprs.is_empty() => { + // A template with no `${}` substitutions is a constant. Prefer + // the cooked value (escapes resolved, WTF-8 → may be `None` for + // a lone surrogate); fall back to the raw text. + tpl.quasis.first().map(|q| { + q.cooked + .as_ref() + .and_then(|c| c.as_str()) + .map(str::to_string) + .unwrap_or_else(|| q.raw.as_str().to_string()) + }) + } + _ => None, + } +} + +/// Truncate a body preview so `--diag` lines stay readable. +fn preview(body: &str) -> String { + const MAX: usize = 48; + if body.chars().count() > MAX { + let head: String = body.chars().take(MAX).collect(); + format!("{}…", head) + } else { + body.to_string() + } +} + +/// Classify a single `eval`/`Function`/`new Function` call site. Pure +/// analysis — `body_arg` is the code-string argument (the *last* arg for +/// `Function`, the *only* arg for `eval`; `None` when the call has no +/// body argument). `byte_offset` is the call's `span.lo.0`, resolved to a +/// line against the currently-installed module source. +pub fn classify( + surface: EvalSurface, + body_arg: Option<&ast::Expr>, + source_file_path: &str, + byte_offset: u32, +) -> EvalClassification { + let package = crate::ir::package_name_for_source_path(source_file_path).map(|s| s.to_string()); + + // Bucket 1: const-foldable. A missing body argument is an empty + // (hence constant) function body, so it folds too. + let const_body = match body_arg { + Some(arg) => const_string_of(arg), + None => Some(String::new()), + }; + + let (bucket, body_preview) = if let Some(body) = &const_body { + (EvalBucket::ConstFoldable, Some(preview(body))) + } else if package + .as_deref() + .is_some_and(|p| KNOWN_CODEGEN_PACKAGES.contains(&p)) + { + // Bucket 2: recognized codegen library with a runtime-built body. + (EvalBucket::KnownLibraryCodegen, None) + } else { + // Bucket 3: genuinely runtime-dynamic. + (EvalBucket::RuntimeUnknown, None) + }; + + EvalClassification { + surface, + bucket, + package, + file: source_file_path.to_string(), + line: crate::ir::current_module_line_at(byte_offset).unwrap_or(0), + body_preview, + } +} + +/// Whether `PERRY_EVAL_DIAG` is set to a truthy value — enables per-site +/// classification logging. +pub fn eval_diag_enabled() -> bool { + env_flag("PERRY_EVAL_DIAG") +} + +/// Whether `PERRY_ALLOW_EVAL` is set — downgrades the bucket-3 refusal to +/// the legacy fall-through for a one-off build. +pub fn eval_override_enabled() -> bool { + env_flag("PERRY_ALLOW_EVAL") +} + +fn env_flag(name: &str) -> bool { + match std::env::var(name) { + Ok(v) => { + let v = v.trim().to_ascii_lowercase(); + !matches!(v.as_str(), "" | "0" | "off" | "false" | "no") + } + Err(_) => false, + } +} + +/// Log a classified site under `PERRY_EVAL_DIAG`. No-op otherwise. +pub fn report(classification: &EvalClassification) { + if eval_diag_enabled() { + eprintln!("{}", classification.diag_line()); + } +} + +/// The single decision point both lowering sites (`new Function` in +/// `expr_new`, `Function(...)`/`eval(...)` in `expr_call`) funnel through. +/// +/// Classifies the site, logs it under `PERRY_EVAL_DIAG`, and returns +/// `Err` (a span-tagged [`crate::error::LowerError`]) only for the +/// runtime-unknown bucket — unless `PERRY_ALLOW_EVAL` is set, which +/// downgrades the refusal to the legacy fall-through. `Ok(())` means the +/// caller should proceed with its existing lowering (const-foldable / +/// known-library sites keep their placeholder behaviour for Phase 0). +pub fn check_site( + surface: EvalSurface, + body_arg: Option<&ast::Expr>, + source_file_path: &str, + span: swc_common::Span, +) -> anyhow::Result<()> { + let classification = classify(surface, body_arg, source_file_path, span.lo.0); + report(&classification); + if classification.is_refused() && !eval_override_enabled() { + return Err(anyhow::Error::new(crate::error::LowerError::new( + classification.refusal_message(), + span, + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::{clear_current_module_source, set_current_module_source}; + use swc_common::{BytePos, Span}; + + fn str_lit(s: &str) -> ast::Expr { + ast::Expr::Lit(ast::Lit::Str(ast::Str { + span: Span::new(BytePos(0), BytePos(0)), + value: s.into(), + raw: None, + })) + } + + /// A non-constant expression stand-in (any shape `const_string_of` + /// can't fold) — `Invalid` needs only a span, so it dodges + /// version-specific `Ident` constructors. + fn non_const() -> ast::Expr { + ast::Expr::Invalid(ast::Invalid { + span: Span::new(BytePos(0), BytePos(0)), + }) + } + + #[test] + fn string_literal_body_is_const_foldable() { + let body = str_lit("return a + b"); + let c = classify(EvalSurface::NewFunction, Some(&body), "/app/main.ts", 0); + assert_eq!(c.bucket, EvalBucket::ConstFoldable); + assert!(!c.is_refused()); + assert_eq!(c.package, None); + assert_eq!(c.body_preview.as_deref(), Some("return a + b")); + } + + #[test] + fn absent_body_is_const_foldable() { + // `new Function()` — empty function body, trivially constant. + let c = classify(EvalSurface::NewFunction, None, "/app/main.ts", 0); + assert_eq!(c.bucket, EvalBucket::ConstFoldable); + assert_eq!(c.body_preview.as_deref(), Some("")); + } + + #[test] + fn runtime_body_in_user_source_is_runtime_unknown() { + let body = non_const(); + let c = classify(EvalSurface::Eval, Some(&body), "/app/main.ts", 0); + assert_eq!(c.bucket, EvalBucket::RuntimeUnknown); + assert!(c.is_refused()); + assert_eq!(c.package, None); + assert!(c.refusal_message().contains("user source")); + assert!(c.refusal_message().contains("eval(...)")); + } + + #[test] + fn runtime_body_in_known_codegen_package_is_known_library() { + let body = non_const(); + let path = "/proj/node_modules/fast-json-stringify/index.js"; + let c = classify(EvalSurface::NewFunction, Some(&body), path, 0); + assert_eq!(c.bucket, EvalBucket::KnownLibraryCodegen); + assert!(!c.is_refused()); + assert_eq!(c.package.as_deref(), Some("fast-json-stringify")); + } + + #[test] + fn runtime_body_in_unknown_package_is_runtime_unknown() { + let body = non_const(); + let path = "/proj/node_modules/sketchy-pkg/dist/x.js"; + let c = classify(EvalSurface::FunctionCall, Some(&body), path, 0); + assert_eq!(c.bucket, EvalBucket::RuntimeUnknown); + assert!(c.is_refused()); + assert_eq!(c.package.as_deref(), Some("sketchy-pkg")); + let msg = c.refusal_message(); + assert!(msg.contains("in package `sketchy-pkg`")); + } + + #[test] + fn known_codegen_with_const_body_prefers_const_foldable() { + // Const body wins over the package match — a literal body is + // compilable regardless of which package it lives in. + let body = str_lit("return 1"); + let path = "/proj/node_modules/ajv/dist/x.js"; + let c = classify(EvalSurface::NewFunction, Some(&body), path, 0); + assert_eq!(c.bucket, EvalBucket::ConstFoldable); + } + + #[test] + fn template_without_substitutions_is_const() { + let body = ast::Expr::Tpl(ast::Tpl { + span: Span::new(BytePos(0), BytePos(0)), + exprs: vec![], + quasis: vec![ast::TplElement { + span: Span::new(BytePos(0), BytePos(0)), + tail: true, + cooked: Some("return 7".into()), + raw: "return 7".into(), + }], + }); + let c = classify(EvalSurface::NewFunction, Some(&body), "/app/main.ts", 0); + assert_eq!(c.bucket, EvalBucket::ConstFoldable); + assert_eq!(c.body_preview.as_deref(), Some("return 7")); + } + + #[test] + fn line_resolved_from_installed_module_source() { + // Offset lands on line 3 (two newlines precede it). + set_current_module_source("a\nb\nnew Function(x)\n".to_string()); + let offset = "a\nb\n".len() as u32; + let body = non_const(); + let c = classify( + EvalSurface::NewFunction, + Some(&body), + "/app/main.ts", + offset, + ); + assert_eq!(c.line, 3); + assert_eq!(c.location(), "/app/main.ts:3"); + assert!(c.refusal_message().contains("/app/main.ts:3")); + clear_current_module_source(); + } + + #[test] + fn long_body_preview_truncated() { + let long = "x".repeat(100); + let body = str_lit(&long); + let c = classify(EvalSurface::NewFunction, Some(&body), "/app/main.ts", 0); + let p = c.body_preview.unwrap(); + assert!(p.ends_with('…')); + assert_eq!(p.chars().count(), 49); // 48 chars + ellipsis + } +} diff --git a/crates/perry-hir/src/ir/constants.rs b/crates/perry-hir/src/ir/constants.rs index 00fb96695a..46c3a119d9 100644 --- a/crates/perry-hir/src/ir/constants.rs +++ b/crates/perry-hir/src/ir/constants.rs @@ -145,6 +145,26 @@ pub fn clear_current_module_source() { CURRENT_MODULE_SOURCE.with(|c| *c.borrow_mut() = None); } +/// #1678: resolve `byte_offset` to a 1-based line number in the +/// currently-installed module source (the same `CURRENT_MODULE_SOURCE` +/// the dynamic-dispatch check uses). Returns `None` when no source is +/// installed or the offset is out of range. Used by the eval/Function +/// classifier to print `file:line` provenance in its refusal diagnostic +/// and `--diag` instrumentation without threading a `SourceMap` into +/// HIR lowering. +pub fn current_module_line_at(byte_offset: u32) -> Option { + CURRENT_MODULE_SOURCE.with(|cell| { + let borrowed = cell.borrow(); + let src = borrowed.as_ref()?; + let offset = byte_offset as usize; + if offset > src.len() { + return None; + } + // Line number = 1 + count of newlines before the offset. + Some(1 + src[..offset].bytes().filter(|&b| b == b'\n').count()) + }) +} + /// #503: look up `// @perry-allow-dynamic` near `byte_offset` in the /// currently-installed module source. Returns true if the annotation /// appears on the same line as the offending site, or on any of the diff --git a/crates/perry-hir/src/ir/mod.rs b/crates/perry-hir/src/ir/mod.rs index c4a1411a9e..c3ea0ee844 100644 --- a/crates/perry-hir/src/ir/mod.rs +++ b/crates/perry-hir/src/ir/mod.rs @@ -20,15 +20,16 @@ mod widget; // ---- constants.rs ---- pub use constants::{ clear_allow_dynamic_stdlib_packages, clear_compile_packages_override, - clear_current_module_source, current_module_has_allow_dynamic_at, determine_module_kind, - dynamic_stdlib_allowed_for_package, is_native_module, is_native_module_with_externals, - package_name_for_source_path, refuse_dynamic_stdlib_dispatch_enabled, requires_stdlib, - set_allow_dynamic_stdlib_packages, set_compile_packages_override, set_current_module_source, - set_refuse_dynamic_stdlib_dispatch, typed_array_kind_for_name, ClassId, EnumId, InterfaceId, - ModuleInitKind, ModuleKind, PosixCredentialKind, TypeAliasId, NATIVE_MODULES, - TYPED_ARRAY_KIND_FLOAT32, TYPED_ARRAY_KIND_FLOAT64, TYPED_ARRAY_KIND_INT16, - TYPED_ARRAY_KIND_INT32, TYPED_ARRAY_KIND_INT8, TYPED_ARRAY_KIND_UINT16, - TYPED_ARRAY_KIND_UINT32, TYPED_ARRAY_KIND_UINT8, TYPED_ARRAY_KIND_UINT8_CLAMPED, + clear_current_module_source, current_module_has_allow_dynamic_at, current_module_line_at, + determine_module_kind, dynamic_stdlib_allowed_for_package, is_native_module, + is_native_module_with_externals, package_name_for_source_path, + refuse_dynamic_stdlib_dispatch_enabled, requires_stdlib, set_allow_dynamic_stdlib_packages, + set_compile_packages_override, set_current_module_source, set_refuse_dynamic_stdlib_dispatch, + typed_array_kind_for_name, ClassId, EnumId, InterfaceId, ModuleInitKind, ModuleKind, + PosixCredentialKind, TypeAliasId, NATIVE_MODULES, TYPED_ARRAY_KIND_FLOAT32, + TYPED_ARRAY_KIND_FLOAT64, TYPED_ARRAY_KIND_INT16, TYPED_ARRAY_KIND_INT32, + TYPED_ARRAY_KIND_INT8, TYPED_ARRAY_KIND_UINT16, TYPED_ARRAY_KIND_UINT32, + TYPED_ARRAY_KIND_UINT8, TYPED_ARRAY_KIND_UINT8_CLAMPED, }; // ---- module.rs ---- diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index 3610229e56..4155dd55fd 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -11,6 +11,7 @@ pub mod dynamic_import; pub mod egress; pub(crate) mod enums; pub mod error; +pub mod eval_classifier; pub mod ir; pub mod js_transform; pub(crate) mod jsx; @@ -33,6 +34,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, EvalBucket, EvalClassification, EvalSurface, +}; pub use ir::*; pub use js_transform::{ fix_cross_module_native_instances, fix_local_native_instances, transform_js_imports, diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics.rs b/crates/perry-hir/src/lower/expr_call/intrinsics.rs index 761756f541..288b74d3dd 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics.rs @@ -69,6 +69,52 @@ pub(super) fn try_require_literal_bail(ctx: &LoweringContext, call: &ast::CallEx Ok(()) } +/// #1678 (Phase 0 of #1677) — classify a bare `Function(...)` / +/// `eval(...)` call. The `Function('return this')()` globalThis fold runs +/// before this (in `lower_call_inner`) and short-circuits, so its inner +/// `Function('return this')` never reaches here. +/// +/// Returns `Err` (span-tagged) only for the runtime-unknown bucket — +/// const-foldable (string-literal body) and known-codegen-library sites +/// log under `PERRY_EVAL_DIAG` and fall through to the existing lowering +/// (a bare `Function`/`eval` ident → `GlobalGet(0)` sentinel) unchanged, +/// to be picked up by later phases. `Ok(())` means proceed. +pub(super) fn check_eval_function_call(ctx: &LoweringContext, call: &ast::CallExpr) -> Result<()> { + let ast::Callee::Expr(callee_expr) = &call.callee else { + return Ok(()); + }; + let mut callee = callee_expr.as_ref(); + while let ast::Expr::Paren(p) = callee { + callee = p.expr.as_ref(); + } + let ast::Expr::Ident(ident) = callee else { + return Ok(()); + }; + let name = ident.sym.as_ref(); + let surface = match name { + "eval" => crate::eval_classifier::EvalSurface::Eval, + "Function" => crate::eval_classifier::EvalSurface::FunctionCall, + _ => return Ok(()), + }; + // A local/func/imported binding named `eval`/`Function` shadows the + // builtin — leave those alone. + if ctx.lookup_local(name).is_some() + || ctx.lookup_func(name).is_some() + || ctx.lookup_imported_func(name).is_some() + { + return Ok(()); + } + // Body argument: the only arg for `eval(code)`, the last arg for + // `Function(p1, p2, body)`. A spread in the body position yields a + // non-constant inner expr → the classifier buckets it runtime-unknown. + let body_arg = match surface { + crate::eval_classifier::EvalSurface::Eval => call.args.first(), + _ => call.args.last(), + } + .map(|a| a.expr.as_ref()); + crate::eval_classifier::check_site(surface, body_arg, &ctx.source_file_path, call.span) +} + /// Issue #76 — `embedWasm("./file.wasm")` from `perry/build` is a /// compile-time intrinsic that bakes the file's bytes directly into the /// produced binary. Resolves the path relative to the current source diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 1b2b006a19..9ea6e4505f 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -56,8 +56,8 @@ use globals::try_global_builtins; use imported_array_methods::try_imported_array_methods; use inline_array_methods::try_inline_array_methods; use intrinsics::{ - try_bare_regexp_call, try_embed_wasm, try_function_return_this, try_iife_call_rewrite, - try_native_module_method_apply_call, try_require_literal_bail, + check_eval_function_call, try_bare_regexp_call, try_embed_wasm, try_function_return_this, + try_iife_call_rewrite, try_native_module_method_apply_call, try_require_literal_bail, }; use local_array_methods::try_local_array_methods; use module_class_static::try_module_class_static; @@ -158,6 +158,10 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result R ast::Expr::Ident(ident) => { let class_name = ident.sym.to_string(); + // #1678 (Phase 0 of #1677): classify `new Function(...)` before + // it reaches the unknown-class fall-through (which silently + // lowers to a class_id=0 empty-object placeholder). The + // runtime-unknown bucket is refused with a precise diagnostic; + // const-foldable / known-codegen sites are logged under + // `PERRY_EVAL_DIAG` and keep their existing placeholder lowering + // for later phases of #1677. Skip when `Function` is shadowed. + if class_name == "Function" + && ctx.lookup_local("Function").is_none() + && ctx.lookup_func("Function").is_none() + && ctx.lookup_class("Function").is_none() + { + // Body is the last argument (`new Function(p1, p2, body)`); + // earlier args are parameter names. + let body_arg = new_expr + .args + .as_ref() + .and_then(|args| args.last()) + .map(|a| a.expr.as_ref()); + crate::eval_classifier::check_site( + crate::eval_classifier::EvalSurface::NewFunction, + body_arg, + &ctx.source_file_path, + new_expr.span, + )?; + } + // #1691: an inline `new Request(...)` / `new Response(...)` / etc. // whose result is consumed immediately (never bound to a local) // skips the var-decl detection in destructuring/var_decl.rs, so