Skip to content

Commit b179cb8

Browse files
authored
fix: Treat npm: alias dependencies as external, not workspace references (#12061)
## Summary Fixes #8989 - When a dependency uses the `npm:` alias syntax (e.g. `"buffer": "npm:buffer@6.0.3"`), turborepo incorrectly resolved it to a workspace of the same name instead of the npm registry package. The `npm:<pkg>@<version>` format explicitly targets the npm registry and should never match a workspace. ## Root cause `DependencyVersion::new("npm:buffer@6.0.3")` splits into `protocol="npm"` and `version="buffer@6.0.3"`. Since `npm` is special-cased to not be treated as external (for transparent workspace support), the code falls through to semver comparison. `"buffer@6.0.3"` fails to parse as a semver range, and the backwards-compatibility fallback treats parse failures as internal matches. ## Fix Added `is_npm_alias()` to detect the `npm:<pkg>@<version>` alias format (including scoped packages like `npm:@scope/pkg@^1.0.0`). Aliased npm dependencies are always treated as external since they explicitly target the npm registry. This is distinct from plain `npm:^1.0.0` ranges which still participate in transparent workspace resolution. ## Testing To understand the fix, the `test_is_npm_alias` and `test_matches_workspace_package` ("handles npm alias with matching workspace name") test cases in `dep_splitter.rs` are the most relevant. A berry lockfile test (`test_npm_alias_does_not_resolve_to_workspace`) verifies the lockfile resolution and pruning paths. A `berry-npm-alias` fixture was added to `lockfile-tests/fixtures/` reproducing the exact scenario from the issue.
1 parent f03cdce commit b179cb8

File tree

9 files changed

+221
-0
lines changed

9 files changed

+221
-0
lines changed

crates/turborepo-lockfiles/src/berry/mod.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,87 @@ mod test {
696696
}
697697
}
698698

699+
#[test]
700+
fn test_npm_alias_does_not_resolve_to_workspace() {
701+
// Regression test for https://github.com/vercel/turborepo/issues/8989
702+
// When a dependency uses `npm:buffer@6.0.3`, it should resolve to the
703+
// npm package, not the workspace with the same name.
704+
let yaml = r#"__metadata:
705+
version: 8
706+
cacheKey: 10c0
707+
708+
"a@workspace:packages/a":
709+
version: 0.0.0-use.local
710+
resolution: "a@workspace:packages/a"
711+
dependencies:
712+
buffer: "npm:buffer@6.0.3"
713+
languageName: unknown
714+
linkType: soft
715+
716+
"base64-js@npm:^1.3.1":
717+
version: 1.5.1
718+
resolution: "base64-js@npm:1.5.1"
719+
checksum: 10c0-abc123
720+
languageName: node
721+
linkType: hard
722+
723+
"root@workspace:.":
724+
version: 0.0.0-use.local
725+
resolution: "root@workspace:."
726+
languageName: unknown
727+
linkType: soft
728+
729+
"buffer@npm:buffer@6.0.3":
730+
version: 6.0.3
731+
resolution: "buffer@npm:6.0.3"
732+
dependencies:
733+
base64-js: "npm:^1.3.1"
734+
ieee754: "npm:^1.2.1"
735+
checksum: 10c0-def456
736+
languageName: node
737+
linkType: hard
738+
739+
"buffer@workspace:packages/buffer":
740+
version: 0.0.0-use.local
741+
resolution: "buffer@workspace:packages/buffer"
742+
languageName: unknown
743+
linkType: soft
744+
745+
"ieee754@npm:^1.2.1":
746+
version: 1.2.1
747+
resolution: "ieee754@npm:1.2.1"
748+
checksum: 10c0-ghi789
749+
languageName: node
750+
linkType: hard
751+
"#;
752+
753+
let data = LockfileData::from_bytes(yaml.as_bytes()).unwrap();
754+
let lockfile = BerryLockfile::new(data, None).unwrap();
755+
756+
// Resolving "buffer" with version "npm:buffer@6.0.3" from workspace "a"
757+
// should return the npm package, not the workspace.
758+
let resolved = lockfile
759+
.resolve_package("packages/a", "buffer", "npm:buffer@6.0.3")
760+
.unwrap();
761+
assert!(resolved.is_some(), "should resolve the npm alias package");
762+
let pkg = resolved.unwrap();
763+
assert_eq!(pkg.key, "buffer@npm:6.0.3");
764+
assert_eq!(pkg.version, "6.0.3");
765+
766+
// Pruning for workspace "a" should include the npm buffer package
767+
let subgraph = lockfile
768+
.subgraph(
769+
&["packages/a".to_string()],
770+
&["buffer@npm:6.0.3".to_string()],
771+
)
772+
.unwrap();
773+
let encoded = String::from_utf8(subgraph.encode().unwrap()).unwrap();
774+
assert!(
775+
encoded.contains("buffer@npm:buffer@6.0.3"),
776+
"pruned lockfile should contain the npm alias entry"
777+
);
778+
}
779+
699780
#[test]
700781
fn test_berry_manifest_into_parts_merges_correctly() {
701782
let mut default_catalog = Map::new();

crates/turborepo-repository/src/package_graph/dep_splitter.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,28 @@ impl<'a> DependencyVersion<'a> {
151151
)
152152
}
153153

