diff --git a/docs/pm/cli/dedupe.mdx b/docs/pm/cli/dedupe.mdx index 47d947a6c137..7f594804449b 100644 --- a/docs/pm/cli/dedupe.mdx +++ b/docs/pm/cli/dedupe.mdx @@ -22,6 +22,8 @@ Each row is a version Bun removed and the version its dependents now use. `bun dedupe` only chooses between versions already in the lockfile and never modifies `package.json`. It never fetches new versions from the registry and never moves a dependency outside its range. Use [`bun update`](/pm/cli/update) for that. +`bun install` and `bun update` run the same collapse over the versions they add, so a fresh install does not create duplicates that one version could have covered. Versions already in `bun.lock` are left as they are; only `bun dedupe` removes those. + ### `--check` and `--dry-run` `--check` reports what Bun would remove without changing anything, and exits `1` if there are duplicates. Use it in CI: diff --git a/docs/pm/cli/update.mdx b/docs/pm/cli/update.mdx index 169b8b4e8d6d..fd5fb0a116c9 100644 --- a/docs/pm/cli/update.mdx +++ b/docs/pm/cli/update.mdx @@ -36,6 +36,8 @@ Updated packages appear in the install summary as `↑ name old → new`, with ` ### What is held back - Bun never widens ranges. A package that depends on `foo@^1.0.0` never gets `foo@2.x`. +- A transitive dependency that shares the copy your own `package.json` entry resolves to (in the root or in a workspace) stays on that copy and moves with it. With `@types/node: ~20` in your `package.json`, the `@types/node: *` that `@types/ws` declares keeps using your `~20` copy instead of nesting the newest major under `@types/ws`. Bun updates a transitive range on its own when it rejects the version your entry moves to, or when you removed the entry. +- After resolving, Bun collapses the versions it added onto the fewest that satisfy every range, so it does not add a copy that an existing version could cover. Two dependencies declaring `foo@^1` and `foo@*` share one `1.x` copy, on `bun install` and on `bun update` alike. Versions already in `bun.lock`, your own `package.json` entries, and `bun audit fix` pins are never moved by this. - Versions in `patchedDependencies` stay put as long as their range allows. Bun reports them as `kept name@version (patched, v1.2.3 available)`. `--latest` and [`bun audit fix`](/pm/cli/audit#bun-audit-fix) do move them; re-create the patch with [`bun patch`](/pm/cli/patch) afterwards. - If a registry request for a transitive package fails, that package keeps its locked version and Bun prints a warning. A failed request for a direct dependency is an error. diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 757bf9a83e97..dfd4d5402f75 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -446,6 +446,9 @@ pub struct PackageManager { // bun dedupe: printed by dedupe::print_dedupe_summary in place of the install summary. pub(crate) dedupe_report: Option, + // Rows `bun audit fix` moved to a fixed version; the post-resolve collapse leaves them there. + pub(crate) fixed_rows: bun_collections::DynamicBitSet, + // add/remove/update --filter: only these importers are linked; None = every importer. pub(crate) filtered_link_targets: Option, @@ -2142,6 +2145,7 @@ pub fn init( wr!(kept_patched, Vec::new()); wr!(kept_patched_text, Vec::new()); wr!(dedupe_report, None); + wr!(fixed_rows, bun_collections::DynamicBitSet::default()); wr!(filtered_link_targets, None); wr!(pending_filtered_write, None); wr!(edited_package_jsons, Vec::new()); @@ -2609,6 +2613,7 @@ fn init_with_runtime_once( wr!(kept_patched, Vec::new()); wr!(kept_patched_text, Vec::new()); wr!(dedupe_report, None); + wr!(fixed_rows, bun_collections::DynamicBitSet::default()); wr!(filtered_link_targets, None); wr!(pending_filtered_write, None); wr!(edited_package_jsons, Vec::new()); diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 718d9c436bcf..dc4410e6411b 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -655,6 +655,9 @@ pub fn install_with_manager( direct_deps_before.redirect_dependents(&mut manager.lockfile); transitive.redirect_dependents(&mut manager.lockfile); redirect_moved_edges(&mut manager.lockfile, &named.moved); + if manager.subcommand != Subcommand::Dedupe { + crate::dedupe::collapse_appended(manager); + } transitive.print_plan(manager, &direct_deps_before, &named.moved); print_kept_patched(manager); diff --git a/src/install/audit_fix.rs b/src/install/audit_fix.rs index 30184dc83f21..234bff1cf583 100644 --- a/src/install/audit_fix.rs +++ b/src/install/audit_fix.rs @@ -1238,6 +1238,11 @@ pub fn enqueue_planned_fixes(manager: &mut PackageManager) -> crate::Result<()> continue; }; enqueue_pinned_as(manager, live_dep_id, pkg_name, pin.to_version)?; + let fixed_rows = &mut manager.fixed_rows; + if fixed_rows.bit_length() <= live_dep_id as usize { + bun_core::handle_oom(fixed_rows.resize(live_dep_id as usize + 1, false)); + } + fixed_rows.set(live_dep_id as usize); manager.summary.update += 1; } } diff --git a/src/install/dedupe.rs b/src/install/dedupe.rs index 7c0dde634d6f..d71a4dce73fa 100644 --- a/src/install/dedupe.rs +++ b/src/install/dedupe.rs @@ -20,6 +20,9 @@ struct Pass<'a> { groups: &'a [Vec], group_of: &'a [u32], pinned: &'a DynamicBitSet, + /// Packages below this id (loaded from the lockfile) keep their edges, and so do root/workspace rows and audit-fixed rows. + frozen_below: Option, + fixed_rows: &'a DynamicBitSet, edges: Vec>, candidates: Vec, edge_count: usize, @@ -42,6 +45,8 @@ impl<'a> Pass<'a> { groups: &'a [Vec], group_of: &'a [u32], pinned: &'a DynamicBitSet, + frozen_below: Option, + fixed_rows: &'a DynamicBitSet, max_candidates: usize, max_edges: usize, ) -> Pass<'a> { @@ -51,6 +56,8 @@ impl<'a> Pass<'a> { groups, group_of, pinned, + frozen_below, + fixed_rows, edges: vec![Vec::new(); groups.len()], candidates: Vec::with_capacity(max_candidates), edge_count: 0, @@ -197,7 +204,7 @@ impl<'a> Pass<'a> { self.cur_c.resize(m, 0); self.required.unmanaged.set_all(false); - for (e, &(dep_id, _)) in edges.iter().enumerate() { + for (e, &(dep_id, direct)) in edges.iter().enumerate() { let dep = &deps[dep_id as usize]; let target = cur[dep_id as usize]; let cur_c = self @@ -208,7 +215,15 @@ impl<'a> Pass<'a> { self.cur_c[e] = cur_c; let row = e * n; - let range = if pinned.is_set(target as usize) || dep.behavior.is_bundled() { + let frozen = self.frozen_below.is_some_and(|below| { + direct + || target < below + || self + .fixed_rows + .is_set_allow_out_of_bound(dep_id as usize, false) + }); + let range = if pinned.is_set(target as usize) || frozen || dep.behavior.is_bundled() + { None } else { effective_npm_range(lockfile, dep_id, dep) @@ -346,7 +361,11 @@ fn plural(n: usize) -> &'static str { if n == 1 { "" } else { "s" } } -fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report { +fn dedupe_lockfile( + lockfile: &mut Lockfile, + frozen_below: Option, + fixed_rows: &DynamicBitSet, +) -> Report { let buf = lockfile.buffers.string_bytes.as_slice(); let pkg_res = lockfile.packages.items_resolution(); let checked = pkg_res.len(); @@ -378,7 +397,9 @@ fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report { .copied() .filter(|&id| pkg_res[id as usize].tag == ResolutionTag::Npm) .collect(); - if candidates.len() < 2 { + if candidates.len() < 2 + || frozen_below.is_some_and(|below| candidates.iter().all(|&id| id < below)) + { continue; } index_sort::sort_indices(&mut candidates, &mut |a, b| { @@ -430,6 +451,8 @@ fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report { &groups, &group_of, &pinned, + frozen_below, + fixed_rows, max_candidates, max_edges, ); @@ -855,7 +878,7 @@ pub fn dedupe_after_differ(manager: &mut PackageManager) { refuse_out_of_date(manager); } - let report = dedupe_lockfile(&mut manager.lockfile); + let report = dedupe_lockfile(&mut manager.lockfile, None, &DynamicBitSet::default()); if report.rows.is_empty() { report_already_deduplicated(manager, &report); } @@ -902,3 +925,22 @@ pub fn dedupe_after_differ(manager: &mut PackageManager) { .enable .set(Enable::FORCE_SAVE_LOCKFILE, true); } + +/// Collapses the versions appended this session onto the fewest that satisfy their edges, so the result does not depend on the order manifests landed in. Packages loaded from the lockfile and root/workspace rows keep their edges. +pub fn collapse_appended(manager: &mut PackageManager) { + let lockfile = &mut *manager.lockfile; + let frozen_below = lockfile.loaded_package_count; + if lockfile.packages.len() as PackageID <= frozen_below { + return; + } + let report = dedupe_lockfile(lockfile, Some(frozen_below), &manager.fixed_rows); + for row in &report.rows { + bun_output::scoped_log!( + PackageManager, + "collapsed {}@{} onto {}", + BStr::new(&row.name), + BStr::new(&row.from), + BStr::new(&row.to) + ); + } +} diff --git a/src/install/update_transitive.rs b/src/install/update_transitive.rs index eaac11d84443..3a124aa52ec9 100644 --- a/src/install/update_transitive.rs +++ b/src/install/update_transitive.rs @@ -263,8 +263,6 @@ struct Pin { #[derive(Default)] pub struct TransitiveUpdate { pins: Vec, - /// Kept for `print_plan` when no install summary will print the rows (`--dry-run`, `--lockfile-only`). - report: Option, } impl TransitiveUpdate { @@ -286,13 +284,9 @@ impl TransitiveUpdate { } edges }; - let (pins, report) = plan_edges(manager, &edges, direct)?; - register_moved(manager, &report.moved)?; - let printed_here = manager.options.dry_run || manager.options.lockfile_only; - Ok(TransitiveUpdate { - pins, - report: printed_here.then_some(report), - }) + let (pins, moved) = plan_edges(manager, &edges, direct)?; + register_moved(manager, &moved)?; + Ok(TransitiveUpdate { pins }) } /// Runs after the differ's own enqueues (including its override/catalog invalidation loops) so the pins win; edges the differ moved off their package are left to it. @@ -348,13 +342,13 @@ impl TransitiveUpdate { return; } let dry_run = options.dry_run; - let mut rows = self - .report - .as_ref() - .map_or_else(Vec::new, |report| report.rows.clone()); + // Read back from the resolutions so rows the collapse re-pointed print where they landed. + let pinned: Vec<(DependencyID, PackageID)> = + self.pins.iter().map(|pin| (pin.dep_id, pin.from)).collect(); let mut pairs = direct.moved_pairs(&manager.lockfile); pairs.extend(named_pairs(&manager.lockfile, named)); - rows.extend(rows_between(manager, pairs)); + pairs.extend(named_pairs(&manager.lockfile, &pinned)); + let mut rows = rows_between(manager, pairs); sort_dedup_rows(&mut rows); let kept = kept_patched_rows(manager); if !dry_run && rows.is_empty() && kept.is_empty() { @@ -417,9 +411,9 @@ pub(crate) fn refresh_children_of( } edges }; - let (pins, report) = plan_edges(manager, &edges, &DirectDependencies::default())?; - register_moved(manager, &report.moved)?; - let update = TransitiveUpdate { pins, report: None }; + let (pins, moved) = plan_edges(manager, &edges, &DirectDependencies::default())?; + register_moved(manager, &moved)?; + let update = TransitiveUpdate { pins }; update.enqueue(manager)?; Ok(update .pins @@ -537,13 +531,6 @@ struct Row { later: Box<[u8]>, } -#[derive(Default)] -struct Report { - rows: Vec, - /// Pre-clean ids of the instances at least one row moves away from. - moved: Vec, -} - /// Registers each npm package of `moved` (pre-clean ids) like a `bun update ` request so the install summary prints its update row; a name with several moving instances keeps its lowest original. pub(crate) fn register_moved( manager: &mut PackageManager, @@ -1018,12 +1005,12 @@ pub(crate) fn plannable_peer_rows( rows } -/// `edges` selects the rows to plan; each moves to the newest release its (post-override/catalog) range allows, or follows its dist-tag. +/// `edges` selects the rows to plan; each moves to the newest release its (post-override/catalog) range allows, or follows its dist-tag. Also returns the pre-clean ids of the instances at least one row moves away from. fn plan_edges( manager: &mut PackageManager, edges: &DynamicBitSet, direct: &DirectDependencies, -) -> crate::Result<(Vec, Report)> { +) -> crate::Result<(Vec, Vec)> { let mut instances: Vec = Vec::new(); let mut kept: Vec = Vec::new(); { @@ -1122,7 +1109,7 @@ fn plan_edges( } instances.retain(|inst| !inst.wants.is_empty()); if instances.is_empty() { - return Ok((Vec::new(), Report::default())); + return Ok((Vec::new(), Vec::new())); } let ids: Vec = instances.iter().map(|inst| inst.pkg_id).collect(); @@ -1136,7 +1123,7 @@ fn plan_edges( let pkg_names = manager.lockfile.packages.items_name(); let mut pins: Vec = Vec::new(); - let mut report = Report::default(); + let mut moved: Vec = Vec::new(); let mut unchecked: Vec<(Box<[u8]>, Box<[u8]>)> = Vec::new(); // Non-inline prerelease strings of planned versions live in the manifest buffer; copied into the lockfile's below. let mut pre_strings: Vec<(core::ops::Range, u64, Box<[u8]>)> = Vec::new(); @@ -1162,9 +1149,9 @@ fn plan_edges( }; let manifest: &PackageManifest = manifest; let manifest_buf: &[u8] = &manifest.string_buf; - let rows_before = report.rows.len(); + let pins_before = pins.len(); for want in &inst.wants { - let (v, to, later) = if want.version.tag == DependencyVersionTag::Npm { + let to = if want.version.tag == DependencyVersionTag::Npm { let range = &want.version.npm().version; let Some(found) = manifest .find_best_version_with_filter(range, buf, min_age, excludes) @@ -1184,7 +1171,7 @@ fn plan_edges( Box::from(v.tag.pre.slice(manifest_buf)), )); } - (v, Some(v), later_than(manifest, v, min_age, excludes)) + Some(v) } else { let tag = want.version.dist_tag().tag.slice(buf); let Some(found) = manifest @@ -1196,22 +1183,16 @@ fn plan_edges( if found.version.order(inst.current, manifest_buf, buf) == Ordering::Equal { continue; } - (found.version, None, Box::default()) + None }; pins.extend(want.dep_ids.iter().map(|&dep_id| Pin { dep_id, from: inst.pkg_id, to, })); - report.rows.push(Row { - name: Box::from(name), - from: text(inst.current.fmt(buf)), - to: text(v.fmt(manifest_buf)), - later, - }); } - if report.rows.len() != rows_before { - report.moved.push(inst.pkg_id); + if pins.len() != pins_before { + moved.push(inst.pkg_id); } } index_sort::sort_vec_unstable_by(&mut unchecked, |a, b| a.cmp(b)); @@ -1230,25 +1211,5 @@ fn plan_edges( } } - sort_dedup_rows(&mut report.rows); - Ok((pins, report)) -} - -/// The `latest` dist-tag when it is newer than the release `v` an in-range move stops at, like the `+` rows' `(vX available)`. -fn later_than( - manifest: &PackageManifest, - v: Semver::Version, - min_age: Option, - excludes: Option<&[&[u8]]>, -) -> Box<[u8]> { - if v.tag.has_pre() { - return Box::default(); - } - let manifest_buf: &[u8] = &manifest.string_buf; - let latest = manifest - .find_by_dist_tag_with_filter(b"latest", min_age, excludes) - .unwrap() - .map(|found| found.version) - .filter(|latest| latest.order(v, manifest_buf, manifest_buf) == Ordering::Greater); - latest.map_or_else(Box::default, |latest| text(latest.fmt(manifest_buf))) + Ok((pins, moved)) } diff --git a/test/cli/install/__snapshots__/bun-install-registry.test.ts.snap b/test/cli/install/__snapshots__/bun-install-registry.test.ts.snap index 0f74856c14a5..1ffc69943009 100644 --- a/test/cli/install/__snapshots__/bun-install-registry.test.ts.snap +++ b/test/cli/install/__snapshots__/bun-install-registry.test.ts.snap @@ -189,9 +189,7 @@ exports[`hoisting text lockfile is hoisted 1`] = ` "hoist-lockfile-3": ["hoist-lockfile-3@1.0.0", "http://localhost:1234/hoist-lockfile-3/-/hoist-lockfile-3-1.0.0.tgz", { "dependencies": { "hoist-lockfile-shared": ">=1.0.1" } }, "sha512-iGz7jH7jxz/zq4OZM8hhT7kUX2Ye1m+45SoyMVcWTM7ZB+cY306Ff1mQePKTjkn84/pJMITMdRgDv/qF8PuQUw=="], - "hoist-lockfile-shared": ["hoist-lockfile-shared@2.0.2", "http://localhost:1234/hoist-lockfile-shared/-/hoist-lockfile-shared-2.0.2.tgz", {}, "sha512-xPWoyP8lv+/JrbClRzhJx1eUsHqDflSTmWOxx82xvMIEs6mbiIuvIp3/L+Ojc6mqex6y426h7L5j0hjLZE3V9w=="], - - "hoist-lockfile-2/hoist-lockfile-shared": ["hoist-lockfile-shared@1.0.2", "http://localhost:1234/hoist-lockfile-shared/-/hoist-lockfile-shared-1.0.2.tgz", {}, "sha512-p7IQ/BbkTRLG/GUx6j2cDQ+vTUc/v9OW9Ss9igh/GFysbr0Qjriz/DiETnISkxYaTFitqOkUSOUkEKyeLNJsfQ=="], + "hoist-lockfile-shared": ["hoist-lockfile-shared@1.0.2", "http://localhost:1234/hoist-lockfile-shared/-/hoist-lockfile-shared-1.0.2.tgz", {}, "sha512-p7IQ/BbkTRLG/GUx6j2cDQ+vTUc/v9OW9Ss9igh/GFysbr0Qjriz/DiETnISkxYaTFitqOkUSOUkEKyeLNJsfQ=="], } } " diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 39a3c387d70f..51bbd5a4c6c4 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -4741,6 +4741,107 @@ describe("hoisting", async () => { lockfile, ); }); + + // hoist-lockfile-1@1.0.0 depends on `hoist-lockfile-shared: *`; the registry has 1.0.1, 1.0.2, 2.0.1 and 2.0.2. + // The project's own range decides which copy that `*` shares, the same way an exact pin would. + async function lockedShared() { + const { packages } = Bun.JSONC.parse(await file(join(packageDir, "bun.lock")).text()) as { + packages: Record; + }; + return Object.fromEntries( + Object.entries(packages) + .filter(([, [resolution]]) => resolution.startsWith("hoist-lockfile-shared@")) + .map(([key, [resolution]]) => [key, resolution]), + ); + } + + async function sharedResolutions() { + await runBunInstall(env, packageDir, { saveTextLockfile: true }); + return lockedShared(); + } + + test("a dependency's `*` shares the version the root's own range resolved to", async () => { + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { "hoist-lockfile-1": "1.0.0", "hoist-lockfile-shared": "^1.0.1" }, + }), + ); + + expect(await sharedResolutions()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2" }); + expect(await exists(join(packageDir, "node_modules", "hoist-lockfile-1", "node_modules"))).toBeFalse(); + }); + + test("a dependency's `*` shares the version a workspace's own range resolved to", async () => { + await Promise.all([ + write( + packageJson, + JSON.stringify({ name: "foo", workspaces: ["packages/*"], dependencies: { "hoist-lockfile-1": "1.0.0" } }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ name: "pkg1", dependencies: { "hoist-lockfile-shared": "^1.0.1" } }), + ), + ]); + + expect(await sharedResolutions()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2" }); + expect(await exists(join(packageDir, "node_modules", "hoist-lockfile-1", "node_modules"))).toBeFalse(); + }); + + test("two dependencies' ranges on the same package share one copy when one version satisfies both", async () => { + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { "hoist-lockfile-1": "1.0.0", "hoist-lockfile-2": "1.0.0" }, + }), + ); + + expect(await sharedResolutions()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2" }); + expect(await exists(join(packageDir, "node_modules", "hoist-lockfile-1", "node_modules"))).toBeFalse(); + expect(await exists(join(packageDir, "node_modules", "hoist-lockfile-2", "node_modules"))).toBeFalse(); + }); + + test("a copy already in bun.lock is kept by a later install that only adds packages", async () => { + await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "hoist-lockfile-1": "1.0.0" } })); + expect(await sharedResolutions()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@2.0.2" }); + + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { "hoist-lockfile-1": "1.0.0", "hoist-lockfile-2": "1.0.0" }, + }), + ); + expect(await sharedResolutions()).toStrictEqual({ + "hoist-lockfile-shared": "hoist-lockfile-shared@2.0.2", + "hoist-lockfile-2/hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2", + }); + }); + + test("`bun add` of a dependency with a `*` range shares the copy the root's own range already resolved to", async () => { + await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "hoist-lockfile-shared": "^1.0.1" } })); + expect(await sharedResolutions()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2" }); + + const { stderr, exited } = spawn({ + cmd: [bunExe(), "add", "hoist-lockfile-1@1.0.0"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([stderr.text(), exited]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + expect((await file(packageJson).json()).dependencies).toEqual({ + "hoist-lockfile-1": "1.0.0", + "hoist-lockfile-shared": "^1.0.1", + }); + expect(await lockedShared()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2" }); + expect(await exists(join(packageDir, "node_modules", "hoist-lockfile-1", "node_modules"))).toBeFalse(); + }); }); describe("transitive file dependencies", () => { diff --git a/test/cli/install/bun-update-transitive.test.ts b/test/cli/install/bun-update-transitive.test.ts index 43d7ae599a80..cfb94bc04375 100644 --- a/test/cli/install/bun-update-transitive.test.ts +++ b/test/cli/install/bun-update-transitive.test.ts @@ -507,24 +507,501 @@ test.concurrent("a transitive dependency pinned exactly by its dependent stays p expect(exitCode).toBe(0); }); -// The root's no-deps@1.0.0 dedupes both dependents onto 1.0.0 before it is dropped; the update forks only the `^1.0.0` edge. -test.concurrent("dependents with different ranges are resolved independently", async () => { +// The root's no-deps@1.0.0 dedupes both dependents onto 1.0.0 before it is dropped; the fixed edge keeps 1.0.0 alive, so the `^1.0.0` edge is held instead of forked into the duplicate `bun dedupe` would remove. +test.concurrent("a range edge is not forked off an instance a fixed sibling keeps alive", async () => { const dependents = { "one-fixed-dep": "1.0.0", "one-range-dep": "1.0.0" }; const dir = await setup({ "package.json": pkgJson({ "no-deps": "1.0.0", ...dependents }) }); const packageJson = pkgJson(dependents); await reinstall(dir, packageJson); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + const before = await lockText(dir); const { stdout, stderr, exitCode } = await run(dir, "update"); - expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectSummary(stdout, noChanges(3, 4)); expectCleanStderr(stderr); + expect(stderr).not.toContain("Saved lockfile"); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); + expect(await lockText(dir)).toBe(before); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +// #38903: `bun update` and `bun dedupe` must reach a fixed point. After dedupe collapses the `^1.0.0` +// edge onto the exact pin's 1.0.0, a bare update holds that edge instead of re-adding the duplicate. +test.concurrent("a deduped lockfile is a fixed point of `bun update`", async () => { + const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0" }) }); + const packageJson = pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }); + await reinstall(dir, packageJson); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]); - const { packages } = await lock(dir); - expect([packages["no-deps"][0], packages["one-range-dep/no-deps"][0]]).toStrictEqual([ - "no-deps@1.0.0", - "no-deps@1.1.0", - ]); + + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + const before = await lockText(dir); + + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectSummary(stdout, noChanges(2, 3)); + expectCleanStderr(stderr); + expect(stderr).not.toContain("Saved lockfile"); + expect(await packageJsonOf(dir)).toStrictEqual(packageJson); + expect(await lockText(dir)).toBe(before); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); + expect(await lockText(dir)).toBe(before); await frozen(dir); +}); + +// Holds cascade: the peer's range rejects 2.0.0, holding bounded-dep's edge; that held edge in turn +// rejects 4.0.0, so wide-dep's edge must be held too instead of forking leaf off the kept 1.0.0. +test.concurrent("a held edge blocks its sibling from forking the instance", async () => { + using server = await serveRegistry({ + "wide-dep": { "1.0.0": { dependencies: { leaf: ">=1.0.0" } } }, + "bounded-dep": { "1.0.0": { dependencies: { leaf: ">=1.0.0 <3.0.0" } } }, + "peer-host": { "1.0.0": { peerDependencies: { leaf: "1.0.0 || >=4.0.0" } } }, + leaf: { "1.0.0": {}, "2.0.0": {}, "4.0.0": {} }, + }); + const packageJson = pkgJson({ "wide-dep": "1.0.0", "bounded-dep": "1.0.0", "peer-host": "1.0.0" }); + const dir = await installServed( + server, + "update-hold-cascade-", + pkgJson({ ...packageJson.dependencies, leaf: "1.0.0" }), + ); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + await reinstall(dir, packageJson); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + const before = await lockText(dir); + + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectNoMoves(stdout); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(await lockText(dir)).toBe(before); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); +}); + +// A direct range that will move is no reason to hold: root leaf `^1.0.0 || ^3.0.0` moves to 3.0.0, +// so parent's `^1.0.0 || ^2.0.0` edge moves to 2.0.0 instead of being held at the 1.0.0 the root leaves. +test.concurrent("a direct edge that moves away does not hold its transitive siblings", async () => { + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "^1.0.0 || ^2.0.0" } } }, + leaf: { "1.0.0": {}, "2.0.0": {}, "3.0.0": {} }, + }); + const dir = await installServed(server, "update-direct-moves-", pkgJson({ parent: "1.0.0", leaf: "1.0.0" })); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + // Widen the root range in package.json and bun.lock, keeping the locked resolution at 1.0.0. + await write(join(dir, "package.json"), stringify(pkgJson({ parent: "1.0.0", leaf: "^1.0.0 || ^3.0.0" }))); + const lockfile = await lockText(dir); + expect(lockfile.split(`"leaf": "1.0.0"`)).toHaveLength(2); + await write(join(dir, "bun.lock"), lockfile.replace(`"leaf": "1.0.0"`, `"leaf": "^1.0.0 || ^3.0.0"`)); + + const { stdout, stderr, exitCode } = await run(dir, "update"); + // The summary prints one row per name; the direct move wins the slot. + expect(movedRows(stdout)).toStrictEqual([movedRow("leaf", "1.0.0", "3.0.0")]); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["2.0.0", "3.0.0"]); + expect(exitCode).toBe(0); + const before = await lockText(dir); + + const again = await run(dir, "dedupe"); + expect(again.stderr).not.toContain("error:"); + expect(again.exitCode).toBe(0); + expect(await lockText(dir)).toBe(before); +}); + +// `--latest -r` resolves the targeted rows by the `latest` dist-tag, so the exact pin jumps to 2.0.0 +// and does not keep 1.0.0 alive; parent's `^1.0.0` edge must still move to its in-range best. +test.concurrent("`bun update --latest -r`: a pin that jumps to latest does not hold its siblings", async () => { + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "^1.0.0" } } }, + leaf: { "1.0.0": {}, "1.5.0": {}, "2.0.0": {} }, + }); + const dir = await installServed(server, "update-latest-target-", pkgJson({ parent: "1.0.0", leaf: "1.0.0" })); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + const { stderr, exitCode } = await run(dir, "update", "--latest", "-r"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0", "2.0.0"]); + expect(exitCode).toBe(0); +}); + +// A workspace member's row is not re-resolved by a bare update from the root, so its parked 1.0.0 +// keeps the instance alive and parent's `*` edge is held: the deduped lockfile stays a fixed point. +test.concurrent("a member's parked edge holds its transitive siblings under a root update", async () => { + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "*" } } }, + leaf: { "1.0.0": {}, "1.5.0": {}, "2.0.0": {} }, + }); + using tmp = tempDir("update-member-hold-", { + "package.json": stringify({ name: "root", workspaces: ["packages/*"], dependencies: { leaf: "1.0.0" } }), + "packages/pkg1/package.json": stringify({ name: "pkg1", dependencies: { leaf: "^1.0.0", parent: "1.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + await install(dir); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + await reinstall(dir, { name: "root", workspaces: ["packages/*"] }); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + const before = await lockText(dir); + + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectNoMoves(stdout); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(await lockText(dir)).toBe(before); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); +}); + +// pkg1's `^1.0.0` sits on leaf@1.0.0 only; it must not hold parent-b's `>=2.0.0` edge on the +// separate leaf@2.0.0 instance, which is free to move to 3.0.0. +test.concurrent("a member's parked row does not hold wants on another instance of its package", async () => { + using server = await serveRegistry({ + "parent-a": { "1.0.0": { dependencies: { leaf: "^1.0.0" } } }, + "parent-b": { "1.0.0": { dependencies: { leaf: ">=2.0.0" } } }, + leaf: { "1.0.0": {}, "2.0.0": {}, "3.0.0": {} }, + }); + const dependents = { "parent-a": "1.0.0", "parent-b": "1.0.0" }; + using tmp = tempDir("update-member-other-instance-", { + "package.json": stringify({ + name: "root", + workspaces: ["packages/*"], + dependencies: { ...dependents, leaf: "2.0.0" }, + }), + "packages/pkg1/package.json": stringify({ name: "pkg1", dependencies: { leaf: "^1.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + await install(dir); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "2.0.0"]); + await reinstall(dir, { name: "root", workspaces: ["packages/*"], dependencies: dependents }); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "2.0.0"]); + + const { stderr, exitCode } = await run(dir, "update"); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "3.0.0"]); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); +}); + +// `--latest -r` bumps the catalog definition itself before resolving, so the catalog row is +// modeled by its rewritten range (never the `latest` dist-tag) and every edge converges on 2.0.0. +test.concurrent("`bun update --latest -r` with a catalog pin converges with dedupe", async () => { + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "*" } } }, + leaf: { "1.0.0": {}, "2.0.0": {} }, + }); + using tmp = tempDir("update-latest-catalog-", { + "package.json": stringify({ name: "root", workspaces: ["packages/*"], catalog: { leaf: "1.0.0" } }), + "packages/pkg1/package.json": stringify({ name: "pkg1", dependencies: { leaf: "catalog:", parent: "1.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + await install(dir); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + const { stderr, exitCode } = await run(dir, "update", "--latest", "-r"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["2.0.0"]); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); +}); + +// `--latest` never moves a row below bun.lock: with `latest` behind the locked 2.0.0, the exact pin +// stays, so parent's `>=2.0.0` edge is held instead of re-forking what dedupe removed. +test.concurrent("`bun update --latest -r`: a pin ahead of `latest` keeps holding its siblings", async () => { + using server = await serveRegistry( + { + parent: { "1.0.0": { dependencies: { leaf: ">=2.0.0" } } }, + leaf: { "1.9.0": {}, "2.0.0": {}, "2.1.0": {} }, + }, + { leaf: { latest: "1.9.0" } }, + ); + const dir = await installServed(server, "update-latest-behind-", pkgJson({ parent: "1.0.0", leaf: "2.0.0" })); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["2.0.0"]); + + const { stderr, exitCode } = await run(dir, "update", "--latest", "-r"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["2.0.0"]); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); +}); + +// Root rows are re-appended unresolved before the plan runs; the snapshot recovers where they sat, +// so a root pin still holds a member-scoped update's wants even though root is out of scope. +test.concurrent("a root pin holds transitive wants when updating from a member", async () => { + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "*" } } }, + leaf: { "1.0.0": {}, "2.0.0": {} }, + }); + using tmp = tempDir("update-member-cwd-", { + "package.json": stringify({ name: "root", workspaces: ["packages/*"], dependencies: { leaf: "1.0.0" } }), + "packages/pkg1/package.json": stringify({ name: "pkg1", dependencies: { parent: "1.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + await install(dir); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); +}); + +// `bun update parent --latest` moves parent 1.0.0 -> 3.0.0; the superseded parent@1.0.0's `~1.0.0` +// row must not hold the new parent's `^1.0.0` child edge, which moves in-range to 1.5.0. +test.concurrent("`bun update --latest`: a superseded version's rows do not hold the new children", async () => { + using server = await serveRegistry({ + parent: { + "1.0.0": { dependencies: { leaf: "~1.0.0" } }, + "3.0.0": { dependencies: { leaf: "^1.0.0" } }, + }, + leaf: { "1.0.0": {}, "1.5.0": {} }, + }); + const dir = await installServed(server, "update-superseded-", pkgJson({ parent: "1.0.0" })); + expect(await lockedVersions(dir, "parent")).toStrictEqual(["1.0.0"]); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + const { stderr, exitCode } = await run(dir, "update", "parent", "--latest"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "parent")).toStrictEqual(["3.0.0"]); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0"]); + expect(exitCode).toBe(0); +}); + +// Same through an intermediate package: the superseded parent@1.0.0 -> mid -> leaf `~1.0.0` chain +// is unreachable once parent moves, so mid's row does not hold the new parent's `^1.0.0` edge. +test.concurrent("`bun update --latest`: a superseded chain's rows do not hold the new children", async () => { + using server = await serveRegistry({ + parent: { + "1.0.0": { dependencies: { mid: "1.0.0" } }, + "3.0.0": { dependencies: { leaf: "^1.0.0" } }, + }, + mid: { "1.0.0": { dependencies: { leaf: "~1.0.0" } } }, + leaf: { "1.0.0": {}, "1.5.0": {} }, + }); + const dir = await installServed(server, "update-superseded-chain-", pkgJson({ parent: "1.0.0" })); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + const { stderr, exitCode } = await run(dir, "update", "parent", "--latest"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "parent")).toStrictEqual(["3.0.0"]); + expect(await lockedVersions(dir, "mid")).toStrictEqual([]); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0"]); + expect(exitCode).toBe(0); +}); + +// Removing a root dependency and updating in one step: the removed subtree's rows are unreachable +// and do not hold the remaining `^1.0.0` want at the 1.0.0 they used to share. +test.concurrent("a removed root dependency's rows do not hold the remaining wants", async () => { + using server = await serveRegistry({ + "parent-a": { "1.0.0": { dependencies: { leaf: "~1.0.0" } } }, + "parent-b": { "1.0.0": { dependencies: { leaf: "^1.0.0" } } }, + leaf: { "1.0.0": {}, "1.5.0": {} }, + }); + const dir = await installServed( + server, + "update-removed-root-", + pkgJson({ "parent-a": "1.0.0", "parent-b": "1.0.0" }), + ); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + await write(join(dir, "package.json"), stringify(pkgJson({ "parent-b": "1.0.0" }))); + const { stderr, exitCode } = await run(dir, "update"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "parent-a")).toStrictEqual([]); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0"]); + expect(exitCode).toBe(0); +}); + +// The workspace analog: a member dropped from the glob keeps its Workspace tag until the clean, +// but its rows are unreachable and do not hold the remaining `^1.0.0` want. +test.concurrent("a removed workspace member's rows do not hold the remaining wants", async () => { + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "^1.0.0" } } }, + leaf: { "1.0.0": {}, "1.5.0": {} }, + }); + using tmp = tempDir("update-removed-member-", { + "package.json": stringify({ name: "root", workspaces: ["packages/*"], dependencies: { parent: "1.0.0" } }), + "packages/a/package.json": stringify({ name: "a", dependencies: { leaf: "~1.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + await install(dir); + const deduped = await run(dir, "dedupe"); + expect(deduped.stderr).not.toContain("error:"); + expect(deduped.exitCode).toBe(0); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + + await write( + join(dir, "package.json"), + stringify({ name: "root", workspaces: [], dependencies: { parent: "1.0.0" } }), + ); + const { stderr, exitCode } = await run(dir, "update"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0"]); + expect(exitCode).toBe(0); +}); + +// An optional dependency the registry cannot resolve is skipped at install and its row stays +// unresolved in bun.lock; a later `bun update` must tolerate that row. +test.concurrent("`bun update` tolerates an unresolved optional dependency row", async () => { + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "^1.0.0" } } }, + leaf: { "1.0.0": {}, "1.5.0": {} }, + }); + using tmp = tempDir("update-optional-unresolved-", { + "package.json": stringify({ name: "root", workspaces: ["packages/*"], dependencies: { parent: "1.0.0" } }), + "packages/a/package.json": stringify({ name: "a", optionalDependencies: { gone: "^1.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + const first = await run(dir, "install"); + expect(first.exitCode).toBe(0); + + const { stderr, exitCode } = await run(dir, "update"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0"]); + expect(exitCode).toBe(0); +}); + +// The root's `^1.0.0` row sits on leaf@1.5.0; its lookup (1.5.0) is below leaf@2.0.0 but the row +// never resolved there, so it must not hold parent's `^2.0.0` want from moving to 2.5.0. +test.concurrent("a direct row on a lower instance does not hold wants on a higher instance", async () => { + const manifests: Manifests = { + parent: { "1.0.0": { dependencies: { leaf: "^2.0.0" } } }, + leaf: { "1.5.0": {}, "2.0.0": {}, "2.5.0": {} }, + }; + using server = await serveRegistry(manifests); + // Tarballs are built eagerly, versions are read per request: hide 2.5.0 from the install only. + const hidden = manifests.leaf["2.5.0"]; + delete manifests.leaf["2.5.0"]; + const dir = await installServed(server, "update-two-instances-", pkgJson({ parent: "1.0.0", leaf: "^1.0.0" })); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0", "2.0.0"]); + manifests.leaf["2.5.0"] = hidden; + + const { stderr, exitCode } = await run(dir, "update"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.5.0", "2.5.0"]); + expect(exitCode).toBe(0); +}); + +// The redirect is first-wins per instance: root's row lands on 3.0.0 first, so pkg1's 4.0.0 landing is +// never offered to peer-host's edge; that edge stays on 1.0.0 and parent's `*` want must be held. +test.concurrent("only the first moved direct row's landing carries an instance's followers", async () => { + const manifests: Manifests = { + parent: { "1.0.0": { dependencies: { leaf: "*" } } }, + "peer-host": { "1.0.0": { peerDependencies: { leaf: "~1.0.0 || ~4.0.0" } } }, + leaf: { "1.0.0": {}, "3.0.0": {}, "4.0.0": {}, "5.0.0": {} }, + }; + using server = await serveRegistry(manifests); + const { "1.0.0": kept, ...hidden } = manifests.leaf; + manifests.leaf = { "1.0.0": kept }; + using tmp = tempDir("update-first-landing-", { + "package.json": stringify({ + name: "root", + workspaces: ["packages/*"], + dependencies: { leaf: "~1.0.0 || 3.0.0", parent: "1.0.0", "peer-host": "1.0.0" }, + }), + "packages/pkg1/package.json": stringify({ name: "pkg1", dependencies: { leaf: "~1.0.0 || 4.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + await install(dir); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + Object.assign(manifests.leaf, hidden); + + const { stderr, exitCode } = await run(dir, "update", "-r"); + expect(stderr).not.toContain("error:"); + // parent's `*` want is held and its edge carried to 3.0.0 instead of forking off a removable 5.0.0. + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.0", "4.0.0"]); + expect(exitCode).toBe(0); + + const check = await run(dir, "dedupe", "--check"); + expect(check.stderr).not.toContain("error:"); + expect(check.exitCode).toBe(0); +}); + +// pkg1's `1.0.0 || 2.0.0` row binds to the workspace member leaf@2.0.0, never the npm leaf@1.0.0 it +// name-matches, so it must not hold parent-b's `~1.0.0` want from vacating that instance to 1.0.5. +test.concurrent("a row a workspace member captures does not hold its package's npm instances", async () => { + const manifests: Manifests = { + "parent-b": { "1.0.0": { dependencies: { leaf: "~1.0.0" } } }, + leaf: { "1.0.0": {}, "1.0.5": {} }, + }; + using server = await serveRegistry(manifests); + const revealed = manifests.leaf["1.0.5"]; + delete manifests.leaf["1.0.5"]; + using tmp = tempDir("update-workspace-capture-", { + "package.json": stringify({ + name: "root", + workspaces: ["packages/*"], + dependencies: { "parent-b": "1.0.0" }, + }), + "packages/leaf/package.json": stringify({ name: "leaf", version: "2.0.0" }), + "packages/pkg1/package.json": stringify({ name: "pkg1", dependencies: { leaf: "1.0.0 || 2.0.0" } }), + }); + const dir = String(tmp); + await servedBunfig(server, dir); + await install(dir); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "workspace:packages/leaf"]); + manifests.leaf["1.0.5"] = revealed; + + const { stderr, exitCode } = await run(dir, "update", "-r"); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.5", "workspace:packages/leaf"]); expect(exitCode).toBe(0); }); @@ -1090,6 +1567,7 @@ async function staleShared() { return { dir, packageJson }; } +// Every row moves within its own range, then the versions the update added collapse onto the fewest that satisfy them: 1.0.2 covers `*`, `^1.0.1` and `>=1.0.1`, so 2.0.2 is not kept. test.concurrent.each([ ["bare", []], ["named", ["hoist-lockfile-shared"]], @@ -1100,7 +1578,7 @@ test.concurrent.each([ expect(stderr).not.toContain("error:"); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(HOIST_DEPENDENTS); - expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2", "2.0.2"]); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2"]); await frozen(dir); expect(exitCode).toBe(0); }); @@ -1109,16 +1587,139 @@ test.concurrent("the plan counts packages, not the edges that move onto them", a const { dir } = await staleShared(); const before = await lockText(dir); const { stdout, stderr, exitCode } = await run(dir, "update", "--dry-run"); - expectDryRun( - stdout, - movedRow("hoist-lockfile-shared", "1.0.1", "1.0.2", "2.0.2"), - movedRow("hoist-lockfile-shared", "1.0.1", "2.0.2"), - ); + expectDryRun(stdout, movedRow("hoist-lockfile-shared", "1.0.1", "1.0.2", "2.0.2")); expectCleanStderr(stderr); expect(await lockText(dir)).toBe(before); expect(exitCode).toBe(0); }); +// The root declares hoist-lockfile-shared itself, so the dependent's row shares the root's copy; `rootRange` is +// widened after the install (the locked 1.0.1 still satisfies it, so nothing re-resolves until an update). +async function sharedWithRoot(dependent: string, rootRange = "1.0.1") { + const dir = await setup({ "package.json": pkgJson({ [dependent]: "1.0.0", "hoist-lockfile-shared": "1.0.1" }) }); + const packageJson = pkgJson({ [dependent]: "1.0.0", "hoist-lockfile-shared": rootRange }); + if (rootRange !== "1.0.1") await reinstall(dir, packageJson); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + return { dir, packageJson, nested: join(dir, "node_modules", dependent, "node_modules", "hoist-lockfile-shared") }; +} + +// hoist-lockfile-1's `*` row follows the root's own hoist-lockfile-shared entry instead of forking off to 2.0.2. +test.concurrent.each([ + ["bare", []], + ["named", ["hoist-lockfile-shared"]], +])( + "a row sharing the root's copy follows the root's range instead of re-resolving on its own (%s)", + async (_, args) => { + const { dir, nested } = await sharedWithRoot("hoist-lockfile-1", "^1.0.1"); + const { stdout, stderr, exitCode } = await run(dir, "update", ...args); + expectSummary(stdout, movedRow("hoist-lockfile-shared", "1.0.1", "1.0.2", "2.0.2"), "", installed(1)); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual( + pkgJson({ "hoist-lockfile-1": "1.0.0", "hoist-lockfile-shared": "^1.0.2" }), + ); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("1.0.2"); + expect(await exists(nested)).toBeFalse(); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + +test.concurrent.each([ + ["bare", []], + ["named", ["hoist-lockfile-shared"]], + ["the dependent with --latest", ["hoist-lockfile-1", "--latest"]], +])("a row sharing the root's exact pin stays on it (%s)", async (_, args) => { + const { dir, nested } = await sharedWithRoot("hoist-lockfile-1"); + await expectNoop(dir, ...args); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + expect(await exists(nested)).toBeFalse(); +}); + +// hoist-lockfile-2's `^1.0.1` rejects the 2.0.2 the root's `>=1.0.1` moves to, so that row is planned on its own. +test.concurrent("a row whose range rejects where the root's copy is going moves within its own range", async () => { + const { dir, packageJson } = await sharedWithRoot("hoist-lockfile-2", ">=1.0.1"); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectMoved(stdout, "hoist-lockfile-shared", "1.0.1", "2.0.2"); + expect(normalize(stdout)).toEndWith(`\n${installed(2)}\n`); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual(packageJson); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2", "2.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("2.0.2"); + expect(await installedVersion(dir, "hoist-lockfile-2", "node_modules", "hoist-lockfile-shared")).toBe("1.0.2"); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +// The root's entry is edited and `bun update` runs right away, so the update sees the old entry in bun.lock and the new one in package.json. +test.concurrent( + "a row is planned on its own when the root's entry is edited out of its range before the update", + async () => { + const { dir } = await sharedWithRoot("hoist-lockfile-2"); + await write( + join(dir, "package.json"), + stringify(pkgJson({ "hoist-lockfile-2": "1.0.0", "hoist-lockfile-shared": "^2.0.1" })), + ); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectMoved(stdout, "hoist-lockfile-shared", "1.0.1", "2.0.2"); + expect(normalize(stdout)).toEndWith(`\n${installed(2)}\n`); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual( + pkgJson({ "hoist-lockfile-2": "1.0.0", "hoist-lockfile-shared": "^2.0.2" }), + ); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2", "2.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("2.0.2"); + expect(await installedVersion(dir, "hoist-lockfile-2", "node_modules", "hoist-lockfile-shared")).toBe("1.0.2"); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + +test.concurrent.each([ + ["bare", []], + ["--dry-run", ["--dry-run"]], +])("a row is planned on its own when the root's entry is removed before the update (%s)", async (_, args) => { + const { dir } = await sharedWithRoot("hoist-lockfile-1"); + const packageJson = pkgJson({ "hoist-lockfile-1": "1.0.0" }); + await write(join(dir, "package.json"), stringify(packageJson)); + const { stdout, stderr, exitCode } = await run(dir, "update", ...args); + expect(movedRows(stdout)).toStrictEqual([movedRow("hoist-lockfile-shared", "1.0.1", "2.0.2")]); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual(packageJson); + if (args.length) { + expect(normalize(stdout)).toEndWith(`\n${wouldUpdate(1)}\n`); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + } else { + expect(normalize(stdout)).toContain(`\n${installed(1)}\n`); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["2.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("2.0.2"); + await frozen(dir); + } + expect(exitCode).toBe(0); +}); + +// pkg2's no-deps entry, which one-range-dep's `^1.0.0` row shares, is edited to `^2.0.0` right before the update; pkg2's +// new package.json is only read while the update resolves, so the row can only be planned once pkg2 has moved. +test.concurrent( + "a row is planned on its own when a member's entry is edited out of its range before the update", + async () => { + const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); + await write(join(dir, "packages/pkg2/package.json"), stringify(member("pkg2", { "no-deps": "^2.0.0" }))); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expect(movedRows(stdout)).toStrictEqual([ + movedRow("no-deps", "1.0.0", "2.0.0"), + movedRow("no-deps", "1.0.0", "1.1.0"), + ]); + expect(stderr).not.toContain("error:"); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonOf(dir, "packages/pkg2")).toStrictEqual(member("pkg2", { "no-deps": "^2.0.0" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0", "2.0.0"]); + expect(await installedVersion(dir, "one-range-dep", "node_modules", "no-deps")).toBe("1.1.0"); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + // peer-deps-fixed@1.0.0 declares peer `no-deps: ^1.0.0`; the root's exact no-deps@1.0.0 is its only provider. test.concurrent.each([ ["bare", []], @@ -1231,7 +1832,10 @@ test.concurrent("in a workspace, `bun update` from one member also re-points a s expect(exitCode).toBe(0); }); -type Manifests = Record }>>; +type Manifests = Record< + string, + Record; peerDependencies?: Record }> +>; type Tags = Record>; // Serves one manifest per name from memory; verdaccio has no parent whose newer version keeps a range on the same child, and its dist-tags cannot move mid-test. `tags` is read per request, so a test can move a tag after installing. @@ -1436,13 +2040,38 @@ test.concurrent( }, ); -// The request binds to the root's exact leaf@1.0.0, which stays put; the copy that moves is parent's nested one. +// parent's `^1.0.0` is satisfied by the root's exact leaf@1.0.0, so it shares that copy and the request has nothing to move. +test.concurrent("`bun update ` keeps a dependent's row on the root's exact pin", async () => { + using server = await serveRegistry(TAGGED_FREE); + const dir = await installServed(server, "update-named-shared-pin-", pkgJson({ parent: "^1.0.0", leaf: "1.0.0" })); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + const before = await withLeafScanner(server, dir); + const { stdout, stderr, exitCode } = await run(dir, "update", "leaf"); + expectNoChangesLine(stdout); + expect(stdout).not.toContain("FATAL:"); + expectCleanStderr(stderr); + expect(await lockText(dir)).toBe(before); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(await exists(join(dir, "node_modules", "parent", "node_modules", "leaf"))).toBeFalse(); + expect(exitCode).toBe(0); +}); + +// The request binds to the root's exact leaf@1.0.0, which stays put; parent's `^1.0.1` rejects that copy, so its own +// one (left on 1.0.1 when the root moved from 1.0.1 down to 1.0.0) is the copy that moves. test.concurrent( "`bun update ` also scans the nested copy it re-resolved when the root's own row stays put", async () => { - using server = await serveRegistry(TAGGED_FREE); - const dir = await installServed(server, "update-named-scan-nested-", pkgJson({ parent: "^1.0.0", leaf: "1.0.0" })); - expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "^1.0.1" } } }, + leaf: { "1.0.0": {}, "1.0.1": {}, "1.1.0": {} }, + }); + const dir = await setupServed( + server, + "update-named-scan-nested-", + pkgJson({ parent: "^1.0.0", leaf: "1.0.1" }), + pkgJson({ parent: "^1.0.0", leaf: "1.0.0" }), + ); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "1.0.1"]); const before = await withLeafScanner(server, dir); const { stdout, exitCode } = await run(dir, "update", "leaf"); const scanned = stdout.match(/^scanned: .*$/m)?.[0] ?? ""; @@ -1450,8 +2079,9 @@ test.concurrent( expect(scanned).toContain("leaf@1.1.0"); expect(stdout).toContain("FATAL: leaf"); expect(await lockText(dir)).toBe(before); - expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "1.0.1"]); expect(await installedVersion(dir, "leaf")).toBe("1.0.0"); + expect(await installedVersion(dir, "parent", "node_modules", "leaf")).toBe("1.0.1"); expect(exitCode).toBe(1); }, ); @@ -1875,11 +2505,10 @@ test.concurrent("`bun update ` from a member leaves a sibling's own entry test.concurrent( "`bun update ` from a member: a sibling whose range rejects the picked version stays put", async () => { - const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); - const pkg2Text = await packageJsonText(dir, "packages/pkg2"); + const { dir, pkg2Text } = await staleMembers("^1.0.0", "~1.0.0"); const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps"); expect(stderr).not.toContain("error:"); - expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.1.0" })); expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]); await frozen(dir); @@ -1887,6 +2516,35 @@ test.concurrent( }, ); +// one-range-dep's `^1.0.0` row shares the no-deps@1.0.0 that pkg2's own `~1.0.0` entry holds; neither request re-resolves pkg2's entry, so the row stays with it rather than forking off to 1.1.0. +test.concurrent.each([ + ["bare", []], + ["named", ["no-deps"]], +])("from a member, a row sharing a sibling's copy stays with that copy (%s)", async (_, args) => { + const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); + const pkg2Text = await packageJsonText(dir, "packages/pkg2"); + const { stdout, stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", ...args); + expectNoMoves(stdout); + expect(stderr).not.toContain("error:"); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +// With pkg2's entry in the request's scope, it moves within `~1.0.0` and one-range-dep's row follows it. +test.concurrent("`bun update -r` moves a sibling's entry and the rows sharing its copy follow", async () => { + const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); + const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "-r", "no-deps"); + expect(stderr).not.toContain("error:"); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonOf(dir, "packages/pkg2")).toStrictEqual(member("pkg2", { "no-deps": "~1.0.1" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); + await frozen(dir); + expect(exitCode).toBe(0); +}); + test.concurrent("`bun update ` from the root does not re-resolve a member's own entry", async () => { const root = { ...ROOT, dependencies: { "no-deps": "^2.0.0" } }; const dir = await setup({ @@ -2002,6 +2660,7 @@ test.concurrent.each([ }, ); +// one-range-dep's `^1.0.0` row shares the copy the aliased entry holds, so it moves with that entry to 1.0.1 rather than to 1.1.0 on its own. test.concurrent("several names in one command are matched independently, aliases through their real name", async () => { const dir = await setup({ "package.json": pkgJson({ "a-dep": "1.0.1", aliased: "npm:no-deps@1.0.0", "one-range-dep": "1.0.0" }), @@ -2011,14 +2670,13 @@ test.concurrent("several names in one command are matched independently, aliases expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); const { stdout, stderr, exitCode } = await run(dir, "update", "a-dep", "no-deps"); - expect(movedRows(stdout)).toContain(A_DEP_ROW); - expect(movedRows(stdout)).toContain(NO_DEPS_ROW); + expect(movedRows(stdout)).toStrictEqual([A_DEP_ROW, movedRow("aliased", "1.0.0", "1.0.1")]); expect(stdout).not.toMatch(/^installed /m); expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual( pkgJson({ "a-dep": "^1.0.10", aliased: "npm:no-deps@~1.0.1", "one-range-dep": "1.0.0" }), ); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1", "1.1.0"]); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]); await frozen(dir); expect(exitCode).toBe(0); diff --git a/test/cli/install/bun-update.test.ts b/test/cli/install/bun-update.test.ts index 785776f85841..241c1895f6d5 100644 --- a/test/cli/install/bun-update.test.ts +++ b/test/cli/install/bun-update.test.ts @@ -1506,21 +1506,31 @@ it("bun update from the root leaves a member's own entry alone; running i it("should update transitive resolutions of a named package", async () => { setHandler( await perNameRegistry(join(package_dir, ".tarballs"), { - shared: { versions: { "1.0.0": {}, "1.1.0": {} }, latest: "1.1.0" }, - "dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.0" } } }, latest: "1.0.0" }, + shared: { versions: { "1.0.0": {}, "1.0.1": {}, "1.1.0": {} }, latest: "1.1.0" }, + "dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.1" } } }, latest: "1.0.0" }, }), ); await writeTextLockfileBunfig(); - // dep-x@1.0.0 depends on shared@^1.0.0, which dedupes onto the root's - // exact shared@1.0.0 at install time. + // dep-x@1.0.0 depends on shared@^1.0.1, which dedupes onto the root's + // exact shared@1.0.1 at install time. + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "root", dependencies: { shared: "1.0.1", "dep-x": "^1.0.0" } }), + ); + await runInPackageDir("install"); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.1"']); + + // Moving the root down to 1.0.0 leaves dep-x's row where it was: a plain + // install never re-resolves a row whose dependent did not change. await writeFile( join(package_dir, "package.json"), JSON.stringify({ name: "root", dependencies: { shared: "1.0.0", "dep-x": "^1.0.0" } }), ); await runInPackageDir("install"); - expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"']); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"', '"shared@1.0.1"']); - // The root's exact `1.0.0` cannot move; dep-x's `^1.0.0` must move to 1.1.0. + // The root's exact `1.0.0` cannot move and does not satisfy dep-x's `^1.0.1`, + // so dep-x's row must move to 1.1.0 on its own. await runInPackageDir("update", "shared"); expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"', '"shared@1.1.0"']); expect( @@ -1528,6 +1538,30 @@ it("should update transitive resolutions of a named package", async () => { ).toMatchObject({ version: "1.1.0" }); }); +// The row re-enters the queue here too, but the root's copy still satisfies it, so it +// lands back on that copy instead of nesting 1.1.0 next to the root's 1.0.0. +it("bun update keeps a transitive row on the root's entry while its range allows it", async () => { + setHandler( + await perNameRegistry(join(package_dir, ".tarballs"), { + shared: { versions: { "1.0.0": {}, "1.1.0": {} }, latest: "1.1.0" }, + "dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.0" } } }, latest: "1.0.0" }, + }), + ); + await writeTextLockfileBunfig(); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "root", dependencies: { shared: "1.0.0", "dep-x": "^1.0.0" } }), + ); + await runInPackageDir("install"); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"']); + const before = await file(join(package_dir, "bun.lock")).text(); + + await runInPackageDir("update", "shared"); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"']); + expect(await file(join(package_dir, "bun.lock")).text()).toBe(before); + expect(await exists(join(package_dir, "node_modules", "dep-x", "node_modules"))).toBeFalse(); +}); + it("bun update --latest holds back only the root's entry; a transitive edge declared as a dist-tag keeps following it", async () => { setHandler( await perNameRegistry(join(package_dir, ".tarballs"), { diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index e91c0e0dd5d4..97a5e35853f7 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -1355,15 +1355,13 @@ for (const backend of ["clonefile", "hardlink", "copyfile"]) { } test("ranged peer dependency resolution is stable across installs from bun.lock", async () => { - // `peer-deps-fixed` has a peer on `no-deps@^1.0.0`. The graph contains both - // no-deps@1.0.1 (exact pin via normal-dep-and-dev-dep, hoisted to the root - // of the saved tree) and no-deps@1.1.0 (via two-range-deps). The fresh - // resolve binds the peer edge to the highest satisfying version (1.1.0) in - // its deferred-peer phase; reloading bun.lock used to re-derive the edge - // from the saved tree paths instead, rebinding it to the hoisted 1.0.1. - // That silently changed the runtime dependency tree on the second install - // and re-keyed the isolated store entry (`+` suffix) on every - // warm install. + // `peer-deps-fixed` has a peer on `no-deps@^1.0.0`. normal-dep-and-dev-dep + // pins no-deps@1.0.1 and the root pins 1.1.0 (a root row survives the + // post-resolve collapse), so the graph keeps two versions the peer's range + // accepts and the edge binds to the highest, 1.1.0. Reloading bun.lock used + // to re-derive the edge from the saved tree paths instead of the saved + // resolution, which re-keyed the isolated store entry (`+` + // suffix) on every warm install. const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" }, }); @@ -1376,6 +1374,7 @@ test("ranged peer dependency resolution is stable across installs from bun.lock" "peer-deps-fixed": "1.0.0", "normal-dep-and-dev-dep": "1.0.0", "two-range-deps": "1.0.0", + "no-deps": "1.1.0", }, }), ); @@ -1383,8 +1382,11 @@ test("ranged peer dependency resolution is stable across installs from bun.lock" await runBunInstall(bunEnv, packageDir); const bunDir = join(packageDir, "node_modules", ".bun"); - // highest satisfying ^1.0.0 in the graph; `toContain` prints the full - // listing when the entry is missing or keyed with a different peer hash + expect((await readdirSorted(bunDir)).filter(e => e.startsWith("no-deps@"))).toEqual([ + "no-deps@1.0.1", + "no-deps@1.1.0", + ]); + // `toContain` prints the full listing when the entry is missing or keyed with a different peer hash const entryName = "peer-deps-fixed@1.0.0+7ff199101204a65d"; expect(await readdirSorted(bunDir)).toContain(entryName); expect(await file(join(bunDir, entryName, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ diff --git a/test/cli/install/nested-overrides.test.ts b/test/cli/install/nested-overrides.test.ts index 9308eb302438..eac6b4b7bf7b 100644 --- a/test/cli/install/nested-overrides.test.ts +++ b/test/cli/install/nested-overrides.test.ts @@ -1891,17 +1891,16 @@ describe.concurrent("per-edge effective range", () => { expect(exitCode).toBe(0); }); - test("bun dedupe moves an edge whose scoped rule is a range onto the version its sibling's rule pins", async () => { + test("an edge whose scoped rule is a range shares the version its sibling's rule pins", async () => { const dir = await project({ dependencies: twoParents, overrides: { "one-fixed-dep@1": { "no-deps": "^1.0.0" }, "one-fixed-dep@2": { "no-deps": "1.0.0" } }, }); await installOk(dir); - expect(await versionSeenBy(dir, "ofd1", "no-deps")).toBe("1.1.0"); + expect(await versionSeenBy(dir, "ofd1", "no-deps")).toBe("1.0.0"); expect(await versionSeenBy(dir, "ofd2", "no-deps")).toBe("1.0.0"); const { out, exitCode } = await run(dir, "dedupe", "--check"); - expect(out).toContain("~ no-deps 1.1.0 -> 1.0.0"); - expect(out).toContain("1 duplicate version can be removed"); - expect(exitCode).toBe(1); + expect(out).toContain("No duplicates"); + expect(exitCode).toBe(0); }); });