Opt-in node_modules resolution
Date: 2026-08-20
Area: modules, security, cli
Related: ADR 0103, ADR 0108
Context
import "zod" failed with Cannot resolve bare module specifier "zod". The
only way to load an npm package was to name every one of its files by hand:
--alias zod=./node_modules/zod/index.js, repeated for each transitive
dependency, since the packages a package imports are themselves bare. A real
chain — zod -> tldts -> tldts-core — needed three aliases that a user had
to discover by reading each package.json, and the aliases broke whenever a
dependency's entry point moved.
The refusal was not an accident. VISION states that
GocciaScript does not target Node host compatibility, and the sandbox-first
posture means the engine should not read directory trees the script never
named. Resolving node_modules by default would give every script an ambient
lookup path reaching to the filesystem root.
So the question was not whether bare specifiers can resolve, but whether the capability can be granted deliberately, and how much of Node's resolver has to come with it.
Decision
Bare-specifier resolution against node_modules exists, is off by default, and
is a subset of Node's ESM resolver.
- Capability, not a default.
--allow-node-modules(and the matching config key) grants it on the shared CLI hosts. Without it the pre-existing refusal message is unchanged, so the default profile gains no ambient authority. An optional value —--allow-node-modules=<dir>— is a ceiling: the walk still starts at the importing file, so nestednode_modulesstill resolve, but nothing above<dir>is ever probed. TGocciaSandboxModuleResolverstays sealed. Its filesystem is a seeded in-memory image with no ambient host access, so there is no ancestor tree to walk and no opt-in is offered. The two resolvers share the refusal message constant so they cannot drift apart in wording.- The supported surface is what packages actually ship. The
exportsmap with string targets, condition objects, arrays,nullblocks, and*patterns ranked by Node'sPACKAGE_EXPORTS_RESOLVEspecificity; then the legacy entry fields. Theimportsmap, self-reference, andnode:builtins are out. - A resolved file may not leave the package it was found in. Node's
PACKAGE_TARGET_RESOLVEvalidation is enforced — no.,.., ornode_modulessegment in a subpath, a pattern's star value, or anexportstarget, and everyexportsstring target must start with./— and the final expanded candidate is checked for containment inside the package directory as well. The ceiling above bounds whichnode_modulesmay be consulted; without the package boundary it would bound nothing, because a..in a specifier or a target would walk straight back out. On the legacy no-exportspath this is stricter than Node, which resolves the subpath as a URL against the package and lets it climb. - Only the
importanddefaultconditions are selected. Node's default ESM condition set is["node", "import"].nodeis dropped deliberately: anodebranch leads to CommonJS or to Node built-ins, neither of which this engine has. - The
modulefield is preferred overmain, which Node does not do. Node ignoresmoduleentirely. GocciaScript loads only ES modules, and the usual shape of anexports-less package is a CommonJSmainbeside an ES modulemodule. Readingmainfirst would refuse packages that ship a usable ES module build.exports, when present, still short-circuits both. - CommonJS is refused by name, not by parse failure. A resolved file is
classified by extension (
.mjs/.mtsand.cjsare decisive), then by the manifest's"type", then by scanning the source for CommonJS markers with no ES module markers. A CommonJS file raisesEModuleIsCommonJS— a subclass of the resolver's not-found exception, so it flows through the existing rewrapping path and stays catchable fromimport(). - The grant is audited. Applying the option emits one
modules.node-modulescapability-audit event per engine, with the effective ceiling as its subject and an empty subject when the walk is unbounded. It is a configuration-time host decision, so individual resolutions emit nothing. - The refusal message obeys ADR 0108. It names the file
package-relatively (
index.js), never by expanded host path; the absolute path travels inResolvedCandidatePathto host reporters only.
Consequences
An npm dependency chain now loads with one flag and no aliases, which is what
makes third-party ES module packages usable at all. The flag is the whole
boundary: anything that can pass it can also pass --alias, so this grants no
authority a host did not already have, but it does widen what a single
grant reaches — every ancestor node_modules rather than one named file. The
ceiling form exists for hosts that want the narrower grant.
Every boundary failure surfaces as the same Module not found: "<specifier>"
refusal rather than a distinct diagnostic. That is deliberate under ADR 0108 —
a probing script learns nothing about the host from the message — but it does
mean a package author who mis-writes an exports target sees the same text as
someone importing a path that was never exported.
The module-field preference means GocciaScript and Node resolve the same
exports-less package to different files. That is a real divergence, not a
bug, and it is the reason a differential suite can only gate the shapes both
runtimes agree on: scripts/differential/m-nodemods.test.js is bun-gated, and
the two goccia-only behaviours live in n-nodemods.goccia.test.js.
The CommonJS classifier reads module source at resolution time and decides by
heuristic. A file with both require and export is read as an ES module,
which is right for interop shims and wrong for nothing seen so far; a file that
declares "type": "module" is never scanned. The cost is one extra read per
node_modules resolution, paid only when the capability is on.
None of this is a step toward Node host compatibility. CommonJS is refused
rather than deferred, node: specifiers stay unresolvable, and the resolver
reads five fields out of package.json.