Skip to content

install: remove node_modules entries that left the lockfile - #32974

Open
robobun wants to merge 13 commits into
mainfrom
claude/farm/4f23685c/prune-stale-node-modules
Open

install: remove node_modules entries that left the lockfile#32974
robobun wants to merge 13 commits into
mainfrom
claude/farm/4f23685c/prune-stale-node-modules

Conversation

@robobun

@robobun robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16176. Fixes #8662.

What does this PR do?

bun install now removes root node_modules entries that are no longer reachable from the lockfile, for both the hoisted and the isolated linker, so a dependency removed from package.json stops being importable instead of lingering on disk forever. Details below.

Problem

Removing a dependency from package.json and running bun install prunes it from bun.lock but never from node_modules. The removed package stays importable on every machine that had installed it (a phantom dependency) and breaks on a fresh clone or in CI. Repeated bun install keeps reporting "no changes" and never cleans it up. Both the hoisted and the isolated linker are affected, and the same thing happens when an upgrade shrinks a package's transitive closure (the #16176 repro).

mkdir repro && cd repro
echo '{"name":"t","dependencies":{"alpha":"^1","gamma":"^1"}}' > package.json
bun install
echo '{"name":"t","dependencies":{"alpha":"^1"}}' > package.json
bun install
grep -c gamma bun.lock            # 0, gone from the lockfile
ls node_modules/gamma             # still there, still require()-able

npm, pnpm, and yarn all remove node_modules/gamma here.

Cause

Both linkers drive purely off the lockfile tree and only add or update the entries it contains. Nothing ever walks node_modules on disk to compare against the new graph. The lockfile diff even notices (src/install/lockfile/Package.rs counts summary.remove with a comment saying "It will be cleaned up later") but nothing does the cleanup. The only place a stale top-level directory is deleted is the bun remove <pkg> subcommand's hardcoded cleanup, which a plain bun install never reaches.

Fix

Add prune_extraneous_node_modules (src/install/PackageInstall.rs), called by both linkers after Lockfile::clean_with_logger has rebuilt the lockfile. It walks the top level of the root node_modules (descending one level into @scope/ directories, and removing a scope directory that ends up empty) and deletes any entry whose folder name is not a dependency alias in the lockfile. Dot-prefixed entries (.bin, .bun, .cache, ...) are never touched, and the pass is skipped for the security scanner's narrowed pre-install (packages_to_install); the full install that follows performs it.

