Skip to content

resolver: throw ERR_INVALID_PACKAGE_CONFIG for malformed package.json - #33890

Closed
robobun wants to merge 12 commits into
mainfrom
farm/7f9b0215/invalid-package-config
Closed

robobun wants to merge 12 commits into
mainfrom
farm/7f9b0215/invalid-package-config

Conversation

@robobun

@robobun robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator

What

An invalid package.json (malformed JSON, non-object root, or a "type" field that is not a string) was silently ignored by the runtime module loader. Every .js file under it loaded with whatever module format content-sniffing or the nearest valid ancestor produced, with no error and no warning.

Node.js refuses the whole package scope with ERR_INVALID_PACKAGE_CONFIG on every load path (static import, import(), require(), entrypoint). A corrupted, half-written, or merge-conflicted package.json must not silently change the module semantics of every file beneath it.

Repro

// repro.mjs
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
const d = mkdtempSync("/tmp/badpkg-");
mkdirSync(`${d}/pkg`);
writeFileSync(`${d}/package.json`, '{"name":"root"}');
writeFileSync(`${d}/pkg/package.json`, "{\n");           // malformed
writeFileSync(`${d}/pkg/t.js`, 'export const x = 7;\nconsole.log("ran");\n');
writeFileSync(`${d}/p.mjs`, 'import * as N from "./pkg/t.js"; console.log("ns", Object.keys(N));');
for (const rt of ["node", process.execPath]) {
  const r = f => { const p = spawnSync(rt, [f], { cwd: d, encoding: "utf8" });
    return p.status === 0 ? p.stdout.trim().split("\n").pop() : (p.stderr.match(/ERR_INVALID_PACKAGE_CONFIG/)?.[0] ?? "exit " + p.status); };
  console.log(rt.split("/").pop().padEnd(8), "| import:", r("./p.mjs").padEnd(30), "| entrypoint:", r("./pkg/t.js"));
}

Before:

node     | import: ERR_INVALID_PACKAGE_CONFIG     | entrypoint: ERR_INVALID_PACKAGE_CONFIG
bun      | import: ns [ "x" ]                     | entrypoint: ran

After:

node     | import: ERR_INVALID_PACKAGE_CONFIG     | entrypoint: ERR_INVALID_PACKAGE_CONFIG
bun      | import: ERR_INVALID_PACKAGE_CONFIG     | entrypoint: ERR_INVALID_PACKAGE_CONFIG

Same divergence held for {"type":42} (schema-invalid), 42 (non-object root), across all four load paths.

Cause

