install: warn when the hoisted tree serves a peer from a root copy outside its range
Closed
RO
2mo ago+245+245+012345678901234567890123456789-13-13-01234567890123456789
Problem
- With the hoisted linker, a peer whose own resolution conflicts with the copy the root package.json put at the top of
node_modulesis served by the root's copy even when its range rejects it (Tree::hoist_dependency, theis_workspace_root_dependencybranch insrc/install/lockfile/Tree.rs, which carried a// TODO: warning about peer dependency version mismatch). Nothing is printed, on that install or on any later one. - Two ways in, both with the registry fixtures (
peer-deps-fixed@1.0.0has a peer onno-deps@^1.0.0):- Root
no-deps: ^1.0.0plus a workspace member with that peer,bun install, then change the root entry to^2.0.0and install again: the output is+ no-deps@2.0.0, exit 0, and the member now getsno-deps@2.0.0. The resolver only re-resolves the root's row;redirect_dependentsleaves the member's peer on 1.1.0 because its range rejects 2.0.0, and it is this dedupe that moves it. - Root
no-deps: 2.0.0,peer-deps-fixed, andprovides-peer-deps-1-0-0(which depends onno-deps@1.0.0): a fresh install binds the peer to the nested 1.0.0, so the resolver's ownwarn: incorrect peer dependencynever fires, and the tree then serves it 2.0.0 on this and every following install.
- Root
- The resolver's warning (
get_or_put_resolved_package,PackageManagerEnqueue.rs) only covers peers the resolver itself bound out of range, so the same package.json warns when installed from scratch and is silent when reached through either sequence above.
Fix
- The branch returns a new
HoistDependencyResult::HoistedOutOfRange(copy)instead ofdedupe()when the peer is required, its range is an npm range, and the copy has a version that the range rejects. Placement is unchanged:process_subtreetreats the result exactly likeHoisted. - The
Filterbuild reports it:warn: incorrect peer dependency "no-deps@2.0.0": "pkg1@workspace:packages/pkg1" requires "no-deps@^1.0.0". That build is the one tree per install that is written tonode_modules(install_hoisted_packages), so the warning prints once per install and describes what was actually installed. TheResolvablebuild also runs on every lockfile load and again inCloner::flush, so reporting from it would print two or three times per install; it stays quiet. (bun prunebuilds aFiltertree as well, but never prints the log afterwards, so it stays silent too.) - Why the conditions: optional peers keep going through
dedupe(), so theResolvablebuild still returnsRebindfor them (#37426) and nothing is reported, matching the resolver, which never binds or warns about optional peers (enqueue_dependency_with_main_and_success_fnreturns early on them). The copy's version comes fromLockfile::package_version, which knows npm versions and versioned workspaces, so a workspace package standing in for an npm name is reported when its version is out of range and not reported when it is in range; git, tarball and unversioned workspace copies have nothing to compare and are not reported. - Why a warning and not a different tree: serving the root's copy is the documented intent of that branch (the root's choice wins), and the existing resolver path already treats the same situation as a warning. This only closes the gap where the decision is made by the tree instead of the resolver. The loader still binds a peer nothing satisfies to the only remaining copy without a warning, so the first sequence warns on the install that makes the change and is quiet afterwards, like a fresh install is; the second sequence warns on every install because every install makes the decision again.
Lockfile::package_versionis the npm/workspace version lookupOverrideMap::owner_ofhad inline;owner_ofnow calls it.- Tests in
test/cli/install/hoist.test.ts, hoisted linker, all asserting the exact warning line(s) printed and that the dependent has no nestednode_modules(it really is served by the root's copy): the workspace member sequence above (one line, printed once); the fresh install with the nested satisfying copy (one line, and again on a reinstall); the root providingno-depsas a workspace at 2.0.0 (reported as"no-deps@workspace:packages/no-deps"), at 1.5.0 (nothing), without a version (nothing), and as afile:tarball of 2.0.0 (nothing, no version to compare; git would take the sameNonearm); andone-optional-peer-depleft bound to a survivingno-deps@1.0.0when the root moves to 2.0.0, in its required (1.0.1, reported) and optional (1.0.2, nothing) versions. The four reporting cases fail without thesrc/change (nothing is printed); the optional case fails if the optional-peer condition is removed; the quiet cases pin theNoneand in-range arms of the version check. - Also run with the debug build:
bun-install-registry,bun-install,bun-lock,bun-lockb,bun-workspaces,bun-update,bun-update-transitive,bun-update-lockfile-sync,bun-add,bun-add-catalog,bun-add-filter,bun-remove,bun-dedupe,bun-prune,bun-audit,bun-pm-why,bun-pm-licenses,catalogs,nested-overrides,frozen-lockfile-pruned,test-dev-peer-dependency-priority,isolated-install,isolated-relink,migrate-bun-lockb-v2and the migration suites. No existing expectation changed;bun-install.test.tsonly fails on the tests that need external hosts, the same as without this change.
Background
- A dependency entry in a package.json is a row (
buffers.dependencies[i]), andbuffers.resolutions[i]is the package it is bound to. The root package.json is package 0; its rows include the workspace members. - The hoisted tree builder (
Tree.rs) walks rows breadth-first and tries to place each one as high innode_modulesas possible. When a level already holds a different package under the same name, a regular row nests under its dependent; a peer row is instead deduped onto the existing copy when the copy satisfies its range, or unconditionally when the copy was placed by a root row. A deduped row places nothing, so its own package is left out of the tree and out ofbun.lock. - The tree is built several times per install:
Resolvablebuilds (every row, for the saved lockfile) whenbun.lockis loaded and again when the lockfile is cleaned before saving, and oneFilterbuild (rows disabled by os/cpu,--omitor workspace filters left out) whose tree is what the hoisted installer writes to disk. The isolated linker does not use this tree for installing, so nothing changes for it. - Optional peers are never resolved by the resolver; the tree binds them to whatever copy it finds, and since #37426 the
Resolvablebuild moves that binding (Rebind) when it dedupes one, so the saved lockfile and the installed tree agree.
Before and after, debug build, registry fixtures
Workspace member sequence (root no-deps ^1.0.0 then ^2.0.0, member pkg1 with peer no-deps@^1.0.0), second install:
# before
Resolving dependencies
Resolved, downloaded and extracted [2]
Saved lockfile
# after
Resolving dependencies
Resolved, downloaded and extracted [2]
warn: incorrect peer dependency "no-deps@2.0.0": "pkg1@workspace:packages/pkg1" requires "no-deps@^1.0.0"
Saved lockfile
Fresh install of peer-deps-fixed@1.0.0, no-deps@2.0.0, provides-peer-deps-1-0-0@1.0.0: before, nothing on stderr beyond the progress lines on the first install and nothing at all on the second and on --frozen-lockfile; after, warn: incorrect peer dependency "no-deps@2.0.0": "peer-deps-fixed@1.0.0" requires "no-deps@^1.0.0" once on each of the three.
Unchanged for comparison, a fresh install of the root ^2.0.0 + pkg1 package.json files prints the resolver's warn: incorrect peer dependency "no-deps@2.0.0" once and is quiet on the following install, before and after.