diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 756826ca3c85..a28e862c9402 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -1734,6 +1734,16 @@ impl Lockfile { debug_assert!( SemverStringBuilder::string_hash(self.str(&package.name)) == package.name_hash ); + // The hoister binds peers by scanning `package_index` under the package name. + debug_assert!( + match self.package_index.get(&package.name_hash) { + Some(PackageIndexEntry::Id(id)) => *id as usize == i, + Some(PackageIndexEntry::Ids(ids)) => ids.iter().any(|&id| id as usize == i), + None => false, + }, + "package {} is not in package_index under its own name", + i + ); debug_assert!( package .dependencies diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index f6960ec29f18..305a91f13247 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -7,7 +7,7 @@ use bun_core::ZStr; use bun_paths::{MAX_PATH_BYTES, PathBuffer, SEP}; use crate::lockfile::package::PackageColumns as _; -use crate::lockfile::{DepSorter, DependencyIDList, DependencyIDSlice, Lockfile}; +use crate::lockfile::{DepSorter, DependencyIDList, DependencyIDSlice, Lockfile, bun_lock}; use crate::package_manager::{PackageManager, WorkspaceFilter}; use crate::{ Dependency, DependencyID, PackageID, PackageNameHash, Resolution, invalid_dependency_id, @@ -493,6 +493,27 @@ impl<'a, const METHOD: BuilderMethod> Builder<'a, METHOD> { self.lockfile().buffers.string_bytes.as_slice() } + /// Binds a peer edge the way loading `bun.lock` does, whatever the resolver, a previous + /// lockfile or a migration left it bound to, so every tree built from these packages agrees. + fn bind_peer(&mut self, dep_id: DependencyID) { + let dependency: &Dependency = &self.dependencies[dep_id as usize]; + if !dependency.behavior.is_peer() { + return; + } + let lockfile_ref = self.lockfile; + let lockfile: &Lockfile = lockfile_ref.get(); + if let Some(pkg_id) = bun_lock::resolve_peer_dep_version_based( + dependency, + &lockfile.catalogs, + &lockfile.package_index, + &lockfile.overrides, + lockfile.packages.items_resolution(), + lockfile.buffers.string_bytes.as_slice(), + ) { + self.resolutions[dep_id as usize] = pkg_id; + } + } + /// Flatten the multi-dimensional ArrayList of package IDs into a single easily serializable array pub(crate) fn clean(&mut self) -> Result { let mut total: u32 = 0; @@ -698,6 +719,7 @@ impl Tree { let sort_buf_len = builder.sort_buf.len(); 'dep: for sort_idx in 0..sort_buf_len { let dep_id = builder.sort_buf[sort_idx]; + builder.bind_peer(dep_id); let pkg_id = builder.resolutions[dep_id as usize]; // filter out disabled dependencies diff --git a/src/install/lockfile/bun.lock.rs b/src/install/lockfile/bun.lock.rs index 87ec99aa581f..8b84643f0730 100644 --- a/src/install/lockfile/bun.lock.rs +++ b/src/install/lockfile/bun.lock.rs @@ -3353,6 +3353,9 @@ fn deferred_peer_range<'a>( /// re-keys isolated-linker store entries (and global-store entry hashes) /// on warm installs. /// +/// The hoister applies the same binding to every peer edge it processes +/// (`tree::Builder::bind_peer`), so a saved tree is the tree its reload rebuilds. +/// /// Peers whose name matches a workspace package need no special casing /// even though the fresh resolver binds them to the workspace before any /// deferral (`'resolve_from_workspace`): the version scan below picks an diff --git a/src/install/yarn.rs b/src/install/yarn.rs index 542067033283..5dfb95a04f08 100644 --- a/src/install/yarn.rs +++ b/src/install/yarn.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use std::io::Write as _; use crate::Error; -use bun_collections::{HashMap, StringHashMap, index_sort}; +use bun_collections::StringHashMap; use bun_install::bin::Bin; use bun_install::dependency::{self, Dependency, DependencyExt as _}; use bun_install::install::{self, DependencyID, PackageID, PackageManager}; @@ -26,7 +26,7 @@ use bun_install::npm; // `bun_install::resolution` stub keeps `Value` as a struct-of-fields and has no `init`. use crate::bun_json; use crate::repository::Repository; -use crate::resolution_real::{Resolution, Tag as ResolutionTag, TaggedValue as ResolutionValue}; +use crate::resolution_real::{Resolution, TaggedValue as ResolutionValue}; use crate::versioned_url::VersionedURL; use bun_core::strings; use bun_paths::PathBuffer; @@ -1380,222 +1380,9 @@ pub(crate) fn migrate_yarn_lockfile<'a>( } } - for (base_name, versions) in scoped_packages.iter_mut() { - let base_name: &[u8] = base_name.as_ref(); - - index_sort::sort_slice_by(versions, |a, b| a.package_id.cmp(&b.package_id)); - - let original_name_hash = string_hash(base_name); - // `remove` drops the value (and thus the `Ids` Vec) automatically. - let _ = this.package_index.remove(&original_name_hash); - } - - for (base_name, versions) in scoped_packages.iter() { - let base_name: &[u8] = base_name.as_ref(); - - for version_info in versions.iter() { - let package_id = version_info.package_id; - - let mut found_in_index = false; - for (_, index_value) in this.package_index.iter() { - match index_value { - lockfile::PackageIndexEntry::Id(id) => { - if *id == package_id { - found_in_index = true; - break; - } - } - lockfile::PackageIndexEntry::Ids(ids) => { - for id in ids.iter() { - if *id == package_id { - found_in_index = true; - break; - } - } - if found_in_index { - break; - } - } - } - } - - if !found_in_index { - let mut fallback_name = Vec::new(); - write!( - &mut fallback_name, - "{}#{}", - bstr::BStr::new(base_name), - package_id - ) - .expect("unreachable"); - - let fallback_hash = string_hash(&fallback_name); - this.get_or_put_id(package_id, fallback_hash)?; - } - } - } - - let mut package_names: Vec<&[u8]> = vec![b"".as_slice(); next_package_id as usize]; - - for (yarn_idx, entry) in yarn_lock.entries.iter().enumerate() { - let package_id = yarn_entry_to_package_id[yarn_idx]; - if package_names[package_id as usize].is_empty() { - package_names[package_id as usize] = Entry::get_name_from_spec(entry.specs[0]); - } - } - - let mut root_packages: StringHashMap = StringHashMap::new(); - - let mut usage_count: StringHashMap = StringHashMap::new(); - for entry_idx in 0..yarn_lock.entries.len() { - let package_id = yarn_entry_to_package_id[entry_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - let base_name = package_names[package_id as usize]; - - for dep_entry in yarn_lock.entries.iter() { - if let Some(deps) = &dep_entry.dependencies { - for (dep_name_key, _) in deps.iter() { - if dep_name_key.as_ref() == base_name { - let count = usage_count.get(base_name).copied().unwrap_or(0); - usage_count.put(base_name, count + 1)?; - } - } - } - } - } - - for entry_idx in 0..yarn_lock.entries.len() { - let package_id = yarn_entry_to_package_id[entry_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - let base_name = package_names[package_id as usize]; - - if root_packages.get(base_name).is_none() { - root_packages.put(base_name, package_id)?; - let name_hash = string_hash(base_name); - this.get_or_put_id(package_id, name_hash)?; - } - } - - let mut scoped_names: HashMap> = HashMap::new(); - let mut scoped_count: u32 = 0; - for entry_idx in 0..yarn_lock.entries.len() { - let package_id = yarn_entry_to_package_id[entry_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - let base_name = package_names[package_id as usize]; - - if let Some(root_pkg_id) = root_packages.get(base_name).copied() { - if root_pkg_id == package_id { - continue; - } - } else { - continue; - } - - let mut scoped_name: Option> = None; - for (dep_entry_idx, dep_entry) in yarn_lock.entries.iter().enumerate() { - let dep_package_id = yarn_entry_to_package_id[dep_entry_idx]; - if dep_package_id == install::INVALID_PACKAGE_ID { - continue; - } - - if let Some(deps) = &dep_entry.dependencies { - for (dep_name_key, _) in deps.iter() { - if dep_name_key.as_ref() == base_name { - if dep_package_id != package_id { - let parent_name = package_names[dep_package_id as usize]; - - let mut potential_name = Vec::new(); - write!( - &mut potential_name, - "{}/{}", - bstr::BStr::new(parent_name), - bstr::BStr::new(base_name) - ) - .expect("unreachable"); - - let mut name_already_used = false; - for existing_name in scoped_names.values() { - if existing_name.as_slice() == potential_name.as_slice() { - name_already_used = true; - break; - } - } - - if !name_already_used { - scoped_name = Some(potential_name); - break; - } - // else: potential_name dropped - } - } - } - if scoped_name.is_some() { - break; - } - } - } - - if scoped_name.is_none() { - let pkg_resolution = this.packages.get(package_id as usize).resolution; - let version_str: Vec = match pkg_resolution.tag { - ResolutionTag::Npm => 'brk: { - let mut version_buf = [0u8; 64]; - let mut cursor = &mut version_buf[..]; - let npm_version = pkg_resolution.npm().version; - let _ = write!( - &mut cursor, - "{}", - npm_version.fmt(this.buffers.string_bytes.as_slice()) - ); - let written = 64 - cursor.len(); - break 'brk version_buf[..written].to_vec(); - } - _ => b"unknown".to_vec(), - }; - let mut name = Vec::new(); - write!( - &mut name, - "{}@{}", - bstr::BStr::new(base_name), - bstr::BStr::new(&version_str) - ) - .expect("unreachable"); - scoped_name = Some(name); - } - - if let Some(final_scoped_name) = scoped_name { - let name_hash = string_hash(&final_scoped_name); - this.get_or_put_id(package_id, name_hash)?; - scoped_names.put(package_id, final_scoped_name)?; - scoped_count += 1; - } - } - let _ = scoped_count; - - for (yarn_idx, entry) in yarn_lock.entries.iter().enumerate() { - let package_id = yarn_entry_to_package_id[yarn_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - - if let Some(resolved) = entry.resolved.as_deref() { - if let Some(real_name) = Entry::get_package_name_from_resolved_url(resolved) { - for spec in entry.specs.iter() { - let alias_name = Entry::get_name_from_spec(spec); - - if alias_name != real_name { - let alias_hash = string_hash(alias_name); - this.get_or_put_id(package_id, alias_hash)?; - } - } - } - } + for id in 0..this.packages.len() { + let name_hash = this.packages.items_name_hash()[id]; + this.get_or_put_id(id as PackageID, name_hash)?; } this.buffers.trees[0].dependencies = lockfile::DependencyIDSlice::new(0, 0); diff --git a/test/cli/install/hoist.test.ts b/test/cli/install/hoist.test.ts index 12919c36b139..471acd7dde14 100644 --- a/test/cli/install/hoist.test.ts +++ b/test/cli/install/hoist.test.ts @@ -1,5 +1,8 @@ -import { afterAll, beforeAll, test } from "bun:test"; +import { file, write } from "bun"; +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { exists, rm } from "fs/promises"; import { VerdaccioRegistry, bunEnv, runBunInstall } from "harness"; +import { join } from "path"; const registry = new VerdaccioRegistry(); @@ -28,3 +31,66 @@ test("should handle resolving optional peer from multiple instances of same pack // this shouldn't hit an assertion await runBunInstall(bunEnv, packageDir); }); + +test("tree written after a ranged peer gains a higher candidate is the tree the next install lays out", async () => { + // `peer-deps-fixed` has a peer on `no-deps@^1.0.0`. As a devDependency it is + // hoisted before the root's `dependencies`, so whatever its peer edge is bound + // to is the `no-deps` that lands at the root of node_modules. Loading bun.lock + // binds such an edge to the highest satisfying version in the lockfile, so + // the install that adds `one-dep` (no-deps@1.0.1, next to one-fixed-dep's + // 1.0.0) has to bind it the same way before hoisting. Otherwise it writes a + // lockfile keyed with 1.0.0 at the root and the very next `bun install` + // relinks node_modules with 1.0.1 at the root, without touching bun.lock. + const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } }); + const noDepsVersion = async (...segments: string[]) => { + const pkg = join(packageDir, "node_modules", ...segments, "no-deps", "package.json"); + return (await exists(pkg)) ? ((await file(pkg).json()) as { version: string }).version : null; + }; + const layout = async () => ({ + root: await noDepsVersion(), + "one-dep": await noDepsVersion("one-dep", "node_modules"), + "one-fixed-dep": await noDepsVersion("one-fixed-dep", "node_modules"), + }); + + await write( + packageJson, + JSON.stringify({ + name: "ranged-peer-roundtrip", + dependencies: { "one-fixed-dep": "1.0.0" }, + devDependencies: { "peer-deps-fixed": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + expect(await layout()).toEqual({ root: "1.0.0", "one-dep": null, "one-fixed-dep": null }); + + await write( + packageJson, + JSON.stringify({ + name: "ranged-peer-roundtrip", + dependencies: { "one-dep": "1.0.0", "one-fixed-dep": "1.0.0" }, + devDependencies: { "peer-deps-fixed": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + const written = await layout(); + const lockfile = await file(join(packageDir, "bun.lock")).text(); + + // the tree on disk is the tree the lockfile describes, so reinstalling from it is a no-op + const { out, err } = await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + expect(out).toContain("(no changes)"); + expect(err).not.toContain("Saved lockfile"); + expect(await layout()).toEqual(written); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); + + // the peer is bound to the highest satisfying version, and peer-deps-fixed hoists it first + expect(written).toEqual({ root: "1.0.1", "one-dep": null, "one-fixed-dep": "1.0.0" }); + expect(lockfile).toContain('"no-deps": ["no-deps@1.0.1"'); + expect(lockfile).toContain('"one-fixed-dep/no-deps": ["no-deps@1.0.0"'); + + // a fresh resolve of the same package.json binds the peer the same way + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await rm(join(packageDir, "bun.lock")); + await runBunInstall(bunEnv, packageDir); + expect(await layout()).toEqual(written); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); +}); diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 59ddce9484b1..1e9a6aa5df67 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -1220,6 +1220,59 @@ test("ranged peer dependency resolution is stable across installs from bun.lock" }); }); +test("ranged peer rebinds in the install that adds a higher satisfying version, not the one after", async () => { + // The first install binds peer-deps-fixed's `no-deps@^1.0.0` to 1.0.0, the + // only candidate. Adding `one-dep` brings in no-deps@1.0.1; loading the + // lockfile that install writes binds the edge to 1.0.1 (highest satisfying), + // so that install has to bind it the same way itself. Otherwise it links the + // 1.0.0 peer variant and the next `bun install`, with nothing changed, + // re-keys the store entry. + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + const bunDir = join(packageDir, "node_modules", ".bun"); + const peerEntries = async () => (await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@")); + const peerNoDepsVersion = async (entry: string) => + ((await file(join(bunDir, entry, "node_modules", "no-deps", "package.json")).json()) as { version: string }) + .version; + + await write( + packageJson, + JSON.stringify({ + name: "rebind-ranged-peer", + dependencies: { "peer-deps-fixed": "1.0.0", "one-fixed-dep": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + const [initialEntry] = await peerEntries(); + expect(await peerNoDepsVersion(initialEntry)).toBe("1.0.0"); + + await write( + packageJson, + JSON.stringify({ + name: "rebind-ranged-peer", + dependencies: { "peer-deps-fixed": "1.0.0", "one-fixed-dep": "1.0.0", "one-dep": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + const entries = await peerEntries(); + const lockfile = await file(join(packageDir, "bun.lock")).text(); + + const { out, err } = await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + expect(out).toContain("(no changes)"); + expect(err).not.toContain("Saved lockfile"); + expect(await peerEntries()).toEqual(entries); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); + + // The superseded variant stays in the store until `bun prune`, like after any peer bump. + const [rebound, ...rest] = entries.filter(entry => entry !== initialEntry); + expect(rest).toEqual([]); + expect(await peerNoDepsVersion(rebound)).toBe("1.0.1"); + expect(readlinkSync(join(packageDir, "node_modules", "peer-deps-fixed"))).toBe( + join(".bun", rebound, "node_modules", "peer-deps-fixed"), + ); +}); + test("aliased peer dependency binds to its real package across installs from bun.lock", async () => { // The peer alias `no-deps` points at `npm:a-dep@^1.0.2` while the real // no-deps package (in two versions) is also in the graph. Loading bun.lock diff --git a/test/cli/install/migration/migrate.test.ts b/test/cli/install/migration/migrate.test.ts index 2429afe854aa..8bb11fffa225 100644 --- a/test/cli/install/migration/migrate.test.ts +++ b/test/cli/install/migration/migrate.test.ts @@ -1366,6 +1366,61 @@ describe("package-lock.json migration fixes", () => { }, ); + test.concurrent( + "a ranged peer npm satisfied with a lower version migrates to the tree a fresh resolve writes", + async () => { + // npm satisfied peer-deps-fixed's `no-deps@^1.0.0` with the 1.0.0 it hoisted. bun binds such a + // peer to the highest satisfying version in the lockfile (1.0.1) whenever it loads bun.lock, and + // peer-deps-fixed, a devDependency, hoists before the root's dependencies: a migrated tree + // built from npm's binding would key 1.0.0 at the root and be rebuilt with 1.0.1 there by the + // first install that loads it. + using registry = localRegistry(); + const entry = (name: string, version: string, info: Record = {}) => ({ + version, + resolved: registry.tarball(name, version), + integrity: registry.integrity(name, version), + ...info, + }); + const root = { + name: "ranged-peer", + dependencies: { "one-dep": "1.0.0", "one-fixed-dep": "1.0.0" }, + devDependencies: { "peer-deps-fixed": "1.0.0" }, + }; + using dir = synthetic( + "npm-migrate-ranged-peer", + { + "package.json": JSON.stringify(root), + "package-lock.json": npmLock("ranged-peer", { + "": root, + "node_modules/no-deps": entry("no-deps", "1.0.0"), + "node_modules/one-dep": entry("one-dep", "1.0.0", { dependencies: { "no-deps": "1.0.1" } }), + "node_modules/one-dep/node_modules/no-deps": entry("no-deps", "1.0.1"), + "node_modules/one-fixed-dep": entry("one-fixed-dep", "1.0.0", { dependencies: { "no-deps": "1.0.0" } }), + "node_modules/peer-deps-fixed": entry("peer-deps-fixed", "1.0.0", { + dev: true, + peerDependencies: { "no-deps": "^1.0.0" }, + }), + }), + }, + registry.url, + ); + const { lock } = await migrate(dir); + expect(lock.packages["no-deps"][0]).toBe("no-deps@1.0.1"); + expect(lock.packages["one-fixed-dep/no-deps"][0]).toBe("no-deps@1.0.0"); + await frozen(dir); + + using freshDir = synthetic( + "npm-migrate-ranged-peer-fresh", + { "package.json": JSON.stringify(root) }, + registry.url, + ); + const fresh = await run(freshDir, "install", "--lockfile-only"); + expect(fresh.exitCode).toBe(0); + const { lock: freshLock } = await readLock(freshDir); + expect(lock.packages).toStrictEqual(freshLock.packages); + }, + ); + test.concurrent("workspace listed in the lockfile but deleted from disk is skipped", async () => { const src = join(ARBORIST, "workspaces-simple-virtual"); const packageLock = JSON.parse(fs.readFileSync(join(src, "package-lock.json"), "utf8")); diff --git a/test/cli/install/migration/yarn-lock-migration.test.ts b/test/cli/install/migration/yarn-lock-migration.test.ts index de69143e155b..c83cec240d83 100644 --- a/test/cli/install/migration/yarn-lock-migration.test.ts +++ b/test/cli/install/migration/yarn-lock-migration.test.ts @@ -1639,4 +1639,76 @@ fsevents@^2.3.2: expect(bunLockContent).toContain("@esbuild/linux-arm64"); expect(bunLockContent).toContain("@esbuild/darwin-arm64"); }); + + test("a peer binds to the highest satisfying version, not the copy yarn.lock resolved its range to", async () => { + // yarn.lock resolved bar's `foo@^1.0.0` peer spec to the foo@1.0.0 it lists first, while + // foo@1.5.0 is in the graph too. Hoisting binds the peer the way every other tree build + // does, and bar hoists before uses-foo1, so the copy written at the root is 1.5.0, the + // same tree a reload of the migrated file builds. + const sha = Buffer.alloc(40, "0").toString(); + await using tmpDir = tempDir("yarn-migration-peer-binding", { + "package.json": JSON.stringify({ + name: "peer-binding", + dependencies: { "bar": "1.0.0", "uses-foo1": "1.0.0", "uses-foo15": "1.0.0" }, + }), + // port 1 refuses connections, so the manifest fetch after the migration fails fast + "bunfig.toml": `[install]\nregistry = "http://localhost:1/"\n`, + "yarn.lock": `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +foo@1.0.0, foo@^1.0.0: + version "1.0.0" + resolved "http://localhost:1/foo/-/foo-1.0.0.tgz#${sha}" + +foo@1.5.0: + version "1.5.0" + resolved "http://localhost:1/foo/-/foo-1.5.0.tgz#${sha}" + +bar@1.0.0: + version "1.0.0" + resolved "http://localhost:1/bar/-/bar-1.0.0.tgz#${sha}" + peerDependencies: + foo "^1.0.0" + +uses-foo1@1.0.0: + version "1.0.0" + resolved "http://localhost:1/uses-foo1/-/uses-foo1-1.0.0.tgz#${sha}" + dependencies: + foo "1.0.0" + +uses-foo15@1.0.0: + version "1.0.0" + resolved "http://localhost:1/uses-foo15/-/uses-foo15-1.0.0.tgz#${sha}" + dependencies: + foo "1.5.0" +`, + }); + + await using migrateResult = Bun.spawn({ + cmd: [bunExe(), "pm", "migrate", "-f"], + cwd: tmpDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + migrateResult.stdout.text(), + migrateResult.stderr.text(), + migrateResult.exited, + ]); + expect(stdout).toBe(""); + expect(stderr).toContain("migrated lockfile from yarn.lock"); + expect(exitCode).toBe(0); + + const lock = Bun.JSONC.parse(fs.readFileSync(join(tmpDir, "bun.lock"), "utf8")) as any; + expect(Object.fromEntries(Object.entries(lock.packages).map(([key, value]: any) => [key, value[0]]))).toEqual({ + "bar": "bar@1.0.0", + "foo": "foo@1.5.0", + "uses-foo1": "uses-foo1@1.0.0", + "uses-foo1/foo": "foo@1.0.0", + "uses-foo15": "uses-foo15@1.0.0", + }); + }); });