Skip to content

resolver: record enclosing package.json regardless of "name" field - #33883

Open
robobun wants to merge 8 commits into
mainfrom
claude/farm/34193d06/resolver-nameless-pkgjson-type
Open

robobun wants to merge 8 commits into
mainfrom
claude/farm/34193d06/resolver-nameless-pkgjson-type

Conversation

@robobun

@robobun robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #8993

Repro

mkdir repro && cd repro
printf '{"type":"commonjs"}' > package.json
printf 'globalThis.__x = 1;' > c.js
printf 'import * as N from "./c.js";\nconsole.log(Object.keys(N), N.default);\n' > index.mjs

Unbundled, Node and Bun agree that c.js is CommonJS (namespace has a default):

$ node index.mjs
[ 'default' ] {}
$ bun index.mjs
[ "default" ] {}

Bundled, bun build classifies the same c.js as ESM:

$ bun build ./index.mjs --target=node > b.js
warn: Import "default" will always be undefined because there is
      no matching export in "c.js"
$ node b.js
[] undefined

Adding an unrelated "name":"p" to the same package.json makes the bundle correct:

$ printf '{"name":"p","type":"commonjs"}' > package.json
$ bun build ./index.mjs --target=node > b2.js && node b2.js
[ 'default' ] {}

Cause

dir_info_uncached only stored a directory's enclosing_package_json when the package.json had a non-empty name (or care_about_bin_folder was set, which is the bun run path). That guard existed for bun run script discovery (#229), but enclosing_package_json is also what finalize_result reads the "type" and sideEffects fields from, so a nameless package.json lost its type in the bundler. Node's module-format rule looks only at the nearest package.json's "type"; "name" is irrelevant. A nameless {"type":"commonjs"} is common in app-root and monorepo-internal package.json files.

Removing that guard exposed a second, latent bug in finalize_result: the path_pair loop evaluates the sideEffects classification for both the primary ("module") and secondary ("main") paths of a main/module resolution, with the secondary's result overwriting the primary's. primary_side_effects_data is paired with path_pair.primary everywhere it is consumed (ParseTask::init, enqueue_entry_item, resolve_import_records); the secondary path gets its own resolve result when it is reached via require(). The name guard had been accidentally masking this for nameless package.jsons by clearing result.package_json before the second iteration, and every dce/PackageJsonSideEffectsArrayKeep* test happens to use a nameless one. A named package.json already took the same broken path on main (new dce/PackageJsonSideEffectsArrayKeepModuleNamedPackage test).

