Summary
An optional require() — guarded by a runtime check and wrapped in try/catch, for a module that may legitimately not exist — is treated as an unresolvable namespace import and hard-fails the build, but only when the containing file has at least one export.
Node and Bun never evaluate the call when the guard is false, and the catch swallows the failure if they do.
Repro
// a.ts
export const X = 1;
let B: any = null;
try { B = require("./missing-generated").DATA; } catch {}
console.log("ok", B === null, X);
$ perry a.ts -o out
Collecting modules...
Error: Could not resolve namespace import `import * as ... from "./missing-generated"` in a.ts (...).
Perry has no stdlib bindings for this module path, so the namespace would compile to an empty object
...
Remove the export line and the same file only warns and builds:
Warning: Could not resolve import './missing-generated' from a.ts
A bare package specifier (require("some-uninstalled-pkg")) also only warns. The hard error is specific to an unresolvable relative specifier in a file that is a real ES module.
Why this looks wrong
crates/perry-hir/src/lower/expr_call/intrinsics/require.rs deliberately lets a require(...) inside a try fall through rather than bailing — see the ctx.optional_require_try_depth == 0 condition and the comment about optional native addons. But the module collector's #629 namespace hard-error in crates/perry/src/commands/compile/collect_modules.rs (~line 1578) fires anyway, so the intent is defeated for this shape.
The #629 rationale is sound for a genuine import * as ns from "..." — an empty namespace silently no-ops. It doesn't hold for a require the program has already guarded and wrapped in try/catch: the author has explicitly said "this may not exist."
Suggested fix: don't apply the namespace hard-error to imports synthesized from a require() that was inside a try (the collector would need the same signal optional_require_try_depth already computes), and warn instead.
Impact
This is the first thing anyone hits compiling the Milo compiler (https://github.com/milo-language/milo) with Perry, and it reads like "Perry cannot build this project":
// src/stdlibBundle.ts — std/ on disk wins; the bundle is a shipped-binary fallback
let BUNDLE: Map<string, string> | null = null;
try {
if (!existsSync(resolve(STDLIB_DIR, "std"))) BUNDLE = require("./stdlib-bundle").STDLIB;
} catch {}
src/stdlib-bundle.ts is gitignored and generated by bun run scripts/bundle-stdlib.ts, so in a fresh clone it does not exist. Perry aborts before compiling anything. The actual fix for that project is to run the generator first — but the error gives no hint that a missing optional file is the cause, and the three suggested remedies (switch to named imports / remove the import / add to perry-stdlib) are all wrong for this shape.
Same pattern appears twice more in that codebase (src/resolver.ts, src/fmtbin.ts), and it is a common "optional generated/embedded asset" idiom generally.
Environment
- perry 0.5.1239, macOS arm64
Summary
An optional
require()— guarded by a runtime check and wrapped intry/catch, for a module that may legitimately not exist — is treated as an unresolvable namespace import and hard-fails the build, but only when the containing file has at least oneexport.Node and Bun never evaluate the call when the guard is false, and the
catchswallows the failure if they do.Repro
Remove the
exportline and the same file only warns and builds:A bare package specifier (
require("some-uninstalled-pkg")) also only warns. The hard error is specific to an unresolvable relative specifier in a file that is a real ES module.Why this looks wrong
crates/perry-hir/src/lower/expr_call/intrinsics/require.rsdeliberately lets arequire(...)inside atryfall through rather than bailing — see thectx.optional_require_try_depth == 0condition and the comment about optional native addons. But the module collector's #629 namespace hard-error incrates/perry/src/commands/compile/collect_modules.rs(~line 1578) fires anyway, so the intent is defeated for this shape.The #629 rationale is sound for a genuine
import * as ns from "..."— an empty namespace silently no-ops. It doesn't hold for arequirethe program has already guarded and wrapped intry/catch: the author has explicitly said "this may not exist."Suggested fix: don't apply the namespace hard-error to imports synthesized from a
require()that was inside atry(the collector would need the same signaloptional_require_try_depthalready computes), and warn instead.Impact
This is the first thing anyone hits compiling the Milo compiler (https://github.com/milo-language/milo) with Perry, and it reads like "Perry cannot build this project":
src/stdlib-bundle.tsis gitignored and generated bybun run scripts/bundle-stdlib.ts, so in a fresh clone it does not exist. Perry aborts before compiling anything. The actual fix for that project is to run the generator first — but the error gives no hint that a missing optional file is the cause, and the three suggested remedies (switch to named imports / remove the import / add to perry-stdlib) are all wrong for this shape.Same pattern appears twice more in that codebase (
src/resolver.ts,src/fmtbin.ts), and it is a common "optional generated/embedded asset" idiom generally.Environment