diff --git a/src/install/yarn.rs b/src/install/yarn.rs index 0d0be5a31f57..cbcc3613b691 100644 --- a/src/install/yarn.rs +++ b/src/install/yarn.rs @@ -236,34 +236,19 @@ impl<'a> Entry<'a> { ParsedNpmAlias { version: b"*" } } + /// Registry tarball URLs look like `//-/-.tgz`, + /// where `` spans two path segments (`@scope/name`) for scoped packages. pub(crate) fn get_package_name_from_resolved_url(url: &[u8]) -> Option<&[u8]> { - if let Some(dash_idx) = strings::index_of(url, b"/-/") { - let mut slash_count: usize = 0; - let mut last_slash: usize = 0; - let mut second_last_slash: usize = 0; - - let mut i = dash_idx; - while i > 0 { - if url[i - 1] == b'/' { - slash_count += 1; - if slash_count == 1 { - last_slash = i - 1; - } else if slash_count == 2 { - second_last_slash = i - 1; - break; - } - } - i -= 1; - } - - if last_slash < dash_idx && url[last_slash + 1] == b'@' { - return Some(&url[second_last_slash + 1..dash_idx]); - } else if last_slash < dash_idx { - return Some(&url[last_slash + 1..dash_idx]); - } + let path = &url[..strings::index_of(url, b"/-/")?]; + let (prefix, name) = strings::rsplit_once_char(path, b'/')?; + if name.is_empty() { + return None; } - - None + let scope_start = strings::last_index_of_char(prefix, b'/').map_or(0, |slash| slash + 1); + if prefix[scope_start..].starts_with(b"@") { + return Some(&path[scope_start..]); + } + Some(name) } } diff --git a/test/cli/install/migration/yarn-lock-migration.test.ts b/test/cli/install/migration/yarn-lock-migration.test.ts index b4744963a17c..de69143e155b 100644 --- a/test/cli/install/migration/yarn-lock-migration.test.ts +++ b/test/cli/install/migration/yarn-lock-migration.test.ts @@ -810,6 +810,99 @@ undici-types@~5.26.4: expect(bunLockContent).toContain('"my-lodash": ["lodash@4.17.21"'); }); + test("yarn.lock with npm aliases of a scoped package keep the scope", async () => { + const typesNode20Integrity = + "sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w=="; + const typesNode18Integrity = "sha512-" + Buffer.alloc(64, 1).toString("base64"); + const needsNodeTypesIntegrity = "sha512-" + Buffer.alloc(64, 2).toString("base64"); + + await using tmpDir = tempDir("yarn-migration-scoped-aliases", { + "package.json": JSON.stringify( + { + name: "scoped-alias-test", + version: "1.0.0", + dependencies: { + "@aliased/node-types": "npm:@types/node@20.11.5", + "@types/node": "^18.0.0", + "my-node-types": "npm:@types/node@20.11.5", + "needs-node-types": "1.0.0", + }, + }, + null, + 2, + ), + // yarn v1 lists every pattern that resolved to the same tarball on one key line, + // so the three aliases of @types/node@20.11.5 share a single entry. + "yarn.lock": `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aliased/node-types@npm:@types/node@20.11.5", "my-node-types@npm:@types/node@20.11.5", "types-alias@npm:@types/node@20.11.5": + version "20.11.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.5.tgz#be10c622ca7fcaa3cf226cf80166abc31389d86e" + integrity ${typesNode20Integrity} + +"@types/node@^18.0.0": + version "18.19.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.0.tgz#1111111111111111111111111111111111111111" + integrity ${typesNode18Integrity} + +needs-node-types@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/needs-node-types/-/needs-node-types-1.0.0.tgz#2222222222222222222222222222222222222222" + integrity ${needsNodeTypesIntegrity} + dependencies: + types-alias "npm:@types/node@20.11.5" +`, + }); + + // After migrating, bun fetches the manifest of every migrated package by the name it + // recorded, so the registry sees which package each alias was resolved to. + const requestedManifests = new Set(); + await using registry = Bun.serve({ + port: 0, + fetch(req) { + requestedManifests.add(decodeURIComponent(new URL(req.url).pathname.slice(1))); + return new Response("not found", { status: 404 }); + }, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "migrate", "-f"], + cwd: tmpDir, + env: { + ...bunEnv, + BUN_CONFIG_REGISTRY: registry.url.href, + BUN_INSTALL_CACHE_DIR: join(tmpDir, ".bun-cache"), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("migrated lockfile from yarn.lock"); + expect(exitCode).toBe(0); + + const lock = Bun.JSONC.parse(await Bun.file(join(tmpDir, "bun.lock")).text()) as { packages: unknown }; + expect(lock.packages).toStrictEqual({ + "@aliased/node-types": ["@types/node@20.11.5", "", {}, typesNode20Integrity], + "@types/node": ["@types/node@18.19.0", "", {}, typesNode18Integrity], + "my-node-types": ["@types/node@20.11.5", "", {}, typesNode20Integrity], + "needs-node-types": [ + "needs-node-types@1.0.0", + "", + { dependencies: { "types-alias": "npm:@types/node@20.11.5" } }, + needsNodeTypesIntegrity, + ], + "types-alias": ["@types/node@20.11.5", "", {}, typesNode20Integrity], + }); + + expect([...requestedManifests].sort()).toStrictEqual(["@types/node", "needs-node-types"]); + }); + test("yarn.lock with resolutions", async () => { await using tmpDir = tempDir("yarn-migration-resolutions", { "package.json": JSON.stringify(