Skip to content

install: keep a package below an ancestor whose version of its peer is out of range - #36300

Open
robobun wants to merge 1 commit into
mainfrom
claude/farm/75a926bf/fix-peer-hoisting
Open

install: keep a package below an ancestor whose version of its peer is out of range#36300
robobun wants to merge 1 commit into
mainfrom
claude/farm/75a926bf/fix-peer-hoisting

Conversation

@robobun

@robobun robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With the hoisted linker, a package is hoisted (or deduplicated onto a copy) at an ancestor node_modules even when that ancestor holds a version of one of its peer dependencies that the peer range rejects, while the package that pulled it in has the right version nested next to itself. From the hoisted position require(peer) finds the wrong version, and when the peer is shared state between the two (ajv / ajv-errors) they end up with different module instances. Reported as Bug(works in npm): Using Orval to generate API client fails after installing deps using bun install #20376:
    SyntaxError: Unexpected token ':'
        at Ajv.compileSchema (.../@stoplight/spectral-core/node_modules/ajv/dist/compile/index.js:89:30)
    
  • Smallest shape: root depends on ajv@6 and @stoplight/spectral-core; spectral-core depends on ajv@^8 and ajv-errors, and ajv-errors has peerDependencies: { ajv: "^8" }. ajv@8 nests under spectral-core, but ajv-errors is hoisted to the root, next to ajv@6.
  • Cause: Tree::hoist_dependency (src/install/lockfile/Tree.rs) only looks at whether an ancestor already holds an entry with the same name as the package being hoisted. The package's own peer edges are not consulted.

Fix

  • When hoist_dependency starts walking up from the directory a package was declared in, Builder::begin_peer_probes collects the package's required peers with an npm range (optional peers, catalog:/dist-tag/git peers, and peers the package also lists as a regular dependency are skipped; nothing is collected for workspace or link: packages, which install as symlinks). If the package that owns that directory has a regular dependency on the peer, that copy is the one the package finds from there, so the probe starts out provided when it is in range and is not created at all when it is not. Otherwise the probe starts out not provided.

  • At every ancestor, Builder::probe_peers looks at the entry with the peer's name, if any. In range: the probe becomes provided (the package would still see that copy if it stayed put). Out of range: if the probe is provided, the hoist stops and the package is placed where it was declared (DependencyLoop, the existing result for a conflict); otherwise the probe is dropped, because a nested copy would resolve the same out-of-range version the hoisted copy does, so the layout stays what it is on main (one shared copy).

  • Only version ranges and the resolutions of entries already placed in the tree are compared (Resolution::satisfies_dependency_version, the comparison the resolver binds peers with). The peer edge's own binding is never read: for required peers it is the highest in-range version anywhere in the lockfile, so comparing ids nested a copy whenever two in-range versions existed, and for non-npm ranges it is re-derived from the printed tree on reload, so a decision based on it would not survive a second install.

  • The probe runs before the same-name match on purpose: when the root holds the package next to an out-of-range peer and a consumer provides an in-range one, deduplicating onto the root copy is the bug.

  • Not covered: a peer-dependent package whose dependent was itself hoisted away from the provider (root no-deps@1, outer -> { mid, no-deps@2 }, mid -> child with a peer on no-deps@2). mid hoists to the root, so nothing child could stay below holds no-deps@2, and it is hoisted to the root and resolves no-deps@1, as on main. Placing it correctly means placing the peer next to it (peer-set placement), which this change does not attempt.

  • Also known: the owner-copy seed in begin_peer_probes reads the owner's full edge list, so in the install-time tree an edge omitted by --production / --omit can seed a probe. Either direction only adds or skips a nested copy; the package never resolves a different version than it does on main, and the pass that writes bun.lock places every edge, so it is exact there. Left for a follow-up.

  • No existing test expectation changes. New tests in test/cli/install/hoist.test.ts (run concurrently), each installing from scratch and again from the written bun.lock:

    • stays next to the peer version it was resolved against: the shape above on registry fixtures. Fails on main.
    • stays below a peer version provided further up than its own dependent: mismatched-peer-deps-lvl0 -> lvl1 -> lvl2, all with peers on no-deps, under a workspace that provides an in-range no-deps while the root pins an out-of-range one. Fails on main (all three are hoisted to the root).
    • hoists past an out-of-range version when nothing below would satisfy the peer either: root pins the peer out of range, a 1.x exists elsewhere in the lockfile, two workspaces depend on the package without providing the peer. One copy at the root; this is the shape the previous revision duplicated.
    • hoists when its own dependent installs an out-of-range copy of the peer: the dependent (a tarball kept under a workspace by a name clash at the root) nests an out-of-range no-deps under itself while the workspace above holds an in-range one; staying put would find the dependent's copy, so the package hoists as on main.
    • hoists when the version at the root satisfies the peer range: two in-range versions, the lower one at the root; the dependent stays shared. This is the shape an id comparison duplicated.
    • ignores a peer that the package also lists as a regular dependency (tarball, since Package::from_npm already drops that peer edge for registry packages) and never nests a workspace package.
  • Verified on the reporter's repro and on the smallest shape with a script that resolves every required peer from the dependent's directory the way node does, and also checks that a package and the package depending on it resolve the peer to the same directory:

    layout out of range split instances
    smallest shape, released bun 2 2
    smallest shape, this branch (fresh and from bun.lock) 0 0
    smallest shape, npm 0 0
    orval repro, released bun, from bun.lock 0 5
    orval repro, this branch (fresh and from bun.lock) 0 0
    orval repro, npm 0 0

    On this branch the orval repro's bun.lock is unchanged by a second install and orval runs to completion. Also run with the debug build: bun-install-registry.test.ts (all), bun-lock.test.ts, bun-workspaces.test.ts, and the peer tests in isolated-install.test.ts.

  • Rebased (squashed to one commit) after install: collapse a workspace's same-name dependency slots into one entry so --frozen-lockfile is stable #36303 and install: pnpm parity — dedupe, prune, pm licenses, audit fix, add --filter/--catalog, nested overrides, transitive update, and workspace fixes #38333 rewrote hoist_dependency. Two mechanical adjustments: it no longer returns a Result, and since it now receives the declaring package's dependency range (input_dep_range), begin_peer_probes takes that directly instead of looking the owner up through the tree's dependency_id. The probe logic itself is unchanged from the reviewed revision; on the rebased main the two fixing tests still fail with this PR's src/ changes reverted and all eight pass with them, and bun-install-registry.test.ts, bun-lock.test.ts and bun-workspaces.test.ts pass.

