Skip to content

Keep unloaded packages out of import() graphs and reject those imports - #1526

Merged
ctate merged 11 commits into
mainfrom
opt/npm-unloaded-dynamic
Oct 28, 2026
Merged

ctate merged 11 commits into
mainfrom
opt/npm-unloaded-dynamic

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on #1458.

Summary

A project marks packages it never loads in its package.json, as "scriptc": { "packages": { "<name>": "unloaded" } }. require() and static imports of such a package fail with Node's MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND, exactly as when the package isn't installed, and the package never joins the program. Two shapes escaped that:

  • A workspace package marked unloaded still resolved to its sources, so an import() of it compiled the package and its whole dependency graph. The workspace resolver now treats an unloaded name as missing. The import() rejects with Node's ERR_MODULE_NOT_FOUND ("Cannot find package '' imported from ").
  • A module that only import() loads, whose static import names a package Node can't resolve (unloaded or absent), failed the build with SC1010. Node resolves that module's static graph when the import() runs, and rejects the promise before any module of the graph evaluates. Preflight now runs its existing resolution walk from each module reached only through a literal import(). When the walk finds a refusal, that import() rejects with Node's message and code, and the refused graph's modules never join the compiled graph or the npm selection. The refusals inside that graph no longer fail the build.

The rejection happens on a microtask after the call, as with any import(). Code that reads the namespace after await still compiles, typed dynamically, because the namespace is never produced. Refusals in the startup graph keep their behavior: the program compiles to Node's startup crash.

Evidence

  • New npm case unloaded-dynamic-import. Both packages are missing under Node, which is the oracle:

    • an import() of an unloaded workspace package, with its namespace destructured;
    • an import() of a local module whose static import names an unloaded package.

    Each rejects with ERR_MODULE_NOT_FOUND and Node's message, and the local module's body never runs. Output is byte-identical to Node's.

  • package-shims.test.ts has a new test that installs both packages: the workspace package linked into node_modules, and an ES module package. Node loads both, which is the control. The compiled program's output is unchanged from the missing-package run.

  • Before this change, the same program failed to build with SC1010, and an installed unloaded workspace package was compiled and run.

  • The existing unloaded-optional-dependency, dynamic-import, dynamic-import-local, workspace-* and workspace-dynamic-import-deps npm cases pass. The npm, npm-static and package-shims suites and the frontend unit tests pass 1106 of 1107; the one failure, node24-url-authority, comes from the sandbox's Node 24.15 versus the pinned 24.22.

  • Zero-cost gate against Compile polyfill packages that are Node builtins as the builtins #1458's tip (8a40fb5, before its readable-stream 3.x commit, which this branch also carries): PASS. All 2502 programs (bench, self, corpus) are byte-identical; bench geomean is −0.18%.

  • The self-hosted compiler builds (identical in the gate), and self-hosting-native-driver.test.ts passes (1155 s).

  • pnpm lint, pnpm format:check and test:ts7 --baselines-only pass. Re-recording all order baselines adds only the new cases' entries.

🤖 Generated with Claude Code

cramforce and others added 3 commits October 20, 2026 07:10
The project's "scriptc": { "packages": { name: "unloaded" } } map covered
require() and static imports of installed packages. Two shapes escaped it:

- A workspace package marked unloaded still resolved to its sources, so an
  import() of it compiled the package and its whole dependency graph. The
  workspace resolver now treats it as missing, and the import() rejects
  with Node's ERR_MODULE_NOT_FOUND.
- A module only import() loads whose static import names a package Node
  cannot resolve (unloaded or absent) failed the build with SC1010. Node
  resolves that graph when the import() runs and rejects its promise
  before any module of it evaluates. Preflight now walks each such root's
  static graph; a refused graph becomes that rejection, with Node's
  message and code, and its modules never join the compiled graph or the
  npm selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 28, 2026 2:15am UTC

Comment thread packages/compiler/src/frontend/program.ts Outdated
@cramforce
cramforce requested a review from ctate October 20, 2026 16:15
Base automatically changed from opt/npm-polyfill-shims to main October 20, 2026 23:28
# Conflicts:
#	packages/compiler/src/frontend/lowering/lowerer.ts
#	packages/compiler/src/frontend/resolve.ts
#	tests/harness/package-shims.test.ts
@ctate ctate closed this Oct 26, 2026
@ctate ctate reopened this Oct 26, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 2 commits October 27, 2026 18:16
Co-Authored-By: Claude 5.5 <noreply@anthropic.com>
The lowerer keeps LoadResult.dynamicImportRejections, the import() roots
that never load. Unit records now check its size, as they check
inlineRequireCycleCalls, so the unit journal covers it.
cramforce and others added 2 commits October 27, 2026 19:05
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A module reached by an already-rejected literal import() is `covered`, but a
direct import() of that module still needs its own rejection entry: its graph
rejects when that import runs, so it must not join the compiled graph merely
because another rejected import also reaches it. Without the entry the module
is compiled and lowering fails on the unloaded binding instead of the
import() rejecting at run time as Node does.

Walk each direct root independently and extend the unloaded-dynamic-import
fixture with a direct import() of the rejected graph's dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

Addressed the one open review item.

  • program.ts:6554 — a literal import() of an already-rejected graph's dependency: fixed in 9ad0492. covered.has(root) skipped the rejection entry for a direct import() of a module inside another rejected graph, so the module was compiled and lowering failed (the reference to 'heavy' (a binding form with no lowering) is not supported yet) instead of the import() rejecting at run time as Node does. Preflight now walks each direct root independently and only skips a root that already has its own rejection entry. The unloaded-dynamic-import fixture imports the rejected graph's dependency directly and the harness asserts the dep: ERR_MODULE_NOT_FOUND rejection; reverting the fix reproduces the lowering failure.
  • Merged origin/main (3e643fb), including the lowerer dynamicImportRejections accounting check that landed on the branch.

Validation: package-shims passes plain and sanitized, and the zero-cost gate is PASS on the final tree (3281 identical, 6 both-fail across 7 shards; self-host compile time within tolerance).

🤖 Generated with Claude Code

@ctate
ctate merged commit 9df673b into main Oct 28, 2026
68 checks passed
@ctate
ctate deleted the opt/npm-unloaded-dynamic branch October 28, 2026 02:53

This branch was successfully deployed

1 active deployment
Preview — 9ad04922 Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants