Skip to content

Commit 9d977f1

Browse files
EmojiPatipatianthonyshew
authored
fix: Detect scoped @turbo/{platform} packages in local binary resolution (#12386)
## Summary - Fix `generate_unplugged_path` to detect scoped `@turbo/{platform}` packages in Berry PnP unplugged directories (was hardcoded to legacy `turbo-{platform}` only) - Fix `?` operator inside the search loop that silently aborted the entire binary resolution when one search root had a malformed `package.json` - Restructure `infer()` to interleave scoped/legacy probing per search root, halving worst-case filesystem I/O during the migration period - Add 7 integration tests for `infer()` covering hoisted, unplugged, fallback, priority ordering, and error resilience ## Context The initial commit extracted `try_find_with_package_path` to support both `@turbo/{platform}` and legacy `turbo-{platform}` formats. A review found three issues: 1. **Unplugged bug**: `generate_unplugged_path` hardcoded the legacy package name prefix for Berry's directory matching, so scoped packages in Berry PnP would silently fall back to global turbo. 2. **Early-return bug** (pre-existing, amplified): The `?` operator on `PackageJson::load().ok()?` returned `None` from the entire search function instead of continuing to the next search root. A broken `package.json` at one root killed discovery of valid installs at other roots. 3. **Doubled I/O**: The `or_else` structure ran all 4 search strategies for scoped, then all 4 again for legacy. Restructuring to try both formats per root reuses each `generate_*_path` result and avoids redundant `.yarnrc.yml` parsing. ## Testing The `infer()` public API previously had zero test coverage. This adds tempdir-based integration tests that create mock package layouts and verify resolution: - `test_infer_hoisted_scoped` / `test_infer_hoisted_legacy_fallback` - `test_infer_scoped_preferred_over_legacy` — verifies priority ordering - `test_infer_malformed_package_json_continues_search` — verifies the `?` fix - `test_infer_unplugged_scoped` / `test_infer_unplugged_legacy` — verifies the Berry fix - `test_infer_empty_dir_returns_none` Run with `cargo test -p turborepo-shim`. --------- Co-authored-by: pati <pati@noreply.codeberg.org> Co-authored-by: Anthony Shew <anthonyshew@gmail.com>
1 parent 2ff8fc9 commit 9d977f1

2 files changed

Lines changed: 281 additions & 41 deletions

File tree

crates/turborepo-shim/src/local_turbo_state.rs

Lines changed: 247 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ impl LocalTurboState {
4444
// - `npm install --install-strategy=shallow` (`npm install --global-style`)
4545
// - `npm install --install-strategy=nested` (`npm install --legacy-bundling`)
4646
// - berry (nodeLinker: "pnpm")
47+
//
48+
// Returns `node_modules/turbo/node_modules` — the caller appends
49+
// package-specific segments. This works for both legacy
50+
// (`turbo-{platform}`) and scoped (`@turbo/{platform}`) packages since
51+
// `join_components` handles multi-segment paths correctly.
4752
fn generate_nested_path(root_path: &AbsoluteSystemPath) -> Option<AbsoluteSystemPathBuf> {
4853
Some(root_path.join_components(&["node_modules", "turbo", "node_modules"]))
4954
}
@@ -109,22 +114,22 @@ impl LocalTurboState {
109114
root_path.as_path().join(yarn_rc.pnp_unplugged_folder)
110115
}
111116

112-
// Unplugged strategy:
113-
// - berry 2.1+
114-
fn generate_unplugged_path(root_path: &AbsoluteSystemPath) -> Option<AbsoluteSystemPathBuf> {
115-
let platform_package_name = TurboState::platform_package_name();
116-
let unplugged_base_path = Self::get_unplugged_base_path(root_path);
117-
117+
// Unplugged strategy (Berry 2.1+): Berry encodes the package identity in
118+
// the unplugged directory name. For scoped `@turbo/linux-64` the dir is
119+
// `@turbo-linux-64-npm-{version}-{hash}`, for legacy `turbo-linux-64` it
120+
// is `turbo-linux-64-npm-{version}-{hash}`.
121+
fn find_in_unplugged(
122+
unplugged_base_path: &Utf8PathBuf,
123+
package_prefix: &str,
124+
) -> Option<AbsoluteSystemPathBuf> {
118125
unplugged_base_path
119126
.read_dir_utf8()
120127
.ok()
121128
.and_then(|mut read_dir| {
122-
// berry includes additional metadata in the filename.
123-
// We actually have to find the platform package.
124129
read_dir.find_map(|item| match item {
125130
Ok(entry) => {
126131
let file_name = entry.file_name();
127-
if file_name.starts_with(platform_package_name) {
132+
if file_name.starts_with(package_prefix) {
128133
AbsoluteSystemPathBuf::new(
129134
unplugged_base_path.join(file_name).join("node_modules"),
130135
)
@@ -138,54 +143,114 @@ impl LocalTurboState {
138143
})
139144
}
140145

141-
// We support six per-platform packages and one `turbo` package which handles
142-
// indirection. We identify the per-platform package and execute the appropriate
143-
// binary directly. We can choose to operate this aggressively because the
144-
// _worst_ outcome is that we run global `turbo`.
146+
/// Try to resolve a local turbo binary at a specific root + package path.
147+
/// Returns `None` if the binary doesn't exist or the package metadata is
148+
/// unreadable — the caller should try the next candidate.
149+
fn try_probe_binary(
150+
root: &AbsoluteSystemPath,
151+
package_path: &[&str],
152+
binary_name: &str,
153+
) -> Option<Self> {
154+
let mut bin_components: Vec<&str> = Vec::with_capacity(package_path.len() + 2);
155+
bin_components.extend_from_slice(package_path);
156+
bin_components.extend_from_slice(&["bin", binary_name]);
157+
158+
let bin_path = root.join_components(&bin_components);
159+
let bin_path = match fs_canonicalize(&bin_path) {
160+
Ok(p) => p,
161+
Err(_) => {
162+
debug!("No local turbo binary found at: {}", bin_path);
163+
return None;
164+
}
165+
};
166+
167+
let mut json_components: Vec<&str> = Vec::with_capacity(package_path.len() + 1);
168+
json_components.extend_from_slice(package_path);
169+
json_components.push("package.json");
170+
let resolved_package_json_path = root.join_components(&json_components);
171+
172+
let Some(platform_package_json) = PackageJson::load(&resolved_package_json_path).ok()
173+
else {
174+
debug!(
175+
"Failed to load package.json at: {}",
176+
resolved_package_json_path
177+
);
178+
return None;
179+
};
180+
let Some(local_version) = platform_package_json.version else {
181+
debug!(
182+
"No version field in package.json at: {}",
183+
resolved_package_json_path
184+
);
185+
return None;
186+
};
187+
188+
debug!("Local turbo path: {}", bin_path.display());
189+
debug!("Local turbo version: {}", &local_version);
190+
Some(Self {
191+
bin_path,
192+
version: local_version,
193+
})
194+
}
195+
196+
// We support twelve per-platform packages (six scoped `@turbo/{platform}`
197+
// and six legacy `turbo-{platform}`) and one `turbo` package which handles
198+
// indirection. We identify the per-platform package and execute the
199+
// appropriate binary directly. We can choose to operate this aggressively
200+
// because the _worst_ outcome is that we run global `turbo`.
145201
//
146-
// In spite of that, the only known unsupported local invocation is Yarn/Berry <
147-
// 2.1 PnP
202+
// In spite of that, the only known unsupported local invocation is
203+
// Yarn/Berry < 2.1 PnP
148204
pub fn infer(root_path: &AbsoluteSystemPath) -> Option<Self> {
149-
let platform_package_name = TurboState::platform_package_name();
150205
let binary_name = TurboState::binary_name();
151206

152-
let platform_package_json_path_components = [platform_package_name, "package.json"];
153-
let platform_package_executable_path_components =
154-
[platform_package_name, "bin", binary_name];
207+
// Prefer scoped `@turbo/{platform}` over legacy `turbo-{platform}`.
208+
// Scoped packages are the canonical format going forward; legacy is
209+
// retained for backward compatibility.
210+
let scoped_path: &[&str] = &[
211+
TurboState::scoped_platform_package_scope(),
212+
TurboState::scoped_platform_package_dir(),
213+
];
214+
let legacy_path: &[&str] = &[TurboState::platform_package_name()];
215+
let package_paths: &[&[&str]] = &[scoped_path, legacy_path];
155216

156-
// These are lazy because the last two are more expensive.
217+
// Ordered cheap-to-expensive: hoisted/nested are pure path joins,
218+
// linked requires symlink resolution, unplugged requires directory
219+
// scanning. Detecting the package manager is more expensive than
220+
// exhaustive search.
157221
let search_functions = [
158222
Self::generate_hoisted_path,
159223
Self::generate_nested_path,
160224
Self::generate_linked_path,
161-
Self::generate_unplugged_path,
162225
];
163226

164-
// Detecting the package manager is more expensive than just doing an exhaustive
165-
// search.
227+
// For each root, try all package formats before moving to the next
228+
// (more expensive) strategy. This avoids redundant filesystem work
229+
// compared to exhausting all roots per format.
166230
for root in search_functions
167231
.iter()
168232
.filter_map(|search_function| search_function(root_path))
169233
{
170-
// Needs borrow because of the loop.
171-
#[allow(clippy::needless_borrow)]
172-
let bin_path = root.join_components(&platform_package_executable_path_components);
173-
match fs_canonicalize(&bin_path) {
174-
Ok(bin_path) => {
175-
let resolved_package_json_path =
176-
root.join_components(&platform_package_json_path_components);
177-
let platform_package_json =
178-
PackageJson::load(&resolved_package_json_path).ok()?;
179-
let local_version = platform_package_json.version?;
180-
181-
debug!("Local turbo path: {}", bin_path.display());
182-
debug!("Local turbo version: {}", &local_version);
183-
return Some(Self {
184-
bin_path,
185-
version: local_version,
186-
});
234+
for package_path in package_paths {
235+
if let Some(state) = Self::try_probe_binary(&root, package_path, binary_name) {
236+
return Some(state);
187237
}
188-
Err(_) => debug!("No local turbo binary found at: {}", bin_path),
238+
}
239+
}
240+
241+
// Unplugged strategy (Berry 2.1+): directory scanning is
242+
// package-name-aware because Berry encodes the identity in the
243+
// directory name. Read the unplugged base path once to avoid
244+
// re-parsing .yarnrc.yml.
245+
let unplugged_base_path = Self::get_unplugged_base_path(root_path);
246+
for package_path in package_paths {
247+
// Berry unplugged dirs use `{name}-npm-{version}-{hash}`.
248+
// For scoped `@turbo/linux-64` this becomes `@turbo-linux-64-npm-...`.
249+
let unplugged_prefix = package_path.join("-");
250+
if let Some(root) = Self::find_in_unplugged(&unplugged_base_path, &unplugged_prefix)
251+
&& let Some(state) = Self::try_probe_binary(&root, package_path, binary_name)
252+
{
253+
return Some(state);
189254
}
190255
}
191256

@@ -233,6 +298,7 @@ pub fn turbo_version_has_shim(version: &str) -> bool {
233298

234299
#[cfg(test)]
235300
mod test {
301+
use tempfile::TempDir;
236302
use test_case::test_case;
237303

238304
use super::*;
@@ -252,4 +318,144 @@ mod test {
252318
fn test_skip_infer_version_constraint(version: &str, expected: bool) {
253319
assert_eq!(turbo_version_has_shim(version), expected);
254320
}
321+
322+
fn create_mock_turbo_install(root: &AbsoluteSystemPath, package_path: &[&str], version: &str) {
323+
let binary_name = TurboState::binary_name();
324+
325+
let mut bin_components: Vec<&str> = package_path.to_vec();
326+
bin_components.extend_from_slice(&["bin", binary_name]);
327+
let bin_file = root.join_components(&bin_components);
328+
bin_file.ensure_dir().unwrap();
329+
bin_file.create_with_contents("").unwrap();
330+
331+
let mut json_components: Vec<&str> = package_path.to_vec();
332+
json_components.push("package.json");
333+
let json_file = root.join_components(&json_components);
334+
json_file.ensure_dir().unwrap();
335+
json_file
336+
.create_with_contents(format!(
337+
r#"{{"name": "test-turbo", "version": "{}"}}"#,
338+
version,
339+
))
340+
.unwrap();
341+
}
342+
343+
fn scoped_path() -> Vec<&'static str> {
344+
vec![
345+
TurboState::scoped_platform_package_scope(),
346+
TurboState::scoped_platform_package_dir(),
347+
]
348+
}
349+
350+
fn legacy_path() -> Vec<&'static str> {
351+
vec![TurboState::platform_package_name()]
352+
}
353+
354+
#[test]
355+
fn test_infer_hoisted_scoped() {
356+
let tmpdir = TempDir::with_prefix("turbo_infer").unwrap();
357+
let root = AbsoluteSystemPath::from_std_path(tmpdir.path()).unwrap();
358+
let nm = root.join_component("node_modules");
359+
360+
create_mock_turbo_install(&nm, &scoped_path(), "2.0.0");
361+
362+
let result = LocalTurboState::infer(root).unwrap();
363+
assert_eq!(result.version(), "2.0.0");
364+
}
365+
366+
#[test]
367+
fn test_infer_hoisted_legacy_fallback() {
368+
let tmpdir = TempDir::with_prefix("turbo_infer").unwrap();
369+
let root = AbsoluteSystemPath::from_std_path(tmpdir.path()).unwrap();
370+
let nm = root.join_component("node_modules");
371+
372+
create_mock_turbo_install(&nm, &legacy_path(), "1.9.0");
373+
374+
let result = LocalTurboState::infer(root).unwrap();
375+
assert_eq!(result.version(), "1.9.0");
376+
}
377+
378+
#[test]
379+
fn test_infer_scoped_preferred_over_legacy() {
380+
let tmpdir = TempDir::with_prefix("turbo_infer").unwrap();
381+
let root = AbsoluteSystemPath::from_std_path(tmpdir.path()).unwrap();
382+
let nm = root.join_component("node_modules");
383+
384+
create_mock_turbo_install(&nm, &scoped_path(), "3.0.0");
385+
create_mock_turbo_install(&nm, &legacy_path(), "2.0.0");
386+
387+
let result = LocalTurboState::infer(root).unwrap();
388+
assert_eq!(result.version(), "3.0.0");
389+
}
390+
391+
#[test]
392+
fn test_infer_empty_dir_returns_none() {
393+
let tmpdir = TempDir::with_prefix("turbo_infer").unwrap();
394+
let root = AbsoluteSystemPath::from_std_path(tmpdir.path()).unwrap();
395+
assert!(LocalTurboState::infer(root).is_none());
396+
}
397+
398+
#[test]
399+
fn test_infer_malformed_package_json_continues_search() {
400+
let tmpdir = TempDir::with_prefix("turbo_infer").unwrap();
401+
let root = AbsoluteSystemPath::from_std_path(tmpdir.path()).unwrap();
402+
let nm = root.join_component("node_modules");
403+
404+
let scoped = scoped_path();
405+
let binary_name = TurboState::binary_name();
406+
407+
// Create scoped binary but with invalid package.json
408+
let mut bin_components: Vec<&str> = scoped.clone();
409+
bin_components.extend_from_slice(&["bin", binary_name]);
410+
let bin_file = nm.join_components(&bin_components);
411+
bin_file.ensure_dir().unwrap();
412+
bin_file.create_with_contents("").unwrap();
413+
414+
let mut json_components: Vec<&str> = scoped.clone();
415+
json_components.push("package.json");
416+
let json_file = nm.join_components(&json_components);
417+
json_file.ensure_dir().unwrap();
418+
json_file.create_with_contents("not valid json").unwrap();
419+
420+
// Create valid legacy install
421+
create_mock_turbo_install(&nm, &legacy_path(), "1.8.0");
422+
423+
// Should fall through to legacy despite scoped binary existing
424+
let result = LocalTurboState::infer(root).unwrap();
425+
assert_eq!(result.version(), "1.8.0");
426+
}
427+
428+
#[test]
429+
fn test_infer_unplugged_scoped() {
430+
let tmpdir = TempDir::with_prefix("turbo_infer").unwrap();
431+
let root = AbsoluteSystemPath::from_std_path(tmpdir.path()).unwrap();
432+
433+
// Berry unplugged dirs use `{identity}-npm-{version}-{hash}`.
434+
// For @turbo/linux-64 → @turbo-linux-64-npm-2.1.0-abc123
435+
let scoped_dir = TurboState::scoped_platform_package_dir();
436+
let unplugged_dir_name = format!("@turbo-{}-npm-2.1.0-abc123", scoped_dir);
437+
438+
let unplugged_nm =
439+
root.join_components(&[".yarn", "unplugged", &unplugged_dir_name, "node_modules"]);
440+
create_mock_turbo_install(&unplugged_nm, &scoped_path(), "2.1.0");
441+
442+
let result = LocalTurboState::infer(root).unwrap();
443+
assert_eq!(result.version(), "2.1.0");
444+
}
445+
446+
#[test]
447+
fn test_infer_unplugged_legacy() {
448+
let tmpdir = TempDir::with_prefix("turbo_infer").unwrap();
449+
let root = AbsoluteSystemPath::from_std_path(tmpdir.path()).unwrap();
450+
451+
let platform_package_name = TurboState::platform_package_name();
452+
let unplugged_dir_name = format!("{}-npm-1.9.0-def456", platform_package_name);
453+
454+
let unplugged_nm =
455+
root.join_components(&[".yarn", "unplugged", &unplugged_dir_name, "node_modules"]);
456+
create_mock_turbo_install(&unplugged_nm, &legacy_path(), "1.9.0");
457+
458+
let result = LocalTurboState::infer(root).unwrap();
459+
assert_eq!(result.version(), "1.9.0");
460+
}
255461
}

crates/turborepo-shim/src/turbo_state.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ impl TurboState {
4848
formatcp!("turbo-{}", TurboState::platform_name())
4949
}
5050

51+
/// Scope segment for `@turbo/{platform}` packages. Split from dir to
52+
/// avoid `/` in a single `join_components` segment (which debug-asserts).
53+
pub const fn scoped_platform_package_scope() -> &'static str {
54+
"@turbo"
55+
}
56+
57+
/// Directory segment under the scope (e.g. `"linux-64"`).
58+
pub const fn scoped_platform_package_dir() -> &'static str {
59+
TurboState::platform_name()
60+
}
61+
5162
pub const fn binary_name() -> &'static str {
5263
{
5364
#[cfg(windows)]
@@ -68,3 +79,26 @@ impl TurboState {
6879
.expect("Failed to read version from version.txt")
6980
}
7081
}
82+
83+
#[cfg(test)]
84+
mod test {
85+
use super::*;
86+
87+
#[test]
88+
fn test_scoped_package_path_segments_have_no_separators() {
89+
let scope = TurboState::scoped_platform_package_scope();
90+
let dir = TurboState::scoped_platform_package_dir();
91+
assert!(
92+
scope.starts_with('@'),
93+
"scope must start with '@' for npm scoped packages"
94+
);
95+
assert!(
96+
!scope.contains('/') && !scope.contains('\\'),
97+
"scope segment must not contain path separators (join_components constraint)"
98+
);
99+
assert!(
100+
!dir.contains('/') && !dir.contains('\\'),
101+
"dir segment must not contain path separators (join_components constraint)"
102+
);
103+
}
104+
}

0 commit comments

Comments
 (0)