Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1257,8 +1257,10 @@ impl<'a> Cloner<'a> {

impl Lockfile {
/// Re-hoists while a pass bound an optional peer late; a reload has that binding up front.
/// Ranged peers bound to a package the tree left out are rebound the way a reload binds them.
pub(crate) fn resolve(&mut self, log: &mut bun_ast::Log) -> Result<(), tree::SubtreeError> {
while self.hoist::<{ tree::BuilderMethod::Resolvable }>(log, None, true, &[], None)? {}
TextLockfile::rebind_peers_to_printed_packages(self)?;
Ok(())
}

Expand Down
115 changes: 102 additions & 13 deletions src/install/lockfile/bun.lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use core::fmt::Write as _;

use crate::bun_json as JSON;
use bun_ast::{Expr, expr::Data as ExprData};
use bun_collections::{HashMap, StringHashMap};
use bun_collections::{DynamicBitSet, HashMap, StringHashMap};
use bun_core::strings;
use bun_core::{self};
use bun_paths::PathBuffer;
Expand Down Expand Up @@ -3356,6 +3356,28 @@ pub(crate) fn resolve_peer_dep_version_based(
overrides: &OverrideMap,
pkg_resolutions: &[Resolution],
string_buf: &[u8],
) -> Option<PackageID> {
resolve_peer_dep_version_based_among(
dep,
catalogs,
package_index,
overrides,
pkg_resolutions,
string_buf,
|_| true,
)
}

/// `resolve_peer_dep_version_based` over the candidates `is_candidate` accepts, for callers
/// whose lockfile holds packages a reload will not see (`rebind_peers_to_printed_packages`).
pub(crate) fn resolve_peer_dep_version_based_among(
dep: &Dependency,
catalogs: &CatalogMap,
package_index: &PackageIndexMap,
overrides: &OverrideMap,
pkg_resolutions: &[Resolution],
string_buf: &[u8],
is_candidate: impl Fn(PackageID) -> bool,
) -> Option<PackageID> {
let range = deferred_peer_range(dep, catalogs, string_buf)?;
// `package_index` is keyed by real package names; `range` (not `dep.name`) carries them for aliases.
Expand Down Expand Up @@ -3402,28 +3424,95 @@ pub(crate) fn resolve_peer_dep_version_based(
PackageIndexEntry::Ids(ids) => ids.as_slice(),
};

let mut first: Option<PackageID> = None;
for &id in candidates {
if (id as usize) < pkg_resolutions.len()
&& pkg_resolutions[id as usize]
.satisfies_dependency_version(range, string_buf, string_buf)
if (id as usize) >= pkg_resolutions.len() || !is_candidate(id) {
continue;
}
if pkg_resolutions[id as usize].satisfies_dependency_version(range, string_buf, string_buf)
{
return Some(id);
}
if first.is_none() {
first = Some(id);
}
}

let first = first?;
let res_tag = pkg_resolutions[first as usize].tag;
let ver_tag = range.tag;
if (res_tag == ResolutionTag::Npm && ver_tag == DependencyVersionTag::Npm)
|| (res_tag == ResolutionTag::Git && ver_tag == DependencyVersionTag::Git)
|| (res_tag == ResolutionTag::Github && ver_tag == DependencyVersionTag::Github)
{
return Some(first);
}

None
}

/// Call after hoisting. The printed tree is the lockfile's only record of which packages
/// exist, and a package whose incoming edges are all ranged peers that `Tree::hoist_dependency`
/// deduped onto another version of the same name is in no tree at all (pnpm's auto-installed
/// peers arrive like this from pnpm-lock.yaml; adding a pinned version of a package that was
/// only auto-installed for a peer creates the same shape). `resolve_peer_dep_version_based`
/// therefore binds such edges to another package on reload, and the install that wrote the
/// lockfile links something different from every install that reads it. Binding them here over
/// the packages the print will contain makes the writing install match its readers.
///
/// Every edge rebound here was deduped during hoisting, so it is not in `hoisted_dependencies`
/// and the tree stays valid; the old target is dropped by the next `clean_with_logger`.
pub(crate) fn rebind_peers_to_printed_packages(
lockfile: &mut BinaryLockfile,
) -> Result<(), bun_alloc::AllocError> {
let pkg_resolutions: &[Resolution] = lockfile.packages.items_resolution();
let package_index = &lockfile.package_index;
let catalogs = &lockfile.catalogs;
let overrides = &lockfile.overrides;
let super::Buffers {
hoisted_dependencies,
resolutions,
dependencies,
string_bytes,
..
} = &mut lockfile.buffers;
let string_buf: &[u8] = string_bytes.as_slice();

// Root and workspaces are printed from the `workspaces` section, everything else from the tree.
let mut printed = DynamicBitSet::init_empty(pkg_resolutions.len())?;
for (pkg_id, res) in pkg_resolutions.iter().enumerate() {
if matches!(res.tag, ResolutionTag::Root | ResolutionTag::Workspace) {
printed.set(pkg_id);
}
}
for &dep_id in hoisted_dependencies.iter() {
let pkg_id = resolutions[dep_id as usize];
if (pkg_id as usize) < pkg_resolutions.len() {
printed.set(pkg_id as usize);
}
}

let &first = candidates.first()?;
if (first as usize) < pkg_resolutions.len() {
let res_tag = pkg_resolutions[first as usize].tag;
let ver_tag = range.tag;
if (res_tag == ResolutionTag::Npm && ver_tag == DependencyVersionTag::Npm)
|| (res_tag == ResolutionTag::Git && ver_tag == DependencyVersionTag::Git)
|| (res_tag == ResolutionTag::Github && ver_tag == DependencyVersionTag::Github)
for (dep, target) in dependencies.iter().zip(resolutions.iter_mut()) {
if (*target as usize) >= pkg_resolutions.len()
|| printed.is_set(*target as usize)
|| !dep.behavior.is_peer()
{
return Some(first);
continue;
}
if let Some(printed_target) = resolve_peer_dep_version_based_among(
dep,
catalogs,
package_index,
overrides,
pkg_resolutions,
string_buf,
|id| printed.is_set(id as usize),
) {
*target = printed_target;
}
}

None
Ok(())
}

// Taking `&mut BinaryLockfile` plus a `&mut Dependency` that
Expand Down
63 changes: 63 additions & 0 deletions test/cli/install/isolated-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,69 @@ test("ranged peer dependency resolution is stable across installs from bun.lock"
});
});