(#22211 attempted the same guard removal and was closed; this is presumably why.)

Fix

  • Always record the nearest package.json as enclosing_package_json at both dir_info_uncached sites.
  • In finalize_result, compute primary_side_effects_data on the first path_pair iteration only.

Tests

test/bundler/esbuild/packagejson.test.ts:

  • TypeCommonJSWithoutName / TypeCommonJSWithoutNameInSubdir: {"type":"commonjs"} with no name, at the root and inherited from a subdirectory; both fail before, pass after.
  • TypeCommonJSWithName: {"name":"p","type":"commonjs"} control; passes before and after.

test/bundler/esbuild/dce.test.ts:

  • PackageJsonSideEffectsArrayKeepModuleNamedPackage: the existing ...KeepModuleImplicitModule fixture with a "name" added; fails on main, passes after.
  • The four existing PackageJsonSideEffectsArrayKeep{Main,Module}* tests continue to pass.

test/cli/run/run-cjs.test.ts (runtime):

  • nameless package.json "type" governs module format: direct-run .js in a subdirectory of a nameless {"type":"commonjs"} scope, with top-level return as the observable (JSC rejects it when the file is misclassified as ESM). Two cases (with and without a named {"type":"module"} ancestor) fail before and pass after; adjacent-file and named-inner controls pass on both.

Also adds bare-import variants (dce/PackageJsonSideEffectsArrayModuleMainBareImport, GlobModuleMainBareImport, ArrayModuleMainBareImportRemove) covering the @tensorflow/tfjs-backend-cpu shape from #8993 (folded in from #36652).

dce.test.ts (74 pass), packagejson.test.ts (77 pass), default.test.ts (151 pass), bundler_cjs.test.ts, bundler_cjs2esm.test.ts, bundler_browser.test.ts and test/js/bun/resolve/ pass with no new failures.

Related: #33807 works around the nameless-package.json skip for the runtime path by walking DirInfo parents in jsc_hooks.rs; this PR fixes the resolver field it was working around.


no test proof · iteration 5 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/esbuild/dce.test.ts

@robobun

robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Aug 1st, 2026

❌ @robobun, your commit ba0c09f has 2 failures in Build #87042 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33883

That installs a local version of the PR into your bun-33883 executable, so you can run:

bun-33883 --bun

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The resolver now treats the nearest package.json, including unnamed files, as authoritative for module type and side-effects behavior. Side-effects classification occurs once, with bundler tests covering CommonJS package metadata and sideEffects array handling.

Changes

Package.json resolution

Layer / File(s) Summary
Nearest package.json selection
src/resolver/dir_info.rs, src/resolver/resolver.rs, test/bundler/esbuild/packagejson.test.ts
Resolver metadata now records unnamed nearest package.json files, and Node-targeted tests cover CommonJS packages at the root and in subdirectories.
Single side-effects classification
src/resolver/resolver.rs, test/bundler/esbuild/dce.test.ts
finalize_result limits side-effects evaluation to one applicable package classification, with a DCE regression test covering a named package and sideEffects array.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary resolver change: recording the nearest package.json regardless of its name field.
Description check ✅ Passed The description explains the cause, fix, scope, and verification results, despite not using the template headings exactly.

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Ignore package.json files without "name" property #6317 - Bun mishandles package.json files without a "name" field; this PR fixes the resolver to unconditionally record enclosing_package_json regardless of whether "name" is present

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #6317

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found — the fix looks correct and well-tested, but since enclosing_package_json feeds several resolver consumers beyond type/sideEffects (e.g. the version-specifier gate at resolver.rs:3125 and the auto-install path), a maintainer should confirm the broadened population is intended across all of them.

Extended reasoning...

Overview

This PR removes the !pkg.name.is_empty() || self.care_about_bin_folder guard at both dir_info_uncached sites in src/resolver/resolver.rs so that enclosing_package_json is always recorded for the nearest package.json, regardless of whether it has a "name" field. It also updates the doc comment on DirInfo::enclosing_package_json and adds three itBundled tests covering the root-dir path, the parent-propagation path, and a with-name control.

The stated motivation is that finalize_result reads "type" and sideEffects off enclosing_package_json, and Node's module-format rule considers only the nearest package.json's "type" — "name" is irrelevant. The PR notes this re-applies d862105 from the Zig resolver that was dropped in the Rust port.

Security risks

None identified. The change only affects which already-parsed PackageJSON object is recorded on a DirInfo; there is no new I/O, no new untrusted-input parsing, and no auth/crypto surface.

Level of scrutiny

Medium-high. The diff itself is tiny and the logic is clearly correct for the headline case ("type" classification). However, enclosing_package_json is read at ~10 sites across resolver.rs, jsc_hooks.rs, run_command.rs, and multi_run.rs. Most are neutral or improved (the bun run consumers already ran with care_about_bin_folder=true, and the jsc_hooks sites want exactly this behavior — #33807 worked around its absence). But at resolver.rs:3125, enclosing_package_json.is_some() gates the "VersionSpecifierNotAllowedHere" error on the global-cache auto-install path; that branch will now fire for nameless-package.json directories where it previously fell through. That's plausibly the right behavior (a nameless package.json is still a package boundary), and is presumably what the original Zig fix did too, but it's a behavior change not covered by the new tests.

Other factors

  • No CODEOWNERS on src/resolver/.
  • Bug-hunting system found no issues.
  • Tests are well-constructed: they exercise both write sites (own-dir and parent-propagation) plus a control, and assert observable bundle output rather than internals.
  • CI build was still in progress at review time.

Given the resolver's blast radius and the secondary consumer at line 3125, I'd like a maintainer to sign off rather than auto-approve.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Consumer audit for the broadened enclosing_package_json population:

  • run_command.rs / multi_run.rs (5 sites): bun run sets care_about_bin_folder = true (run_command.rs:607, 3182, 3610) before reading enclosing_package_json; the old guard !name.is_empty() || care_about_bin_folder already admitted nameless package.jsons on this path. No behavior change.
  • jsc_hooks.rs (4 sites): all are dir_info.package_json().or(dir_info.enclosing_package_json). package_json() is the directory's own package.json and is populated regardless of name, so the .or() only reaches enclosing_package_json when the directory itself has none and an ancestor's is being inherited. A nameless ancestor's "type" is now picked up; that's the Node rule, and it's the behavior parser: .mjs/"type":"module" is authoritative over module/exports identifier refs #33807 walks DirInfo parents manually to get.
  • resolver.rs:1671: the bundler "type"/sideEffects read this PR fixes.
  • resolver.rs:3125: the VersionSpecifierNotAllowedHere auto-install gate, reachable only with global_cache == force. The Zig resolver at d862105 has the identical check (dir_info.enclosing_package_json != null) after the same guard removal, so this matches the reference. A nameless package.json is still a package boundary, so rejecting pkg@version from inside one is consistent with the named case.

The diff is the same two-site guard removal as d862105; every consumer either already saw the broadened population (via care_about_bin_folder) or gets Node-correct "type" inheritance it was previously missing.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find issues with the change, but this broadens enclosing_package_json population across all resolver consumers and reworks the sideEffects loop in finalize_result — the same guard removal was previously attempted and closed (#22211), so a maintainer familiar with the resolver should confirm the consumer audit and the iteration-2 result.package_json interaction.

Extended reasoning...

Overview

This PR touches the module resolver (src/resolver/resolver.rs, src/resolver/dir_info.rs) and adds four bundler tests. Two native changes:

  1. Removes the !name.is_empty() || care_about_bin_folder guard at both dir_info_uncached sites so enclosing_package_json is always the nearest package.json, matching Node's module-format rule.
  2. Adds a side_effects_done latch in finalize_result so primary_side_effects_data is computed only against path_pair.primary and not overwritten by the secondary (main/module dual-package) path. This also gates the result.package_json = None clear and the first sideEffects block behind !side_effects_done, so they no longer run on iteration 2.

Security risks

None identified. No untrusted-input parsing, auth, crypto, or filesystem-write paths are touched.

Level of scrutiny

High. enclosing_package_json is read by bun run, bun build, the runtime module loader (jsc_hooks.rs), and the auto-install gate — the blast radius covers essentially every module resolution. The PR description notes that #22211 attempted the identical guard removal and was closed because it broke the PackageJsonSideEffectsArrayKeep* tests; this PR pairs the removal with the finalize_result fix that addresses that. The consumer audit posted on the PR looks thorough and the reasoning is sound, but this is exactly the kind of resolver-semantics change where a maintainer with context on the Zig→Rust port and the #229/#22211 history should sign off.

Other factors

  • The && !side_effects_done addition to the if let Some(existing) = ... block also skips the existing.name.is_empty() → result.package_json = None clear on iteration 2. In the common case (primary and secondary in the same package dir) this is a no-op, but it is a second-order behavior change beyond the sideEffects computation itself and deserves a human eye.
  • Test coverage is good: three new packagejson/TypeCommonJS* cases (fail-before/pass-after + control) and one new dce/...NamedPackage case that reproduces the latent bug on main. The description reports the full dce, packagejson, default, bundler_cjs*, bundler_browser, and test/js/bun/resolve/ suites pass.
  • No CODEOWNERS entry matches these paths.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

On the iteration-2 result.package_json interaction:

After iteration 1, result.package_json is always populated (either kept from the resolution-time value, or set via the .or_else(dir.enclosing_package_json) fallback, which is now non-null since enclosing_package_json is the nearest package.json unconditionally). The iteration-2 .or_else is therefore a no-op, so skipping the existing block on iteration 2 leaves result.package_json exactly as iteration 1 left it.

Case-by-case against main:

  • Named result.package_json: on main the name.is_empty() clear did not fire on either iteration, so result.package_json stayed set across both. Same here.
  • care_about_bin_folder: primary and secondary come from the same package.json's main/module fields; even when they live in different subdirs, the iteration-1 fallback already picked the primary's dir.enclosing_package_json, and the iteration-2 .or_else is a no-op since it is already Some. Same result.
  • Nameless result.package_json: on main iteration 2 cleared it and the (name-gated) fallback left it None, so the post-loop module_type read saw None. After this PR it is the nameless package.json. That difference is exactly the fix.

So the only case whose outcome changes is the one the PR is fixing; the other cases end up with the same result.package_json as main.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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 `@src/resolver/resolver.rs`:
- Around line 1625-1634: In the path-iteration logic, mark the primary/secondary
side-effect classification as consumed before attempting read_dir_info. Update
the state immediately after obtaining each path name and before the
read_dir_info call, then remove the later assignment so early continue paths
cannot leave side_effects_done false; apply the same adjustment to the
corresponding logic near the secondary occurrence.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8550608d-9486-498f-b364-ecb889fec49d

📥 Commits

Reviewing files that changed from the base of the PR and between eec0425 and dbfaf98.

📒 Files selected for processing (4)
  • src/resolver/dir_info.rs
  • src/resolver/resolver.rs
  • test/bundler/esbuild/dce.test.ts
  • test/bundler/esbuild/packagejson.test.ts

Comment thread src/resolver/resolver.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this changes core resolver semantics (enclosing_package_json population + the finalize_result sideEffects loop) in a way a prior attempt (#22211) got wrong — worth a human look at the iteration-2 / care_about_bin_folder interaction and the broadened consumer set.

Extended reasoning...

Overview

Two coupled resolver changes: (1) dir_info_uncached now records enclosing_package_json unconditionally at both sites, dropping the !name.is_empty() || care_about_bin_folder guard; (2) finalize_result now latches is_primary before the read_dir_info early-continue and gates both sideEffects blocks on it, so primary_side_effects_data is computed only from path_pair.primary. Doc comment updated in dir_info.rs. Four new bundler tests (three packagejson/TypeCommonJS*, one dce/PackageJsonSideEffectsArrayKeepModuleNamedPackage).

Security risks

None identified. This is module-resolution metadata plumbing; no auth, crypto, permissions, or untrusted-input parsing surface is touched.

Level of scrutiny

High. Module resolution is a critical, load-bearing path — enclosing_package_json feeds "type" classification (CJS vs ESM) and sideEffects/DCE for every bundled file, and is read by run_command.rs, jsc_hooks.rs, and the auto-install gate. The fix is a semantic broadening (nameless package.json now counts as a package boundary everywhere), and a previous attempt at exactly this guard removal (#22211) was closed after regressing sideEffects — the second half of this PR is the fix for that latent regression. That coupling is the kind of thing a maintainer familiar with the resolver should sanity-check.

Other factors

  • The PR thread contains a thorough consumer audit and an iteration-2 result.package_json case analysis from robobun; both look sound to me, but they hinge on invariants about when result.package_json is populated pre-loop and whether the .or_else(dir.enclosing_package_json) fallback is now always non-None — worth a maintainer confirming.
  • The if existing.name.is_empty() || self.care_about_bin_folder { result.package_json = None; } at resolver.rs:1674 is now only reachable on iteration 1 (gated by compute_side_effects); the care_about_bin_folder arm of that clear now behaves differently on iteration 2 vs before. The robobun analysis argues this is a no-op in practice because iteration 1's fallback already populated result.package_json, but I'd want a human to confirm that reasoning.
  • CodeRabbit's early-continue ordering issue was addressed in 312fae0 and the thread is resolved.
  • Test coverage is targeted and includes a control case; the author reports the broader bundler/resolve suites pass locally.

@robobun

robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status: the bundler and runtime test lanes touched by this diff are green; dce.test.ts, packagejson.test.ts, run-cjs.test.ts and the rest of test/bundler/ resolver tests do not appear in any failure annotation across the last several builds. The remaining red on build 87042 is unrelated and also hitting main and other concurrent PR builds:

  • test/cli/install/bun-upgrade.test.ts (Windows aarch64): "Canary builds are not available for this platform yet". Also red on main build 87044 and PR builds 87038, 87046, 87048.
  • test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts (x64-asan): JSC ExceptionScope::assertNoException() SIGABRT. Known-flaky stress test, quarantined in 9dc6c37 on main.
  • compile-windows-metadata.test.ts flaky-retry (Windows PE resource encoding, unrelated to module resolution) and the usual fetch/napi/terminal flaky-retry warnings.

None touch the resolver or module-format classification. Ready for review.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Added runtime coverage in test/cli/run/run-cjs.test.ts: direct-run .js entry in a subdirectory of a nameless {"type":"commonjs"} scope, with top-level return as the observable (JSC rejects it when misclassified as ESM). Two cases fail on main and pass with this change; adjacent-file and named-inner controls pass on both.

Also merged main (the previous CI failure was test/js/sql/postgres-binary-array-bounds.test.ts on Windows x64-baseline, connection refused, unrelated).

Comment thread test/cli/run/run-cjs.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for applying the combined-object assertion — that resolves the only nit from the last pass. No bugs found this run. I'm deferring rather than auto-approving because this changes resolver semantics on a hot path (broadens enclosing_package_json population + reworks the finalize_result per-iteration state that a prior attempt at the same guard removal tripped over), and a maintainer familiar with the resolver should sign off.

What was reviewed:

  • The two dir_info_uncached guard removals and their downstream consumers (run_command, jsc_hooks, the auto-install gate) — the audit posted in-thread checks out.
  • The finalize_result iteration-2 behavior: with compute_side_effects gating the existing block, the nameless-clear at L1661 no longer fires on the secondary; result.package_json stays as iteration 1 left it, which matches the case-by-case analysis posted earlier.
  • The is_primary capture-before-read_dir_info fix for the early-continue path.
  • New tests: bundler (packagejson/TypeCommonJS*, dce/...NamedPackage) and runtime (run-cjs.test.ts) — each has a control case and the failing cases use observable behavior (namespace shape / top-level return).
Extended reasoning...

Overview

Two changes to src/resolver/:

  1. dir_info_uncached now records enclosing_package_json unconditionally at both sites, dropping the !name.is_empty() || care_about_bin_folder guard. This aligns with Node's rule that the nearest ancestor package.json's "type" governs .js module format regardless of whether it has a "name".
  2. finalize_result gains an is_primary flag (captured and cleared before the fallible read_dir_info call) so primary_side_effects_data is computed only against path_pair.primary, not overwritten by the secondary. This latent bug was previously masked by the name guard for the nameless-package.json fixtures used in the existing DCE tests; the new dce/PackageJsonSideEffectsArrayKeepModuleNamedPackage test shows it was already broken on main for named packages.

Test coverage spans the bundler path (three packagejson/TypeCommonJS* cases + one DCE case) and the runtime path (four run-cjs.test.ts cases with controls). The doc comment on DirInfo::enclosing_package_json is updated to match.

Security risks

None identified. This is module-resolution metadata bookkeeping; no untrusted-input parsing, no filesystem writes, no auth/crypto surface.

Level of scrutiny

High. The resolver is a critical shared path — every module load and every bundle goes through it — and enclosing_package_json has multiple consumers (run_command, jsc_hooks, the bundler finalize_result, the auto-install VersionSpecifierNotAllowedHere gate). A previous attempt at the identical guard removal (#22211) was closed, and this PR itself discovered a latent overwrite bug that the guard had been masking. The finalize_result loop still contains the existing.name.is_empty() || care_about_bin_folder clear-then-.or_else-restore dance (L1661–1668), whose interaction with the new compute_side_effects gate on iteration 2 is subtle enough that the author posted a dedicated case-by-case analysis. That reasoning holds up on inspection, but this is exactly the kind of state-machine change a resolver maintainer should confirm.

Other factors

  • All prior review threads (CodeRabbit's early-continue concern, my combined-assertion nit) are resolved and applied.
  • The consumer audit in-thread is thorough and matches what I see in the code.
  • CI bundler lanes are reported green; remaining red is documented as unrelated infra flake.
  • Tests follow harness conventions (tempDir, bunEnv, concurrent pipe drain, combined-object assertion) and include controls that pass on both main and the branch.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes #8993 (@tensorflow/tfjs-core / @tensorflow/tfjs-backend-cpu being tree-shaken out of a bun build bundle).

Those packages have "main" pointing at a prebundled CJS file that is not listed in "sideEffects", while "module" points at dist/index.js which is. With a "name" field present, the secondary-path overwrite in finalize_result marked the module entry as side-effect-free and the whole package was dropped. Verified that the bun build index.ts --target bun repro from #8993 produces a working bundle on this branch.

robobun added 7 commits August 1, 2026 09:00
bun build was ignoring {"type":"commonjs"} in a package.json that had no
"name" field, so a .js file that the unbundled program (and Node) treat
as CommonJS was bundled as ESM: a namespace import came back empty and
its default member was undefined. Adding an unrelated "name" field to
the same package.json made the bundle correct.

dir_info_uncached only stored a directory's enclosing_package_json when
the package.json had a non-empty name (or care_about_bin_folder was set
for bun run). That guard was introduced for bun run script discovery,
but enclosing_package_json is also what finalize_result reads the "type"
and sideEffects fields from, so a nameless package.json lost its type in
the bundler. Node's module-format rule looks only at the nearest
package.json's "type"; "name" is irrelevant.

Always record the nearest package.json as enclosing_package_json. This
re-applies d862105, which made the same change in resolver.zig but
was not carried into the Rust port.
The path_pair loop in finalize_result evaluates the sideEffects
classification for both the primary ("module") and secondary ("main")
paths of a main/module resolution, with the secondary's result
overwriting the primary's. primary_side_effects_data is paired with
path_pair.primary everywhere it is consumed (ParseTask::init,
enqueue_entry_item, resolve_import_records); the secondary path gets
its own resolve result if it is ever reached via require().

The previous commit exposed this by keeping result.package_json
populated on the second iteration for a nameless package.json, where
it was previously cleared and the second-iteration sideEffects write
skipped. A named package.json already took the same broken path (new
dce/PackageJsonSideEffectsArrayKeepModuleNamedPackage test fails on
main).
If read_dir_info failed for the primary path, the continue skipped the
latch update and the secondary iteration would still compute
primary_side_effects_data. Capture the first-iteration flag before the
early exit so the secondary is never treated as primary.
…ubdir

Exercises the runtime face of the enclosing_package_json name gate: a .js
entry in a subdirectory of a nameless {"type":"commonjs"} scope was being
classified by the next named ancestor instead. The top-level-return body
makes the misclassification observable as a SyntaxError from JSC.

Two fail-before cases (with and without a named outer {"type":"module"}
ancestor) and two controls (adjacent file; inner package.json has a name).
@robobun
robobun force-pushed the claude/farm/34193d06/resolver-nameless-pkgjson-type branch from 9f88494 to 3f9f1e0 Compare August 1, 2026 09:28
Comment thread src/resolver/dir_info.rs Outdated
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (was 358 commits behind), trimmed the flagged comments, and folded in the bare-import sideEffects tests from #36652 covering the @tensorflow/tfjs-backend-cpu shape (#8993).

Locally: dce.test.ts 82 pass / 0 fail, packagejson.test.ts + run-cjs.test.ts 92 pass / 0 fail. All four ModuleMainBareImport / ArrayKeepModuleNamedPackage tests fail on system bun and pass with the fix.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this is a resolver semantics change (enclosing_package_json population + finalize_result iteration ordering) where a prior attempt (#22211) was reverted, so it's worth a human sign-off.

What was reviewed:

  • The two name.is_empty() guard removals in dir_info_uncached against the consumer audit (run_command sets care_about_bin_folder, jsc_hooks reads via .or(), auto-install gate at 3125) — no unintended broadening found.
  • The is_primary latch in finalize_result: confirmed it clears before read_dir_info so an early continue on primary can't let secondary write primary_side_effects_data; iteration-2 skip of the name.is_empty() clear is a no-op because .or_else at 1611 sees a populated result.package_json from iteration 1.
  • Tests: bundler + runtime coverage for both the nameless-type and the sideEffects-overwrite class; the four new dce tests exercise named packages (the shape #22211 broke).
Extended reasoning...

Overview

Two coupled resolver fixes plus tests:

  1. dir_info_uncached (two sites): drop the !name.is_empty() || care_about_bin_folder guard so enclosing_package_json records the nearest package.json unconditionally. This is what finalize_result reads "type" and sideEffects from; Node's module-format rule ignores "name".
  2. finalize_result: latch is_primary before read_dir_info and gate both sideEffects blocks on it, so primary_side_effects_data is computed only for path_pair.primary. The old code overwrote it with the secondary ("main") path's classification, which the name-guard had been accidentally masking for nameless packages.

Tests: 3 bundler cases in packagejson.test.ts (nameless type:commonjs at root/subdir + named control), 4 dce cases covering the named-package sideEffects overwrite and the tfjs bare-import shape from #8993, and 4 runtime cases in run-cjs.test.ts using top-level return as the CJS-vs-ESM observable.

Security risks

None. Module-format classification and tree-shaking metadata; no untrusted input parsing, auth, or filesystem-write surface changes.

Level of scrutiny

High. The resolver is on every import path for both bun build and bun run, and enclosing_package_json fans out to ~10 consumers. A semantically similar earlier attempt (#22211) was closed — this PR explains why (the sideEffects overwrite it exposed) and fixes that too, but that history is exactly why a maintainer should confirm the iteration-2 reasoning: on secondary the existing.name.is_empty() || care_about_bin_folder clear at line 1606 no longer runs, and correctness relies on the .or_else(dir.enclosing_package_json) at 1611 being a no-op because iteration 1 always leaves result.package_json populated (which in turn depends on fix #1). The chain holds, but it's the kind of coupled invariant a resolver owner should eyeball.

Other factors

  • Consumer audit already posted in-thread and re-checked here: bun run sets care_about_bin_folder before every read, so no behavior change there; jsc_hooks.rs gains Node-correct type inheritance (which #33807 currently walks parents to work around); the auto-install VersionSpecifierNotAllowedHere gate now fires for nameless-package.json boundaries too, matching the reference Zig resolver.
  • CodeRabbit's early-continue concern was addressed (latch moved before read_dir_info); my earlier nit on combined-object subprocess assertions was applied.
  • CI: bundler/resolver lanes green across five builds; the one remaining red (test-net-connect-memleak.js on Alpine x64) is a known flake tracked separately and unrelated to this diff.
  • The PR description notes #33807 works around the same field at the runtime layer; a maintainer may want to decide whether that workaround should be unwound in a follow-up.

Jarred-Sumner pushed a commit that referenced this pull request Sep 3, 2026
…ed or not (#41232)

### Problem
- Regression on main from #41150. No release has it. `bun build` reads
`"type"` from the wrong package.json, so it misses a `{ "type": "module"
}` that has no `"name"`. Two common places: a project root, and a dual
package's `dist/esm/package.json`.
- A `.js` or `.ts` file there gets `exports.default` for the default
import of a CommonJS module with `__esModule`. Node, esbuild and bun
1.4.0 give the whole `module.exports`. The bundle has
`__toESM(require_tsdep())`, without `, 1`.
- Cause: `finalize_result` (`src/resolver/resolver.rs:1669`) read
`"type"` from the package root, or else from `enclosing_package_json`.
`dir_info_uncached` (`src/resolver/resolver.rs:6357`) sets that field
only for a named package.json (#229).

### Fix
- `DirInfo` gets `package_json_for_module_type`: the nearest
package.json in the directory or above it, named or not.
`finalize_result` reads `"type"` from it for the primary path. The
extension still wins.
- Correct because esbuild uses this rule, and Node ignores `"name"` too.
- Only the bundler reads `Result.module_type`. `enclosing_package_json`
does not change. The four runtime lookups in `src/runtime/jsc_hooks.rs`
are out of scope.
- Verified: `test/bundler/bundler_cjs.test.ts`, 10 new cases, 9 fail on
main. Self-reviewed: 3 concerns raised, 3 addressed. Other suites in
Notes.

### Background
- `__toESM(mod, isNodeMode)` builds the ESM view of a CommonJS module.
With `, 1` (Node mode), `default` is the whole `module.exports`. Without
it, `default` is `mod.default` when `__esModule` is set.
- `DirInfo` is the resolver's cached record for one directory. Its
"enclosing" fields come from the parent.
- Open PRs in this area: #33883, #33807, #33890, #40940. This PR
supersedes none. Notes cover #40940.

<details><summary>Notes</summary>

Found by comparing `bun build` on main with bun 1.4.0, Node 26 and
esbuild 0.25. No issue is open for it.

Repro for the dual-package face:

```sh
D=$(mktemp -d); cd $D; mkdir -p node_modules/pkg/dist/esm node_modules/tsdep
echo '{"name":"tsdep","version":"1.0.0","main":"index.js"}' > node_modules/tsdep/package.json
echo 'Object.defineProperty(exports,"__esModule",{value:true}); exports.default=function styled(){}; exports.css="css";' > node_modules/tsdep/index.js
echo '{"name":"pkg","version":"1.0.0","main":"./dist/esm/index.js"}' > node_modules/pkg/package.json
echo '{"type":"module"}' > node_modules/pkg/dist/esm/package.json
echo 'import styled from "tsdep"; export const seen = typeof styled + "/" + typeof styled.default;' > node_modules/pkg/dist/esm/index.js
echo 'import { seen } from "pkg"; console.log(seen);' > app.mjs
node app.mjs                                                              # object/function
bun build ./app.mjs --target=node --outfile=out.mjs && node out.mjs       # main: function/undefined, this PR: object/function
```

For the project-root face, put `{ "type": "module" }` (no `"name"`) in
the project's package.json and bundle a `.js` file that imports `tsdep`.

Faces of the bug on main. Each has a test:

- A project package.json with `"type"` and no `"name"` (case 58).
- The nested marker reached through `"main"`, `"module"` or a relative
path (cases 53, 55, 56). Through an exports map it worked, because
`handle_esm_resolution` reads the file's own directory.
- A nested package.json with a `"name"`, reached through `"main"` (case
54). `result.package_json` was the package root, so the nested file was
not read at all.
- A file in a subdirectory of the marker (case 57).

Why a new field instead of widening `enclosing_package_json`: that field
also names the package for `sideEffects`, the auto-install version gate
and `bun run` script discovery. #33883 widens it for every consumer and
had to rework the `sideEffects` loop in `finalize_result` to keep the
DCE tests passing.

Four cases pin the lookup rule. Each result matches esbuild 0.25.1:

- Case 59: a nameless `{ "type": "commonjs" }` below a `"type":
"module"` package wins, because it is the nearest.
- Case 60: a nearest package.json without `"type"` is the scope. The
lookup does not continue to a typed package root, so the importer is not
ESM by type. Main read the root's `"type"` here. Node prints
`object/function` for this shape, but only because it detects ESM syntax
in a file with no `"type"`. #41150 chose the esbuild rule for such
files.
- Case 61: the lookup does not stop at a `node_modules` directory. A
package without a package.json of its own takes the `"type"` above it.
Node prints the same result.
- Case 62: only the primary path decides. With the default target,
`"module"` is the primary path and `"main"` is the fallback for
`require()`. The fallback's package.json does not count. esbuild has the
same check. The case fails when the check is removed.

Overlap with #40940: it adds a field with the same name, but its lookup
stops at `node_modules`, and the runtime reads it too. If #40940 lands
after this PR, it must choose one rule for the field. Case 61 pins the
crossing for the bundler. Node's stop can still apply at the runtime
read sites. #40940 also calls the lookup for the fallback path, which
case 62 rejects.

Out of scope: the runtime's four lookups (`src/runtime/jsc_hooks.rs`
lines 1474, 2972, 3220 and 4071) keep
`package_json().or(enclosing_package_json)`. So `bun run` still skips a
nameless package.json above the file's own directory. That gap predates
#41150. For this import, `bun run` 1.4.1 gives `exports.default` for
every importer, even `.mjs`.

Self-review, the three concerns and what changed:

- Document the `node_modules` rule on the field. Done in
`src/resolver/dir_info.rs`.
- Add a default-target case with both `"main"` and `"module"`. That is
case 62.
- Say in this body that no release has the bug, lead with the
project-root face, and name the runtime lookups that stay.

Suites run with the fix on a debug ASAN build, after a rebase on main:
`bundler_cjs` (62), `esbuild/packagejson`, `esbuild/dce`,
`esbuild/default`, `bundler_cjs2esm`, `bundler_npm`, `bundler_edgecase`,
`bundler_regressions`, `bundler_splitting`, `bundler_barrel`,
`cli/run/run-cjs`, `test/js/bun/resolve`. All pass except the second
case of `test/js/bun/resolve/load-same-js-file-a-lot.test.ts`. It times
out at 5 s on this build with and without this change (back-to-back runs
on the same machine).
</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_cjs.test.ts
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs.test.ts:
(pass) bundler > cjs/__toESM_import_syntax_with_esModule [945.38ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [445.45ms]
(pass) bundler > cjs/__toESM_import_syntax_function [371.94ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [387.72ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [361.78ms]
(pass) bundler > cjs/__toESM_import_syntax_namespace [367.76ms]
(pass) bundler > cjs/__toESM_target_node [455.64ms]
(pass) bundler > cjs/__toESM_target_browser [413.38ms]
(pass) bundler > cjs/__toESM_target_bun [455.22ms]
(pass) bundler > cjs/__toESM_format_esm [462.40ms]
(pass) bundler > cjs/__toESM_format_cjs_with_import [377.93ms]
(pass) bundler > cjs/__toESM_mjs_reexport [431.60ms]
(pass) bundler > cjs/__toESM_mjs_reexport_with_esModule [432.63ms]
(pass) bundler > cjs/__toESM_deep_reexport_chain [368.70ms]
(pass) bundler > cjs/__toESM_reexport_with_rename [443.84ms]
(pass) bundler > cjs/__toESM_default_prop
... (truncated)

release without fix: 20 FAILED
bun test v1.4.1-canary.1 (a6c4cc2)

test/bundler/bundler_cjs.test.ts:
runtime failed file: /tmp/bun-build-tests/bun-4t1lr0/cjs/__toESM_import_syntax_with_esModule/out.js
stdout output:
{"__esModule":true,"default":{"value":"default export"},"named":"named export"}
---
expected stdout:
{"value":"default export"}
---
1913 |               console.log(`---`);
1914 |               console.log(`expected ${name}:`);
1915 |               console.log(expected);
1916 |               console.log(`---`);
1917 |             }
1918 |             expect(result).toBe(expected);
                                  ^
error: expect(received).toBe(expected)

Expected: "{"value":"default export"}"
Received: "{"__esModule":true,"default":{"value":"default export"},"named":"named export"}"

      at <anonymous> (/workspace/bun/test/bundler/expectBundled.ts:1918:28)
(fail) bundler > cjs/__toESM_import_syntax_with_esModule [29.17ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [11.71ms]
(pass) bundler > cjs/__toESM_import_syntax_function [9.90ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [9.43ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [9.97ms]
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_cjs.test.ts
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs.test.ts:
(pass) bundler > cjs/__toESM_import_syntax_with_esModule [1024.06ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [473.45ms]
(pass) bundler > cjs/__toESM_import_syntax_function [489.71ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [494.25ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [388.89ms]
(pass) bundler > cjs/__toESM_import_syntax_namespace [381.97ms]
(pass) bundler > cjs/__toESM_target_node [389.92ms]
(pass) bundler > cjs/__toESM_target_browser [453.17ms]
(pass) bundler > cjs/__toESM_target_bun [481.14ms]
(pass) bundler > cjs/__toESM_format_esm [415.53ms]
(pass) bundler > cjs/__toESM_format_cjs_with_import [376.77ms]
(pass) bundler > cjs/__toESM_mjs_reexport [451.67ms]
(pass) bundler > cjs/__toESM_mjs_reexport_with_esModule [380.06ms]
(pass) bundler > cjs/__toESM_deep_reexport_chain [453.02ms]
(pass) bundler > cjs/__toESM_reexport_with_rename [430.08ms]
(pass) bundler > cjs/__toESM_default_pro
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     822e3b2
  features     baseline

23 deps, 131 codegen, 1172 objects in 647ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 25 installs across 62 packages (no changes) [10.00ms]
[2/1244] gen bindgenv2
[3/1244] gen ErrorCode+*.h
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 1 install across 2 packages (no changes) [3.00ms]
[5/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1217] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[7/1217] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[8/1217] fetch tinycc
[tinycc] up to date
[9/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 111 installs across 104 packages (no changes) [15.00
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/resolver/dir_info.rs         |   9 ++
 src/resolver/resolver.rs         |  20 +++--
 src/resolver/result.rs           |  15 +---
 test/bundler/bundler_cjs.test.ts | 182 ++++++++++++++++++++++++++++++++++++++-
 4 files changed, 207 insertions(+), 19 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                              reads  edits  tests
src/resolver/dir_info.rs              3      4     33
src/resolver/resolver.rs              8      5     34
src/resolver/result.rs                1      1     33
test/bundler/bundler_cjs.test.ts      3     11     33
```

</details>

<!-- robobun:evidence:end -->

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.build does not seem to include all imports

1 participant