Skip to content

compile: optional require() inside try/catch hard-fails as an unresolved namespace import (only in files with exports) #6873

Description

@proggeramlug

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions