From 62bc88806f056f6e8e5cea957bc3b817344bdce0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:25:14 +0000 Subject: [PATCH 1/5] install: link wildcard deps to prerelease workspace members in any position A '*' range on a name that is also a workspace member resolved differently depending on who declared it: declared by another member it linked the workspace, declared by the root it went to the registry, because Package::parse_dependency decided link-vs-override with Group::satisfies alone while get_or_put_resolved_package also accepts is_star for members. Prerelease member versions fail satisfies under npm semver rules, so the two predicates disagreed exactly there. Mirror the resolver's rule in the parse predicate. This also stops the per-install lockfile re-save for member-declared wildcards on prerelease members: parse now produces the same workspace-tagged dependency the bun.lock loader produces, so the loaded and fresh roots compare equal. --- src/install/lockfile/Package.rs | 12 +- test/cli/install/bun-workspaces.test.ts | 194 ++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index da74ede1d3b9..81f587a537c8 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -1751,11 +1751,13 @@ impl Package { } dependency::version::Tag::Npm => { if let Some(workspace_version) = workspace_version { - let satisfies = - dependency_version - .npm() - .version - .satisfies(workspace_version, buf, buf); + // A wildcard accepts the member even when its version is a + // prerelease the range does not satisfy, mirroring + // `get_or_put_resolved_package` so the decision is the same + // wherever the dependency is declared. + let npm_version = &dependency_version.npm().version; + let satisfies = npm_version.satisfies(workspace_version, buf, buf) + || npm_version.is_star(); if pm.options.link_workspace_packages && satisfies { // `String::sliced` takes `&'a self`; bind the unwrapped // value so the borrow outlives the parse call. diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index 8892276eaf73..e82f14174ce3 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -193,6 +193,200 @@ test("dependency on workspace without version in package.json", async () => { } }); +// A `*` range accepts a workspace member whose version is a prerelease even +// though `*` does not satisfy prereleases under npm semver rules, and the +// outcome must not depend on whether the root or another member declares the +// dependency (`no-deps` also exists in the registry with 2.0.0 as latest). +test.concurrent("root wildcard dependency on a prerelease workspace member links the workspace", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + workspaces: ["packages/*"], + dependencies: { + "no-deps": "*", + }, + }), + ), + write( + join(packageDir, "packages", "no-deps", "package.json"), + JSON.stringify({ name: "no-deps", version: "3.0.0-beta.1" }), + ), + ]); + + { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + const lock = await file(join(packageDir, "bun.lock")).text(); + expect(lock).toContain("no-deps@workspace:packages/no-deps"); + expect(lock).not.toContain("no-deps@2.0.0"); + expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe( + "3.0.0-beta.1", + ); + + // repeat install converges: no re-save, identical lockfile + { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lock); + + { + await using proc = spawn({ + cmd: [bunExe(), "install", "--frozen-lockfile"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } +}); + +test.concurrent("member wildcard dependency on a prerelease workspace member stays linked and stable", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + workspaces: ["packages/*"], + }), + ), + write( + join(packageDir, "packages", "no-deps", "package.json"), + JSON.stringify({ name: "no-deps", version: "3.0.0-beta.1" }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ + name: "pkg1", + version: "1.0.0", + dependencies: { + "no-deps": "*", + }, + }), + ), + ]); + + { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + const lock = await file(join(packageDir, "bun.lock")).text(); + expect(lock).toContain("no-deps@workspace:packages/no-deps"); + expect(lock).not.toContain("no-deps@2.0.0"); + const hoisted = join(packageDir, "node_modules", "no-deps", "package.json"); + const nested = join(packageDir, "packages", "pkg1", "node_modules", "no-deps", "package.json"); + const installedPkgJson = (await exists(nested)) ? nested : hoisted; + expect((await file(installedPkgJson).json()).version).toBe("3.0.0-beta.1"); + + // repeat install converges: no re-save, identical lockfile + { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lock); + + { + await using proc = spawn({ + cmd: [bunExe(), "install", "--frozen-lockfile"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } +}); + +// Boundary guard: only a wildcard gets the prerelease exception. A range the +// member's prerelease version does not satisfy keeps installing from the +// registry. Passes on an unmodified build by design. +test.concurrent("non-wildcard range on a prerelease workspace member still installs from the registry", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + workspaces: ["packages/*"], + dependencies: { + "no-deps": "^1.0.0", + }, + }), + ), + write( + join(packageDir, "packages", "no-deps", "package.json"), + JSON.stringify({ name: "no-deps", version: "3.0.0-beta.1" }), + ), + ]); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "ignore", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lock = await file(join(packageDir, "bun.lock")).text(); + expect(lock).toContain("no-deps@1.1.0"); + expect(lock).not.toContain("no-deps@workspace:packages/no-deps"); + expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe("1.1.0"); +}); + test.concurrent("allowing negative workspace patterns", async () => { using ctx = await setupTest(); const { packageDir, env } = ctx; From b6e64e9b5aaacbdeaa0ab8f97b2ce24a509fb4e3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:27:45 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- src/install/lockfile/Package.rs | 4 ++-- test/cli/install/bun-workspaces.test.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 81f587a537c8..0ca5a3293b0b 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -1756,8 +1756,8 @@ impl Package { // `get_or_put_resolved_package` so the decision is the same // wherever the dependency is declared. let npm_version = &dependency_version.npm().version; - let satisfies = npm_version.satisfies(workspace_version, buf, buf) - || npm_version.is_star(); + let satisfies = + npm_version.satisfies(workspace_version, buf, buf) || npm_version.is_star(); if pm.options.link_workspace_packages && satisfies { // `String::sliced` takes `&'a self`; bind the unwrapped // value so the borrow outlives the parse call. diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index e82f14174ce3..9bd3a9e61ba2 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -234,9 +234,7 @@ test.concurrent("root wildcard dependency on a prerelease workspace member links const lock = await file(join(packageDir, "bun.lock")).text(); expect(lock).toContain("no-deps@workspace:packages/no-deps"); expect(lock).not.toContain("no-deps@2.0.0"); - expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe( - "3.0.0-beta.1", - ); + expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe("3.0.0-beta.1"); // repeat install converges: no re-save, identical lockfile { From 31cb88d656b92f83bf615d1155acc2afd51037b3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:28:27 +0000 Subject: [PATCH 3/5] tighten predicate comment --- src/install/lockfile/Package.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 0ca5a3293b0b..ca719a7b3e0f 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -1751,10 +1751,9 @@ impl Package { } dependency::version::Tag::Npm => { if let Some(workspace_version) = workspace_version { - // A wildcard accepts the member even when its version is a - // prerelease the range does not satisfy, mirroring - // `get_or_put_resolved_package` so the decision is the same - // wherever the dependency is declared. + // `is_star` mirrors `get_or_put_resolved_package`: a wildcard + // accepts the member even when its version is a prerelease + // the range does not satisfy. let npm_version = &dependency_version.npm().version; let satisfies = npm_version.satisfies(workspace_version, buf, buf) || npm_version.is_star(); From df33bcb2d82a9b86356d98febb73b11364a08058 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:18:59 +0000 Subject: [PATCH 4/5] install: share the workspace-member acceptance rule between parse and resolve Extract npm_range_accepts_workspace_member so Package::parse_dependency and get_or_put_resolved_package call one predicate instead of keeping two hand-copied expressions in sync; the drift between them was this bug. --- .../PackageManager/PackageManagerEnqueue.rs | 13 +++++++------ src/install/dependency.rs | 17 +++++++++++++++++ src/install/lockfile/Package.rs | 15 ++++++++------- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index f0c91da56b53..6215acf64168 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -2290,12 +2290,13 @@ fn get_or_put_resolved_package( let buf = this.lockfile.buffers.string_bytes.as_slice(); let npm_group = &version.npm().version; if this.options.link_workspace_packages - && ((workspace_version.is_some() - && npm_group.satisfies(*workspace_version.unwrap(), buf, buf)) - // https://github.com/oven-sh/bun/pull/10899#issuecomment-2099609419 - // if the workspace doesn't have a version, it can still be used if - // dependency version is wildcard - || (workspace_path.is_some() && npm_group.is_star())) + && dependency::npm_range_accepts_workspace_member( + npm_group, + workspace_version.copied(), + workspace_path.is_some(), + buf, + buf, + ) { let Some(root_package) = this.lockfile.root_package() else { break 'resolve_from_workspace; diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 3a2661f63ab6..ce95a30e11b4 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -586,6 +586,23 @@ pub(crate) fn is_safe_install_folder_name(name: &[u8]) -> bool { true } +/// Whether an npm range accepts a same-name workspace member. Parse +/// (`Package::parse_dependency`) and resolution +/// (`get_or_put_resolved_package`) share this rule; if they disagree, +/// link-vs-registry depends on which package declares the dependency. +/// A wildcard accepts any present member, even versionless (#10899) or +/// with a prerelease version the range does not otherwise satisfy. +pub(crate) fn npm_range_accepts_workspace_member( + range: &Semver::query::Group, + workspace_version: Option, + has_workspace_path: bool, + range_buf: &[u8], + version_buf: &[u8], +) -> bool { + workspace_version.is_some_and(|v| range.satisfies(v, range_buf, version_buf)) + || (has_workspace_path && range.is_star()) +} + /// assumes version is valid pub fn without_build_tag(version: &[u8]) -> &[u8] { if let Some(plus) = strings::index_of_char(version, b'+') { diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index ca719a7b3e0f..ecd66b92463f 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -1751,13 +1751,14 @@ impl Package { } dependency::version::Tag::Npm => { if let Some(workspace_version) = workspace_version { - // `is_star` mirrors `get_or_put_resolved_package`: a wildcard - // accepts the member even when its version is a prerelease - // the range does not satisfy. - let npm_version = &dependency_version.npm().version; - let satisfies = - npm_version.satisfies(workspace_version, buf, buf) || npm_version.is_star(); - if pm.options.link_workspace_packages && satisfies { + let accepts = dependency::npm_range_accepts_workspace_member( + &dependency_version.npm().version, + Some(workspace_version), + true, + buf, + buf, + ); + if pm.options.link_workspace_packages && accepts { // `String::sliced` takes `&'a self`; bind the unwrapped // value so the borrow outlives the parse call. let wp = workspace_path.unwrap(); From d42ecd93df737beb6d592193ceec1482b0eec689 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:31:48 +0000 Subject: [PATCH 5/5] ci: retrigger