install: warn when the hoisted tree serves a peer from a root copy outside its range

Closed
RO
2mo ago
+245-13

Problem

  • With the hoisted linker, a peer whose own resolution conflicts with the copy the root package.json put at the top of node_modules is served by the root's copy even when its range rejects it (Tree::hoist_dependency, the is_workspace_root_dependency branch in src/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.0 has a peer on no-deps@^1.0.0):
    • Root no-deps: ^1.0.0 plus a workspace member with that peer, bun install, then change the root entry to ^2.0.0 and install again: the output is + no-deps@2.0.0, exit 0, and the member now gets no-deps@2.0.0. The resolver only re-resolves the root's row; redirect_dependents leaves 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, and provides-peer-deps-1-0-0 (which depends on no-deps@1.0.0): a fresh install binds the peer to the nested 1.0.0, so the resolver's own warn: incorrect peer dependency never fires, and the tree then serves it 2.0.0 on this and every following install.
  • 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 of dedupe() 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_subtree treats the result exactly like Hoisted.
  • The Filter build 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 to node_modules (install_hoisted_packages), so the warning prints once per install and describes what was actually installed. The Resolvable build also runs on every lockfile load and again in Cloner::flush, so reporting from it would print two or three times per install; it stays quiet. (bun prune builds a Filter tree as well, but never prints the log afterwards, so it stays silent too.)
  • Why the conditions: optional peers keep going through dedupe(), so the Resolvable build still returns Rebind for them (#37426) and nothing is reported, matching the resolver, which never binds or warns about optional peers (enqueue_dependency_with_main_and_success_fn returns early on them). The copy's version comes from Lockfile::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_version is the npm/workspace version lookup OverrideMap::owner_of had inline; owner_of now 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 nested node_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 providing no-deps as 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 a file: tarball of 2.0.0 (nothing, no version to compare; git would take the same None arm); and one-optional-peer-dep left bound to a surviving no-deps@1.0.0 when 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 the src/ change (nothing is printed); the optional case fails if the optional-peer condition is removed; the quiet cases pin the None and 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-v2 and the migration suites. No existing expectation changed; bun-install.test.ts only 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]), and buffers.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 in node_modules as 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 of bun.lock.
  • The tree is built several times per install: Resolvable builds (every row, for the saved lockfile) when bun.lock is loaded and again when the lockfile is cleaned before saving, and one Filter build (rows disabled by os/cpu, --omit or 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 Resolvable build 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.