Skip to content

install: prune stale workspace node_modules in hoisted installs - #29794

Open
robobun wants to merge 8 commits into
mainfrom
farm/a068037a/prune-stale-workspace-node-modules
Open

install: prune stale workspace node_modules in hoisted installs#29794
robobun wants to merge 8 commits into
mainfrom
farm/a068037a/prune-stale-workspace-node-modules

Conversation

@robobun

@robobun robobun commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #29793

Repro

mkdir -p repro/packages/backend/node_modules/is-number
cd repro
echo '{"name":"root","private":true,"workspaces":["packages/backend"]}' > package.json
echo '{"name":"@repro/backend","dependencies":{"is-number":"^7.0.0"}}' > packages/backend/package.json
echo '{"name":"is-number","version":"0.0.0-stale"}' > packages/backend/node_modules/is-number/package.json

cd packages/backend
bun update --latest is-number --linker hoisted

Before: is-number@7.0.0 installs at the root, but the pre-existing packages/backend/node_modules/is-number@0.0.0-stale survives and shadows it during module resolution from the workspace. After: the stale workspace-local copy is removed.

Cause

The hoisted installer drives off Lockfile's tree, which only yields node_modules directories that still contain dependencies. When every dep in a workspace hoists to the root, that workspace's own tree node is dropped, so the installer never visits packages/<workspace>/node_modules and nothing removes stale entries there. (isolated_install rebuilds each workspace's node_modules from scratch, so it doesn't have this bug; the reporter confirmed a fresh isolated install doesn't reproduce.)

Fix

Before the install loop runs, walk each workspace's node_modules/ (plus one level into @scope/) and delete any entry whose name is a dependency the workspace declares but the tree resolves elsewhere, which is exactly the shadowing case: the stale copy wins module resolution over the hoisted one. Entries the lockfile never placed (a manual bun link, a hand-dropped folder) survive installs, and dotfiles (.bin, .cache) are untouched; empty @scope/ dirs are cleaned up. The prune only runs on unfiltered installs: --filter installs leave unselected workspaces' node_modules alone, and the security scanner's narrowed pre-install pass (packages_to_install) is skipped because the full install that follows performs the prune. Both policies match the semantics #38333's dedupe and --filter tests codify, with bun prune as the tool for extraneous entries.

Tests (test/cli/install/bun-update.test.ts)

  • stale workspace-local copy removed + dep hoisted to root
  • legitimately non-hoistable workspace-local copy preserved across a second install
  • --filter preserves the excluded workspace's non-hoistable copy (and still prunes genuinely stale entries)
  • scoped-package (@scope/pkg) pruning with empty-scope cleanup

3 of the 4 fail on base (the non-hoistable preservation test is a no-regression guard), all pass with the fix.

Rebase note

main ported the package manager from Zig to Rust while this PR was open, so the fix lives in src/install/hoisted_install.rs (the .zig file is no longer compiled). Rebased onto current main and squashed to a single commit. Rebased again after #36360 landed: merged its new imports (pack, basename) with this PR's (exists, setDefaultTimeout) and kept both its appended named-update tests and this PR's four tests; no logic changes on either side. Rebased a third time after #38333 (pnpm parity) landed: its tests establish that filtered installs leave unselected workspaces alone, so the prune is now gated to unfiltered installs, and its collapsed-row test was adapted (an unfiltered install now prunes the stale nested copy immediately, so the test re-plants it before exercising the filtered update's removal).


[review] gate passed · iteration 18 · 3 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-update.test.ts
bun test v1.4.0 (77f1a979f)

test/cli/install/bun-update.test.ts:
(pass) should update to latest version of dependency (~) [899.57ms]
(pass) should update to latest versions of dependencies (~) [800.58ms]
(pass) lockfile should not be modified when there are no version changes, issue#5888 [2026.69ms]
(pass) --recursive updates dependencies and peerDependencies in workspace members [648.74ms]
(pass) --recursive --latest updates workspace members to the latest version [963.63ms]
(pass) --filter updates only matching workspaces, leaving siblings and root untouched [775.11ms]
566 | it("--filter pkg-a removes the nested copy whose row it collapsed", async () => {
567 |   await nestedBazRepo("0.0.5", "0.0.3", { pkgA: "~0.0.3" });
568 |   expect(await rootBazVersion()).toBe("0.0.5");
569 |   // The unfiltered install that applied the widened range collapsed pkg-a's
570 |   // nested row and pruned the on-disk copy with it (#29793).
571 |   expect(await exists(pkgABazDir())).toBeFalse();
                    
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (a18eb4208)

test/cli/install/bun-update.test.ts:
(pass) should update to latest version of dependency (~) [313.80ms]
(pass) should update to latest versions of dependencies (~) [299.14ms]
(pass) lockfile should not be modified when there are no version changes, issue#5888 [637.38ms]
(pass) --recursive updates dependencies and peerDependencies in workspace members [221.44ms]
(pass) --recursive --latest updates workspace members to the latest version [213.57ms]
(pass) --filter updates only matching workspaces, leaving siblings and root untouched [235.80ms]
(pass) --filter pkg-a removes the nested copy whose row it collapsed [308.87ms]
(pass) --filter excluding a workspace leaves that workspace's node_modules alone [1734.63ms]
(pass) --filter with multiple patterns selects the union of matching workspaces [149.90ms]
(pass) named update -r --latest rewrites every workspace that declares the name, keeping each file's style [161.30ms]
(pass) named update --filter rewrites only the selected workspace [310.11ms]
(pass) named update accepts -F as the short form of --filter [264.71ms]
(pass) named update --filter of a workspace that does not depend o
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-update.test.ts
bun test v1.4.0 (77f1a979f)

test/cli/install/bun-update.test.ts:
(pass) should update to latest version of dependency (~) [700.42ms]
(pass) should update to latest versions of dependencies (~) [803.55ms]
(pass) lockfile should not be modified when there are no version changes, issue#5888 [1691.99ms]
(pass) --recursive updates dependencies and peerDependencies in workspace members [482.08ms]
(pass) --recursive --latest updates workspace members to the latest version [500.52ms]
(pass) --filter updates only matching workspaces, leaving siblings and root untouched [465.07ms]
(pass) --filter pkg-a removes the nested copy whose row it collapsed [676.64ms]
(pass) --filter excluding a workspace leaves that workspace's node_modules alone [792.39ms]
(pass) --filter with multiple patterns selects the union of matching workspaces [502.03ms]
(pass) named update -r --latest rewrites every workspace that declares the name, keeping each file's style [534.52ms]
(pass) named update --filter rewrites only the selected
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 906ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/24] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/24] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[3/24] gen cpp.rs (cppbind)
[4/24] gen JS modules (bundle-modules)
Preprocess modules (10798ms)
Bundle modules (139ms)
Postprocesss modules (245ms)
Bundle Functions (799ms)
Generate Code (44ms)

[12.05s] Bundled "src/js" for production
  2632 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/10] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/bo