test("a ranged peer whose auto-installed version drops out of the saved tree is rebound before linking", async () => {
// The first install auto-installs no-deps@1.1.0 for peer-deps-fixed's
// `no-deps@^1.0.0`; nothing else depends on it. Adding one-dep pins
// no-deps@1.0.1, which is hoisted to the root of the saved tree, so the
// peer edge dedupes onto it and 1.1.0 is no longer written to bun.lock.
// Reloading that bun.lock binds the edge to 1.0.1, so the install that
// writes it has to link 1.0.1 as well, or the next install re-keys the
// entry.
const { packageJson, packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated" },
});

await write(
packageJson,
JSON.stringify({
name: "dropped-peer-target",
dependencies: {
"peer-deps-fixed": "1.0.0",
},
}),
);

await runBunInstall(bunEnv, packageDir);

const bunDir = join(packageDir, "node_modules", ".bun");
const autoInstalledEntry = "peer-deps-fixed@1.0.0+7ff199101204a65d";
const reboundEntry = "peer-deps-fixed@1.0.0+f8a822eca018d0a1";
expect(await readdirSorted(bunDir)).toContain(autoInstalledEntry);
expect(await file(join(bunDir, autoInstalledEntry, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
version: "1.1.0",
});

await write(
packageJson,
JSON.stringify({
name: "dropped-peer-target",
dependencies: {
"one-dep": "1.0.0",
"peer-deps-fixed": "1.0.0",
},
}),
);

await runBunInstall(bunEnv, packageDir);

const bunLock = await file(join(packageDir, "bun.lock")).text();
expect(bunLock).toContain(`"no-deps": ["no-deps@1.0.1"`);
expect(bunLock).not.toContain("no-deps@1.1.0");
expect(await readlink(join(packageDir, "node_modules", "peer-deps-fixed"))).toBe(
join(".bun", reboundEntry, "node_modules", "peer-deps-fixed"),
);
expect(await file(join(bunDir, reboundEntry, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
const entries = await readdirSorted(bunDir);

const { out } = await runBunInstall(bunEnv, packageDir, { savesLockfile: false });

expect(out).toContain("(no changes)");
expect(await readdirSorted(bunDir)).toEqual(entries);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(bunLock);
});

test("aliased peer dependency binds to its real package across installs from bun.lock", async () => {
// The peer alias `no-deps` points at `npm:a-dep@^1.0.2` while the real
// no-deps package (in two versions) is also in the graph. Loading bun.lock
Expand Down
86 changes: 86 additions & 0 deletions test/cli/install/migration/pnpm-lock-v9.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ const NO_DEPS_1_0_0_INTEGRITY =
"sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw==";
const NO_DEPS_1_0_1_INTEGRITY =
"sha512-3X6cn4+UJdXJuLPu11v8i/fGLe2PdI6v1yKTELam04lY5esCAFdG/qQts6N6rLrL6g1YRq+MKBAwxbmUQk355A==";
const NO_DEPS_1_1_0_INTEGRITY =
"sha512-ebG2pipYAKINcNI3YxdsiAgFvNGp2gdRwxAKN2LYBm9+YxuH/lHH2sl+GKQTuGiNfCfNZRMHUyyLPEJD6HWm7w==";
const NO_DEPS_2_0_0_INTEGRITY =
"sha512-W3duJKZPcMIG5rA1io5cSK/bhW9rWFz+jFxZsKS/3suK4qHDkQNxUTEXee9/hTaAoDCeHWQqogukWYKzfr6X4g==";
const ONE_DEP_1_0_0_INTEGRITY =
Expand Down Expand Up @@ -1627,6 +1629,90 @@ ${variants}`;
expect(peerDepsDirs[0]).not.toBe(peerDepsDirs[1]);
});

test("the migrating install links the peer version its bun.lock reloads with the isolated linker", async () => {
// pnpm auto-installed no-deps@1.1.0 for peer-deps-fixed's `no-deps: ^1.0.0` while one-dep pins
// no-deps@1.0.1, so 1.1.0 is only reachable through the peer edge. The hoisted tree dedupes that
// edge onto 1.0.1, bun.lock never records 1.1.0, and reloading binds the edge to 1.0.1. The
// migrating install has to link the same binding, or the next install relinks the entry.
const { packageDir } = await verdaccio.createTestDir({
bunfigOpts: { linker: "isolated" },
files: {
"package.json": JSON.stringify({
name: "v9-auto-installed-peer",
dependencies: { "one-dep": "1.0.0", "peer-deps-fixed": "1.0.0" },
}),
"pnpm-lock.yaml": `lockfileVersion: '9.0'

importers:

.:
dependencies:
one-dep:
specifier: 1.0.0
version: 1.0.0
peer-deps-fixed:
specifier: 1.0.0
version: 1.0.0(no-deps@1.1.0)

packages:

no-deps@1.0.1:
resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}}

no-deps@1.1.0:
resolution: {integrity: ${NO_DEPS_1_1_0_INTEGRITY}}

one-dep@1.0.0:
resolution: {integrity: ${ONE_DEP_1_0_0_INTEGRITY}}

peer-deps-fixed@1.0.0:
resolution: {integrity: ${PEER_DEPS_FIXED_1_0_0_INTEGRITY}}
peerDependencies:
no-deps: ^1.0.0

snapshots:

no-deps@1.0.1: {}

no-deps@1.1.0: {}

one-dep@1.0.0:
dependencies:
no-deps: 1.0.1

peer-deps-fixed@1.0.0(no-deps@1.1.0):
dependencies:
no-deps: 1.1.0
`,
},
});

const migrating = await run(packageDir, "install");

expect(migrating.stderr).toContain("migrated lockfile from pnpm-lock.yaml");
expect(migrating.stderr).not.toContain("error:");
expect(migrating.exitCode).toBe(0);

const bunLock = await bunLockOf(packageDir);
expect(bunLock).toContain(`"no-deps": ["no-deps@1.0.1"`);
expect(bunLock).not.toContain("no-deps@1.1.0");

const storeDir = join(packageDir, "node_modules", ".bun");
const peerDepsFixedDir = realpathSync(join(packageDir, "node_modules", "peer-deps-fixed"));
expect((await Bun.file(join(dirname(peerDepsFixedDir), "no-deps", "package.json")).json()).version).toBe("1.0.1");
const store = readdirSync(storeDir).sort();
expect(store).not.toContain("no-deps@1.1.0");

const reinstall = await run(packageDir, "install");

expect(reinstall.stderr).not.toContain("error:");
expect(reinstall.stdout).toContain("(no changes)");
expect(reinstall.exitCode).toBe(0);
expect(readdirSync(storeDir).sort()).toEqual(store);
expect(realpathSync(join(packageDir, "node_modules", "peer-deps-fixed"))).toBe(peerDepsFixedDir);
expect(await bunLockOf(packageDir)).toBe(bunLock);
});

test("a peer met in one importer and unmet in another is bound from the met variant", async () => {
// pnpm sorts the unsuffixed (peer-unmet) variant first; the met variant must still bind the peer
const { packageDir } = await verdaccio.createTestDir({
Expand Down