fix(compile): keep an optional try-wrapped require as a runtime require (#6873) - #6878
Conversation
…re (#6873) `try { x = require("./generated") } catch {}` aborted the build with "Could not resolve namespace import" whenever the module was not on disk — the shape every "optional generated/embedded asset" idiom uses. transform_static_literal_requires hoists literal requires to a top-level `import * as <tmp>`, which discards both the runtime guard and the catch, and #629 makes an unresolvable namespace import a hard error. The #629 rationale (an empty namespace silently no-ops and the user never learns why) does not apply here: the author explicitly said this may not exist, and Node/Bun either skip the call or swallow the throw. A specifier is OPTIONAL when every one of its call sites is inside a `try`. An optional specifier that does not resolve on disk is no longer hoisted — the call stays a runtime `require`, which is exactly what bare package specifiers already do and reproduces Node's behavior (throws, caught, binding keeps its prior value; verified byte-identical against `node --experimental-strip-types`). Deliberately narrow: - optional AND resolvable still hoists, so a module that IS present is compiled in and loads (Perry's CJS-interop extension; plain node-ESM cannot require at all), - one unguarded call site makes the specifier load-bearing again, - `try` is matched as a keyword, so `retry {` does not qualify. Detection runs over the comment/string-masked source, so a miss either way is safe: a false negative keeps today's hoisting, a false positive only downgrades an unresolvable module to a runtime require. Found compiling the Milo compiler, whose src/stdlib-bundle.ts is gitignored and generated by a build step. A fresh clone could not be compiled at all, and the error's three suggested remedies (named imports / remove the import / add to perry-stdlib) were all wrong for this shape. With this change a bundle-less checkout compiles cleanly.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe compiler detects fully try-wrapped static ChangesOptional require compilation
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant ModuleCollector
participant StaticRequireTransformer
participant Filesystem
ModuleCollector->>StaticRequireTransformer: pass source, compile packages, and module_dir
StaticRequireTransformer->>StaticRequireTransformer: classify require call sites inside try blocks
StaticRequireTransformer->>Filesystem: check relative or absolute specifier resolution
Filesystem-->>StaticRequireTransformer: resolved or unresolved module
StaticRequireTransformer-->>ModuleCollector: hoisted import or runtime require
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
crates/perry/src/commands/compile/collect_modules/static_require_transform.rs (1)
227-248: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
EXTENSIONSomits.json.An extensionless optional require that resolves to a sibling
.jsonfile (e.g.require("./config")→config.json) won't be detected as resolvable, sinceEXTENSIONSonly lists TS/JS variants. The literal-extension case (require("./config.json")) is already covered by thebase.is_file()check above, so impact is narrow, but worth aligning with the real resolver's extension set if it includes JSON.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry/src/commands/compile/collect_modules/static_require_transform.rs` around lines 227 - 248, The relative_specifier_resolves function must recognize extensionless sibling and index JSON files when matching the resolver’s supported extensions. Add json to its EXTENSIONS set while preserving the existing base.is_file check and non-relative specifier behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@crates/perry/src/commands/compile/collect_modules/static_require_transform.rs`:
- Around line 26-44: Update the optional classification loop around
literal_require_call_re to iterate the raw source only after filtering each
match with masked_source, matching the hoisting loop’s blank-span check. Ignore
captures whose call span is masked out as a comment or string, while preserving
classification for real require calls and the existing is_inside_try_block
behavior; add a regression test covering a commented specifier before a
try-guarded call if tests are available.
---
Nitpick comments:
In
`@crates/perry/src/commands/compile/collect_modules/static_require_transform.rs`:
- Around line 227-248: The relative_specifier_resolves function must recognize
extensionless sibling and index JSON files when matching the resolver’s
supported extensions. Add json to its EXTENSIONS set while preserving the
existing base.is_file check and non-relative specifier behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1b72f45a-880e-4786-ba70-21e263cb9ede
📒 Files selected for processing (3)
changelog.d/6873-optional-require-try.mdcrates/perry/src/commands/compile/collect_modules.rscrates/perry/src/commands/compile/collect_modules/static_require_transform.rs
…ng (#6873) The optional-classification loop iterated raw-source captures without the masked-span filter the hoisting loop applies, so a `require("./x")` mention that exists only inside a comment or a string literal counted as a real call site. If that phantom match sat outside a `try`, `*all_in_try &= in_try` flipped the specifier to mandatory and the hoist reintroduced the very #6873 hard error this branch removes — for any file that happens to reference the same path in a comment. Apply the same blank-span check the hoisting loop uses. Regression test asserts a commented mention plus a quoted mention, both outside any try, do not defeat the classification. Verified it fails without the filter and passes with it. Reported by CodeRabbit on #6878.
|
Good catch — valid, and fixed in badd1a6. The classification loop iterated raw-source captures without the masked-span filter the hoisting loop applies, so a Applied the suggested filter, and added |
Fixes #6873.
Problem
The "optional generated/embedded asset" idiom aborted the build:
transform_static_literal_requireshoists literal requires to a top-levelimport * as <tmp>, discarding both the runtime guard and thecatch, and#629 makes an unresolvable namespace import a hard error.
The #629 rationale — an empty namespace silently no-ops and the user never
learns why — does not hold here. The author explicitly said this may not
exist, and Node/Bun either never evaluate the call or swallow the throw.
Worse, the error's three suggested remedies (switch to named imports / remove
the import / add to perry-stdlib) are all wrong for this shape.
Fix
A specifier is optional when every one of its call sites is inside a
try.An optional specifier that does not resolve on disk is no longer hoisted — the
call stays a runtime
require, which is exactly what bare package specifiersalready do today, and reproduces Node: it throws, the catch fires, the binding
keeps its prior value.
Deliberately narrow:
compiled in and loads. (Perry supports
requirein ESM-ish sources as aCJS-interop extension; plain node-ESM cannot
requireat all.)and it keeps being hoisted.
tryis matched as a keyword —retry {does not qualify.Detection runs over the comment/string-masked source, so a miss either way is
safe: a false negative keeps today's hoisting, and a false positive only
downgrades an unresolvable module to a runtime require — which is Node's
behavior anyway.
Verification
Missing-module case is byte-identical to
node --experimental-strip-types:Four new unit tests in
static_require_transform: missing optional require isleft alone, resolvable optional require still hoists (via a
tempfiledir),a specifier also used outside
trystays hoisted, andretry {does not opena try block. All 11 tests in that module pass.
End to end: with
src/stdlib-bundle.tsremoved (its real gitignored state in afresh clone), the Milo compiler now compiles cleanly — exit 0, no error and no
unresolved-import warning, because nothing is synthesized at all.
How this was found
Compiling the Milo compiler (https://github.com/milo-language/milo). Its
src/stdlib-bundle.tsis gitignored and produced by a build step(
bun run scripts/bundle-stdlib.ts), so a fresh clone could not be compiledwith Perry at all — the first thing anyone hits, and it reads like "Perry
cannot build this project" rather than "you skipped a build step". The same
pattern appears twice more in that codebase (
src/resolver.ts,src/fmtbin.ts).Summary by CodeRabbit
require()calls insidetry/catchwhen the module specifier can’t be resolved.require()calls.try { ... }regions, including theretry { ... }non-matching edge case.trydetection edge cases.