diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index a22e23128ffc..54d142e9310f 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -1107,12 +1107,110 @@ impl Tree { } } + // a bundle's dependency stops here, but must not shadow what resolves through here + if !AS_DEFINED + && this.id == hoist_root_id + && Tree::hoist_root_resolves_elsewhere( + this.id, + package_id, + dependency.name_hash, + builder, + ) + { + return Ok(HoistDependencyResult::DependencyLoop); // 3 + } + // place the dependency in the current tree Ok(HoistDependencyResult::Placement(Placement { id: this.id, bundled: false, })) // 2 } + + /// Whether a `name_hash` edge that a loaded `bun.lock` would rebind to `hoist_root` resolves elsewhere. + fn hoist_root_resolves_elsewhere( + hoist_root: Id, + package_id: PackageID, + name_hash: PackageNameHash, + builder: &Builder<'_, METHOD>, + ) -> bool { + // an unbound optional peer saves no entry; once bound, `Lockfile::resolve` hoists again + if package_id == invalid_package_id { + return false; + } + + let trees = builder.list.items_tree(); + if trees[hoist_root as usize].parent == INVALID_ID { + return false; + } + + let entry_lists = builder.list.items_dependencies(); + let deps: &[Dependency] = builder.dependencies; + let resolutions: &[PackageID] = &*builder.resolutions; + let resolution_lists = builder.resolution_lists; + + let resolves_elsewhere = |pkg_id: PackageID| -> bool { + let pkg_deps = resolution_lists[pkg_id as usize]; + (pkg_deps.begin()..pkg_deps.end()).any(|dep_id| { + let dep = &deps[dep_id as usize]; + dep.name_hash == name_hash + && !dep.behavior.is_bundled() + && resolutions[dep_id as usize] != package_id + }) + }; + + // `tree_owner`'s regular dependencies placed in its own node_modules, `tree_id` + let nested_in = move |tree_id: Id, tree_owner: PackageID| { + let own_deps = resolution_lists[tree_owner as usize]; + trees[tree_id as usize] + .dependencies + .get(entry_lists[tree_id as usize].as_slice()) + .iter() + .filter(move |&&dep_id| { + own_deps.contains(dep_id) && !deps[dep_id as usize].behavior.is_bundled() + }) + .map(move |&dep_id| resolutions[dep_id as usize]) + .filter(|&pkg_id| pkg_id != invalid_package_id) + }; + + let owner = resolutions[trees[hoist_root as usize].dependency_id as usize]; + if resolves_elsewhere(owner) { + return true; + } + + let mut any_nested = false; + for pkg_id in nested_in(hoist_root, owner) { + if resolves_elsewhere(pkg_id) { + return true; + } + any_nested = true; + } + if !any_nested { + return false; + } + + // The nested packages' own trees hold more of them. A tree comes after its parent. + let mut scope: Vec<(Id, PackageID)> = vec![(hoist_root, owner)]; + for (id, tree) in trees.iter().enumerate().skip(hoist_root as usize + 1) { + let Some(&(_, parent_owner)) = + scope.iter().find(|(scope_id, _)| *scope_id == tree.parent) + else { + continue; + }; + if !resolution_lists[parent_owner as usize].contains(tree.dependency_id) + || deps[tree.dependency_id as usize].behavior.is_bundled() + { + continue; + } + let tree_owner = resolutions[tree.dependency_id as usize]; + if nested_in(id as Id, tree_owner).any(&resolves_elsewhere) { + return true; + } + scope.push((id as Id, tree_owner)); + } + + false + } } // ────────────────────────────────────────────────────────────────────────── diff --git a/test/cli/install/bun-lock.test.ts b/test/cli/install/bun-lock.test.ts index f4b57da8b754..a3c97dffcdd0 100644 --- a/test/cli/install/bun-lock.test.ts +++ b/test/cli/install/bun-lock.test.ts @@ -1376,3 +1376,175 @@ it("an optional peer is rebound when another version of its package takes the sl await run(["install", "--lockfile-only"]); expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); }); + +// The bundled-shadow-* fixtures are described in +// registry/packages/create-bundled-shadow-packages.ts. In short: host depends +// on shared@1.0.0, which is hoisted to the root, and bundles inner, which +// depends on shared@2.0.0. A bundle's dependencies hoist no further than the +// bundling package's node_modules, but `host/shared` in bun.lock is also what +// host's own `shared` edge resolves to when the lockfile is loaded again, and +// likewise for anything nested under host that resolves shared from the root. +// https://github.com/oven-sh/bun/issues/29263 +it("a bundled dependency's dependency does not take a slot the bundling package resolves through", async () => { + const { packageDir, packageJson } = await registry.createTestDir({ + bunfigOpts: { saveTextLockfile: true, linker: "hoisted" }, + }); + const run = makeInstallRunner(packageDir); + const hostNodeModules = join(packageDir, "node_modules", "bundled-shadow-host", "node_modules"); + + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { "bundled-shadow-host": "1.0.0", "bundled-shadow-shared": "1.0.0" }, + }), + ); + await run(["install"]); + const fresh = await file(join(packageDir, "bun.lock")).text(); + expect(fresh).toContain('"bundled-shadow-shared": ["bundled-shadow-shared@1.0.0"'); + expect(fresh).toContain( + '"bundled-shadow-host/bundled-shadow-inner/bundled-shadow-shared": ["bundled-shadow-shared@2.0.0"', + ); + expect(fresh).not.toContain('"bundled-shadow-host/bundled-shadow-shared"'); + + // inner's shared@2.0.0 ships inside host's tarball; host itself uses the root copy. + const layout = async () => ({ + root: (await file(join(packageDir, "node_modules", "bundled-shadow-shared", "package.json")).json()).version, + host: await readdirSorted(hostNodeModules), + inner: ( + await file( + join(hostNodeModules, "bundled-shadow-inner", "node_modules", "bundled-shadow-shared", "package.json"), + ).json() + ).version, + }); + const expectedLayout = { root: "1.0.0", host: ["bundled-shadow-inner"], inner: "2.0.0" }; + expect(await layout()).toEqual(expectedLayout); + + // Every install that starts from the lockfile has to give host the same + // shared as the install that wrote it. + await run(["install"]); + expect(await layout()).toEqual(expectedLayout); + + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await run(["install", "--frozen-lockfile"]); + expect(await layout()).toEqual(expectedLayout); + + await run(["install", "--lockfile-only"]); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(fresh); +}); + +it("the isolated linker links the bundling package against the same dependency on a reinstall", async () => { + const { packageDir, packageJson } = await registry.createTestDir({ + bunfigOpts: { saveTextLockfile: true, linker: "isolated" }, + }); + const run = makeInstallRunner(packageDir); + const hostStoreEntry = join(packageDir, "node_modules", ".bun", "bundled-shadow-host@1.0.0", "node_modules"); + const sharedVersion = async (...dir: string[]) => + (await file(join(...dir, "bundled-shadow-shared", "package.json")).json()).version; + // `host` is what gets linked next to host in its store entry; `inner` is the + // copy that came out of host's tarball. + const layout = async () => ({ + host: await sharedVersion(hostStoreEntry), + inner: await sharedVersion( + hostStoreEntry, + "bundled-shadow-host", + "node_modules", + "bundled-shadow-inner", + "node_modules", + ), + }); + + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { "bundled-shadow-host": "1.0.0", "bundled-shadow-shared": "1.0.0" }, + }), + ); + await run(["install"]); + expect(await layout()).toEqual({ host: "1.0.0", inner: "2.0.0" }); + + // The store is rebuilt from the resolutions bun.lock loads, which come from + // the saved paths. + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await run(["install", "--frozen-lockfile"]); + expect(await layout()).toEqual({ host: "1.0.0", inner: "2.0.0" }); +}); + +it("a bundled dependency's dependency does not take a slot a dependency nested under the bundling package resolves through", async () => { + const { packageDir, packageJson } = await registry.createTestDir({ + bunfigOpts: { saveTextLockfile: true, linker: "hoisted" }, + }); + const run = makeInstallRunner(packageDir); + + // deep-host itself does not depend on shared. consumer@1.0.0 nests under it + // (consumer@2.0.0 holds the root), mid@1.0.0 nests under consumer (mid@2.0.0 + // holds the root), and mid's shared@1.0.0 is hoisted to the root, up through + // deep-host's node_modules. deep-host bundles wrapper, whose inner depends on + // shared@2.0.0, and that is hoisted after mid's shared is already at the root. + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "bundled-shadow-deep-host": "1.0.0", + "bundled-shadow-consumer": "2.0.0", + "bundled-shadow-mid": "2.0.0", + }, + }), + ); + await run(["install"]); + const fresh = await file(join(packageDir, "bun.lock")).text(); + expect(fresh).toContain('"bundled-shadow-shared": ["bundled-shadow-shared@1.0.0"'); + expect(fresh).toContain( + '"bundled-shadow-deep-host/bundled-shadow-consumer/bundled-shadow-mid": ["bundled-shadow-mid@1.0.0"', + ); + expect(fresh).toContain( + '"bundled-shadow-deep-host/bundled-shadow-inner/bundled-shadow-shared": ["bundled-shadow-shared@2.0.0"', + ); + expect(fresh).not.toContain('"bundled-shadow-deep-host/bundled-shadow-shared"'); + + // mid@1.0.0 has to keep finding shared@1.0.0 at the root on a reinstall. + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await run(["install", "--frozen-lockfile"]); + expect({ + root: (await file(join(packageDir, "node_modules", "bundled-shadow-shared", "package.json")).json()).version, + deepHost: await readdirSorted(join(packageDir, "node_modules", "bundled-shadow-deep-host", "node_modules")), + }).toEqual({ root: "1.0.0", deepHost: ["bundled-shadow-consumer", "bundled-shadow-wrapper"] }); + + await run(["install", "--lockfile-only"]); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(fresh); +}); + +it("a bundled dependency's optional peer bound late does not take a slot the bundling package resolves through", async () => { + const { packageDir, packageJson } = await registry.createTestDir({ + bunfigOpts: { saveTextLockfile: true, linker: "hoisted" }, + }); + const run = makeInstallRunner(packageDir); + + // peer-inner (bundled) has an optional peer on shared and depends on + // peer-leaf, which depends on shared@2.0.0. The peer is still unbound when + // peer-inner is hoisted and gets bound to peer-leaf's shared@2.0.0 inside the + // bundle; peer-host's own shared@1.0.0 at the root must not be shadowed by + // either of them. + await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "bundled-shadow-peer-host": "1.0.0" } })); + await run(["install"]); + const fresh = await file(join(packageDir, "bun.lock")).text(); + expect(fresh).toContain('"bundled-shadow-shared": ["bundled-shadow-shared@1.0.0"'); + expect(fresh).toContain( + '"bundled-shadow-peer-host/bundled-shadow-peer-inner/bundled-shadow-shared": ["bundled-shadow-shared@2.0.0"', + ); + expect(fresh).toContain( + '"bundled-shadow-peer-host/bundled-shadow-peer-leaf/bundled-shadow-shared": ["bundled-shadow-shared@2.0.0"', + ); + expect(fresh).not.toContain('"bundled-shadow-peer-host/bundled-shadow-shared"'); + + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await run(["install", "--frozen-lockfile"]); + expect(await readdirSorted(join(packageDir, "node_modules", "bundled-shadow-peer-host", "node_modules"))).toEqual([ + "bundled-shadow-peer-inner", + ]); + + await run(["install", "--lockfile-only"]); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(fresh); +}); diff --git a/test/cli/install/registry/packages/bundled-shadow-consumer/bundled-shadow-consumer-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-consumer/bundled-shadow-consumer-1.0.0.tgz new file mode 100644 index 000000000000..9009eb4ab9e2 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-consumer/bundled-shadow-consumer-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-consumer/bundled-shadow-consumer-2.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-consumer/bundled-shadow-consumer-2.0.0.tgz new file mode 100644 index 000000000000..4a0528c331dd Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-consumer/bundled-shadow-consumer-2.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-consumer/package.json b/test/cli/install/registry/packages/bundled-shadow-consumer/package.json new file mode 100644 index 000000000000..1b72e9817278 --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-consumer/package.json @@ -0,0 +1,32 @@ +{ + "_id": "bundled-shadow-consumer", + "name": "bundled-shadow-consumer", + "dist-tags": { + "latest": "2.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-consumer", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-mid": "1.0.0" + }, + "_id": "bundled-shadow-consumer@1.0.0", + "dist": { + "integrity": "sha512-bYPAnSxRZS5jNQuvfR+PV/RC9MhWSCW0C+aB2GZi0xEQ9jmhUO+HAgK18dvtgF7IaXTqXM3F2ToI1eH5nsVLTA==", + "shasum": "825e428cd7ddc7af4be726cc1728ab0c735385da", + "tarball": "http://localhost:4873/bundled-shadow-consumer/-/bundled-shadow-consumer-1.0.0.tgz" + } + }, + "2.0.0": { + "name": "bundled-shadow-consumer", + "version": "2.0.0", + "_id": "bundled-shadow-consumer@2.0.0", + "dist": { + "integrity": "sha512-5rOVsz3v9j2P1FaFAxsgnp50QmpGwjlPs3KO29gvUoO3RWlmYS5WQDJStSGMO8lzyWW+UCoR08IW3/WodfMjSg==", + "shasum": "79c93410f8ab5a6135323994a946978174e46dca", + "tarball": "http://localhost:4873/bundled-shadow-consumer/-/bundled-shadow-consumer-2.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-deep-host/bundled-shadow-deep-host-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-deep-host/bundled-shadow-deep-host-1.0.0.tgz new file mode 100644 index 000000000000..994e7975304a Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-deep-host/bundled-shadow-deep-host-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-deep-host/package.json b/test/cli/install/registry/packages/bundled-shadow-deep-host/package.json new file mode 100644 index 000000000000..e6e2a50f9d52 --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-deep-host/package.json @@ -0,0 +1,26 @@ +{ + "_id": "bundled-shadow-deep-host", + "name": "bundled-shadow-deep-host", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-deep-host", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-consumer": "1.0.0", + "bundled-shadow-wrapper": "1.0.0" + }, + "bundleDependencies": [ + "bundled-shadow-wrapper" + ], + "_id": "bundled-shadow-deep-host@1.0.0", + "dist": { + "integrity": "sha512-0AapvZZN+czyjr1l05qbGQaBHKNiLw+2BFxouwkst7BvNfJUv09ZgHtRL1tb7QGHhm1VwwR/Vg70Uz+IOzmeXA==", + "shasum": "9e14944bae0586dc76c6ec9f0cd68af710da5a94", + "tarball": "http://localhost:4873/bundled-shadow-deep-host/-/bundled-shadow-deep-host-1.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-host/bundled-shadow-host-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-host/bundled-shadow-host-1.0.0.tgz new file mode 100644 index 000000000000..ddd4d8f53a08 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-host/bundled-shadow-host-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-host/package.json b/test/cli/install/registry/packages/bundled-shadow-host/package.json new file mode 100644 index 000000000000..ffe6427964d9 --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-host/package.json @@ -0,0 +1,26 @@ +{ + "_id": "bundled-shadow-host", + "name": "bundled-shadow-host", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-host", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-inner": "1.0.0", + "bundled-shadow-shared": "1.0.0" + }, + "bundleDependencies": [ + "bundled-shadow-inner" + ], + "_id": "bundled-shadow-host@1.0.0", + "dist": { + "integrity": "sha512-8OfobtACp9H+WHwCFhoRxW43zR9DIrtdPucaIwom3zp5jNJYQ1d6yu7y4x2FG8bixlhxR+iQ8E3/p0P+c4Ir1A==", + "shasum": "44e59de3f83ac5536a0391ec996b6a7cda06d203", + "tarball": "http://localhost:4873/bundled-shadow-host/-/bundled-shadow-host-1.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-inner/bundled-shadow-inner-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-inner/bundled-shadow-inner-1.0.0.tgz new file mode 100644 index 000000000000..3d693f9f0948 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-inner/bundled-shadow-inner-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-inner/package.json b/test/cli/install/registry/packages/bundled-shadow-inner/package.json new file mode 100644 index 000000000000..c93e775a16fb --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-inner/package.json @@ -0,0 +1,22 @@ +{ + "_id": "bundled-shadow-inner", + "name": "bundled-shadow-inner", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-inner", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-shared": "2.0.0" + }, + "_id": "bundled-shadow-inner@1.0.0", + "dist": { + "integrity": "sha512-vLarWIx0f5OlsgpY6rtzIKtoQAjH7HRTrhDBpFtK5eMreRh8i9MmNNBIkmQogQGHxnl8f/A2aqYwUPtmMKb84A==", + "shasum": "9247c2d44500bedc5f78cff3e90fd15d0facfd71", + "tarball": "http://localhost:4873/bundled-shadow-inner/-/bundled-shadow-inner-1.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-mid/bundled-shadow-mid-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-mid/bundled-shadow-mid-1.0.0.tgz new file mode 100644 index 000000000000..c988ee89e649 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-mid/bundled-shadow-mid-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-mid/bundled-shadow-mid-2.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-mid/bundled-shadow-mid-2.0.0.tgz new file mode 100644 index 000000000000..b229926d5279 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-mid/bundled-shadow-mid-2.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-mid/package.json b/test/cli/install/registry/packages/bundled-shadow-mid/package.json new file mode 100644 index 000000000000..3bf3734455ff --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-mid/package.json @@ -0,0 +1,32 @@ +{ + "_id": "bundled-shadow-mid", + "name": "bundled-shadow-mid", + "dist-tags": { + "latest": "2.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-mid", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-shared": "1.0.0" + }, + "_id": "bundled-shadow-mid@1.0.0", + "dist": { + "integrity": "sha512-4MOqBV/VRn5PtCNewlOA7+eOJKtsuccEieFuQ96Lu4OTZS1OPnyUJzIRa2HeRi5WK35gljqjl8CyPrIqscI6zw==", + "shasum": "f91280a96d184e738029c7b5aeb4f71f23e547b6", + "tarball": "http://localhost:4873/bundled-shadow-mid/-/bundled-shadow-mid-1.0.0.tgz" + } + }, + "2.0.0": { + "name": "bundled-shadow-mid", + "version": "2.0.0", + "_id": "bundled-shadow-mid@2.0.0", + "dist": { + "integrity": "sha512-j+uNZ9sZt6/3vbWIGpYJ47vFQA7LA1LbcLvYxkr5uqWqwwJKl80HhTaad/HJ34sT9se8bZdEEk5ZGHKQQUHuUA==", + "shasum": "80339c1747d9733eeddfa4d67045ce92191a510e", + "tarball": "http://localhost:4873/bundled-shadow-mid/-/bundled-shadow-mid-2.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-peer-host/bundled-shadow-peer-host-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-peer-host/bundled-shadow-peer-host-1.0.0.tgz new file mode 100644 index 000000000000..813157da0bcf Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-peer-host/bundled-shadow-peer-host-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-peer-host/package.json b/test/cli/install/registry/packages/bundled-shadow-peer-host/package.json new file mode 100644 index 000000000000..4ac11bd1435e --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-peer-host/package.json @@ -0,0 +1,26 @@ +{ + "_id": "bundled-shadow-peer-host", + "name": "bundled-shadow-peer-host", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-peer-host", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-peer-inner": "1.0.0", + "bundled-shadow-shared": "1.0.0" + }, + "bundleDependencies": [ + "bundled-shadow-peer-inner" + ], + "_id": "bundled-shadow-peer-host@1.0.0", + "dist": { + "integrity": "sha512-hOrxItd+YcG100DXs5GSOSc+o+Uvcd5KBgJjhvmd6LWQivdoKo02bPCPD+Rijug4zHwIQQeQFbnBazfPg88TAw==", + "shasum": "4ff479a3931d1864c8f082429009e8a2ac0db57f", + "tarball": "http://localhost:4873/bundled-shadow-peer-host/-/bundled-shadow-peer-host-1.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-peer-inner/bundled-shadow-peer-inner-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-peer-inner/bundled-shadow-peer-inner-1.0.0.tgz new file mode 100644 index 000000000000..ec2ee59ff739 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-peer-inner/bundled-shadow-peer-inner-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-peer-inner/package.json b/test/cli/install/registry/packages/bundled-shadow-peer-inner/package.json new file mode 100644 index 000000000000..b53b73ad4800 --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-peer-inner/package.json @@ -0,0 +1,30 @@ +{ + "_id": "bundled-shadow-peer-inner", + "name": "bundled-shadow-peer-inner", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-peer-inner", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-peer-leaf": "1.0.0" + }, + "peerDependencies": { + "bundled-shadow-shared": "*" + }, + "peerDependenciesMeta": { + "bundled-shadow-shared": { + "optional": true + } + }, + "_id": "bundled-shadow-peer-inner@1.0.0", + "dist": { + "integrity": "sha512-9WVtLLR333oOU/3JH65YiSFv5AcBi3fVnlfCXxN2bOKOuIyqiTTJUZ3Pnm2zhYWeYmtfCv8jCL0BQamya58d+A==", + "shasum": "e86fb60082902406bd3b88240bda768635c6d9f9", + "tarball": "http://localhost:4873/bundled-shadow-peer-inner/-/bundled-shadow-peer-inner-1.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-peer-leaf/bundled-shadow-peer-leaf-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-peer-leaf/bundled-shadow-peer-leaf-1.0.0.tgz new file mode 100644 index 000000000000..14808068ca00 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-peer-leaf/bundled-shadow-peer-leaf-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-peer-leaf/package.json b/test/cli/install/registry/packages/bundled-shadow-peer-leaf/package.json new file mode 100644 index 000000000000..66288fe681c2 --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-peer-leaf/package.json @@ -0,0 +1,22 @@ +{ + "_id": "bundled-shadow-peer-leaf", + "name": "bundled-shadow-peer-leaf", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-peer-leaf", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-shared": "2.0.0" + }, + "_id": "bundled-shadow-peer-leaf@1.0.0", + "dist": { + "integrity": "sha512-cqixyTfl9uvul3XQXjIPUW07Sbfh8ueHWgS7hMcE8EtbFCZABZhpgoGE+NLav9EzAtTuwFMr2JV/LjnwhZHQrA==", + "shasum": "37e2dbd482682fc7a7a5729a3cfd616bea0ba8ee", + "tarball": "http://localhost:4873/bundled-shadow-peer-leaf/-/bundled-shadow-peer-leaf-1.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-shared/bundled-shadow-shared-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-shared/bundled-shadow-shared-1.0.0.tgz new file mode 100644 index 000000000000..7392ac9187d5 Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-shared/bundled-shadow-shared-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-shared/bundled-shadow-shared-2.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-shared/bundled-shadow-shared-2.0.0.tgz new file mode 100644 index 000000000000..e295a9a5dd2b Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-shared/bundled-shadow-shared-2.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-shared/package.json b/test/cli/install/registry/packages/bundled-shadow-shared/package.json new file mode 100644 index 000000000000..5e5214bdad4f --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-shared/package.json @@ -0,0 +1,29 @@ +{ + "_id": "bundled-shadow-shared", + "name": "bundled-shadow-shared", + "dist-tags": { + "latest": "2.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-shared", + "version": "1.0.0", + "_id": "bundled-shadow-shared@1.0.0", + "dist": { + "integrity": "sha512-Zm9lIGft1cDn7OnUPkhkl+SKTLspMB6yNbab9WxVTeopTuHTQUMmtdmhHWIjorE1vdVoAmsM6C0LiqR3NHGORg==", + "shasum": "6332e8de0bed6ca017cbfce4fb476b06bdcad9f5", + "tarball": "http://localhost:4873/bundled-shadow-shared/-/bundled-shadow-shared-1.0.0.tgz" + } + }, + "2.0.0": { + "name": "bundled-shadow-shared", + "version": "2.0.0", + "_id": "bundled-shadow-shared@2.0.0", + "dist": { + "integrity": "sha512-a32XNaBdL5ammNQNPz/iWcW9iYJAC+vAu7XGu5ZeXTgxEBwJferXAWsdvrpR8UKbqc1AkwVlhW9q+Ygqrb0VQg==", + "shasum": "ffde31aa884a9e9b1b8e7e18a85aa07a99e55dfc", + "tarball": "http://localhost:4873/bundled-shadow-shared/-/bundled-shadow-shared-2.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/bundled-shadow-wrapper/bundled-shadow-wrapper-1.0.0.tgz b/test/cli/install/registry/packages/bundled-shadow-wrapper/bundled-shadow-wrapper-1.0.0.tgz new file mode 100644 index 000000000000..01a0188e0a9e Binary files /dev/null and b/test/cli/install/registry/packages/bundled-shadow-wrapper/bundled-shadow-wrapper-1.0.0.tgz differ diff --git a/test/cli/install/registry/packages/bundled-shadow-wrapper/package.json b/test/cli/install/registry/packages/bundled-shadow-wrapper/package.json new file mode 100644 index 000000000000..932ba9ccd212 --- /dev/null +++ b/test/cli/install/registry/packages/bundled-shadow-wrapper/package.json @@ -0,0 +1,22 @@ +{ + "_id": "bundled-shadow-wrapper", + "name": "bundled-shadow-wrapper", + "dist-tags": { + "latest": "1.0.0" + }, + "versions": { + "1.0.0": { + "name": "bundled-shadow-wrapper", + "version": "1.0.0", + "dependencies": { + "bundled-shadow-inner": "1.0.0" + }, + "_id": "bundled-shadow-wrapper@1.0.0", + "dist": { + "integrity": "sha512-oNjMSn8UuncNL0QOe+g4H/lnBTdDaHblibo3/FIwXQhtj/stdtrckJBW3svJII23M03kpIQT9zL86LOVokMo4w==", + "shasum": "89b99e0d115ec1c580bde8f72344e9b8d4878d81", + "tarball": "http://localhost:4873/bundled-shadow-wrapper/-/bundled-shadow-wrapper-1.0.0.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/create-bundled-shadow-packages.ts b/test/cli/install/registry/packages/create-bundled-shadow-packages.ts new file mode 100644 index 000000000000..0d97d938cb30 --- /dev/null +++ b/test/cli/install/registry/packages/create-bundled-shadow-packages.ts @@ -0,0 +1,153 @@ +#!/usr/bin/env bun +/** + * Generates the `bundled-shadow-*` fixtures used by bun-lock.test.ts. + * + * The dependencies of a bundled dependency are hoisted no further than the + * bundling package's node_modules. That folder is also what the bundling + * package itself, and any regular dependency nested inside it, resolve through + * on their way up the tree, and a loaded bun.lock rebinds their edges by + * walking up the saved paths. So a dependency of the bundle may only land + * there if none of them resolve that name to something else higher up. + * + * The bundling package resolves the name higher up: + * + * - bundled-shadow-host@1.0.0 -> shared@1.0.0, and bundles inner@1.0.0 + * - bundled-shadow-inner@1.0.0 -> shared@2.0.0 + * - bundled-shadow-shared@1.0.0 no dependencies + * - bundled-shadow-shared@2.0.0 no dependencies + * + * host's shared@1.0.0 is hoisted to the root, so inner's shared@2.0.0 has to + * stay at `host/inner/shared` in bun.lock; at `host/shared` it would be what + * host's own `shared` edge resolves to when the lockfile is loaded again. + * + * The same through an optional peer that is still unbound when the bundle is + * hoisted, and gets bound to the bundle's own copy: + * + * - bundled-shadow-peer-host@1.0.0 -> shared@1.0.0, and bundles peer-inner@1.0.0 + * - bundled-shadow-peer-inner@1.0.0 -> peer-leaf@1.0.0, optional peer on shared + * - bundled-shadow-peer-leaf@1.0.0 -> shared@2.0.0 + * + * And a regular dependency nested inside the bundling package resolves the + * name higher up (installed next to consumer@2.0.0 and mid@2.0.0 at the root so + * that consumer@1.0.0 nests under deep-host and mid@1.0.0 under consumer): + * + * - bundled-shadow-deep-host@1.0.0 -> consumer@1.0.0, and bundles wrapper@1.0.0 + * - bundled-shadow-consumer@1.0.0 -> mid@1.0.0 + * - bundled-shadow-consumer@2.0.0 no dependencies + * - bundled-shadow-mid@1.0.0 -> shared@1.0.0 + * - bundled-shadow-mid@2.0.0 no dependencies + * - bundled-shadow-wrapper@1.0.0 -> inner@1.0.0 + * + * Hoisting is breadth-first and consumer sorts before wrapper, so mid's + * shared@1.0.0 has been hoisted to the root (up through deep-host's + * node_modules) by the time inner's shared@2.0.0 reaches that folder. + * + * The tarballs carry their bundled packages like `npm pack` would, so the + * bundled copies are what gets installed for them. + */ + +import { mkdir, writeFile } from "fs/promises"; +import { join } from "path"; + +const packagesDir = import.meta.dir; + +const prefix = "bundled-shadow-"; + +type Manifest = { + version: string; + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + bundleDependencies?: string[]; +}; + +const packages: Record = { + host: [ + { + version: "1.0.0", + dependencies: { [`${prefix}inner`]: "1.0.0", [`${prefix}shared`]: "1.0.0" }, + bundleDependencies: [`${prefix}inner`], + }, + ], + inner: [{ version: "1.0.0", dependencies: { [`${prefix}shared`]: "2.0.0" } }], + shared: [{ version: "1.0.0" }, { version: "2.0.0" }], + "peer-host": [ + { + version: "1.0.0", + dependencies: { [`${prefix}peer-inner`]: "1.0.0", [`${prefix}shared`]: "1.0.0" }, + bundleDependencies: [`${prefix}peer-inner`], + }, + ], + "peer-inner": [ + { + version: "1.0.0", + dependencies: { [`${prefix}peer-leaf`]: "1.0.0" }, + peerDependencies: { [`${prefix}shared`]: "*" }, + peerDependenciesMeta: { [`${prefix}shared`]: { optional: true } }, + }, + ], + "peer-leaf": [{ version: "1.0.0", dependencies: { [`${prefix}shared`]: "2.0.0" } }], + "deep-host": [ + { + version: "1.0.0", + dependencies: { [`${prefix}consumer`]: "1.0.0", [`${prefix}wrapper`]: "1.0.0" }, + bundleDependencies: [`${prefix}wrapper`], + }, + ], + consumer: [{ version: "1.0.0", dependencies: { [`${prefix}mid`]: "1.0.0" } }, { version: "2.0.0" }], + mid: [{ version: "1.0.0", dependencies: { [`${prefix}shared`]: "1.0.0" } }, { version: "2.0.0" }], + wrapper: [{ version: "1.0.0", dependencies: { [`${prefix}inner`]: "1.0.0" } }], +}; + +function manifestOf(name: string, version: string): Manifest { + const manifest = packages[name.slice(prefix.length)]?.find(m => m.version === version); + if (!manifest) throw new Error(`no manifest for ${name}@${version}`); + return manifest; +} + +// Lays a package out under `dir` the way it would be inside a published +// tarball, with each of its dependencies nested in its own node_modules. +function addBundledTree(files: Record, dir: string, name: string, version: string) { + const manifest = manifestOf(name, version); + files[`${dir}/package.json`] = JSON.stringify({ name, ...manifest }, null, 2); + for (const [dep, depVersion] of Object.entries(manifest.dependencies ?? {})) { + addBundledTree(files, `${dir}/node_modules/${dep}`, dep, depVersion); + } +} + +for (const [suffix, manifests] of Object.entries(packages)) { + const name = prefix + suffix; + const dir = join(packagesDir, name); + await mkdir(dir, { recursive: true }); + + const versions: Record = {}; + let latest = ""; + for (const manifest of manifests) { + const pkgJson = { name, ...manifest }; + const files: Record = { "package/package.json": JSON.stringify(pkgJson, null, 2) }; + for (const bundled of manifest.bundleDependencies ?? []) { + addBundledTree(files, `package/node_modules/${bundled}`, bundled, manifest.dependencies![bundled]); + } + const tarball = join(dir, `${name}-${manifest.version}.tgz`); + await Bun.Archive.write(tarball, files, { compress: "gzip" }); + + const bytes = await Bun.file(tarball).bytes(); + versions[manifest.version] = { + ...pkgJson, + _id: `${name}@${manifest.version}`, + dist: { + integrity: `sha512-${Buffer.from(new Bun.CryptoHasher("sha512").update(bytes).digest()).toString("base64")}`, + shasum: new Bun.CryptoHasher("sha1").update(bytes).digest("hex"), + tarball: `http://localhost:4873/${name}/-/${name}-${manifest.version}.tgz`, + }, + }; + latest = manifest.version; + } + + await writeFile( + join(dir, "package.json"), + JSON.stringify({ _id: name, name, "dist-tags": { latest }, versions }, null, 2), + ); +} + +console.log("Created bundled-shadow test packages");