154+
/// Returns true if this dependency uses an npm alias (`npm:pkg@version`).
155+
/// This is distinct from a plain npm range (`npm:^1.0.0`). When a package
156+
/// name is present in the specifier, the user is explicitly requesting an
157+
/// npm registry package, which should never resolve to a workspace.
158+
fn is_npm_alias(&self) -> bool {
159+
if self.protocol != Some("npm") {
160+
return false;
161+
}
162+
// npm alias format: `npm:<package-name>@<version>`
163+
// A scoped package looks like `npm:@scope/pkg@version`, so we need to
164+
// handle the leading `@` carefully.
165+
let name_and_version = if let Some(rest) = self.version.strip_prefix('@') {
166+
// Scoped: skip past scope to find the `@version` separator
167+
rest.find('@').map(|i| i + 1)
168+
} else {
169+
self.version.find('@')
170+
};
171+
// If there's an `@` separating a package name from a version, and the
172+
// part before it isn't empty, this is an alias.
173+
name_and_version.is_some_and(|i| i > 0)
174+
}
175+
154176
fn is_external(&self) -> bool {
155177
// The npm protocol for yarn by default still uses the workspace package if the
156178
// workspace version is in a compatible semver range. See https://github.com/yarnpkg/berry/discussions/4015
@@ -185,6 +207,9 @@ impl<'a> DependencyVersion<'a> {
185207
// Other protocols are assumed to be external references ("github:", etc)
186208
false
187209
}
210+
// npm: alias syntax (e.g. `npm:buffer@6.0.3` or `npm:@scope/pkg@^1.0.0`)
211+
// means the user explicitly wants the npm registry package, not a workspace.
212+
Some("npm") if self.is_npm_alias() => false,
188213
_ if self.version == "*" => true,
189214
_ if package_version.is_empty() => {
190215
// The workspace version of this package does not contain a version, no version
@@ -253,6 +278,9 @@ mod test {
253278
#[test_case("1.2.3", Some("foo"), "workspace:@scope/foo@*", Some("@scope/foo"), true ; "handles pnpm alias star")]
254279
#[test_case("1.2.3", Some("foo"), "workspace:@scope/foo@~", Some("@scope/foo"), true ; "handles pnpm alias tilde")]
255280
#[test_case("1.2.3", Some("foo"), "workspace:@scope/foo@^", Some("@scope/foo"), true ; "handles pnpm alias caret")]
281+
#[test_case("6.0.3", Some("buffer"), "npm:buffer@6.0.3", None, true ; "handles npm alias with matching workspace name")]
282+
#[test_case("1.2.3", None, "npm:other-pkg@^1.0.0", None, true ; "handles npm alias with different package name")]
283+
#[test_case("1.2.3", None, "npm:@scope/foo@^1.0.0", None, true ; "handles npm alias with scoped package")]
256284
#[test_case("1.2.3", None, "1.2.3", None, false ; "no workspace linking")]
257285
#[test_case("1.2.3", None, "workspace:1.2.3", Some("@scope/foo"), false ; "no workspace linking with protocol")]
258286
#[test_case("", None, "1.2.3", None, true ; "no workspace package version")]
@@ -318,6 +346,21 @@ mod test {
318346
transitive_dependencies: None,
319347
},
320348
);
349+
map.insert(
350+
PackageName::Other("buffer".to_string()),
351+
PackageInfo {
352+
package_json: PackageJson {
353+
version: Some("6.0.3".to_string()),
354+
..Default::default()
355+
},
356+
package_json_path: AnchoredSystemPathBuf::from_raw(
357+
["packages", "buffer", "package.json"].join(std::path::MAIN_SEPARATOR_STR),
358+
)
359+
.unwrap(),
360+
unresolved_external_dependencies: None,
361+
transitive_dependencies: None,
362+
},
363+
);
321364
map
322365
};
323366

@@ -336,6 +379,17 @@ mod test {
336379
);
337380
}
338381