Each linker builds its own kept set as "every folder name the lockfile can legitimately place at the root", independent of install flags: the root package's declared dependency aliases (all behaviors, so --production, --omit, --filter, and --cpu/--os overrides never turn a reinstall into a delete of packages the lockfile still contains) unioned with the linker's own root placements and with the names of packages that have a patchedDependencies entry. For the hoisted linker the root placements are the unfiltered root tree (snapshotted before filter() runs), because hoisted transitives legitimately live at the root; for the isolated linker they are every lockfile alias matching publicHoistPattern (the store's own root entry cannot be used because it is narrowed by --filter). Patched package names are included because bun patch materializes the package being patched at the root node_modules/<name> even when no tree places it there; without them, bun patch --commit removed the folder it had just told the user about (caught by the existing bun patch workspace test in bun-patch.test.ts, which passes again). A direct dependency that is removed but survives as someone else's transitive is therefore kept by the hoisted linker (it is still correctly hoisted there) and pruned by the isolated linker, whose root must only contain direct dependencies.

Two places the prune deliberately never runs or reaches:

  • Global installs. bun add -g/bun install -g operate inside the global install directory, which is also the bun link registry: bun link records a package there as a bare symlink with no entry in the global package.json or lockfile, so pruning it would destroy every link registration. The global directory is a tool registry, not a project.
  • Symlinks, under the hoisted linker. bun link <pkg> in a consumer is --no-save by default, so the local node_modules/<pkg> symlink is also recorded in no manifest. Hoisted extraneous entries are always real extracted directories; a root symlink there is a workspace link, a file: folder dependency, or a bun link, none of which the prune should touch (declining to delete them is exactly main's behavior today). The isolated linker's own root entries are symlinks into node_modules/.bun, so it keeps pruning them; isolated plus an unsaved bun link is a pre-existing gap (install: isolated linker honors active bun link #30289).

Two details worth calling out:

  • The set is keyed by the alias (Dependency.name), because that is the node_modules/<name> folder both installers write. Dependency.name_hash is the hash of the resolved package name, so for "moo": "npm:@barn/moo" it would miss node_modules/moo and delete it.
  • A pruned package's node_modules/.bin/<cmd> symlink would otherwise be left dangling (turning a stale-but-working bin into an ENOENT), so the prune finishes with the dangling-symlink sweep bun remove already performs. That sweep is extracted into a shared prune_dangling_bin_links and the bun remove path now calls it. The prune itself is best effort: a stale directory the filesystem refuses to delete (locked, read-only) is left behind rather than failing the install, matching bun remove and npm.

The existing add trusted, delete, then add again test in bun-install-lifecycle-scripts.test.ts was written with a comment saying to update it "when we change bun install to delete dependencies from node_modules for both cases"; updated as instructed (the manual-edit branch now matches the bun rm branch, and the re-add installs the pruned transitive dependency as well).

Unrelated to the prune but required to run these suites at all: the test harness now starts Verdaccio bound to 127.0.0.1 explicitly. With only a port it listens on whatever getaddrinfo("localhost") returns first, and on hosts where that is ::1 every registry-backed install test fails with ConnectionRefused.

How did you verify your code works?

12 new tests in test/cli/install/bun-install-registry.test.ts under prunes stale node_modules entries (<linker>), run for both the hoisted and the isolated linker:

  • removing a direct dependency removes it from node_modules
  • removing a scoped dependency removes it and cleans up the empty @scope directory
  • removing a dependency with a transitive dep removes both (the Running bun install does not delete extraneous dependencies #16176 shape; bun remove <package> should remove the dependencies that the package depended on if they are no longer used #8662 is the same mechanism via bun remove, covered by the updated add trusted, delete, then add again lifecycle test, whose hoisted transitive what-bin is now pruned too)
  • unrelated user directories and dotfiles are not removed
  • removing the last dependency removes it
  • a file: folder dependency survives the prune
  • a dependency demoted to transitive-only is kept by the hoisted linker and pruned from the root by the isolated linker
  • removing a dependency also removes its now-dangling .bin symlink
  • a publicHoistPattern hoist from a --filtered-out workspace survives a filtered install

plus two standalone tests: a bun link registration in the global directory survives bun add -g, and an unsaved bun link <pkg> in a consumer survives bun install.

16 of them fail on the current release build and pass with this change, as do the 4 updated lifecycle-script tests. The --filter, global-install, and unsaved-link tests additionally fail on intermediate revisions of this branch that lacked their respective guards, which is how those three cases were pinned.

Beyond the suites, both linkers were checked against a workspace symlink at the root (preserved), --production with dev dependencies on disk (preserved), an npm: aliased scoped package (preserved, while an unrelated removed package alongside it is pruned), and a publicHoistPattern-hoisted transitive at the isolated root (preserved).

The per-workspace node_modules directories are out of scope here; the hoisted side of that is #29793 / #29794.

robobun added 2 commits June 28, 2026 00:29
A dependency removed from package.json and reinstalled was pruned from
bun.lock but never from node_modules, so the package stayed importable
on every machine that had installed it (and broke on fresh clones). Both
the hoisted and the isolated linker only added or updated entries.

Add a shared prune that runs after the lockfile is cleaned: any top level
node_modules entry (including one level into @scope/) whose folder name
is not a dependency alias in the lockfile is removed. Keying the kept set
to the lockfile graph rather than to what a single invocation places at
the root means --production, --omit, --filter, and --cpu/--os overrides
never turn a reinstall into a delete of packages the lockfile still has.
Dot-prefixed entries (.bin, .bun, .cache) are never touched.

Also bind the test Verdaccio registry to 127.0.0.1 explicitly; with only
a port it listens on whatever getaddrinfo(localhost) returns first, and
on hosts where that is ::1 every registry-backed test fails with
ConnectionRefused.
The 'add trusted, delete, then add again' test's own comment says to
update it once bun install starts deleting removed dependencies from
node_modules. The manual-edit (withRm: false) branch now converges onto
the bun rm (withRm: true) branch: uses-what-bin is gone from disk on
both paths, and re-adding it installs both it and its hoisted transitive
dependency what-bin.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds pruning helpers for stale node_modules entries, wires them into hoisted and isolated installs, updates install tests for removed packages, scoped packages, transitive dependencies, dotfiles, .bin cleanup, and binds Verdaccio to 127.0.0.1 in the test harness.

Changes

Extraneous node_modules pruning

Layer / File(s) Summary
Prune helpers
src/install/PackageInstall.rs, src/install/PackageManager/updatePackageJSONAndInstall.rs
Adds shared pruning helpers for top-level packages, scoped packages, patched dependencies, and dangling .bin symlinks; replaces the inline .bin cleanup with the shared helper.
Install flow pruning
src/install/hoisted_install.rs, src/install/isolated_install.rs
Computes expected root keep sets and calls the pruning helper when reusing an existing node_modules tree.
Install pruning tests
test/cli/install/bun-install-registry.test.ts, test/cli/install/bun-install-lifecycle-scripts.test.ts
Adds coverage for direct, scoped, transitive, dotfile, local file:, empty-tree, linker-specific, and .bin pruning cases, and updates lifecycle assertions for the new delete-and-readd behavior.

Verdaccio IPv4 loopback binding

Layer / File(s) Summary
Explicit loopback bind
test/harness.ts
Updates VerdaccioRegistry.start() to listen on 127.0.0.1:<port> and reformats the fork call.

Suggested reviewers

  • Jarred-Sumner
  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: pruning stale node_modules entries after lockfile cleanup.
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.
Description check ✅ Passed The description follows the required template and includes a detailed summary plus verification steps.

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

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:49 AM PT - Jun 28th, 2026

@robobun, your commit c4a6dc4 has 2 failures in Build #66190 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32974

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

bun-32974 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. bun remove doesn't remove the package from node_modules when using isolated linker #26305 - bun remove with isolated linker removes package from package.json/bun.lock but leaves it physically in node_modules, allowing imports to continue
  2. bun remove <package> should remove the dependencies that the package depended on if they are no longer used #8662 - After bun remove, transitive-only dependencies (e.g. js-tokens, loose-envify) are left behind in node_modules as orphans
  3. Bun doesn't remove hardlinks when using linker = isolated #21216 - Under isolated linker, after bun update, old hardlinked package versions remain in node_modules alongside the new version
  4. bun install --frozen-lockfile leaves spurious nested dependencies #22702 - Switching branches and running bun install --frozen-lockfile leaves spurious nested dependencies on disk that no longer match the lockfile

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

Fixes #26305
Fixes #8662
Fixes #21216
Fixes #22702

🤖 Generated with Claude Code

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Went through all four. One is fixed by this PR, three are not; the three that are not each live in a location this prune deliberately does not walk, so I have not added them to the Fixes list.

#8662 - fixed. bun remove react leaves react's hoisted transitive dependencies (js-tokens, loose-envify) at the top level of node_modules. bun remove already performed an install pass; that pass now prunes them because they left the lockfile graph. This is exercised by the updated add trusted, delete, then add again test in bun-install-lifecycle-scripts.test.ts: after bun rm uses-what-bin, its hoisted transitive what-bin is now removed as well, which is why that test's re-add asserts 2 packages installed. Added Fixes #8662 to the description.

#26305 - not fixed. The stale copy there is packages/<workspace>/node_modules/drizzle-kit, a per-workspace node_modules under the isolated linker. This prune only walks the root node_modules; per-workspace directories are a separate gap (the hoisted side of it is #29793 / #29794).

#21216 - not fixed. The leftover old versions live in node_modules/.bun/<pkg>@<old-version>/, the isolated linker's content store. .bun is dot-prefixed and this prune never enters dot-prefixed entries, intentionally. Reclaiming stale store entries is a garbage-collection problem with different safety requirements (a store entry can be shared by several consumers).

#22702 - not fixed. The spurious entries described there are nested, node_modules/dependencyB/node_modules/dependencyA. This prune only walks the top level of the root node_modules (plus one level into @scope/ directories); it never descends into a package's own nested node_modules.

@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

🤖 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/install/PackageInstall.rs`:
- Around line 2592-2600: The keep-set in the installation cleanup path is being
built from every lockfile dependency instead of only the aliases actually linked
at the project root, which leaves stale root-level entries behind after direct
dependencies are removed. Update the helper around the expected set construction
and the cleanup logic to use the root-visible alias set for the active linker,
or thread that root-linked alias set into this function, so the removal check
only preserves entries that are still linked at the root. Keep the fix localized
to the code that builds expected and the subsequent node_modules cleanup in the
install flow.
- Around line 2609-2632: The prune logic is swallowing filesystem errors, so
update the cleanup path around iterate_dir, prune_extraneous_scope, and
dir.delete_tree to return the first encountered error instead of continuing
silently. Thread that error back through the caller in PackageInstall.rs so bun
install fails when pruning cannot complete, and make sure any recursive
scope/package deletion also propagates failures rather than discarding them.
🪄 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: 38ce182e-1142-488e-abf9-4577de64578f

📥 Commits

Reviewing files that changed from the base of the PR and between 0f9331d and 2fbf296.

📒 Files selected for processing (6)
  • src/install/PackageInstall.rs
  • src/install/hoisted_install.rs
  • src/install/isolated_install.rs
  • test/cli/install/bun-install-lifecycle-scripts.test.ts
  • test/cli/install/bun-install-registry.test.ts
  • test/harness.ts

Comment thread src/install/PackageInstall.rs Outdated
Comment thread src/install/PackageInstall.rs
Comment thread src/install/PackageInstall.rs Outdated
robobun and others added 2 commits June 28, 2026 02:15
…n links

Two issues from review.

The keep-set was every dependency alias in the lockfile, which over-keeps
at the root: a package demoted from a direct dependency to transitive-only
(something else still depends on it) stayed as a root node_modules symlink
under the isolated linker, exactly the phantom dependency that linker is
meant to prevent. Build a per-linker set instead, each independent of
install flags: the root package's declared dependency aliases (all
behaviors, so --production/--omit never turn a reinstall into a delete)
unioned with the linker's own root placements, the unfiltered root tree for
hoisted (transitives legitimately live there) and the root store entry's
dependencies for isolated (which include publicHoistPattern hoists). A
lockfile with no dependencies has zero trees; that contributes an empty
tree set rather than skipping the prune.

Pruning a package also left its node_modules/.bin symlink dangling, so
scripts got ENOENT on the link target instead of command not found. Sweep
dangling .bin symlinks after the prune, reusing the loop bun remove
already ran; it is extracted into prune_dangling_bin_links and the bun
remove path now calls the shared helper.

@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

🤖 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/install/PackageInstall.rs`:
- Around line 2653-2659: The cleanup in the `File::openat` error branch is too
broad and currently unlinks `.bin` entries for any failure, not just
missing-target cases. Update the `match sys::File::openat(...)` handling in the
bin-shim iterator so `sys::unlinkat` is only called for missing-target errors
like `ENOENT` or `ENOTDIR`, and leave other errors untouched so valid shims are
preserved.

In `@test/cli/install/bun-install-registry.test.ts`:
- Around line 9366-9373: The binEntries helper is swallowing every readdir
failure and returning an empty array, which can mask real filesystem or
permission issues. Update the catch in binEntries to only convert ENOENT from
readdirSorted(join(packageDir, "node_modules", ".bin")) into [] and rethrow any
other error so the final assertion in this test still fails on unexpected .bin
read problems.
🪄 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: 97084a23-e3d2-4a91-afb4-6c7c5fbac95b

📥 Commits

Reviewing files that changed from the base of the PR and between 2fbf296 and 620efef.

📒 Files selected for processing (5)
  • src/install/PackageInstall.rs
  • src/install/PackageManager/updatePackageJSONAndInstall.rs
  • src/install/hoisted_install.rs
  • src/install/isolated_install.rs
  • test/cli/install/bun-install-registry.test.ts

Comment thread src/install/PackageInstall.rs Outdated
Comment thread test/cli/install/bun-install-registry.test.ts
The dangling .bin sweep removed an entry on any open failure, so an
EACCES, EMFILE, or transient I/O error on a perfectly good shim would
delete it. Only ENOENT and ENOTDIR mean the target is gone; everything
else leaves the entry alone. This also applies to the bun remove path,
which shares the helper. Tighten the test's .bin readdir helper the same
way so only a missing directory reads as empty.
Comment thread src/install/isolated_install.rs Outdated
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs
robobun and others added 2 commits June 28, 2026 03:10
… hoists

Three holes from review, each reproduced by a new regression test that
fails on the previous commit.

Global installs chdir into the global install dir, so the prune ran on
<global>/node_modules. That directory is also the bun link registry:
bun link records a package there as a bare symlink with no entry in the
global package.json or lockfile, so bun add -g deleted every link
registration and then its now dangling global bin shim. The global
directory is a tool registry, not a project; never prune it.

bun link <pkg> in a consumer is --no-save by default, so the local
node_modules/<pkg> symlink is recorded in no manifest either and the
next bun install deleted it. The hoisted prune now never deletes a
symlink: hoisted extraneous entries are always real extracted
directories, and a root symlink there is a workspace link, a file:
folder dependency, or a bun link, none of which it should touch. The
isolated linker keeps pruning symlinks because they are its own root
entries.

The isolated keep-set used the root store entry's dependencies, which
are narrowed by --filter, so a publicHoistPattern hoist originating in a
filtered-out workspace was deleted by a filtered install. The set is now
the root package's declared aliases unioned with every lockfile alias
matching publicHoistPattern, both independent of install flags.

@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/install/isolated_install.rs`:
- Around line 1998-2011: In the isolated install flow, the `expected.put(...)`
calls are currently ignoring `AllocError`, which can leave the keep-set
incomplete before `prune_extraneous_node_modules()` runs. Update this section to
propagate failures from `StringHashMap::put` instead of discarding them, and
thread the `Result` through the surrounding install routine so the prune step
only proceeds when both inserts succeed. Use the existing `expected`
map-building logic and the caller path around `prune_extraneous_node_modules()`
to locate the change.
🪄 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: 4e77817b-113f-40e6-882c-1081368ac306

📥 Commits

Reviewing files that changed from the base of the PR and between 620efef and 386ca33.

📒 Files selected for processing (4)
  • src/install/PackageInstall.rs
  • src/install/hoisted_install.rs
  • src/install/isolated_install.rs
  • test/cli/install/bun-install-registry.test.ts

Comment thread src/install/isolated_install.rs
Comment thread src/install/PackageInstall.rs Outdated
…ilures

bun patch materializes the package being patched at the root
node_modules/<name> even when no tree places it there (under the
isolated linker it is a symlink into the store), so the prune deleted it
and bun patch --commit lost the folder it had just told the user to
edit. Both keep-sets now include the name of every package with a
patchedDependencies entry, matched the same way the installer matches
patches. Caught by the existing bun patch workspace test.

readdir does not fill d_type on some filesystems (NFS, FUSE, bind
mounts), so a bun link symlink could be reported as Unknown and deleted
despite keep_symlinks. Resolve Unknown entries with an lstat, mirroring
walker_skippable.

Insertion failures while building a keep-set now propagate instead of
being ignored; an incomplete set must never reach the prune.
Comment thread src/install/PackageInstall.rs Outdated
robobun and others added 2 commits June 28, 2026 04:16
Same d_type gap as the prune's symlink guard: on filesystems that do not
populate d_type, every .bin entry reported Unknown and the sweep was a
no-op. Reuse entry_is_symlink, which falls back to lstat only for
Unknown entries. This also covers the bun remove path that shares the
helper.

@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/install/PackageInstall.rs (1)

2628-2629: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t descend into top-level @scope symlinks.

A top-level node_modules/@scope symlink is routed into prune_extraneous_scope(), which opens it as a directory before deleting child entries. In the isolated flow (keep_symlinks = false), that can follow a symlink outside node_modules and prune the target directory’s contents. Snapshot the symlink bit and unlink/skip the top-level symlink itself instead of passing it to the scope walker. As per coding guidelines, “lexical containment is defeated by symlinks — re-verify after realpath, prefer O_NOFOLLOW-style atomic flags over check-then-act.”

Also applies to: 2756-2764

🤖 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/install/PackageInstall.rs` around lines 2628 - 2629, The top-level `@scope`
handling in PackageInstall::prune_extraneous and prune_extraneous_scope is
following symlinks into a scope directory before deletion, which can escape
node_modules; detect when the entry is a symlink and do not descend into it.
Snapshot the symlink state for the top-level `@scope` entry, then unlink or skip
that symlink directly instead of passing it into prune_extraneous_scope, and
apply the same fix in the later `@scope` branch that uses the same walker.

Source: Coding guidelines

🤖 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/install/hoisted_install.rs`:
- Around line 151-170: The new keep-set build in the hoisted install flow can
exit early before the restore guard is armed, which may leave the lockfile
buffers and hoisted dependencies filtered instead of restored. In the
`hoisted_install` logic, wrap the `keep(dep_id)` loop and
`extend_expected_with_patched_packages` call in a temporary closure or result
block, then create `_restore_buffers` before applying the fallible result with
`?`. Keep the fix centered on the `keep` helper and
`package_install::extend_expected_with_patched_packages` path so the RAII guard
always exists before any fallible operation.

In `@src/install/PackageInstall.rs`:
- Around line 2667-2678: The resolution handling in PackageInstall::prune is
dropping entries when resolution.fmt(...) exceeds the fixed 512-byte buffer,
which can cause patched root packages to be omitted from expected. Update the
loop in the package resolution formatting path to use the growable
name_and_version buffer directly for formatting name@version instead of relying
on resolution_buf, so long patched-package resolutions are always preserved and
included in expected.

---

Outside diff comments:
In `@src/install/PackageInstall.rs`:
- Around line 2628-2629: The top-level `@scope` handling in
PackageInstall::prune_extraneous and prune_extraneous_scope is following
symlinks into a scope directory before deletion, which can escape node_modules;
detect when the entry is a symlink and do not descend into it. Snapshot the
symlink state for the top-level `@scope` entry, then unlink or skip that symlink
directly instead of passing it into prune_extraneous_scope, and apply the same
fix in the later `@scope` branch that uses the same walker.
🪄 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: a106d841-6c7f-43fd-b47b-883e8d52424d

📥 Commits

Reviewing files that changed from the base of the PR and between 386ca33 and 59ff910.

📒 Files selected for processing (3)
  • src/install/PackageInstall.rs
  • src/install/hoisted_install.rs
  • src/install/isolated_install.rs

Comment thread src/install/hoisted_install.rs
Comment thread src/install/PackageInstall.rs Outdated
… the fixed version buffer

A top-level node_modules/@scope entry that is a symlink was passed to
the scope walker, which opens through the link, so an isolated install
could delete the contents of a directory outside node_modules. Symlinked
scope entries are now never descended into; deleting a symlink itself is
safe because delete_tree unlinks it without following.

The keep-set build is fallible and ran before the scopeguard that
restores the pre-filter tree buffers was armed, so an early return would
have left the filtered buffers in place. It now runs after the guard and
reads the snapshot through it.

Formatting a patched package's resolution through a fixed 512 byte
buffer silently dropped entries with longer resolutions (git or tarball
URLs) from the keep-set; format straight into the growable buffer.

@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 earlier findings look addressed and I didn't spot anything new, but this introduces destructive node_modules pruning on every bun install with a fair amount of edge-case surface (global/link registry, symlinks, --filter, publicHoistPattern, patched packages) — worth a human sign-off on the keep-set semantics before it ships.

Extended reasoning...

Overview

Adds prune_extraneous_node_modules and supporting helpers (extend_expected_with_patched_packages, entry_is_symlink, prune_dangling_bin_links, prune_extraneous_scope) in src/install/PackageInstall.rs (~250 new lines), wires them into both hoisted_install.rs and isolated_install.rs, replaces the inline .bin sweep in updatePackageJSONAndInstall.rs with the shared helper, updates lifecycle-script test expectations, adds ~400 lines of new registry tests across both linkers, and binds Verdaccio to 127.0.0.1 in the test harness.

Security risks

The new code performs delete_tree on entries inside the project's root node_modules based on a computed keep-set. The blast radius is bounded to that directory (dot-prefixed entries are skipped, @scope symlinks are never followed, global installs are gated out, hoisted symlinks are preserved), and there is no path-traversal vector since names come from readdir and are passed to *at syscalls relative to the open dir fd. No auth/crypto/permissions code is touched. The real risk is correctness rather than security: an incomplete keep-set silently deletes packages the lockfile still places at the root.

Level of scrutiny

High. This is a behavioral change to bun install itself — every install on an existing tree now walks node_modules and may delete_tree entries. The PR went through several review rounds where each uncovered a case that would have destroyed user state (global bun link registry, unsaved consumer bun link, --filter-narrowed publicHoistPattern hoists, DT_UNKNOWN filesystems, long patched-dep resolutions). All of those are now addressed and pinned by tests, but the number of iterations is itself a signal that the keep-set semantics are subtle enough to merit a human reviewer confirming the final design — particularly the linker-specific union rules and the best-effort-on-delete-failure decision.

Other factors

Test coverage is thorough (12 parameterized tests × 2 linkers plus 2 standalone link/global tests, plus the updated lifecycle test). All inline review threads from coderabbit and from me are resolved. The harness change (Verdaccio 127.0.0.1 bind) is a low-risk test-infra fix. CI build #66161 is in progress on the latest commit. Given the destructive nature and the volume of edge-case handling that accumulated during review, I'm deferring rather than approving.

@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 earlier findings look addressed and I didn't spot anything new, but this introduces a destructive prune on every bun install's hot path with a lot of edge-case guards (global, bun link, --filter, patched deps, symlinks, DT_UNKNOWN), so it's worth a maintainer's eyes before merging.

Extended reasoning...

Overview

This PR adds prune_extraneous_node_modules to src/install/PackageInstall.rs and wires it into both hoisted_install.rs and isolated_install.rs, so that bun install deletes top-level node_modules entries that are no longer in the lockfile. It also extracts the .bin dangling-symlink sweep into a shared prune_dangling_bin_links helper (now called by both bun remove and the new prune), adds an extend_expected_with_patched_packages helper, and ships ~400 lines of new test coverage in bun-install-registry.test.ts plus updates to the lifecycle-scripts test. A small unrelated fix binds Verdaccio to 127.0.0.1 in the test harness.

Security risks

No injection/auth/crypto surface. The risk is correctness rather than security: a destructive delete_tree now runs on every non-fresh bun install against the user's node_modules. The failure mode of an under-populated keep-set is silently deleting legitimate user state (workspace symlinks, bun link registrations, patched packages, publicHoistPattern hoists). The PR went through eight+ review rounds, each of which found a real case where the original implementation would have deleted something it shouldn't (global link registry, unsaved consumer-side links, --filter-narrowed hoists, DT_UNKNOWN filesystems, long patched-resolution strings, RAII-guard ordering). All have been addressed and pinned by tests, but the volume of edge cases is itself a signal that this surface deserves maintainer sign-off.

Level of scrutiny

High. This is not a mechanical change — it changes bun install from purely additive to actively pruning, on the most-executed code path in the package manager, across both linkers. There are design decisions a maintainer should ratify: pruning on every install vs. an explicit bun prune, the hoisted linker's "never delete symlinks" heuristic, the acknowledged remaining gap for unsaved bun link under the isolated linker (#30289), and best-effort vs. hard-fail on deletion errors.

Other factors

The bug-hunting system found nothing on the current revision; all my prior inline findings and all CodeRabbit findings are resolved. Test coverage is thorough (12 new parametrized tests across both linkers plus two standalone link-survival tests), and the PR description is unusually detailed. CI build #66190 is in progress. Given the blast radius and the number of correctness fixes already applied during review, I'm deferring rather than approving.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for the reviewers: the diff's own lanes are green and the remaining red is unrelated to this change.

Build #66190 (current head) finished with 282 passing jobs, including every install test lane; bun-install-registry.test.ts, bun-install-lifecycle-scripts.test.ts, bun-patch.test.ts, and the rest of test/cli/install pass everywhere they ran. The 4 failures are:

  • darwin 26 aarch64 - test-bun: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. The runner never obtained the compiled binary and zero tests executed. The same lane failed the same way on the previous build (#66161) on a different agent, so it is the artifact store for that macOS pool, not the change.
  • alpine 3.23 x64 - test-bun and alpine 3.23 x64-baseline - test-bun: the MySQL test container never became healthy on the runner (Failed to start service mysql_native_password ... application not healthy after 1m0s), failing test/js/sql/sql-mysql*.
  • darwin 14 aarch64 - test-bun: PostgreSQL TLS and Valkey (Redis) service-backed suites plus one snapshot, the same external-services class.

None of these involve bun install, and this PR does not touch SQL, Valkey, or the macOS artifact store. I already used one retrigger for the macOS artifact timeout (it reproduced), so I am not going to keep pushing empty commits at it. Happy to follow up on anything a human review turns up.

dylan-conway pushed a commit that referenced this pull request Jul 30, 2026
…om bun.lock (#35681)

### Repro

```sh
bun add -D optional-peer-deps   # has an optional peer on no-deps; no-deps is NOT in bun.lock
bun add no-deps                 # no-deps now in bun.lock as a direct dep
bun remove no-deps
```

After the remove, `bun.lock` still contains a `packages` entry for
`no-deps`:

```json
"packages": {
  "no-deps": ["no-deps@1.0.0", "...", {}, "sha512-..."],
  "optional-peer-deps": ["optional-peer-deps@1.0.0", "...", { "peerDependencies": { "no-deps": "*" }, "optionalPeers": ["no-deps"] }, "sha512-..."]
}
```

Same thing happens when `no-deps` is removed from `package.json` by hand
and `bun install` is re-run. npm and yarn 1.x both return the lockfile
to its pre-`add` state. Reported as the second repro on #8662
(#8662 (comment));
distinct from the `node_modules` pruning part of that issue, which is
#32974.

### Cause

`Lockfile::clean_with_logger` rebuilds the package list by a
reachability walk from the root (`Package::clone` over each resolution
slot). Once `hoist` has filled an optional-peer slot with a valid
`PackageID`, `Package::clone` treats it exactly like a hard dependency
edge: it is enqueued and the target survives the clean. During fresh
resolve the slot is never populated independently
(`enqueue_dependency_with_main_and_success_fn` returns early for
optional peers) and is only filled by `hoist` when the target is already
in the tree via some other edge; the clean step did not honour that
invariant.

### Fix

In `Package::clone`, write `invalid_package_id` to an optional-peer
resolution slot instead of enqueuing the old target. `Cloner::flush`
finishes with `resolve()`/`hoist()`, which already walks the freshly
cloned tree and fills an optional-peer slot only when the target package
was kept alive by some non-optional-peer edge, so a still-referenced
peer target is re-bound and an unreferenced one is dropped together with
its transitives.

Because the slot is now always re-derived from the post-clean tree
instead of carried over, a lockfile that contains a history-dependent
nested optional-peer placement (the old carried value diverged from what
a fresh install would place) may be rewritten once on the first `bun
install` after upgrading. The rewritten value is the fresh-install
value, so this is a one-time convergence rather than churn.

### Verification

Four tests in `test/cli/install/bun-lock.test.ts`:

- `bun remove` of a package that also satisfied an optional peer returns
`bun.lock` to its pre-`add` state and round-trips `--frozen-lockfile`
- `bun install` after editing the same removal into `package.json` does
the same
- control: when a hard dependency elsewhere still pulls the package in,
`bun.lock` is byte-identical before and after the add/remove pair
- idempotence guard: a non-wildcard optional peer (`no-deps@^1.0.0`)
with two versions of `no-deps` in the tree (1.0.1 and 2.0.0) produces a
byte-identical lockfile across two consecutive installs and passes
`--frozen-lockfile`

The first two fail on the released build with the `no-deps` entry quoted
above and pass with this change; the control and idempotence tests pass
on both. Also ran the peer/optional filters of
`bun-install-registry.test.ts`, `isolated-install.test.ts`,
`hoist.test.ts`, `bun-remove.test.ts` and `bun-lockb.test.ts` with no
new failures.

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

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/install/bun-lock.test.ts

<!-- robobun:evidence:end -->
Jarred-Sumner added a commit that referenced this pull request Aug 14, 2026
…ilter/--catalog, nested overrides, transitive update, and workspace fixes (#38333)

Brings Bun's package manager to parity with pnpm for monorepo workflows,
and fixes the bugs found while checking every command against pnpm's
implementation, pnpm's test suites, pnpm's open issue tracker, pnpm's
docs, npm's arborist fixtures, and — for `bun update` — running real
pnpm and Bun side by side on the same projects.

### What does this PR do?

#### New commands

- **`bun dedupe [--check]`** — collapses duplicate versions in
`bun.lock` onto the smallest set that still satisfies every dependent's
range, using only versions already in the lockfile, then installs. Never
downgrades a direct dependency unless that is the only way to drop a
version; keeps patched versions (and anything needed to reach them, and
says so); refuses to run on a lockfile that is behind `package.json`.
`--check` exits 1 without writing.
- **`bun prune [--production | --omit=…] [--dry-run] [--filter <ws>]`**
— removes everything in `node_modules` that the lockfile does not put
there; `--production` leaves exactly what `bun install --production`
would. Hoisted and isolated layouts, Windows junctions and shims,
workspace links, bundled deps; refuses when `package.json` and
`bun.lock` disagree or when `node_modules` was laid out by a different
linker; understands turbo-pruned checkouts.
- **`bun pm licenses [--json] [--prod|--dev] [--long] [--filter <ws>]`**
— installed packages grouped by license, with a `(dev)` marker and
`paths`/`license`/`description` in `--json`.
- **`bun audit fix [--latest] [--dry-run] [--json]`** — moves each
vulnerable package to the lowest safe version its dependents accept, per
installed instance; rewrites exact pins when that is the only way;
`--latest` also rewrites your own declared ranges (root, workspace,
catalog) so a semver-major fix can be taken, and every blocked or
unfixable item is followed by the command that resolves it (`bun audit
fix --latest`, `bun audit --ignore GHSA-…`); re-audits the tree it
actually installed and reports/exits from that second response (npm's
`_submitQuickAudit`), so an advisory that starts at the version it moved
to is not missed; works across registries; security fixes bypass
`minimumReleaseAge` with an annotation. `bun audit --json` honors
`--audit-level`/`--ignore` for its exit code; `--omit` is honored by
`audit` and `licenses`.

#### `bun update` semantics (pnpm's model)

- A bare `bun update` re-resolves **transitive** packages too — every
edge moves to the newest version its own range (or dist-tag) allows, per
dependent, so `bun.lock` no longer stays stale after an update;
overrides/catalogs changed since the last install are honored. From a
workspace member or `--filter`, only what the selected workspaces reach
is re-resolved; from the root, everything.
- `bun update <name>` reaches any depth, matches `npm:` aliases by real
name, updates in place, never adds to `package.json`, and errors on a
name nothing selected depends on. `-r`/`--filter` fan a named update out
across workspaces. `--latest` never downgrades a locked version that is
ahead of the tag, and `update <name> --latest` also refreshes that
package's own dependencies.
- Plain updates keep dist-tag literals and non-caret ranges (`*`, `1.x`,
`^1 || ^2`) exactly as written and only move the lockfile; `--latest`
rewrites them to the resolved version as before. `bun update -i` applies
only what you selected. New: positional patterns (`bun update
'@types/*'`), `--dev`/`--prod`/`--no-optional`, `-L`, `bun up`.
- `package.json` is written after resolution and `bun.lock`'s declared
ranges, overrides and catalogs are re-derived from the final
`package.json`, replacing the per-command literal rewriting; a no-op
update leaves the file byte-identical.

#### Overrides

- **Nested overrides** (#6608): npm's nested objects, yarn's `a/b` paths
and pnpm's `a>b` selectors, applied to the direct parent→child edge;
**version-scoped targets** (`"lodash@<4.17.21": "4.17.21"`, the shape
`pnpm audit --fix` writes), matched against the dependent's declared
range as pnpm does. Rules persist inside the `overrides` section and the
file is stamped `lockfileVersion: 3` **only when such rules exist** —
existing lockfiles are byte-identical. Flat overrides additionally fix
`$ref` to workspace-member deps, catalog-valued rules going stale, and
warn on pnpm's `-` / `pkg@` forms.

#### Workspaces and filters

- `bun add|remove|update … --filter <ws>` (also `-F`, also `bun install
<pkg> --filter`) edits the selected workspaces' `package.json` files and
**links only those workspaces**, like `bun install --filter`. Filters
gain pnpm's relation selectors (`foo...`, `...foo`, `foo^...`,
`...^foo`) and `{dir}` subtrees, for the install family **and** `bun run
--filter`; `--filter` may precede the subcommand; every command warns
about patterns that match nothing; `add`/`remove` no longer select the
root implicitly.
- `bun add <pkg> --catalog[=name]` reuses an existing catalog entry,
keeps a range an explicit version fits, catalogs the range a package
already declares, decides per target, and refuses workspace names and
local paths; a plain `bun add` uses a default-catalog entry when one
exists. A package defined in both `catalog` and `catalogs.default` is an
error. `catalog:` peers of registry packages bind to the importer's copy
instead of the root catalog.
- `--frozen-lockfile` / `bun ci` on turbo-pruned monorepos: pruned-away
workspaces are tolerated, a survivor depending on a pruned workspace is
an error, catalog subsets are accepted, and an overrides/catalogs change
is a frozen failure.

#### One output vocabulary

Every command here prints the install family's shapes: header, glyph
rows (`+`/`-`/`↑`, dedupe's `↳ name old → new`), exactly one noun-first
summary line with counts and a duration (`2 duplicate versions removed,
3 packages installed (checked 5 packages) [12ms]`, `N packages removed
(checked C) [t]`), no-ops that say what was checked, remedies printed as
copy-pasteable command lines, warnings as `warn:`, `--silent` printing
nothing, and errors with their remedy together on stderr. Transitive and
named updates render as the summary's `↑` rows (once per package;
`--dry-run` prints the same rows plus `N packages would be updated`);
dedupe reports after the install it triggers, so lifecycle-script output
never splits it. A lockfile whose bytes did not change is no longer
rewritten (`Saved lockfile` only prints on a real write;
`--lockfile-only` no-ops print `Done! Checked N packages (no changes)`).
This came out of running every command against fixtures and comparing
with `install`/`add`/`remove` (95 findings, all fixed).

#### Config precedence

A project's `bunfig.toml` now beats any `.npmrc` (project or user-level)
for the same key (npmrc files → bunfig's set fields → CLI); npmrc-only
settings such as `//host/:_authToken` still attach to bunfig-declared
registries, matched by host and path regardless of how either file
spells the trailing slash.

#### Lockfile migration

- `package-lock.json`: rebuilt around a reachability walk that derives
each resolution from the entry itself. Fixes `git+https://github.com/…`
resolutions being written unparseably (the next install threw the
lockfile away), root `bundleDependencies` migrating to an **empty**
lockfile, lockfileVersion 1 (and npm's upcoming 4) making `bun install`
exit 1 instead of resolving fresh, dependency-level bundles,
`dependencies`+`optionalDependencies` double edges, unreferenced entries
aborting the migration, duplicate packages for identical `name@version`
at nested paths, lost `optionalPeers`, lost integrity when a bundled
copy was seen first, and `overrides` not being carried over. All 57 of
arborist's v2/v3 fixture projects are vendored and migrated under
snapshot.
- `pnpm-lock.yaml` v9: bare-hash `patchedDependencies`, snapshot
aliases, `catalog:default`, recorded tarball URLs, git `path:`,
multi-document files, `runtime:` entries, named registries,
peer-suffixed keys chosen per importer, injected workspaces,
manifest-only importer deps.

#### Isolated linker

- An existing store entry whose dependencies re-resolved (override,
dedupe, update) now has its links refreshed on the next install
(measured cost below). `bun prune` builds the same store the installer
builds, so stale `name@version+<peerhash>` variants left by peer bumps
are removed and a kept package's real entry never is (whether it was
installed with full or `--production` features); on the hoisted linker,
dedupe / audit fix / update delete the nested copies whose rows they
collapsed instead of leaving the old copy loadable.
- Blocked entries resume through per-entry intrusive waiter lists
instead of a scan of every store entry after each completion (robobun's
#25983/#28425 attempted this). Measured on the reporter's repro from
#25799 (2,259 store entries) and a synthetic 6,425-entry monorepo, PR
build vs merge-base build: main-thread CPU in the link phase drops 0.85
→ 0.33 s and 5.5 → 0.85 s (the removed work grows quadratically); wall
time is unchanged with spare cores and 16% / 26% faster pinned to one
CPU, the CI/Docker shape in those reports. (The minute-long installs
originally reported were peer resolution, fixed before this PR's base.)
- Two pre-existing leaks surfaced by the new LSan-enabled tests are
fixed: the header buffer of every authenticated registry request, and
the per-entry lifecycle-script lists.

#### Other bug fixes

`bun add x@npm:pkg` writes a range; `bun add --trust a b` no longer
drops `b` when `a` was already trusted; `bun add x --dev` no longer
rewrote every group in `bun.lock`; `catalog:` peer hoisting;
`dependency::Version::eql` treated all `catalog:` specifiers as equal; a
`file:` package whose dependencies reach itself (its own name, an `npm:`
alias under its own name, two link targets depending on each other — the
shapes a `package-lock.json` migration produces, and #25202's
`workspace:.` self-reference) hung `bun install` forever in the hoisting
tree — the migration shapes now install, and #25202's literal shape now
terminates with `Workspace dependency "foo" not found` rather than
installing as npm does; a peer of a `file:` package that was only placed
nested was also written to `optionalPeers` in a migrated bun.lock, so
the next `--frozen-lockfile` failed and a plain install rewrote the
lockfile; `catalog:` literals in `bun update`; alias output in the
install summary; help/completions for everything above.

#### Behavior changes to note in the release notes

- `bun update` moves transitive packages; `bun update <name>` no longer
adds an undeclared package (exit 1); `--production`/`--prod` on update
means "only update `dependencies` and `optionalDependencies`" (a group
filter like `--dev`, not the install flag) and `-r`+names with no match
is an error; `-i` updates only the selection.
- Project `bunfig.toml` overrides any `.npmrc`.
- `bun install <pkg> --filter x` edits `x` (not the root); `bun add y
--filter x` no longer installs a package named `x`; `add`/`remove
--filter '*'` no longer includes the root.
- A plain `bun add x` in a workspace whose default catalog lists `x`
writes `catalog:`; `audit fix` may rewrite exact pins;
`--frozen-lockfile --lockfile-only` writes nothing; overrides/catalog
changes fail frozen installs.
- One-time lockfile churn after upgrading for projects with `catalog:`
peers or dead `pkg@range` override rows; lockfiles that use
nested/scoped overrides are v3 and unreadable by older Bun (only when
opted in). Turborepo, Nx and Dependabot have been checked; the needed
upstream changes are open (nrwl/nx#36666 covers v2 and v3;
vercel/turborepo#13740 accepts v3 and preserves the object rows through
prune — turborepo main today parses v2 and rejects v3; dependabot needs
nothing). Note v2 itself only exists on the 1.4 line.
- `bun audit --json` keeps npm's contract: `--audit-level`/`--ignore`
decide the exit code, the JSON document is the full registry report
(closes #31013 as won't-change). Automatic removal of stale
`node_modules` entries on plain `bun install` (#32974) is separate from
this PR: `bun prune` is the manual form, and dedupe / audit fix / update
now clean up the nested copies they collapse on the hoisted linker;
#32974 should reuse prune's planner, and #29512 (sbom) is sequenced
after this so it can build on `reachable.rs` instead of carrying its own
walk.
- Deliberately kept where we differ from pnpm: root `bun update` covers
the whole workspace; dependents whose ranges allow follow a moved
version (one copy, not two); `--latest` works on transitive names;
`--no-save` touches neither file; prune deletion failures exit 1; audit
requests stay per-registry.

#### Performance

Measured on a 1,113-package Next/Prisma/MUI app (PR build vs a PR build
of the merge base, interleaved, plus canary and 1.3.14): every hoisted
cell is within noise except no-op install, +0.8 ms (+2%, identical
syscalls); isolated no-op is +1.9 ms (+3.9%) — the deliberate cost of
re-checking existing entries' links every install rather than persisting
a stamp file. Everything added is otherwise off the plain-install path
(gated on the feature being used or on a diff), and id-indexed sets are
bitsets.

### How did you verify your code works?

~1,100 new or ported test cases across the install suites (designed
behavior, cases ported from pnpm's suites, pinning tests for pnpm bugs
this implementation is immune to, arborist's fixtures, and the CI review
findings), all `toStrictEqual`; the whole `test/cli/install` directory
passes locally and the existing suites are unchanged except where a
pre-existing expectation was deliberately changed (each listed above).
`bun update` was additionally verified with a rerunnable differential
harness that runs pnpm 11 and this branch on 26 scenario families
against one registry and diffs the resulting resolutions edge by edge —
after this PR only the deliberate differences above remain. Ecosystem:
Turborepo, Nx and Dependabot were checked against the new lockfile
output.

Co-authored work absorbed with credit: @kjanat's #38190 (alias handling,
co-author on the commit), @charpeni's #31143 and @crystalin's #34407
(both superseded), and the tests of the earlier `bun update` PRs (#31752
by @zlotnika, #33127, #36381, #36729, #38224). robobun's #34688
(folder-dependency cycles; its tests are lifted, co-author on the
commit) and #37289 (migrated optionalPeers; its test is lifted,
co-author on the commit) were fixed independently here and are closed by
this PR. #28422's quadratic scan is fixed here as well (already closed).

Related but not closed — `bun prune` gives these a manual fix while the
automatic-cleanup asks stay open: #8662, #26305, #29793, #21216, #16176.
Also related: #10930, #26970, #26751.

Fixes #1343
Fixes #3605
Fixes #14719
Fixes #24122
Fixes #18612
Fixes #20238
Fixes #25826
Fixes #23615
Fixes #26973
Fixes #20593
Closes #31013
Fixes #28959
Fixes #28402
Fixes #27897
Fixes #26675
Fixes #10949
Fixes #18504
Fixes #13388
Fixes #24523
Fixes #6608
Fixes #19059
Fixes #16569
Fixes #8262
Fixes #11901
Fixes #13469
Fixes #25202
Closes #29664
Closes #31143
Closes #34407
Closes #34688
Closes #37289
Closes #38190

---------

Co-authored-by: Kaj Kowalski <info@kajkowalski.nl>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant