From 67b8e857cc5b76181fa3a407d2d833b0f335c775 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:23:48 +0000 Subject: [PATCH 1/3] Start fix for #1904 Assisted-by: Claude Code:claude-opus-5-5 From 9106f378606c622d78b077fbf482fdeca8ed5ff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:31:41 +0000 Subject: [PATCH 2/3] Name the workspace root in vendored no-lock refusal Running a vendored scan, get or vendor from a workspace member with no lockfile of its own refused with "no lockfile ... run your package manager's install first". Following that advice only updated the workspace root's lock, so the next run refused the same way. The vendored refusal now keeps its vendor_lockfile_missing code but says which lock installs the member and which directory to vendor from, the same lookup hosted mode's refusal already used. The lookup (package.json and vlt workspaces, pnpm workspace or lockfile-dir) moves out of the hosted refusal into one shared helper; hosted messages are unchanged. Fixes #1904 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/vendor/vendor_failure_paths.rs | 46 +++++ .../src/hosted/governing_root.rs | 178 +++++++++++------- .../src/vendor/npm_flavor.rs | 96 +++++++++- 3 files changed, 247 insertions(+), 73 deletions(-) diff --git a/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs index 8934b7608..0d448f7f6 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs @@ -176,6 +176,52 @@ fn missing_package_with_no_lock_and_no_ledger_fails_before_any_fetch() { ); } +/// #1904: run from an npm workspace member with no lock of its own, the +/// vendored refusal keeps `vendor_lockfile_missing` but names the +/// workspace root to vendor from, instead of the "run your package +/// manager's install first" remedy (an install in the member only +/// updates the root lock, so that advice loops). Nothing is written. +#[test] +fn lockless_workspace_member_refusal_names_the_workspace_root() { + let fx = npm_fixture(); + let root = fx.root(); + let member = root.join("packages/a"); + std::fs::create_dir_all(&member).unwrap(); + for entry in ["node_modules", ".socket", "package.json"] { + std::fs::rename(root.join(entry), member.join(entry)).unwrap(); + } + std::fs::write( + root.join("package.json"), + br#"{"name":"app","version":"1.0.0","private":true,"workspaces":["packages/*"]}"#, + ) + .unwrap(); + + let (code, env) = vendor_cli(&member, &["--offline"]); + assert_eq!(code, 1, "the member run must fail closed: {env:#}"); + let failed = find_event(&env, "failed", Some("vendor_lockfile_missing")); + assert_eq!(failed["purl"], PURL); + let detail = failed["error"].as_str().unwrap_or_default(); + let canonical = std::fs::canonicalize(root).unwrap(); + assert!( + detail.contains(&format!("vendor from {}", canonical.display())) + && detail.contains("workspace member"), + "the refusal names the workspace root: {env:#}" + ); + assert!( + !detail.contains("install first"), + "no looping install remedy: {env:#}" + ); + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "the root lock is untouched" + ); + assert!( + !member.join(".socket/vendor").exists(), + "nothing is vendored into the member" + ); +} + // ───────────────────────────────────────────────────────────────────── // 3. hosted-pin takeover guard (v5: lockfile pins, no redirect ledger) // ───────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 8d3d37075..0bff496de 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -106,41 +106,12 @@ pub async fn refusal( } } if candidates.iter().any(|c| c.dep.ecosystem == "npm") { - let workspace = if has_own_npm_family_lock(root) { - member_stray_lock_refusal(root).await - } else { - nearer_root( - package_json_workspace_refusal(root).await, - vlt_workspace_refusal(root).await, - ) - }; - if let Some(lock) = pnpm_lock_elsewhere(root).await { - let dir = lock.parent().unwrap_or(&lock); - // A `package.json` workspace root nested inside the pnpm lock's - // directory is nearer the member and owns its lock (Bugbot on - // #901); otherwise pnpm's workspace or `lockfile-dir` governs. - if let Some((ws_root, refusal)) = workspace { - let pnpm_dir = tokio::fs::canonicalize(dir) - .await - .unwrap_or_else(|_| dir.to_path_buf()); - if ws_root != pnpm_dir && ws_root.starts_with(&pnpm_dir) { - return Some(refusal); - } + if has_own_npm_family_lock(root) { + if let Some(refusal) = member_stray_lock_refusal(root).await { + return Some(refusal); } - return Some(Refusal { - code: PNPM_LOCKFILE_ELSEWHERE.to_string(), - message: format!( - "{} has no lockfile of its own: pnpm installs it from {}, which a \ - hosted run here cannot see; run socket-patch from {} (the directory \ - holding pnpm-lock.yaml); nothing was written", - root.display(), - lock.display(), - dir.display() - ), - }); - } - if let Some((_, refusal)) = workspace { - return Some(refusal); + } else if let Some(elsewhere) = npm_lock_elsewhere(root).await { + return Some(elsewhere.hosted_refusal()); } if trust_lockfile_config { if let Some(refusal) = pnpm_settings_elsewhere(root) { @@ -204,6 +175,79 @@ async fn cargo_member_refusal(root: &Path) -> Option { }) } +/// A project directory with no npm-family lock of its own that its +/// package manager installs from a lock in another directory: a member of +/// a `package.json` or vlt workspace, or a directory pnpm installs from an +/// ancestor workspace's (or `lockfile-dir`'s) `pnpm-lock.yaml`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LockElsewhere { + /// The hosted refusal code. + code: &'static str, + /// The directory to run from. + pub(crate) dir: PathBuf, + /// What [`Self::dir`] is, for the "run from" hint. + pub(crate) dir_kind: &'static str, + /// Which lock installs the project, without a remedy. + pub(crate) detail: String, +} + +impl LockElsewhere { + /// The hosted refusal: a hosted run here would pin a lock nothing reads. + fn hosted_refusal(&self) -> Refusal { + Refusal { + code: self.code.to_string(), + message: format!( + "{}, which a hosted run here cannot see; run socket-patch from {} ({}); \ + nothing was written", + self.detail, + self.dir.display(), + self.dir_kind + ), + } + } +} + +/// `Some` when `root` holds no npm-family lock of its own and its package +/// manager installs it from a lock in another directory (see +/// [`LockElsewhere`]). Shared by the hosted refusal and the vendored +/// `vendor_lockfile_missing` refusal (#1904), so both name the directory +/// to run from. Of a `package.json` or vlt workspace root and a pnpm lock +/// directory, the nearer one governs; a tie goes to pnpm. +pub(crate) async fn npm_lock_elsewhere(root: &Path) -> Option { + if has_own_npm_family_lock(root) { + return None; + } + let workspace = nearer_root( + package_json_workspace_elsewhere(root).await, + vlt_workspace_elsewhere(root).await, + ); + let Some(lock) = pnpm_lock_elsewhere(root).await else { + return workspace; + }; + let dir = lock.parent().unwrap_or(&lock).to_path_buf(); + // A `package.json` workspace root nested inside the pnpm lock's + // directory is nearer the member and owns its lock (Bugbot on #901); + // otherwise pnpm's workspace or `lockfile-dir` governs. + if let Some(ws) = workspace { + let pnpm_dir = tokio::fs::canonicalize(&dir) + .await + .unwrap_or_else(|_| dir.clone()); + if ws.dir != pnpm_dir && ws.dir.starts_with(&pnpm_dir) { + return Some(ws); + } + } + Some(LockElsewhere { + code: PNPM_LOCKFILE_ELSEWHERE, + detail: format!( + "{} has no lockfile of its own: pnpm installs it from {}", + root.display(), + lock.display() + ), + dir, + dir_kind: "the directory holding pnpm-lock.yaml", + }) +} + /// The `pnpm-lock.yaml` pnpm reads for a project directory that holds no /// npm-family lock of its own, when it lives elsewhere and exists: /// @@ -289,26 +333,23 @@ const WORKSPACE_ROOT_FAMILIES: [NpmLockFamily; 3] = /// lock may itself be a member of an outer workspace (yarn berry's nested /// worktrees), so the walk goes on with that root as the member and /// refuses at the first root that holds an npm, yarn or Bun lock; a chain -/// that ends without one (never installed), or at a Rush root, refuses -/// nothing. Returns the -/// governing root with the refusal, so [`refusal`] can weigh it against -/// the pnpm check (the nearer root wins; a tie goes to pnpm's message). -async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { +/// that ends without one (never installed), or at a Rush root, finds +/// nothing. [`npm_lock_elsewhere`] weighs the root against the pnpm check +/// (the nearer root wins; a tie goes to pnpm). +async fn package_json_workspace_elsewhere(root: &Path) -> Option { let (ancestor, locks) = package_json_workspace_root(root).await?; - let refusal = Refusal { - code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), - message: format!( + Some(LockElsewhere { + code: WORKSPACE_LOCKFILE_ELSEWHERE, + detail: format!( "{} is a workspace member with no lockfile of its own: the workspace \ - root {} lists it under \"workspaces\" and installs it from {}, which a \ - hosted run here cannot see; run socket-patch from {} (the workspace \ - root); nothing was written", + root {} lists it under \"workspaces\" and installs it from {}", root.display(), ancestor.display(), join_paths(&ancestor, &locks), - ancestor.display() ), - }; - Some((ancestor, refusal)) + dir: ancestor, + dir_kind: "the workspace root", + }) } /// A workspace member's own locks that its package manager never reads @@ -697,9 +738,9 @@ fn pnpm_lists_unmodeled(globs: &[String], rel: &[String]) -> bool { } /// The hosted refusal for [`member_stray_lock`]. -async fn member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { +async fn member_stray_lock_refusal(root: &Path) -> Option { let stray = member_stray_lock(root).await?; - let refusal = Refusal { + Some(Refusal { code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), message: format!( "{}; run socket-patch from {} (the {}); nothing was written", @@ -707,8 +748,7 @@ async fn member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { stray.root.display(), stray.root_kind ), - }; - Some((stray.root, refusal)) + }) } fn join_paths(dir: &Path, names: &[&str]) -> String { @@ -720,7 +760,7 @@ fn join_paths(dir: &Path, names: &[&str]) -> String { } /// The governing `package.json` workspace root of a member and the -/// workspace locks it holds (see [`package_json_workspace_refusal`]). +/// workspace locks it holds (see [`package_json_workspace_elsewhere`]). async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'static str>)> { let canonical = tokio::fs::canonicalize(root) .await @@ -933,25 +973,22 @@ async fn read_head(path: &Path) -> String { /// would find the member's copy, pin nothing and report success. The /// nearest ancestor whose patterns match is the root; one without a lock /// (never installed) refuses nothing. -async fn vlt_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { +async fn vlt_workspace_elsewhere(root: &Path) -> Option { let (ancestor, source) = vlt_workspace_root(root).await?; - let lock = ancestor.join(VLT_LOCK); - let refusal = Refusal { - code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), - message: format!( + Some(LockElsewhere { + code: WORKSPACE_LOCKFILE_ELSEWHERE, + detail: format!( "{} is a workspace member with no lockfile of its own: the workspace \ - root {} lists it under \"{}\" in {} and installs it from {}, \ - which a hosted run here cannot see; run socket-patch from {} (the \ - workspace root); nothing was written", + root {} lists it under \"{}\" in {} and installs it from {}", root.display(), ancestor.display(), source.field(), ancestor.join(source.file()).display(), - lock.display(), - ancestor.display() + ancestor.join(VLT_LOCK).display(), ), - }; - Some((ancestor, refusal)) + dir: ancestor, + dir_kind: "the workspace root", + }) } /// The file a vlt workspace root lists its members in. @@ -982,7 +1019,7 @@ impl VltWorkspaceSource { /// The nearest ancestor whose vlt workspace patterns ([`vlt_workspace_groups`]) /// match the project directory, when it holds `vlt-lock.json` (see -/// [`vlt_workspace_refusal`]), with the file that lists the member. +/// [`vlt_workspace_elsewhere`]), with the file that lists the member. async fn vlt_workspace_root(root: &Path) -> Option<(PathBuf, VltWorkspaceSource)> { let canonical = tokio::fs::canonicalize(root) .await @@ -1160,13 +1197,10 @@ async fn vlt_json_declares_workspaces(dir: &Path) -> bool { .is_some() } -/// The refusal of the nearer (deeper) of two governing roots. -fn nearer_root( - a: Option<(PathBuf, Refusal)>, - b: Option<(PathBuf, Refusal)>, -) -> Option<(PathBuf, Refusal)> { +/// The nearer (deeper) of two governing roots; a tie goes to `a`. +fn nearer_root(a: Option, b: Option) -> Option { match (a, b) { - (Some(a), Some(b)) => Some(if b.0.starts_with(&a.0) && b.0 != a.0 { + (Some(a), Some(b)) => Some(if b.dir.starts_with(&a.dir) && b.dir != a.dir { b } else { a diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 153255f5a..360fefd3b 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -370,7 +370,16 @@ async fn detect_vendorable_npm_flavor_with( project_root: &Path, linker: impl FnOnce() -> Option, ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { - let found = detect_npm_lock_flavor(project_root).await?; + let found = match detect_npm_lock_flavor(project_root).await { + Ok(found) => found, + Err(("vendor_lockfile_missing", detail)) => { + return Err(( + "vendor_lockfile_missing", + lock_elsewhere_detail(project_root).await.unwrap_or(detail), + )); + } + Err(refusal) => return Err(refusal), + }; if found.0 != NpmLockFlavor::YarnBerry { return Ok(found); } @@ -400,6 +409,23 @@ async fn detect_vendorable_npm_flavor_with( )) } +/// #1904: the `vendor_lockfile_missing` detail for a project with no lock +/// of its own that its package manager installs from a lock in another +/// directory (a workspace member, or a pnpm `lockfile-dir`), naming the +/// directory to vendor from. Running install in the member only updates +/// that other lock, so the generic "run install first" remedy would loop. +/// `None` when no other directory governs the project. Hosted's +/// `redirect_*_lockfile_elsewhere` refusal reads the same lookup. +async fn lock_elsewhere_detail(project_root: &Path) -> Option { + let elsewhere = crate::hosted::governing_root::npm_lock_elsewhere(project_root).await?; + Some(format!( + "{}, so there is no lockfile here for vendoring to rewire; vendor from {} ({})", + elsewhere.detail, + elsewhere.dir.display(), + elsewhere.dir_kind + )) +} + /// A workspace member whose own lock its package manager never reads /// (npm #1094, Bun #1101, vlt #1134: members install from the workspace /// root's lock), so vendoring into it would wire nothing. Refused as a @@ -1076,6 +1102,74 @@ mod tests { tokio::fs::write(root.join(name), content).await.unwrap(); } + /// #1904: a workspace member with no lock of its own is installed from + /// the workspace root's lock. The vendored refusal keeps its code but + /// names that root, as hosted's refusal and the stray-lock refusal + /// (#1094) do, instead of a "run install first" remedy that loops. + #[tokio::test] + async fn lockless_workspace_member_refusal_names_the_workspace_root() { + for lock in ["package-lock.json", "yarn.lock", "bun.lock"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + touch( + root, + "package.json", + r#"{"name":"app","private":true,"workspaces":["packages/*"]}"#, + ) + .await; + touch(root, lock, "").await; + let member = root.join("packages/a"); + tokio::fs::create_dir_all(&member).await.unwrap(); + touch(&member, "package.json", r#"{"name":"a"}"#).await; + + let (code, detail) = detect_vendorable_npm_flavor(&member).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_missing", "{lock}"); + let canonical = tokio::fs::canonicalize(root).await.unwrap(); + assert!( + detail.contains(&format!("vendor from {}", canonical.display())) + && detail.contains("(the workspace root)") + && detail.contains(&canonical.join(lock).display().to_string()) + && detail.contains("workspace member"), + "{lock}: {detail}" + ); + assert!(!detail.contains("install first"), "{lock}: {detail}"); + } + } + + /// #1904, pnpm: a pnpm workspace member is installed from the root's + /// pnpm-lock.yaml; the refusal names the directory holding it. + #[tokio::test] + async fn lockless_pnpm_member_refusal_names_the_lock_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + touch(root, "pnpm-workspace.yaml", "packages:\n - packages/*\n").await; + touch(root, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n").await; + let member = root.join("packages/a"); + tokio::fs::create_dir_all(&member).await.unwrap(); + touch(&member, "package.json", r#"{"name":"a"}"#).await; + + let (code, detail) = detect_vendorable_npm_flavor(&member).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_missing"); + let canonical = tokio::fs::canonicalize(root).await.unwrap(); + assert!( + detail.contains(&format!("vendor from {}", canonical.display())) + && detail.contains("pnpm-lock.yaml"), + "{detail}" + ); + } + + /// A lockless project that no workspace governs keeps the generic + /// "run your package manager's install first" refusal. + #[tokio::test] + async fn lockless_standalone_project_keeps_the_install_remedy() { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "package.json", r#"{"name":"solo"}"#).await; + let (code, detail) = detect_vendorable_npm_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_missing"); + assert!(detail.contains("install first"), "{detail}"); + assert!(!detail.contains("vendor from"), "{detail}"); + } + const YARN_V1: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ # yarn lockfile v1\n\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n"; const YARN_BERRY: &str = From 8a97c8ddd6715e7d056951ce06bbd1d17d75dd6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:53:21 +0000 Subject: [PATCH 3/3] Tailor the member refusal to what vendor can do Review follow-ups to the workspace-member refusal: - A pnpm project whose lock lives elsewhere through lockfile-dir was told to vendor from the lock's directory, which holds no project. It now names the setting and points at hosted mode instead. - The message showed "." for the default --cwd; it now shows the member's own path. - Callers that keep only the refusal code skip the workspace lookup. - The run-from wording follows the stray-lock refusal's convention. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 19 +++- .../src/vendor/npm_flavor.rs | 97 ++++++++++++++----- 2 files changed, 87 insertions(+), 29 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 0bff496de..c24cca7fa 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -185,8 +185,14 @@ pub(crate) struct LockElsewhere { code: &'static str, /// The directory to run from. pub(crate) dir: PathBuf, - /// What [`Self::dir`] is, for the "run from" hint. + /// What [`Self::dir`] is, for the "run from" hint (`workspace root`; + /// worded like [`MemberStrayLock::root_kind`]). pub(crate) dir_kind: &'static str, + /// Whether a vendored run from [`Self::dir`] would wire the project: + /// `true` for a workspace root, which installs its members, and + /// `false` for a pnpm `lockfile-dir` outside any pnpm workspace, which + /// holds only the lock. + pub(crate) vendorable_dir: bool, /// Which lock installs the project, without a remedy. pub(crate) detail: String, } @@ -197,7 +203,7 @@ impl LockElsewhere { Refusal { code: self.code.to_string(), message: format!( - "{}, which a hosted run here cannot see; run socket-patch from {} ({}); \ + "{}, which a hosted run here cannot see; run socket-patch from {} (the {}); \ nothing was written", self.detail, self.dir.display(), @@ -243,8 +249,9 @@ pub(crate) async fn npm_lock_elsewhere(root: &Path) -> Option { root.display(), lock.display() ), + vendorable_dir: dir.join(PNPM_WORKSPACE).is_file(), dir, - dir_kind: "the directory holding pnpm-lock.yaml", + dir_kind: "directory holding pnpm-lock.yaml", }) } @@ -348,7 +355,8 @@ async fn package_json_workspace_elsewhere(root: &Path) -> Option join_paths(&ancestor, &locks), ), dir: ancestor, - dir_kind: "the workspace root", + dir_kind: "workspace root", + vendorable_dir: true, }) } @@ -987,7 +995,8 @@ async fn vlt_workspace_elsewhere(root: &Path) -> Option { ancestor.join(VLT_LOCK).display(), ), dir: ancestor, - dir_kind: "the workspace root", + dir_kind: "workspace root", + vendorable_dir: true, }) } diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 360fefd3b..c3393179b 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -355,8 +355,27 @@ async fn sniff_yarn_lock(view: &ProjectView<'_>) -> Result Result<(NpmLockFlavor, Vec), (&'static str, String)> { + match detect_vendorable_npm_flavor_code(project_root).await { + Err(("vendor_lockfile_missing", detail)) => Err(( + "vendor_lockfile_missing", + lock_elsewhere_detail(project_root).await.unwrap_or(detail), + )), + found => found, + } +} + +/// [`detect_vendorable_npm_flavor`] for callers that keep only the +/// refusal code: the detail is the probe's own. +async fn detect_vendorable_npm_flavor_code( + project_root: &Path, ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { detect_vendorable_npm_flavor_with(project_root, || { crate::crawlers::pkg_managers::yarn_node_linker(project_root) @@ -370,16 +389,7 @@ async fn detect_vendorable_npm_flavor_with( project_root: &Path, linker: impl FnOnce() -> Option, ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { - let found = match detect_npm_lock_flavor(project_root).await { - Ok(found) => found, - Err(("vendor_lockfile_missing", detail)) => { - return Err(( - "vendor_lockfile_missing", - lock_elsewhere_detail(project_root).await.unwrap_or(detail), - )); - } - Err(refusal) => return Err(refusal), - }; + let found = detect_npm_lock_flavor(project_root).await?; if found.0 != NpmLockFlavor::YarnBerry { return Ok(found); } @@ -411,19 +421,35 @@ async fn detect_vendorable_npm_flavor_with( /// #1904: the `vendor_lockfile_missing` detail for a project with no lock /// of its own that its package manager installs from a lock in another -/// directory (a workspace member, or a pnpm `lockfile-dir`), naming the -/// directory to vendor from. Running install in the member only updates -/// that other lock, so the generic "run install first" remedy would loop. -/// `None` when no other directory governs the project. Hosted's -/// `redirect_*_lockfile_elsewhere` refusal reads the same lookup. +/// directory (a workspace member, or a pnpm `lockfile-dir`). Running +/// install in the project only updates that other lock, so the generic +/// "run install first" remedy would loop: a workspace member is told to +/// vendor from the workspace root, a `lockfile-dir` project that vendor +/// cannot wire it. `None` when no other directory governs the project. +/// Hosted's `redirect_*_lockfile_elsewhere` refusal reads the same lookup. async fn lock_elsewhere_detail(project_root: &Path) -> Option { - let elsewhere = crate::hosted::governing_root::npm_lock_elsewhere(project_root).await?; - Some(format!( - "{}, so there is no lockfile here for vendoring to rewire; vendor from {} ({})", - elsewhere.detail, - elsewhere.dir.display(), - elsewhere.dir_kind - )) + // The member's own path, not the default `--cwd` `.`. + let shown = tokio::fs::canonicalize(project_root) + .await + .unwrap_or_else(|_| project_root.to_path_buf()); + let elsewhere = crate::hosted::governing_root::npm_lock_elsewhere(&shown).await?; + Some(if elsewhere.vendorable_dir { + format!( + "{}, so there is no lockfile here for vendoring to rewire; vendor from {} (the {})", + elsewhere.detail, + elsewhere.dir.display(), + elsewhere.dir_kind + ) + } else { + format!( + "{}; vendor does not rewire a pnpm lock kept in another directory \ + (`lockfile-dir`): use `socket-patch scan --mode hosted` from {} (the {}), or \ + remove `lockfile-dir` and run `pnpm install` to write a lock here", + elsewhere.detail, + elsewhere.dir.display(), + elsewhere.dir_kind + ) + }) } /// A workspace member whose own lock its package manager never reads @@ -650,7 +676,7 @@ pub async fn preflight_packages( project_root: &Path, packages: &[(&str, &PatchRecord)], ) -> Vec> { - let flavor = match detect_vendorable_npm_flavor(project_root).await { + let flavor = match detect_vendorable_npm_flavor_code(project_root).await { Ok((flavor, _probe_warnings)) => flavor, Err((code, _detail)) => return vec![Err(code); packages.len()], }; @@ -689,7 +715,7 @@ pub async fn lock_text_refusals( project_root: &Path, packages: &[(&str, &PatchRecord)], ) -> Vec> { - let flavor = match detect_vendorable_npm_flavor(project_root).await { + let flavor = match detect_vendorable_npm_flavor_code(project_root).await { Ok((flavor, _)) => flavor, Err(_) => return vec![None; packages.len()], }; @@ -1158,6 +1184,29 @@ mod tests { ); } + /// #1904, pnpm `lockfile-dir` outside a pnpm workspace: the lock's + /// directory holds no project, so the refusal names the setting and + /// hosted mode instead of telling the user to vendor from there. + #[tokio::test] + async fn lockfile_dir_project_refusal_names_the_setting_not_a_dead_end() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + touch(root, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n").await; + let proj = root.join("proj"); + tokio::fs::create_dir_all(&proj).await.unwrap(); + touch(&proj, "package.json", r#"{"name":"p"}"#).await; + touch(&proj, ".npmrc", "lockfile-dir=..\n").await; + + let (code, detail) = detect_vendorable_npm_flavor(&proj).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_missing"); + assert!( + detail.contains("lockfile-dir") && detail.contains("scan --mode hosted"), + "{detail}" + ); + assert!(!detail.contains("vendor from"), "{detail}"); + assert!(!detail.contains("install first"), "{detail}"); + } + /// A lockless project that no workspace governs keeps the generic /// "run your package manager's install first" refusal. #[tokio::test]