382+
#[test_case("npm:buffer@6.0.3", true ; "npm alias unscoped")]
383+
#[test_case("npm:@scope/pkg@^1.0.0", true ; "npm alias scoped")]
384+
#[test_case("npm:^1.2.3", false ; "npm range")]
385+
#[test_case("npm:*", false ; "npm wildcard")]
386+
#[test_case("workspace:*", false ; "workspace protocol")]
387+
#[test_case("^1.0.0", false ; "bare range")]
388+
fn test_is_npm_alias(version: &str, expected: bool) {
389+
let dep = DependencyVersion::new(version);
390+
assert_eq!(dep.is_npm_alias(), expected, "is_npm_alias({version})");
391+
}
392+
339393
#[test_case("1.2.3", None ; "non-workspace")]
340394
#[test_case("workspace:1.2.3", None ; "workspace version")]
341395
#[test_case("workspace:*", None ; "workspace any")]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
nodeLinker: node-modules
2+
3+
enableTransparentWorkspaces: false
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"packageManager": "yarn-berry",
3+
"packageManagerVersion": "yarn@4.4.0",
4+
"lockfileName": "yarn.lock",
5+
"frozenInstallCommand": ["yarn", "install", "--immutable"]
6+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "berry-npm-alias",
3+
"version": "0.0.0",
4+
"private": true,
5+
"workspaces": [
6+
"packages/*"
7+
],
8+
"packageManager": "yarn@4.4.0"
9+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "a",
3+
"version": "1.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"buffer": "npm:buffer@6.0.3"
7+
}
8+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "buffer",
3+
"version": "6.0.3",
4+
"private": true
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"tasks": {
3+
"build": {}
4+
}
5+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# This file is generated by running "yarn install" inside your project.
2+
# Manual changes might be lost - proceed with caution!
3+
4+
__metadata:
5+
version: 8
6+
cacheKey: 10c0
7+
8+
"a@workspace:packages/a":
9+
version: 0.0.0-use.local
10+
resolution: "a@workspace:packages/a"
11+
dependencies:
12+
buffer: "npm:buffer@6.0.3"
13+
languageName: unknown
14+
linkType: soft
15+
16+
"base64-js@npm:^1.3.1":
17+
version: 1.5.1
18+
resolution: "base64-js@npm:1.5.1"
19+
checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf
20+
languageName: node
21+
linkType: hard
22+
23+
"berry-npm-alias@workspace:.":
24+
version: 0.0.0-use.local
25+
resolution: "berry-npm-alias@workspace:."
26+
languageName: unknown
27+
linkType: soft
28+
29+
"buffer@npm:buffer@6.0.3":
30+
version: 6.0.3
31+
resolution: "buffer@npm:6.0.3"
32+
dependencies:
33+
base64-js: "npm:^1.3.1"
34+
ieee754: "npm:^1.2.1"
35+
checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0
36+
languageName: node
37+
linkType: hard
38+
39+
"buffer@workspace:packages/buffer":
40+
version: 0.0.0-use.local
41+
resolution: "buffer@workspace:packages/buffer"
42+
languageName: unknown
43+
linkType: soft
44+
45+
"ieee754@npm:^1.2.1":
46+
version: 1.2.1
47+
resolution: "ieee754@npm:1.2.1"
48+
checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb
49+
languageName: node
50+
linkType: hard

0 commit comments

Comments
 (0)