Background

  • The hoisted linker builds the node_modules layout from the resolved lockfile in Tree.rs. Each tree is one node_modules directory, built breadth first, so every ancestor is complete by the time a directory's own entries are placed. hoist_dependency walks from the directory a dependency was declared in (AS_DEFINED) up through its ancestors and either dedupes onto an entry with the same name, keeps walking, or, on a conflict, places the package back in the directory it was declared in.
  • A peer dependency is a package the dependent expects to find next to itself rather than under itself. In the lockfile it is an edge like any other; for required peers the resolver binds it to the highest version in the lockfile that satisfies the range (resolve_peer_dep_version_based reproduces this on load), and optional, * and non-npm peers are bound to whatever ends up next to them. This change does not read those bindings.
  • hoist_dependency has a root-dependency override: a peer edge that reaches a version pinned by the root package.json dedupes onto it even when the range rejects it. This PR does not change that; it is why nesting a package whose peer is provided nowhere below the root would change nothing, and why such conflicts are dropped.

Fixes #20376


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

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

test/cli/install/hoist.test.ts:
(pass) should handle resolving optional peer from multiple instances of same package [306.16ms]
73 |       },
74 |     });
75 | 
76 |     const nodeModules = join(packageDir, "node_modules");
77 |     await installAndCheck(packageDir, async () => {
78 |       expect(await readdirSorted(join(nodeModules, "hoisting-peer-check-parent", "node_modules"))).toEqual([
                                                                                                        ^
error: expect(received).toEqual(expected)

  [
-   "hoisting-peer-check-child",
    "no-deps",
  ]

- Expected  - 1
+ Received  + 0

      at <anonymous> (/workspace/bun/test/cli/install/hoist.test.ts:78:100)
      at async installAndCheck (/workspace/bun/test/cli/install/hoist.test.ts:49:11)
      at async <anonymous> (/workspace/bun/test/cli/install/hoist.test.ts:77:11)
(fail) peer dependencies decide how far a package hoists > stays next to the peer version it was resolved against [
... (truncated)

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

test/cli/install/hoist.test.ts:
(pass) should handle resolving optional peer from multiple instances of same package [47.70ms]
(pass) peer dependencies decide how far a package hoists > ignores a peer that the package also lists as a regular dependency [58.68ms]
(pass) peer dependencies decide how far a package hoists > never nests a workspace package [58.62ms]
(pass) peer dependencies decide how far a package hoists > hoists past an out-of-range version when nothing below would satisfy the peer either [59.14ms]
(pass) peer dependencies decide how far a package hoists > hoists when its own dependent installs an out-of-range copy of the peer [60.55ms]
(pass) peer dependencies decide how far a package hoists > stays next to the peer version it was resolved against [61.20ms]
(pass) peer dependencies decide how far a package hoists > hoists when the version at the root satisfies the peer range [61.88ms]
(pass) peer dependencies decide how far a package hoists > stays below a peer version provided further up than its own dependent [62.43ms]

 8 pass
 0 fail
 171 expect() calls
Ran 8 tests across 1 file. [966.00ms]
__F:0:S:0
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/hoist.test.ts
bun test v1.4.0 (725bf3fbc)

test/cli/install/hoist.test.ts:
(pass) should handle resolving optional peer from multiple instances of same package [321.82ms]
(pass) peer dependencies decide how far a package hoists > stays next to the peer version it was resolved against [455.10ms]
(pass) peer dependencies decide how far a package hoists > hoists past an out-of-range version when nothing below would satisfy the peer either [497.31ms]
(pass) peer dependencies decide how far a package hoists > stays below a peer version provided further up than its own dependent [563.70ms]
(pass) peer dependencies decide how far a package hoists > hoists when the version at the root satisfies the peer range [544.48ms]
(pass) peer dependencies decide how far a package hoists > hoists when its own dependent installs an out-of-range copy of the peer [790.15ms]
(pass) peer dependencies decide how far a package hoists > never nests a workspace package [446.59ms]
(pass) peer dependencies decide how far a package hoists > ignores a
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 762ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/6] 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_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compiling�[0m bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
�[1m�[92m   Compiling�[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
�[1m�[92m   Compiling�[0m bun_css_jsc v0.0.0 (/workspace/bun/src/css_jsc)
�[1m�[92m   Compiling�[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
�[1m�[92m   Compiling�[0m bun_sys_jsc v0.0.0 (/workspace/bun/src/sys_jsc)
�[1m�[92m   Compiling�[0m bun_sql_jsc v0.0.0 (/workspace/bun/src/sql_jsc)
... (truncated)
diff hotspot
src/install/lockfile.rs        |   1 +
 src/install/lockfile/Tree.rs   | 125 ++++++++++++++++++
 test/cli/install/hoist.test.ts | 289 ++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 413 insertions(+), 2 deletions(-)

gate history · 4 passed · 2 rejected · iteration 11

evidence per changed file
file                            reads  edits  tests
src/install/lockfile.rs             4      1      0
src/install/lockfile/Tree.rs       18     19      0
test/cli/install/hoist.test.ts      5     12      0

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8451b14-f871-46a1-a9c4-890e8b49c107

📥 Commits

Reviewing files that changed from the base of the PR and between 8326d1b and b790347.

📒 Files selected for processing (3)
  • src/install/lockfile.rs
  • src/install/lockfile/Tree.rs
  • test/cli/install/hoist.test.ts

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

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:04 PM PT - Aug 16th, 2026

@robobun, your commit b790347cf124d8b5b24b0334d07c58fc8d5bfb7a passed in Build #99543! 🎉


🧪   To try this PR locally:

bunx bun-pr 36300

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

bun-36300 --bun

Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs, but this changes the hoisting algorithm in a way that alters node_modules layout for every install where a peer version differs across the tree — worth a human eye from someone who owns the install code.

What was reviewed:

  • The new peer-conflict check runs only on ancestors (!AS_DEFINED), which are fully populated by the BFS queue before being visited, so the check isn't order-sensitive.
  • The unsafe accesses reuse the same bounds invariants as the existing name-match loop just below.
  • Both updated assertions in bun-install-registry.test.ts now match their test titles / yarn semantics; the yarn peer-deps test's "5 packages installed" line was checked and still holds.
Extended reasoning...

Overview

This PR adds a guard to Tree::hoist_dependency in src/install/lockfile/Tree.rs: while walking up ancestor trees during hoisting, if an ancestor already holds a different resolution for one of the package's peer dependencies, hoisting stops there (DependencyLoop) so the package stays nested next to the peer version it actually resolved against. Two existing tests in bun-install-registry.test.ts had their assertions updated to reflect the new (correct) layout, and a new regression test was added in hoist.test.ts using pre-existing hoisting-peer-check-* registry fixtures.

Security risks

None. This is install-layout logic operating on already-resolved lockfile data; no new untrusted-input parsing, no new syscalls, no path construction from user data.

Level of scrutiny

High. The hoisted linker's tree builder decides the on-disk node_modules shape for every bun install. The change is small and well-targeted, but it is an algorithm change with user-visible effects: two existing tests changed output (the yarn "different peer dependencies are different instances" test flipped from true/true/false to false/true/true, and the workspace peer test now installs 3 packages instead of 2 with an extra nested copy). Both changes look like corrections rather than regressions — the yarn test now actually matches its own title — but a maintainer who knows this code should confirm that returning DependencyLoop here (rather than, say, a targeted Placement) composes correctly with the rest of the hoist state machine, and that the extra nesting in the workspace case is the intended tradeoff.

Other factors

The mechanism is sound: the check is gated on !AS_DEFINED, so it only fires on ancestor trees, and because subtrees are processed BFS via TreeFiller, ancestors are fully populated before any descendant tries to hoist through them — the check sees the complete ancestor dep list, not a partial one. The unsafe { deps.get_unchecked(...) } / raw-pointer reads reuse exactly the same invariants documented for the name-match loop immediately below. The added nested loop is O(peers × ancestor-deps) per ancestor; peers are typically 0–3, so perf impact should be negligible, but I did not benchmark. The new test covers both fresh install and reinstall-from-lockfile. Given the algorithm-level nature and the two existing-test behavior changes, deferring to a human.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs, but this changes the core hoisting algorithm and flips assertions in two pre-existing tests, so it's worth a human look from someone who owns src/install/.

What was reviewed:

  • Traced the new !AS_DEFINED peer check through the recursion — DependencyLoop propagates to the AS_DEFINED=true frame and falls through to Placement, consistent with how the existing name-collision path already behaves.
  • Confirmed the hoisting-peer-check-parent/-child fixtures exist in test/cli/install/registry/packages/ (they aren't shown in the diff).
  • Checked the yarn-compat test: the new false\ntrue\ntrue output matches what the test name says should happen (two peer-deps instances are no longer ===), and the 5 packages installed count is unchanged because both peer-deps copies were already counted.
Extended reasoning...

Overview

Adds ~30 lines to Tree::hoist_dependency in src/install/lockfile/Tree.rs: at each ancestor visited during hoisting (!AS_DEFINED), if that ancestor already holds a different resolution for any of the hoistee's peer dependencies, hoisting stops there (DependencyLoop). Adds a new regression test in hoist.test.ts (with new registry fixtures) and updates two existing assertions in bun-install-registry.test.ts whose behavior changed.

Security risks

None. No untrusted-input parsing was added; the new loop reads existing lockfile buffers via the same get_unchecked invariants as the surrounding code, gated on package_id != invalid_package_id so resolution_lists[package_id] is in bounds.

Level of scrutiny

High. The hoisted linker's tree builder runs on every install and the placement it chooses is user-observable at runtime via require() resolution. Two pre-existing tests now assert different output — the yarn-compat one now matches its own name, and the workspace one now nests peer-deps-fixed (with a nested no-deps@1.0.0) instead of leaving it at root next to no-deps@2.0.0. Both look more correct than before, but a maintainer should confirm those layout changes are intended and match npm.

Other factors

The check only fires when the ancestor has already had the conflicting peer placed in it, so correctness depends on DepSorter ordering / queue processing having placed the peer first. In the shipped test cases that holds (root pins no-deps before the parent's subtree is processed; no-deps sorts before peer-deps-fixed), but I'd want a human to confirm there isn't an ordering where the peer arrives after the dependent and the check is skipped. The added inner loop is O(peers × ancestor-deps) per ancestor per package — likely fine, but worth a sanity check on a large tree.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs, but this changes the core hoisting algorithm in a way that alters on-disk node_modules layouts (two existing tests had their expected output flipped), so it's worth a human look from someone who owns the install/lockfile code.

What was reviewed:

  • Traced the !AS_DEFINED gate and DependencyLoop propagation through the hoist_dependency recursion — the new check only runs on ancestors and falls through to Placement at the originating subtree, consistent with the existing name-conflict path.
  • Checked the get_unchecked bounds — same invariants as the pre-existing name-match loop below it.
  • Confirmed the flipped yarn-compat assertion (false\ntrue\ntrue) now matches the test's stated intent, and the hoisting-peer-check-* verdaccio fixtures already exist on main.
Extended reasoning...

Overview

This PR adds a peer-dependency-aware guard to Tree::hoist_dependency in src/install/lockfile/Tree.rs. When walking up the ancestor chain (AS_DEFINED = false), it now checks whether the current ancestor tree already holds a different resolution for any of the hoisting package's peer dependencies; if so it returns DependencyLoop, which keeps the package nested next to the peer version it actually resolved against. A new test in hoist.test.ts covers the fresh-install and install-from-lockfile paths, and two existing assertions in bun-install-registry.test.ts were updated to reflect the new (correct) layout.

Security risks

None. This is pure in-process resolution logic over lockfile buffers; no untrusted input parsing, no filesystem path construction, no network. The new get_unchecked calls use the same bounds invariants as the adjacent pre-existing loop.

Level of scrutiny

High. hoist_dependency runs for every dependency of every package during bun install with the hoisted linker, and its output determines the physical node_modules layout that ships to users. The change is small, but it's a semantic change to a load-bearing algorithm — two existing tests had their expected behavior changed (the yarn-compat "different peer deps → different instances" test flipped from true/true/false to false/true/true, and the workspace-peer test now installs 3 packages instead of 2 with peer-deps-fixed nested). Both changes look like genuine fixes rather than regressions, but someone who owns this code should sign off on the layout change and any disk-usage implications (packages that previously deduped to root will now sometimes stay nested).

Other factors

  • The new check is order-dependent: it only fires if the conflicting peer has already been placed in the ancestor tree by the time this package is processed. DepSorter handles the common case, but I can't rule out orderings where the peer lands after the dependent and the guard is bypassed — worth a domain expert's eye.
  • The hoisting-peer-check-parent/-child verdaccio fixtures referenced by the new test already exist on main (added in an earlier PR), so the test is self-contained.
  • CI on the completed lanes is green for both touched test files per the author's report; the red is unrelated build-job timeouts.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The ajv shape does need to nest, but not like this. The comparison has to be by range, not package id, and it must not read optional or * peer bindings; as written it duplicates packages in any lockfile that has two in-range versions of a peer, and the yarn test flip is that duplication, not npm's layout.

  • id comparison against the lockfile-wide binding, and it runs before the same-package dedupe (Tree.rs:1013)
  • optional and * peer bindings are outputs of hoisting and of the bun.lock path walk, so bun.lock and node_modules can disagree (Tree.rs:998)
  • only the direct-dependent shape is fixed; one level up the peer still resolves to the root copy (Tree.rs:1016)
  • workspace packages get inert nested symlinks and new bun.lock keys (Tree.rs:992)
    #37426 landed on main and changes this function; the hunk still applies cleanly but the optional peer interaction above gets worse with it, so rebase before the next push.

Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread test/cli/install/bun-install-registry.test.ts Outdated
@robobun robobun changed the title install: keep peer-dependent packages next to the peer version they resolved against install: keep a package below an ancestor whose version of its peer is out of range Aug 13, 2026
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 26a9be6 and merged up to current main (includes #37426); PR body rewritten for the new shape.

  • The ancestor's entry is compared by range (satisfies_dependency_version), falling back to the bound package only for an exact match; no root override.
  • Only required peers are read; optional peers are skipped, and * never reaches the binding comparison.
  • Skipped for workspace and link: resolutions, and for peer edges shadowed by a regular dependency of the same name.
  • Yarn test back to main's expectation. Three new tests in hoist.test.ts cover the range case, the shadowed peer (tarball), and workspaces, each from scratch and from the written bun.lock.
  • The one-level-up shape from your last thread is not handled; noted in the body, it runs into the root override.

Checked with a script that resolves every required peer from the dependent's directory and compares it with what the dependent's parent resolves: smallest shape goes from 2 out-of-range / 2 split instances to 0 / 0, the orval repro from 5 split instances (lockfile install) to 0 on both install paths, matching npm; its bun.lock is stable across a second install. Details and the test list are in the body.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One thing left, same root cause as before. The five items from the last round are addressed at this head: the ancestor entry is compared by range with no root override, optional peers are skipped and * never reaches the id fallback, workspace and link: packages and peer edges shadowed by a regular dependency are skipped, the yarn expectation is back to main's, and the one-level-up shape is written down. What remains is that the check still nests when nesting cannot change what the package resolves, and in a common shape (root pins the peer outside the range and a second version of that name exists anywhere in the lockfile) that now installs one copy of the peer-dependent package per consumer where main installs one; the id fallback is also unstable between the first and second install for ranges the range comparison cannot evaluate. Both inline; layouts there are traced from the source, not run. The branch is merged up past the main I have locally, so nothing to say about conflicts.

Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

a8e0acf addresses both. The check is now two steps: the required npm-range peers of the package are collected when its walk starts, each flagged with whether the tree it is leaving will hold an in-range version; each ancestor then either marks a peer as provided (in-range entry) or, on an out-of-range entry, stops the hoist if the peer is provided and drops the peer if not. The peer binding is not read anywhere. Net effect on the shapes you traced: root-pinned-out-of-range with consumers that do not provide the peer hoists one copy as on main (tested), the chain where the provider is above the direct dependent still nests (tested), and bun-install-registry.test.ts is back to its main version, so no existing expectation changes. Both repros still come out at 0 out of range / 0 split instances on fresh and lockfile installs with a stable bun.lock; body updated.

Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread test/cli/install/hoist.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both nits from my previous pass are addressed in 2c48523: the probe is now dropped when the owner has a resolved non-peer edge on the peer name that fails the range (Some(false) => continue 'peers, covered by the new "hoists when its own dependent installs an out-of-range copy of the peer" test), and the describe block is .concurrent. No new findings on re-review. Leaving this for a human sign-off since the a8e0acf redesign of the probe hasn't been re-reviewed by a maintainer yet.

Checked on this pass: begin_peer_probes is only reached from the AS_DEFINED recursion site where next_id's owner is a resolved package (the Folder/bundled/unresolved cases short-circuit before it), so the owner_dep_idresolutions[...] index is always valid. probe_peers reads tree.dependencies via the per-entry DependencyIDList, which is populated for all ancestors by the time the walk reaches them (BFS), and swap_remove on an unprovided out-of-range hit keeps the loop index correct.

Extended reasoning...

Overview

The PR adds a peer-aware guard to the hoisted linker's Tree::hoist_dependency walk. Two new methods on Builder (begin_peer_probes, probe_peers) collect a package's required npm-range peers when it starts walking up from its declaring tree, track whether any tree along the walk holds an in-range version, and stop the hoist (DependencyLoop) at the first ancestor holding an out-of-range version — but only if an in-range version is reachable from where the package started. A single Vec<PeerProbe> scratch buffer is added to Builder and cleared per package. Seven new tests in hoist.test.ts cover the fix, its inverse cases, and the exclusions (workspace/symlink packages, peers shadowed by a regular dep, owner installing an out-of-range copy). No existing test expectations change.

Changes since my last review

Commit 2c48523 addressed both prior nits: (1) when the owner has a resolved non-peer edge on the peer name whose resolution fails the child's range, the probe is now skipped entirely (Some(false) => continue 'peers) rather than pushed with provided = false, preventing an ancestor's shadowed in-range copy from spuriously flipping provided; a dedicated test ("hoists when its own dependent installs an out-of-range copy of the peer") covers this. (2) The test block is now describe.concurrent. The bug-hunting pass on this revision found nothing.

Security risks

None. This is deterministic tree-layout logic over already-resolved lockfile data; no untrusted input parsing, no I/O, no allocation driven by external sizes.

Level of scrutiny

High. This is the core hoisting decision for every dependency in every hoisted install, and subtle mistakes here silently change node_modules layouts across the ecosystem. The maintainer review history on this PR bears that out: three rounds of substantive correctness feedback (id-vs-range comparison, optional-peer instability, dist-tag/non-npm binding instability, workspace symlinks, redundant nesting when nothing is gained), each of which reshaped the approach. The current begin_peer_probes/probe_peers design was introduced in a8e0acf specifically to address the last round; the maintainer has not yet reviewed that revision.

Other factors

  • The PR body is thorough and explicitly documents the one shape not fixed (peer-set placement when the provider itself was hoisted away).
  • bun-install-registry.test.ts is back to its main-branch expectations, so the change is now purely additive on the test side.
  • All seven new tests install twice (fresh + from bun.lock) and assert exact directory contents and versions.
  • I verified the new code paths for index safety: begin_peer_probes is only called from the AS_DEFINED branch after the bundled/folder/unresolved early-outs, so owner_dep_id != ROOT_DEP_ID (the root tree has no parent to walk to) and resolutions[owner_dep_id] is a valid package; probe_peers reads ancestor dependency lists that BFS ordering guarantees are already populated.

Given the maintainer's active engagement and the non-trivial redesign since their last look, deferring to a human sign-off rather than auto-approving.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks ready to merge from this side. Both remaining items are addressed at this head: the probe now only nests when a copy the package would still see from its declared position is in range and the ancestor's is not, only npm ranges are collected and no peer binding is read, and the owner's own out-of-range copy skips the probe. Read the new Tree.rs code and traced the seven layouts from it; also ran the new hoist.test.ts against a main build, where the two fixing tests fail and the five unchanged-layout tests pass, so the as-before claims hold. bun-install-registry.test.ts is back to main's version and none of the touched files moved on main since the merge-base. One optional test nit inline.

Comment thread test/cli/install/hoist.test.ts Outdated
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. The optional nit is in as ca40287 (second install asserts no "Saved lockfile"; passes for all seven shapes). The only other change since your look is d62d889, test-only: the concurrent tests now each get their own BUN_INSTALL_CACHE_DIR, because the runner sets one per file and the Windows lanes failed with ENOTEMPTY when several of them extracted no-deps@2.0.0 into it at once (the Windows move-into-cache path in extract_tarball.rs evicts a concurrent winner on retry; filed separately, not touched here). Nothing under src/ changed since a8e0acf apart from the owner-copy skip in 2c48523 you already read.

@robobun
robobun force-pushed the claude/farm/75a926bf/fix-peer-hoisting branch from ca40287 to 725bf3f Compare August 16, 2026 19:48
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (725bf3f, squashed) to clear the conflicts from #36303 / #38333 rewriting hoist_dependency. Only mechanical changes on top of what you reviewed: no more Result return, and begin_peer_probes now takes the input_dep_range the refactor threads through instead of resolving the owner via the tree's dependency_id (net diff in Tree.rs is three lines shorter). Re-verified on the rebased main: the two fixing tests fail with this PR's src/ reverted, all eight pass with it, and the registry, bun-lock and workspaces suites pass. Details in the body.

…s out of range

With the hoisted linker a package was hoisted past an ancestor holding a
version of one of its peer dependencies that the peer range rejects, even
when the package that pulled it in had an in-range version nested next to
it, so at runtime it resolved the wrong version (ajv-errors next to the
root's ajv@6 while spectral-core used its own ajv@8).

When a package starts hoisting out of the directory it was declared in,
its required npm-range peers are collected. A peer starts out provided if
the owner of that directory installs an in-range copy, and is not
considered at all if the owner installs an out-of-range one, since that
copy is what the package would find either way. Walking up, an ancestor
holding an in-range version marks the peer provided; one holding an
out-of-range version stops the hoist if the peer is provided and drops
the peer otherwise, because a nested copy would resolve the same version
the hoisted one does. Only ranges and the resolutions of entries already
in the tree are compared; peer bindings are never read. Optional peers,
peers shadowed by a regular dependency of the same name, and workspace or
link: packages (installed as symlinks) are left alone.

Fixes #20376
@robobun
robobun force-pushed the claude/farm/75a926bf/fix-peer-hoisting branch from 73f11e3 to b790347 Compare August 16, 2026 20:10
Comment thread src/install/lockfile/Tree.rs
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.

Bug(works in npm): Using Orval to generate API client fails after installing deps using bun install

3 participants