... (truncated)
diff hotspot
src/install/hoisted_install.rs      | 261 ++++++++++++++++++++++++++++
 src/install/prune.rs                |   2 +-
 test/cli/install/bun-update.test.ts | 334 +++++++++++++++++++++++++++++++++++-
 3 files changed, 593 insertions(+), 4 deletions(-)

gate history · 4 passed · 0 rejected · iteration 18

evidence per changed file
file                                 reads  edits  tests
src/install/hoisted_install.rs          27     35    123
src/install/prune.rs                     1      2    123
test/cli/install/bun-update.test.ts     27     24    123

root cause · written by the author bot

The root cause was that the hoisted installer only wrote to node_modules directories that the hoisted dependency tree placed packages into, so when a dependency previously installed inside a workspace's local node_modules was later hoisted to the root, the stale workspace-local copy was never visited or removed and continued to shadow the updated package during module resolution. The fix adds a prune step before hoisted installs that uses the unfiltered lockfile tree to compute which dependency folder names each workspace's node_modules should contain, then deletes workspace-local entries (…

@robobun

robobun commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:05 PM PT - Aug 14th, 2026

@robobun, your commit f693a65 is building: #97058

@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a pre-install prune step for hoisted installs that, using the unfiltered lockfile tree captured before filtering, computes expected direct dependency folder names per workspace and removes stale top-level and @scope/* entries from each workspace's packages/<workspace>/node_modules. Failures are best-effort and ignored.

Changes

Cohort / File(s) Summary
Hoisted installer
src/install/hoisted_install.zig
Adds a pre-install cleanup that builds expected direct-dependency folder names per workspace from the unfiltered lockfile tree (before Lockfile.filter()), maps workspace package IDs to on-disk workspace paths, walks each packages/<ws>/node_modules, and deletes top-level entries (and @scope/*) not in the expected set. Missing directories and individual IO errors are tolerated; prune failures are swallowed. Attention: filesystem deletions and lockfile ID → workspace path mapping.
Regression tests
test/cli/install/bun-update.test.ts
Adds tests covering hoisted-linker pruning behavior in workspace installs/updates, including stale workspace-local removal, preservation of intentionally non-hoistable workspace-local packages, filtered-install behavior, and pruning of scoped packages and empty scope dirs. Switches test helper import to exists from fs/promises.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code changes directly address the linked issue #29793 by implementing workspace node_modules pruning before hoisted package installs and adding test coverage for the fix.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the hoisted install pruning bug: core logic in hoisted_install.zig and regression tests in bun-update.test.ts.
Title check ✅ Passed The title clearly and concisely describes the main change: pruning stale workspace node_modules entries during hoisted installs.
Description check ✅ Passed The description explains the bug, cause, fix, reproduction, test coverage, and verification results, although it does not use the template headings exactly.

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

Comment thread src/install/hoisted_install.zig Outdated
Comment thread src/install/hoisted_install.zig Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fixes in 6e28d84 look correct — keying expected_by_ws_path by the workspace filesystem path and building it from original_trees resolves both issues I flagged, and the new "preserves non-hoistable" test is good coverage. Given this adds unconditional deleteTree pruning to every hoisted workspace install, I'd still like a human to sign off.

Extended reasoning...

Overview

This PR adds a pre-install pruning pass to installHoistedPackages (src/install/hoisted_install.zig) that walks each workspace's node_modules/ and recursively deletes any top-level entry (and one level into @scope/) that the unfiltered hoisted tree does not place there. It fixes #29793, where stale workspace-local packages shadow hoisted copies during module resolution. ~225 lines of new Zig plus two regression tests in test/cli/install/bun-update.test.ts.

Prior review and fixes

My first-pass review found two data-loss bugs in the original revision: (1) the expected-set map was keyed by tree-iterator paths (node_modules/<pkg-name>/node_modules) while lookups used filesystem paths (packages/<dir>/node_modules), so lookups always missed and every workspace's node_modules was wiped; and (2) the expected set was built from the post-filter() trees, so --filter and the security-scanner pre-install would nuke excluded workspaces. Commit 6e28d84 fixes both by keying the map via workspace_fs_path_by_pkg_id (resolved from pkg_resolutions[pkg_id].tag == .workspace) and by passing original_trees/original_tree_dep_ids (captured before filter(), populated by cleanWithLoggerresolve()hoist(.resolvable)). A second test now asserts that a legitimately non-hoistable workspace-local package survives a re-install. I verified workspace_paths are stored with forward slashes across all writers (migration.zig:403 asserts no backslashes), so the Windows-only posix normalization on the map key being absent on the lookup key is harmless in practice.

Security risks

None in the conventional sense — no auth, network input, or untrusted data parsing. The risk profile here is destructive filesystem operations: deleteTree runs against user project directories on every hoisted install with workspaces, and the first revision would have silently destroyed legitimate nested packages. The corrected logic looks sound, but errors in the expected-set computation translate directly into deleted user files with no recovery path (catch {} swallows everything).

Level of scrutiny

High. hoisted_install.zig is the core package install path for the default linker; this code runs on every bun install/bun update in monorepos. The change is non-trivial new logic (not a mechanical fix), the initial revision contained two distinct data-loss bugs, and edge cases like nested workspaces, Windows path handling, trailing-slash normalization asymmetry between map-key and lookup-key, and the expected = null fallthrough semantics all merit a maintainer's eye.

Other factors

The two new tests cover the headline bug and the over-prune regression, but there is no test for --filter interaction, scoped-package pruning, or the security-scanner packages_to_install path. CI build #48382 is still in progress. Given the blast radius and that I already caught two destructive bugs here, I'm deferring rather than approving.

Comment thread src/install/hoisted_install.zig Outdated
Comment thread src/install/hoisted_install.zig Outdated
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/a068037a/prune-stale-workspace-node-modules branch from 511d1ad to 2e1008f Compare May 4, 2026 10:33

@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 feedback is addressed and I didn't find new issues, but this adds unconditional deleteTree logic to every hoisted workspace install — given the earlier revisions had data-loss edge cases and the failure mode is silent deletion, it's worth a human pass over the final expected == null semantics and the security-scanner / packages_to_install entry point.

Extended reasoning...

Overview

Adds a pre-install prune step to src/install/hoisted_install.zig (~230 new lines: pruneStaleWorkspaceNodeModules, workspaceNodeModulesKey, pruneNodeModulesAt, pruneScopedNodeModules) plus four regression tests in test/cli/install/bun-update.test.ts. The prune walks every workspace's node_modules/ and @scope/* and deleteTrees any entry not present in the unfiltered lockfile tree for that workspace. Runs unconditionally whenever workspace_paths.count() > 0, i.e. on every hoisted install in a monorepo.

Security risks

No injection/auth/crypto surface. The risk profile here is availability/data-loss: a logic error in key construction or tree-to-workspace mapping silently deletes legitimate package directories. Two such bugs already existed in earlier revisions of this PR (tree-iterator-path vs filesystem-path key mismatch; filtered-tree vs unfiltered-workspace-list mismatch wiping excluded workspaces and the security-scanner pre-install nuking everything). Both are fixed and now covered by tests, but the expected == null → delete every non-dotfile entry fallthrough remains the load-bearing behavior for the original bug, so any future divergence in workspaceNodeModulesKey or workspace_fs_path_by_pkg_id routes straight to mass deletion.

Level of scrutiny

High. This is production-critical install-path code that performs irreversible filesystem deletions on user projects, and it executes on every bun install/bun update in a hoisted workspace setup — not an opt-in flag. The logic depends on subtle lockfile invariants (tree dependency_id → resolution → .workspace tag → workspace_paths name-hash lookup → filesystem path) that aren't obvious from local reading. The PR history demonstrates the subtlety: the first two revisions would have caused data loss in common scenarios (--filter, security-scanner pre-install).

Other factors

  • All three of my prior inline comments are resolved; the author factored out workspaceNodeModulesKey so map insert and lookup are structurally identical, switched to original_trees for filter-safety, and moved to bun.FD.deleteTree.
  • Test coverage is good: stale removal, non-hoistable preservation, --filter preservation, scoped-package pruning.
  • No CODEOWNERS for src/install/.
  • Remaining things a human reviewer may want to sanity-check: behavior when getWorkspacePackageID returns 0 for a workspace that does exist (it skips the map insert but the workspace is still walked with expected = null); whether the security-scanner pre-install's original_trees is always the full unfiltered set at that call site; and whether deleting entries while iterating the directory is safe across all platforms' DirIterator implementations.

@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 my earlier feedback has been addressed and the bug hunter found nothing new, but this adds ~250 lines of recursive-delete logic to the hot path of every hoisted workspace install — given the blast radius (and the asan unreachable hit in bun-install.test.ts on the latest CI run), I'd like a human to sign off on the approach before it lands.

Extended reasoning...

Overview

The PR adds a pre-install prune step to src/install/hoisted_install.zig (~250 new lines across four functions: pruneStaleWorkspaceNodeModules, workspaceNodeModulesKey, pruneNodeModulesAt, pruneScopedNodeModules) plus four regression tests in test/cli/install/bun-update.test.ts. On every hoisted install that has workspaces, it walks each workspace's node_modules/ (and one level into @scope/) and deleteTrees any entry the unfiltered lockfile tree does not place there.

Security risks

No injection/auth/crypto surface. The risk profile here is data loss: recursive deletion of directories under user-controlled workspace paths, driven by lockfile state. The first two revisions of this PR had exactly that failure mode (key-namespace mismatch and --filter interaction both caused legitimate packages to be wiped); both were fixed after review and now have dedicated tests. Paths are derived from lockfile.workspace_paths (already posix-normalized at every write site) and opened relative to bun.FD.cwd(), so there's no obvious traversal vector beyond what the lockfile already controls.

Level of scrutiny

High. This runs unconditionally on every bun install/bun update in a workspace monorepo with the hoisted linker, and its failure mode is silent recursive deletion. The semantic choice — "delete anything in a workspace's node_modules that the current lockfile doesn't place there" — also removes packages a user manually dropped in, or artifacts left by another package manager. That may well be the right call (it matches the issue's intent), but it's a behavioral policy decision a maintainer should ratify.

Other factors

  • Review history: three rounds of my own inline feedback (two 🔴 correctness bugs, one 🟡 key-normalization nit, one 🟡 .stdDir() nit) have all been addressed and resolved; subsequent commits (c98d98e, 2e1008f, 0b4b117) further harden against getWorkspacePackageID failure and mid-iteration deletes.
  • CI: the most recent robobun status (commit c98d98e) reports musl build failures and pid 4162 reached unreachable code in test/cli/install/bun-install.test.ts on x64-asan — worth confirming whether that's related to this change before merging.
  • Callers: installHoistedPackages is invoked from both install_with_manager.zig and security_scanner.zig; the original --filter/packages_to_install over-prune was specifically dangerous for the security-scanner path, and while it's now driven off original_trees, a maintainer eye on that interaction would be valuable.
  • Test coverage: good — the four new tests cover the happy path, non-hoistable preservation, --filter exclusion, and scoped-package pruning.

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

Earlier feedback is all addressed and I didn't find new issues, but this adds ~270 lines of destructive deleteTree logic to the hot bun install path for every monorepo — given the first revision had two bugs that wiped legitimate workspace node_modules, the design choice to prune even --filter-excluded workspaces, and the asan/musl CI failures on c98d98e, this one really wants human eyes before merge.

Extended reasoning...

Overview

This PR adds a pre-install prune step to src/install/hoisted_install.zig (~270 new lines) that walks every workspace's node_modules/ (and one level into @scope/) and recursively deletes any top-level entry the unfiltered lockfile tree doesn't place there. It also adds four regression tests in test/cli/install/bun-update.test.ts covering the original shadowing bug, preservation of legitimately non-hoistable nested packages, --filter interaction, and scoped-package pruning, plus a setDefaultTimeout bump.

Security risks

No auth/crypto/network surface. The risk is purely destructive filesystem behavior: deleteTree runs against paths derived from lockfile.workspace_paths relative to cwd. Paths come from the lockfile (not arbitrary user input mid-request), dotfile entries are preserved, and the walk is bounded to <ws>/node_modules top-level + @scope/*. I don't see an injection or traversal vector, but a logic error here translates directly into silently deleting user files — which is exactly what the first revision of this PR did under --filter and the security-scanner pre-install before it was fixed.

Level of scrutiny

High. This runs unconditionally on every hoisted bun install/bun update in any repo with workspaces, and the failure mode is silent data loss (errors are swallowed with catch {}). The initial revision shipped two separate bugs that each would have wiped entire workspace node_modules directories; both were caught in review and fixed, but that history argues for a careful human pass on the final logic rather than a bot sign-off.

Other factors

  • All three of my prior inline comments (two 🔴 destructive-prune bugs, one 🟡 bun.FD.deleteTree convention nit) have been addressed and resolved in subsequent commits.
  • There's a non-trivial design decision baked in: the prune walks the unfiltered tree, so a bun install --filter X will still delete stale entries in workspaces the user explicitly excluded. The test asserts this is intentional, but it's the kind of UX/semantics call a maintainer should ratify.
  • The robobun CI comment shows musl build failures and an x64-asan "reached unreachable code" in bun-install.test.ts for commit c98d98e; several hardening commits landed after, but I can't confirm CI is green on the current head.
  • Test coverage for the new behavior is solid (four targeted cases including the over-prune guards), which raises confidence but doesn't substitute for a human review of code that does recursive deletes in the install hot path.

@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 my earlier feedback is addressed and I didn't find anything new in this revision, but since this adds an unconditional deleteTree sweep over every workspace's node_modules on every hoisted install, it's worth a human sign-off on the pruning semantics — specifically that wiping anything in <ws>/node_modules not placed by the current lockfile (e.g. npm linked packages, manually-dropped folders) is the intended product behavior.

Extended reasoning...

Overview

Adds ~270 lines to src/install/hoisted_install.zig introducing pruneStaleWorkspaceNodeModules and helpers, called unconditionally from installHoistedPackages whenever workspace_paths.count() > 0. For each workspace it computes the set of folder names the unfiltered lockfile tree places directly in <ws>/node_modules, then walks the on-disk directory (one level, plus one level into @scope/) and deleteTrees every non-dotfile entry not in that set. Four new regression tests in bun-update.test.ts cover the original shadowing bug, preservation of legitimately non-hoistable nested packages, --filter safety, and scoped-package pruning.

Security risks

No injection/auth/data-exposure surface. The risk profile here is data loss: this is destructive filesystem code (deleteTree) running on every hoisted workspace install, driven by lockfile-derived state. The first revision of this PR contained two separate data-loss bugs (path-key namespace mismatch causing every workspace node_modules to be wiped; --filter wiping excluded workspaces) that I flagged and the author fixed. The current revision looks correct — keys are derived through a single workspaceNodeModulesKey helper, the expected set is built from pre-filter original_trees, and workspaces whose getWorkspacePackageID lookup fails are skipped rather than walked with an empty expected set.

Level of scrutiny

High. installHoistedPackages is the hot path for every bun install/bun update in a hoisted-linker monorepo, and the new code unconditionally deletes directories based on a computed expected set. Beyond correctness, there is an embedded product decision: anything a user has placed in packages/<ws>/node_modules that the lockfile doesn't account for (an npm link/bun linked package, a manually-vendored folder, output from another tool) will now be silently removed on the next install. That may well be the right call, but it's a behavior change a human owner of bun install should ratify rather than a bot.

Other factors

  • All three of my prior review rounds are resolved; the author responded to each with targeted fixes (key unification, original_trees, shared workspaceNodeModulesKey, bun.FD.deleteTree).
  • Test coverage is good for the cases that broke earlier revisions, though there's no test asserting that an npm link-style symlink in a workspace node_modules is (or isn't) preserved — which is the main product-behavior question.
  • Errors are swallowed (catch {}) by design so prune failures never block installs; that's reasonable but means edge-case misbehavior would be silent.
  • src/install/ is core package-manager code; CODEOWNERS-level scrutiny is appropriate.

@robobun
robobun force-pushed the farm/a068037a/prune-stale-workspace-node-modules branch from f2159db to 5f91995 Compare June 2, 2026 01:58

@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 feedback is addressed and carried into the Rust port, and I didn't find new issues — but this adds ~250 lines that recursively delete directories inside every workspace's node_modules on every hoisted install, reimplemented from scratch after the Zig→Rust rebase, so it's worth a human pass over the deletion logic (especially the expected == None → wipe-everything path and the cwd-relative open on Windows).

Extended reasoning...

Overview

The PR adds a pre-install sweep to src/install/hoisted_install.rs that, for each workspace, opens <workspace>/node_modules, computes the set of folder names the unfiltered lockfile tree places there, and delete_trees anything else (including one level into @scope/ dirs). Four regression tests in bun-update.test.ts cover the original shadowing bug, preservation of legitimately non-hoistable nested packages, --filter safety, and scoped-package pruning. Since my last review the entire implementation moved from hoisted_install.zig to hoisted_install.rs following the package-manager port on main; the logic was rewritten rather than mechanically translated.

Security risks

No injection/auth/crypto surface. The risk profile is data loss: this code runs unconditionally on every hoisted install in a workspace repo and recursively deletes directories under user-controlled paths. The first two iterations of this PR had exactly that failure mode (wiping legitimate nested packages, wiping --filter-excluded workspaces). The current revision fixes those — workspace_node_modules_key() is a single shared helper, the expected set is built from original_trees captured before filter(), and unresolvable workspaces are skipped — but the expected == None branch still means "delete every non-dotfile entry," so any future divergence between the map key and lookup key (or a workspace whose tree node is absent for an unanticipated reason) degrades to a full wipe.

Level of scrutiny

High. This is core bun install behavior touching the filesystem destructively, with errors deliberately swallowed (best-effort), so a wrong deletion produces no diagnostic. The Rust reimplementation is new code that no human has reviewed yet — only the Zig version went through review cycles. Specific spots worth a human eye: the cfg!(windows) separator normalization being applied to a path that's then passed to open_dir_for_iteration(cwd, …); the linear fs_path_for scan inside the tree loop (fine for typical workspace counts but O(workspaces × trees)); and Dir::from_fd ownership vs. the borrowed parent_dir: Fd passed into prune_scoped_node_modules.

Other factors

Test coverage is solid and directly exercises the previously-broken cases. All four of my earlier inline comments are resolved and the fixes survived the port. No outstanding reviewer comments. Given the destructive nature, the fresh-rewrite-after-rebase, and this PR's own history of data-loss bugs in earlier revisions, deferring to a human is the safer call.

Comment thread src/install/hoisted_install.rs Outdated
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI is green on everything my diff touches. On build #59756 all 224 non-darwin test-bun lanes passed (Linux x64 / x64-asan / musl / alpine / ubuntu / Windows), including the install test lanes that exercise this change. The only red is 3 darwin … test-bun lanes marked Expired — the macOS CI agents never got allocated before the job window closed, which is an agent-capacity/scheduling issue, not a test failure. (The previous build also had an unrelated darwin aarch64 - build-cpp die on a GitHub HTTP 429 while fetching mimalloc; I already spent my one retrigger re-rolling that.)

This needs a maintainer to re-run the expired darwin lanes / merge — I don't want to spam more ci: retrigger commits. The change itself is scoped to src/install/hoisted_install.rs + test/cli/install/bun-update.test.ts and passes the fail-before/pass-after gate (3 of the 4 new tests fail on base, all pass with the fix).

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Status at head f693a65 (build #97058): the only CI red is test/js/node/test/parallel/test-http-chunk-problem.js, which fails identically on every open PR built against current main (for example #38720 and #38774, both unrelated changes), so it is a main-side regression this PR cannot affect. The build auto-retried three times on that same shard.

Everything this diff touches is green: bun-update.test.ts (160 pass, including the four new tests and the adapted collapsed-row test), bun-dedupe.test.ts, bun-add-filter.test.ts, and the security-scanner workspace tests all pass locally at this head, and the three new tests fail on unmodified main at the merge base.

All automated review findings are addressed (declared-deps-only pruning, filtered/scanner gates, symlinked @scope guard via open_real_subdir, dangling .bin sweep via prune_bins); the latest bot review found no new issues and asks for human sign-off on the prune policy, which is what this PR now needs.

@robobun
robobun force-pushed the farm/a068037a/prune-stale-workspace-node-modules branch from 7cef654 to 3a9c810 Compare June 5, 2026 21:45
Comment thread test/cli/install/bun-update.test.ts
@robobun
robobun force-pushed the farm/a068037a/prune-stale-workspace-node-modules branch from f155403 to 4d9d54b Compare August 13, 2026 21:59
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs Outdated
Comment thread src/install/hoisted_install.rs
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (5fb1bca). The only conflict was in bun-update.test.ts where #36360 appended tests at the same location as this PR's; kept both sides (all 42 tests pass locally). Also condensed the comment blocks the comment-cop flagged. Review threads are swept.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/install/hoisted_install.rs:885-893 — nit: sys::rmdirat(parent_dir, "@scope") runs while scope_dir (the owning Dir handle for that same directory) is still live — it doesn't drop until the closing brace two lines later. Dir::delete_tree documents the opposite ordering as a Windows requirement (src/sys/dir.rs:188-196: "On Windows we must close before removing") and explicitly closes the fd before unlinkat_a(..., AT_REMOVEDIR). FILE_SHARE_DELETE makes it work today (Windows CI passed, and the scoped-prune test would catch a hard failure), so this is a convention match rather than a demonstrated bug: adding drop(scope_dir); before the if !has_remaining block mirrors dir.rs:196.

    Extended reasoning...

    What

    prune_scoped_node_modules opens scope_dir (an owning Dir for the @scope directory) at the top of the function, then — inside if !has_remaining { ... } — calls sys::rmdirat(parent_dir, z) to remove that same directory while scope_dir is still live. The Dir handle (and the iter created from scope_dir.fd()) don't drop until the function's closing brace, one line after the rmdirat.

    The codebase's own recursive-delete, Dir::delete_tree, follows the opposite ordering and documents why. At src/sys/dir.rs:188-196:

    // Reached the end of the directory entries — exhausted; remove the
    // directory itself. On Windows we must close before removing.
    let dir_fd = top.iter.dir();
    ...
    stack.pop();
    let _ = close(dir_fd);
    
    ... unlinkat_a(parent_dir, &name, AT_REMOVEDIR) ...

    Per REVIEW.md: "If neighboring code does something differently than you're about to, find out why." This is the same operation ("remove a directory I've been iterating") with an explicitly commented ordering requirement in the in-tree helper.

    Step-by-step

    Take the scoped-prune test's setup: packages/backend/node_modules/@stale/pkg/ exists, nothing else under @stale/.

    1. sys::open_dir_for_iteration(parent_dir, b"@stale")scope_dir: Dir owns an open handle to @stale.
    2. Iteration finds pkg; it's not in expected, so scope_dir.delete_tree(b"pkg") removes it. has_remaining stays false.
    3. if !has_remainingsys::rmdirat(parent_dir, "@stale\0") runs. scope_dir is still open on @stale.
    4. On POSIX this is fine — you can rmdir a directory with an open fd on it. On Windows, whether this succeeds depends on the sharing mode and the delete semantics in effect.
    5. }scope_dir drops, closing the handle.

    Why this works today (and is therefore a nit)

    bun_sys opens directory handles with FILE_SHARE_DELETE (part of the FILE_SHARE constant at src/sys/lib.rs:6432-6434), and modern Windows honors POSIX-delete semantics for FILE_DISPOSITION_INFORMATION_EX, so rmdirat on a directory with an open share-delete handle succeeds. The scoped-prune test asserts expect(exists("@stale")).toBe(false), which would fail on Windows if the rmdirat were rejected — and per the PR timeline Windows CI passed. So there's no demonstrated failure here; this is a divergence from an explicitly documented in-tree convention for the identical operation.

    Impact if it ever did fail

    Purely cosmetic. let _ = sys::rmdirat(...) swallows the error, scope_dir drops on the very next line, and the now-empty @stale/ directory is left behind — the exact outcome the block exists to prevent, but not a correctness issue (module resolution walks past empty scope dirs).

    Fix

    One line, mirroring dir.rs:196 exactly:

        }
    
        drop(scope_dir);
    
        // Remove the scope directory if it ended up empty.
        if !has_remaining {

    (iter was created from scope_dir.fd() and is also still live at the rmdirat; dropping scope_dir closes the underlying fd, but for symmetry with the snapshot-then-delete pattern in prune_node_modules_at you could drop(iter) first as well.)

  • 🟡 src/install/hoisted_install.rs:118-134 — nit: this 17-line block restates the doc-comment on prune_stale_workspace_node_modules (lines 641–658) almost verbatim — both explain the shadowing problem, why original_trees is used, why packages_to_install is gated, and both cite #29793. Per REVIEW.md ("Only comment what the code cannot say. One line. … Prefer links to GitHub issues"), the call site can collapse to something like // Sweep stale workspace node_modules before the install loop (#29793). and let the function's doc-comment carry the detail. The comment-cop bot has flagged 10 similar multi-paragraph blocks in this diff (lines 134, 659, 679, 704, 717, 771, 782, 795, 799, 809, 828); most narrate what the code already says and can be trimmed the same way.

    Extended reasoning...

    What

    The github-actions comment-cop check posted 10 unresolved inline comments on src/install/hoisted_install.rs (lines 134, 659, 679, 704, 717, 771, 782, 795, 799, 809, 828), each quoting CLAUDE.md rule #13: "If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code." This corresponds to REVIEW.md's explicit rule under Code style & idioms reviewers enforce:

    Only comment what the code cannot say. One line. Never restate what the code does. Never narrate the change. Prefer links to GitHub issues.

    None of the flagged blocks are SAFETY comments (which are required and exempt) — they're explanatory prose.

    The clearest instance: call-site vs. doc-comment duplication

    The 17-line block at lines 118–134 preceding the prune_stale_workspace_node_modules(...) call and the 18-line /// doc-comment on the function itself (lines 641–658) say the same thing twice:

    Fact Call-site comment (118–134) Doc-comment (641–658)
    Stale workspace-local copies shadow hoisted ones "those directories would shadow the hoisted copies during module resolution" "the leftover workspace-local copy shadows the hoisted one during module resolution"
    Tree iterator never visits empty workspace node_modules "The installer only visits trees that still have dependencies, so … stale entries are never seen" "The tree iterator only visits node_modules directories that still contain entries, so those stale folders are otherwise never seen"
    original_trees is unfiltered so --filter doesn't wipe excluded workspaces "We build the expected set from the unfiltered original_trees on purpose: with --filter …" "trees/tree_dep_ids are the unfiltered buffers captured before Lockfile::filter() runs, so entries a --filter-excluded workspace legitimately owns stay put"
    Issue reference "(Issue #29793.)" "issue #29793"

    The only content unique to the call site is the packages_to_install.is_none() gate rationale — and that's already self-evident from the condition plus a one-line note.

    Step-by-step: how each flagged block violates the rule

    1. Lines 118–134 (call site): duplicates the function doc-comment; narrates the change ("Remove stale packages … before the install loop runs"). → Collapse to one line + issue link; the function name and doc already say all of it.
    2. Lines 641–658 (function doc): 4 paragraphs. Trim to what the signature can't express — the why (shadowing) and the contract (trees must be unfiltered). Two–three lines suffice.
    3. Lines ~674–679 ("Resolve every workspace to (package id, filesystem-relative path). Only workspaces that resolve cleanly are walked …"): restates what the loop body does. The if pkg_id == 0 { continue; } line is self-explanatory.
    4. Lines ~700–704 ("Map of workspace filesystem-relative node_modules path …"): restates the type declaration and its use.
    5. Lines ~715–717 ("Only trees whose dependency_id resolves to a workspace package …"): restates the Tag::Workspace guard four lines below.
    6. Lines ~767–771 (workspace_node_modules_key doc, 4 lines): the second half narrates a hypothetical failure mode; "single source of truth for the map key" is enough.
    7. Lines ~780–782 / 795–800 / 807–809 / 825–828 in prune_node_modules_at / prune_scoped_node_modules: each is a multi-line paragraph narrating what the next few lines do ("Snapshot the directory listing before deleting anything …", "Scoped package directory. Recurse one level …"). Per REVIEW.md these should be one line or nothing — the readdir-snapshot rationale is the only one that carries non-obvious information ("batch refill after a delete can re-surface entries"), and even that fits on one line.

    Why existing code doesn't prevent it

    The comment-cop check is an automated GitHub Action that flags multi-paragraph comment blocks in the diff; it doesn't auto-fix. The 10 unresolved bot comments are still open on the PR (posted 2026-08-13T21:59Z), so this is currently a live check the author needs to address.

    Impact

    Style/maintainability only — no runtime effect. The code works correctly; the concern is (a) the same explanation living in two places will drift, (b) narration-heavy comments bury the one non-obvious fact per block, and (c) it violates a repo-enforced convention that comment-cop is actively flagging. This is a nit, not a correctness defect.

    Fix

    Keep one canonical explanation on the function doc-comment (trimmed), and reduce the call site to:

    // Sweep stale workspace node_modules before installing (#29793); skipped on the
    // scanner's narrowed pre-install pass — the full install that follows does it.
    if packages_to_install.is_none() && this.lockfile.workspace_paths.count() > 0 {
        prune_stale_workspace_node_modules(&this.lockfile, &original_trees, &original_tree_dep_ids);
    }

    For the other flagged blocks: delete the ones that restate the code (679, 704, 717, 809, 828), and cut the rest to a single line stating only the non-obvious invariant (e.g. line 795 → // Snapshot before deleting: batch readdir refill after unlink can re-surface entries.).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this PR and the bug-hunting pass found no issues. Because the change adds a destructive pre-install pass that recursively deletes entries from every workspace's node_modules/ based on a lockfile-derived expected set, a human look would still be worthwhile — the failure mode of a wrong expected set is silent data loss.

Checked: the map key and lookup key now go through the same workspace_node_modules_key helper; the prune uses the unfiltered original_trees so --filter doesn't wipe excluded workspaces (covered by test); packages_to_install.is_none() gates out the security-scanner pre-install; dotfiles (.bin, .cache) are skipped; pkg_id == 0 workspaces are excluded so a stale-lockfile miss can't wipe a directory with an empty expected set.
Confirmed Lockfile::filter() only rewrites buffers.trees/hoisted_dependencies — the dependencies/string_bytes/packages buffers the prune indexes via original_trees are unchanged, so the pre-filter indices stay valid.

Extended reasoning...

Overview

Adds ~230 lines to src/install/hoisted_install.rs implementing prune_stale_workspace_node_modules and helpers, invoked once before the hoisted install loop. For each workspace it opens <ws>/node_modules/, builds an expected-folder-name set from the unfiltered lockfile tree, and delete_trees any top-level entry (plus one level into @scope/) not in that set. Four tests are added to test/cli/install/bun-update.test.ts covering the repro, a non-hoistable-preservation guard, --filter interaction, and scoped-package cleanup.

Security risks

The new code recursively deletes directories under user-controlled workspace paths. Paths come from lockfile.workspace_paths (already posix-normalized at every write site) and are opened relative to Fd::cwd(); no user-supplied string reaches delete_tree beyond directory-entry names read from the fd. The main risk is over-deletion if the expected-set construction misses a legitimately-placed entry (e.g. an edge in tree/resolution tag handling), which would silently remove packages the lockfile actually expects. No injection/auth surface.

Level of scrutiny

High. This is a behavioral change to bun install/bun update on every hoisted monorepo, and the operation is irreversible rm -rf on user directories. Correctness hinges on lockfile-tree invariants (a workspace with anything placed in its node_modules always has a matching Tree node whose dependency_id resolves to a Tag::Workspace package present in workspace_paths), on filter() not perturbing the buffers the pre-filter tree indices point into, and on dependency name matching the on-disk folder name for aliased deps. These held up under my reading, but they're the kind of invariants a package-manager maintainer should sign off on.

Other factors

The PR has been through 17 iterations; my four prior nits (key-normalization asymmetry, stdDir().deleteTree, security-scanner double-prune, unread stdout pipes) were all addressed and are resolved. The comment-cop bot's flags were addressed in 5fb1bca. Tests use the local dummy registry, drain stderr, and 3 of 4 fail on base per the gate evidence. The setDefaultTimeout(5min) addition is broad but matches other install test files. No outstanding human review comments.

Closes #29793.

A previous package-local install (or a manual edit) can leave
`packages/<workspace>/node_modules/<pkg>` behind. When the current
hoisted install hoists `<pkg>` to the root, the leftover workspace-local
copy shadows the hoisted one during module resolution, so
`bun update --latest <pkg> --linker hoisted` reports success while the
workspace keeps resolving the stale version.

The hoisted installer drives off the lockfile tree, which only lists
`node_modules` directories that still hold dependencies. When a
workspace's deps all hoist to the root, its own tree is dropped and the
installer never visits `packages/<workspace>/node_modules`, so the stale
entry is never removed (isolated installs rebuild each workspace's
`node_modules` from scratch and sidestep this).

Before the install loop runs, walk each workspace's `node_modules/`
(plus one level into `@scope/`) and delete any entry the tree layout
doesn't place there. The expected set is built from the unfiltered tree
buffers captured before `Lockfile::filter()`, so `--filter`-excluded
workspaces keep the packages they legitimately own: only genuinely
stale entries (not placed anywhere by the lockfile) are removed. The
prune is skipped on the security scanner's narrowed pre-install pass
(`packages_to_install`); the full install that follows performs it.

Tests: stale removal + hoist, non-hoistable workspace-local copy
preserved across re-install, `--filter` preservation of excluded
workspaces, and scoped-package (`@scope/pkg`) pruning with empty-scope
cleanup.
--filter installs leave unselected workspaces' node_modules alone
(bun prune handles those), matching the semantics the pnpm-parity
change established. Adapt the collapsed-row test: an unfiltered
install now prunes the stale nested copy immediately, so re-plant
it before exercising the filtered update's removal.
@robobun
robobun force-pushed the farm/a068037a/prune-stale-workspace-node-modules branch from 5fe9ad9 to 735d5c5 Compare August 14, 2026 23:09
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs Outdated
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (735d5c5). This one was more than a textual conflict: #38333 (pnpm parity) landed tests that codify filtered-install semantics, so the prune is now gated to unfiltered installs (--filter installs leave unselected workspaces' node_modules alone; bun prune covers those). #38333 also fixed the update path for the basic shadowing case, so the install-path tests here are what still fail on base (3 of them). Its collapsed-row test was adapted: an unfiltered install now prunes the stale nested copy immediately, so the test re-plants it before exercising the filtered update's removal. Full file passes 160/160 locally; details in the PR description's rebase note.

A workspace entry is stale only when its name is a dependency the
workspace declares and the tree resolves elsewhere; that is the
shadowing case from #29793. Entries the lockfile never placed (a
manual bun link, a hand-dropped folder) survive installs, matching
the dedupe and add --filter tests that codify that policy.
Comment thread test/cli/install/bun-update.test.ts
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs
Comment thread test/cli/install/bun-update.test.ts Outdated
Comment thread src/install/hoisted_install.rs Outdated
Reuse prune.rs's open_real_subdir (lstat + O_NOFOLLOW) so the scoped
descent cannot delete through a symlink outside the workspace's
node_modules, matching the guard bun prune uses for the same walk.
Comment thread src/install/hoisted_install.rs
Comment thread src/install/hoisted_install.rs
A previously nested dep with bins leaves links in the workspace's
node_modules/.bin when the prune removes it; reuse prune_bins (now
pub(crate)) the way remove_collapsed_copies' housekeeping does.
Comment thread src/install/hoisted_install.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the latest revision (f693a65) and found no new issues — the prune_bins sweep addresses my last note, and the earlier open_real_subdir switch covers the symlinked-@scope descent. Because this adds a recursive-delete pass to every unfiltered hoisted install and rewrites a #38333 test assertion, a human sign-off on the prune policy (declared-deps-only, pre-filter tree, --filter/scanner gates) would still be worthwhile.

What was reviewed:

  • Prunable-set construction — only names the workspace declares, minus names the tree places locally; undeclared entries and dotfiles untouched.
  • Symlink handling — scoped descent now refuses symlinked @scope; unscoped delete_tree acts on the entry name so a symlink is unlinked, not followed.
  • .bin sweep gated on deleted_any; both #[cfg] branches of prune_bins made pub(crate).
  • The Resolvable-vs-Filter tree divergence under --production/--omit is over-conservative only (never over-deletes), per the earlier thread.
Extended reasoning...

Overview

Adds a pre-install prune pass to install_hoisted_packages (~260 new lines in src/install/hoisted_install.rs) that walks each workspace's node_modules/ and @scope/*, deleting entries whose name the workspace declares as a dependency but the hoisted tree places elsewhere. Makes open_real_subdir and prune_bins pub(crate) in src/install/prune.rs for reuse. Adds four tests and adapts one #38333 test in bun-update.test.ts.

Security risks

The operation is a recursive delete inside user-controlled node_modules trees. The scoped-descent symlink-escape I flagged earlier is now guarded by open_real_subdir (lstat + O_NOFOLLOW). The unscoped path calls delete_tree on the entry name relative to the real node_modules fd, which unlinks a symlink rather than following it. Paths derive from the lockfile's workspace_paths (repo-local package.json data), not from network input. No auth/crypto surface.

Level of scrutiny

High. This is destructive filesystem behavior wired into every unfiltered hoisted bun install/update in a workspace repo — a hot path in core package-manager infrastructure. The PR has been through 18 iterations with several substantive corrections (symlink escape, .bin dangling links, filtered-install semantics, a disputed test rewrite). The design choices — prune only declared names (leaving bun link/hand-dropped folders to bun prune), use the pre-filter() Resolvable tree (accepting a false-negative under --production/--omit), and skip filtered/scanner-narrowed installs — are reasonable but are policy calls a maintainer should ratify.

Other factors

All prior automated findings are addressed as of f693a65; the bug hunter found nothing new this run. One open item is a comment-cop bot flag on the two-line comment above the new prune_bins call, consistent with its earlier flags the author already answered. The nestedBazRepo test rewrite at line ~571 was disputed in an earlier round; the author's empirical rebuttal (lockfile dump shows only baz@0.0.5 after install #2) is plausible and the gate evidence shows the file passing on HEAD, but the assertion change to a pre-existing #38333 test is worth a human glance.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun update --linker hoisted leaves stale workspace-local dependency that shadows updated root install

1 participant