From 6fc52da372e4b76388da63400f731c6e1741c070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 20 Aug 2026 07:37:10 +0200 Subject: [PATCH 1/2] fix(hir): ambient `declare function require` must not shadow the require intrinsic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A body-less `declare function require(name: string): any` was registered as an external FFI declaration. Since #8343's require_is_shadowed_by_local that registration made every require guard treat the global as shadowed, so `require("node:fs")` lowered to a call to a `require` symbol no archive defines and failed at link (Undefined symbols: "_require"). The ambient declare names the compile-time require intrinsic — skip the FFI registration for it. A `function require(...)` WITH a body (e.g. the CJS wrap's synthetic require) still shadows via register_func. Fixes #8447 --- crates/perry-hir/src/lower/lower_module_fn.rs | 14 +++++ crates/perry-hir/src/lower/tests.rs | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index adfb7b78bf..1f91aaca79 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -624,6 +624,20 @@ pub fn lower_module_full( continue; } + // #8447: `declare function require(...)` is the ambient-typing + // idiom for "the global CommonJS require exists" — it names the + // compile-time require intrinsic, not an external C symbol (no + // archive defines `require`; an FFI call to it can never link). + // Registering it as an imported func makes every require guard + // see a shadowing binding (`require_is_shadowed_by_local`, + // `try_require_literal`), which since #8343 lowered + // `require("node:fs")` to a call to that nonexistent symbol. + // A `function require(...)` WITH a body (e.g. the CJS wrap's + // synthetic require) still shadows via `register_func` below. + if func_name == "require" { + continue; + } + // No implementation exists - treat as external FFI declaration // Extract parameter types for FFI signature let param_types: Vec = fn_decl diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index b16e022c74..4aa02682ed 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -990,3 +990,55 @@ fn method_local_shadowing_the_class_name_still_wins_in_new() { "a method-scope local named after the class must still win for `new`: {body}" ); } + +/// #8447: the ambient-typing idiom `declare function require(name: string): any` +/// names the global require intrinsic — it must NOT be registered as an +/// external FFI function. That registration made every require-shadowing guard +/// (`require_is_shadowed_by_local`, `try_require_literal`) treat the global as +/// shadowed since #8343, so `require("node:fs")` lowered to a call to a +/// `require` symbol no archive defines, and every consumer failed at link +/// (`Undefined symbols: "_require"`). +#[test] +fn test_ambient_require_declare_does_not_shadow_the_intrinsic() { + let source = r#" + declare function require(name: string): any; + function probe(): string { + const fs = require("node:fs"); + return typeof fs.constants.O_RDONLY; + } + console.log(probe()); + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let dump = format!("{hir:?}"); + assert!( + !dump.contains("ExternFuncRef { name: \"require\""), + "an ambient `declare function require` must not lower calls to an \ + extern `require` symbol — nothing defines it, so linking fails: {dump}" + ); + assert!( + dump.contains("\"fs\""), + "the require(\"node:fs\") call must resolve to the fs native module: {dump}" + ); +} + +/// The counterpart (#8343's intent, unchanged): a `function require(...)` WITH +/// a body — e.g. the CJS wrap's synthetic require — is a real user binding and +/// must keep shadowing the intrinsic, so the call stays a plain user-function +/// call instead of a native-module namespace binding. +#[test] +fn test_user_require_function_with_body_still_shadows_the_intrinsic() { + let source = r#" + function require(name: string): string { return "shadowed:" + name; } + const fs = require("node:fs"); + console.log(fs); + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let dump = format!("{hir:?}"); + assert!( + !dump.contains("NativeModuleRef(\"fs\")"), + "a user `function require` with a body shadows the intrinsic — the \ + call must not be rewritten into a native-module namespace: {dump}" + ); +} From bfce762748df215f43f85215b10aebf4a4c78d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 20 Aug 2026 07:38:01 +0200 Subject: [PATCH 2/2] docs(changelog): fragment for #8452 --- changelog.d/8452-ambient-require-declare.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8452-ambient-require-declare.md diff --git a/changelog.d/8452-ambient-require-declare.md b/changelog.d/8452-ambient-require-declare.md new file mode 100644 index 0000000000..a0340285e1 --- /dev/null +++ b/changelog.d/8452-ambient-require-declare.md @@ -0,0 +1 @@ +fix(hir): an ambient `declare function require(name: string): any` no longer shadows the require intrinsic (#8447). The body-less declaration was registered as an external FFI function; since #8343's `require_is_shadowed_by_local` that made `require("node:fs")` lower to a call to a `require` symbol no archive defines, failing every consumer at link (`Undefined symbols: "_require"` — compile-smoke's `test_issue_8002_8003_thread_realm_caches`, one of parity's 27 dark-debt compile_fails). A `function require(...)` WITH a body (the CJS wrap's synthetic require, #8343) still shadows. Regression pair in `perry-hir::lower::tests`; the ambient test fails without the fix.