Three layers swallowed the failure:

  1. JsonCache::parse_rows converts a JSON parse Err into Ok(None), pushing diagnostics into the resolver log only.
  2. PackageJSON::parse returned None on Ok(None) / non-object root, and only warned on non-string "type". (A 0-byte file is intentionally left as {} to preserve Bun's existing JSONC-empty convention; see jsonc.test.ts.)
  3. Resolver::dir_info_uncached did .ok().flatten() and cached package_json = None, so the parent scope was inherited for module-type resolution.

The process-lifetime DirInfo cache then treated the invalid file exactly like "no package.json here" for the rest of the process.

Fix

  • PackageJSON gains an invalid: bool field.
  • PackageJSON::parse returns a poisoned entry (invalid = true, path set, everything else default) instead of None on parse failure / non-object root, and marks non-string "type" as invalid.
  • DirInfo gains nearest_package_json: the first package.json walking up, regardless of name or validity. This is Node's actual package-scope boundary for "type" resolution, distinct from enclosing_package_json (which skips nameless entries per Support running package.json scripts from package.json without a name #229 for bun run/bin purposes). A valid nameless {} or {"type":...} between a poisoned ancestor and a file correctly terminates the scope.
  • The runtime module-type lookups in jsc_hooks.rs use nearest_package_json. transpile_file throws ERR_INVALID_PACKAGE_CONFIG (with the offending path) when it needs the package.json to determine the module format of a .js/.ts file. .mjs/.cjs still load fine since their format comes from the extension, matching Node.
  • enclosing_package_json keeps its original assignment condition. A package.json with a non-string "type" but valid scripts still becomes enclosing, so bun run <script> finds it (matches npm/pnpm/yarn). filter_run / multi_run filter out only the unparseable poisoned entries (empty source_contents) so the existing "skipping this workspace package" warning still fires for those while scripts from a fully-parsed entry with a bad "type" continue to run.

Unknown string "type" values ("type":"nonsense") continue to be treated as unset, also matching Node.

One side effect for bun run <script>: when the cwd's own package.json is truly unparseable, bun now stops there ("Script not found") instead of silently falling through to a parent directory's scripts. npm/pnpm/yarn all refuse to fall through in that case (they error on the parse), so this brings bun run in line with them.

Tests

describe.concurrent("ERR_INVALID_PACKAGE_CONFIG") in test/js/bun/resolve/resolve-error.test.ts covers the 3 invalid shapes x 4 load paths, plus negative cases (.mjs/.cjs still load, unknown string "type" does not error, parent scope unaffected, error is catchable with .code, valid nameless intermediate stops poison propagation). 14 of the 22 cases fail on main and pass with this change. Additional regression guards in filter-workspace.test.ts and run_command.test.ts cover the non-string-"type" + scripts case.


no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/filter-workspace.test.ts test/cli/run/run_command.test.ts test/js/bun/resolve/resolve-error.test.ts

An invalid package.json (malformed JSON, non-object root, or a "type"
field that is not a string) was silently ignored by the runtime module
loader: the resolver cached package_json = None for that directory and
enclosing_package_json fell through to the parent scope, so every .js
file under it loaded with whatever module format content-sniffing or the
nearest valid ancestor gave.

Node.js instead refuses the whole package scope with
ERR_INVALID_PACKAGE_CONFIG on every load path (static import, dynamic
import(), require(), entrypoint). A corrupted or half-written
package.json must not silently change the module format of every file
beneath it.

PackageJSON::parse now returns a poisoned entry (invalid = true) instead
of None on parse failure / non-object root, and marks non-string "type"
as invalid too. dir_info_uncached treats a poisoned entry as the
enclosing scope boundary so child directories do not inherit past it.
transpile_file throws ERR_INVALID_PACKAGE_CONFIG when deriving the
module format of a .js/.ts file from a poisoned package.json; .mjs/.cjs
still load (format comes from the extension), matching Node.
@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The resolver now preserves invalid package.json files, tracks the nearest package boundary, reports invalid module configurations, and updates CLI package discovery. Tests cover CLI execution, loader errors, extension overrides, and scope-boundary behavior.

Package configuration resolution

Layer / File(s) Summary
PackageJSON invalid-state parsing
src/resolver/package_json.rs
PackageJSON records invalid configurations, including malformed JSON, non-object values, and non-string type fields, while returning poisoned parse results.
Nearest package boundary tracking
src/resolver/dir_info.rs, src/resolver/resolver.rs
DirInfo stores the nearest package JSON, and resolver traversal propagates or replaces it at package boundaries.
Runtime module resolution and CLI filtering
src/runtime/jsc_hooks.rs, src/runtime/cli/filter_run.rs, src/runtime/cli/multi_run.rs
Runtime module detection uses the nearest package JSON, invalid configurations return ERR_INVALID_PACKAGE_CONFIG, and CLI discovery filters poisoned results.
Invalid configuration regression coverage
test/cli/run/filter-workspace.test.ts, test/cli/run/run_command.test.ts, test/js/bun/resolve/resolve-error.test.ts
Tests cover non-string type values, script execution, resolver errors, extension overrides, and package scope boundaries.
🚥 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 states the main change: throwing ERR_INVALID_PACKAGE_CONFIG for malformed package.json.
Description check ✅ Passed The description covers the behavior change, rationale, repro, fix, and testing, even though the headings differ from the template.

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

@robobun

robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:16 AM PT - Jul 10th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 33890

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

bun-33890 --bun

autofix-ci Bot and others added 2 commits July 10, 2026 04:03
…lter

PackageJSON::parse now returns Some(invalid) instead of None for
malformed JSON, so the let-else in filter_run / multi_run no longer hit
their skip path and the "Failed to read ... skipping this workspace
package" warning was lost. Filter the poisoned entry back to None at
these call sites so the existing behavior is preserved.
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/resolver/package_json.rs
Comment thread src/resolver/package_json.rs
enclosing_package_json skips nameless package.json files (the #229
name-gate), so routing the invalid flag through it let a poisoned
grandparent leak past a valid nameless intermediate (e.g. {} or
{"type":"commonjs"}) and wrongly reject files Node accepts.

DirInfo now carries nearest_package_json: the first package.json walking
up, regardless of name or validity, which is Node's actual package-scope
boundary for "type" resolution. The runtime module-type lookups in
jsc_hooks use this field instead. enclosing_package_json keeps its
original semantics (named, valid) so bun run / bin resolution are
unchanged.

Also treat a 0-byte package.json as invalid: the JSON parser
short-circuits empty input to a synthetic {} before parsing, but Node
rejects it as ERR_INVALID_PACKAGE_CONFIG.
Comment thread src/runtime/cli/filter_run.rs Outdated
robobun and others added 2 commits July 10, 2026 04:47
A non-string "type" sets invalid = true but parsing continues, so a
package like {"name":"foo","type":42,"scripts":{...}} is fully usable
for script execution (npm/pnpm/yarn all run it). The blanket
.filter(|p| !p.invalid) skipped it with a misleading "Failed to read".

Only the poisoned placeholder entries (malformed JSON / empty / non-object
root) have empty source_contents; filter on that instead so fully-parsed
entries with a bad "type" pass through unchanged.
Comment thread src/resolver/resolver.rs Outdated
robobun and others added 2 commits July 10, 2026 05:03
The gate blocked a fully-parsed package.json with a non-string "type"
from becoming enclosing_package_json, so plain `bun run <script>` on
{"name":"foo","type":42,"scripts":{...}} stopped finding the script
(npm/pnpm/yarn all run it).

enclosing_package_json is now back to its pre-PR condition; the
name-gate already excludes truly-poisoned entries (empty name) when
care_about_bin_folder is false, and when it is true (bun run startup)
a poisoned entry just has scripts=None so the lookup falls through.
module-type scoping uses the separate nearest_package_json field.

@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.

All prior review feedback has been addressed and I found nothing new, but this touches core resolver/module-loading semantics (new nearest_package_json scope boundary, poisoned PackageJSON entries, an intentional bun run fallthrough change) and uses source_contents.is_empty() as the discriminator between "unparseable" and "invalid-type-only" — worth a human sign-off on the design.

Extended reasoning...

Overview

This PR changes how Bun's resolver and runtime module loader handle malformed package.json files, adding Node-compatible ERR_INVALID_PACKAGE_CONFIG errors. It touches:

  • src/resolver/package_json.rs — adds invalid: bool, returns poisoned entries instead of None for malformed JSON / empty file / non-object root / non-string "type"
  • src/resolver/dir_info.rs + resolver.rs — adds nearest_package_json (Node's package-scope boundary, distinct from the name-gated enclosing_package_json)
  • src/runtime/jsc_hooks.rs — 4 module-type lookup sites switched to nearest_package_json; transpile_file now throws ERR_INVALID_PACKAGE_CONFIG for .js/.ts under a poisoned scope
  • src/runtime/cli/{filter_run,multi_run}.rs — filter poisoned entries so the existing "skipping this workspace package" warning still fires
  • 3 test files with a 28-case matrix plus regression guards

Security risks

None identified. No auth/crypto/permissions surface. The change tightens error handling (more failures surfaced, not fewer). The only new user-facing string is the error message, formatted via the standard ErrCode machinery.

Level of scrutiny

High. The resolver's DirInfo cache and module-type determination are hot-path, process-lifetime-cached, and every .js/.ts load goes through transpile_file. This PR went through four rounds of review corrections (poison leaking through nameless intermediates, 0-byte files missed, --filter warning lost, non-string-"type" breaking bun run scripts), each of which was a real regression. The final shape looks correct, but the iteration count itself signals this is subtle territory.

Other factors

  • Design smell: filter_run.rs/multi_run.rs distinguish "truly poisoned" from "invalid-type-only" via !p.source_contents.is_empty() — a content heuristic rather than a dedicated flag. It works because the invalid() closure returns source: init_path_string(path, b""), but a maintainer might reasonably prefer splitting invalid into two flags as suggested in earlier review.
  • Intentional behavior change: bun run <script> in a directory with a truly-malformed package.json now stops with "Script not found" instead of falling through to a parent's scripts. The PR description justifies this against npm/pnpm/yarn, but it's a user-visible change a maintainer should ack.
  • Test coverage is thorough (4 invalid shapes × 4 load paths + negative cases + the two bun run regression guards), and all my prior inline comments are resolved with dedicated test cases.
  • The enclosing_package_json assignment condition is now byte-identical to main (per df3fd20), so bundler/bin resolution should be unaffected — but I have not exhaustively audited every other consumer of PackageJSON::parse returning Some(invalid) where it previously returned None.

The empty-file-is-invalid check broke test/js/bun/resolve/jsonc.test.ts
which asserts the existing Bun convention that an empty package.json (and
tsconfig.json) parses as {} via the JSONC loader. Running any .ts file in
a directory with an empty package.json became impossible.

Node rejects a 0-byte package.json as ERR_INVALID_PACKAGE_CONFIG, but
Bun already deliberately diverges here (empty JSONC -> {}), so keep that
and drop the 0-byte case from the ERR_INVALID_PACKAGE_CONFIG matrix.
Comment thread src/resolver/package_json.rs

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/jsc_hooks.rs (1)

1347-1359: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Keep read_dir_info_package_json on enclosing_package_json
LoaderResult.package_json feeds ParseTask::init, which turns it into package_name for barrel optimization. Switching this bundler-facing hook to nearest_package_json can stop at a nameless boundary package.json and clear the inherited package name, changing bundler output for nested packages. src/runtime/jsc_hooks.rs:1347

🤖 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 `@src/runtime/jsc_hooks.rs` around lines 1347 - 1359, The
read_dir_info_package_json hook must preserve the enclosing package metadata
used by ParseTask::init and barrel optimization. In the
read_dir_info_package_json match within the resolver hook, return
dir_info.package_json() only and remove the fallback to
dir_info.nearest_package_json, while retaining the existing pointer conversion
and None handling.
🤖 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/package_json.rs`:
- Around line 519-529: Introduce a public `PackageJSON::is_poisoned` predicate
that returns true when `invalid` is set and `source_contents` is empty,
documenting the distinction from parseable objects with invalid `"type"` values.
Replace the duplicated `!p.invalid || !p.source_contents.is_empty()` checks in
the `filter_run` and `multi_run` consumers with `.filter(|p| !p.is_poisoned())`.

In `@src/runtime/jsc_hooks.rs`:
- Around line 4158-4180: Update the invalid package configuration error in the
module-type resolution block to quote the package.json path using
bun_core::fmt::quote, matching the convention used by the CLI equivalent.
Replace the raw BStr path interpolation in the ERR_INVALID_PACKAGE_CONFIG
message while preserving the existing error flow and message wording.

---

Outside diff comments:
In `@src/runtime/jsc_hooks.rs`:
- Around line 1347-1359: The read_dir_info_package_json hook must preserve the
enclosing package metadata used by ParseTask::init and barrel optimization. In
the read_dir_info_package_json match within the resolver hook, return
dir_info.package_json() only and remove the fallback to
dir_info.nearest_package_json, while retaining the existing pointer conversion
and None handling.
🪄 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: a10ccacf-30cd-433f-894a-0ae4f329dd8b

📥 Commits

Reviewing files that changed from the base of the PR and between 6bb5135 and 1f6d112.

📒 Files selected for processing (9)
  • src/resolver/dir_info.rs
  • src/resolver/package_json.rs
  • src/resolver/resolver.rs
  • src/runtime/cli/filter_run.rs
  • src/runtime/cli/multi_run.rs
  • src/runtime/jsc_hooks.rs
  • test/cli/run/filter-workspace.test.ts
  • test/cli/run/run_command.test.ts
  • test/js/bun/resolve/resolve-error.test.ts

Comment thread src/resolver/package_json.rs
Comment thread src/runtime/jsc_hooks.rs
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Re the outside-diff comment on src/runtime/jsc_hooks.rs:1347 (read_dir_info_package_json hook should stay on enclosing_package_json for ParseTask::init / barrel optimization):

Traced the data flow and I don't think that's where it goes. read_dir_info_package_json is the VmLoaderCtx vtable hook, called only from bundler/options.rs:get_loader_and_virtual_source, which is called only from VirtualMachine.rs:3940 (fetchWithoutOnLoadPlugins). There, lr.package_json is read exclusively for module_type (line 3950-3953) and nothing else.

ParseTask::init (bundle_v2.rs) gets its package_json from the resolver's Result via finalize_result (resolver.rs:1669-1671), which still reads dir.enclosing_package_json and is unchanged by this PR.

So keeping nearest_package_json at :1347 is correct for the one consumer (module-type) and doesn't affect barrel optimization.

Comment thread src/resolver/package_json.rs
Comment thread src/runtime/jsc_hooks.rs
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is ready for review. The two most recent CI runs each failed on a single test unrelated to this change:

  • build 71376: test/js/node/test/sequential/test-net-localport.js with EADDRINUSE 127.0.0.1:44037 on linux x64-baseline (port collision)
  • build 71382: test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js with a JSC exception-scope assertion (!scope.exception() || !hasSlot) SIGABRT on x64-asan

Neither touches the resolver or package.json handling. All review threads are resolved; the new tests (resolve-error.test.ts, filter-workspace.test.ts, run_command.test.ts) and the previously-failing jsonc.test.ts pass on every lane where they ran. Holding off on further retriggers.

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 -->
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-10, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

1 participant