From a0f58c680ea01abdee9326a33a620fc590dba6b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:23:45 +0000 Subject: [PATCH 1/4] Start fix for #1564 Assisted-by: Claude Code:claude-opus-5-5 From 84c671ae8e2f9b1ff48acf41ba88fae4da21e5b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:34:22 +0000 Subject: [PATCH 2/4] Test vlt 1.4 pnpm-workspace.yaml members (#1564) vlt 1.4 installs a member listed by the root pnpm-workspace.yaml packages: from the root vlt-lock.json when neither vlt.json nor package.json declares workspaces. These tests show hosted, vendored and VEX runs from such a member treating it as its own project, and pin the vlt 1.3.8 and precedence controls that must stay as they are. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 191 ++++++++++++++++++ .../src/vendor/npm_flavor.rs | 14 ++ .../socket-patch-core/src/vex/discover/mod.rs | 14 ++ 3 files changed, 219 insertions(+) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 42032f9ff..6274d40c4 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -2530,6 +2530,197 @@ mod tests { assert_eq!(code(&tmp.path().join("packages/a"), "npm").await, None); } + /// A `vlt-lock.json` whose edges leave from the given dep ids. + fn vlt_lock_with_edges(from: &[&str]) -> String { + let edges: Vec = from + .iter() + .map(|id| format!(r#""{id} left-pad": "prod 1.3.0 ~npm~left-pad@1.3.0""#)) + .collect(); + format!( + "{{\n \"lockfileVersion\": 1,\n \"options\": {{}},\n \"nodes\": {{}},\n \"edges\": {{\n {}\n }}\n}}\n", + edges.join(",\n ") + ) + } + + /// #1564: vlt 1.4 reads a root `pnpm-workspace.yaml`'s `packages:` when + /// neither `vlt.json` nor `package.json` has a `workspaces` field, and + /// installs a member it lists from the root's `vlt-lock.json`. A member + /// is refused with or without a stray lock of its own, whether or not + /// the root has a `vlt.json` at all. + #[tokio::test] + async fn vlt_pnpm_workspace_yaml_member_is_refused() { + for vlt_json in [None, Some(r#"{"registries":{}}"#)] { + for stray in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", r#"{"private":true}"#); + if let Some(text) = vlt_json { + write(tmp.path(), VLT_JSON, text); + } + write( + tmp.path(), + PNPM_WORKSPACE, + "packages:\n - \"packages/*\"\n", + ); + write( + tmp.path(), + VLT_LOCK, + &vlt_lock_with_edges(&["file~_d", "workspace~packages+a"]), + ); + write(tmp.path(), "packages/a/package.json", "{}"); + if stray { + write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + } + let member = tmp.path().join("packages/a"); + let case = format!("vlt.json: {vlt_json:?}, stray: {stray}"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .unwrap_or_else(|| panic!("{case}: member must be refused")); + assert_eq!(refused.code, WORKSPACE_LOCKFILE_ELSEWHERE, "{case}"); + assert!( + refused.message.contains(VLT_LOCK) + && refused.message.contains("nothing was written"), + "{case}: {}", + refused.message + ); + if !stray { + assert!( + refused.message.contains(PNPM_WORKSPACE), + "{case}: {}", + refused.message + ); + } + assert_eq!(member_stray_lock(&member).await.is_some(), stray, "{case}"); + assert_eq!(code(tmp.path(), "npm").await, None, "{case}"); + + // A member with its own vlt.json is its own vlt project. + write(tmp.path(), "packages/a/vlt.json", "{}"); + assert!(member_stray_lock(&member).await.is_none(), "{case}"); + if stray { + assert_eq!(code(&member, "npm").await, None, "{case}"); + } + } + } + } + + /// #1564: vlt 1.3.8 and older ignore `pnpm-workspace.yaml`, so a lock + /// with no edge from any directory its `packages:` list is theirs and + /// the directory is its own project. vlt 1.4 records an edge from every + /// member it installed that has a dependency. `vlt.json` and + /// `package.json` `workspaces` fields win over the file, as does a `!` + /// pattern in it; a root that was never installed governs nothing; a + /// lock that cannot be read keeps the refusing side. + #[tokio::test] + async fn vlt_pnpm_workspace_yaml_reads_the_root_lock() { + let listed = "packages:\n - \"packages/*\"\n"; + for (case, vlt_json, package_json, yaml, root_lock, refused) in [ + ( + "vlt 1.4 lock", + None, + r#"{"private":true}"#, + listed, + Some(vlt_lock_with_edges(&["workspace~packages+b"])), + true, + ), + ( + "vlt 1.3.8 lock", + None, + r#"{"private":true}"#, + listed, + Some(vlt_lock_with_edges(&["file~_d"])), + false, + ), + ( + "empty lock", + None, + r#"{"private":true}"#, + listed, + Some(vlt_lock_with_edges(&[])), + false, + ), + ( + "unreadable lock", + None, + r#"{"private":true}"#, + listed, + Some("not json".to_string()), + true, + ), + ( + "never installed", + None, + r#"{"private":true}"#, + listed, + None, + false, + ), + ( + "package.json workspaces win", + None, + r#"{"private":true,"workspaces":["tools/*"]}"#, + listed, + Some(vlt_lock_with_edges(&["workspace~packages+a"])), + false, + ), + ( + "empty package.json workspaces win", + None, + r#"{"private":true,"workspaces":[]}"#, + listed, + Some(vlt_lock_with_edges(&["workspace~packages+a"])), + false, + ), + ( + "vlt.json workspaces win", + Some(r#"{"workspaces":"tools/*"}"#), + r#"{"private":true}"#, + listed, + Some(vlt_lock_with_edges(&["workspace~packages+a"])), + false, + ), + ( + "negated in the yaml", + None, + r#"{"private":true}"#, + "packages:\n - \"packages/*\"\n - \"!packages/a\"\n", + Some(vlt_lock_with_edges(&["workspace~packages+b"])), + false, + ), + ( + "not listed", + None, + r#"{"private":true}"#, + "packages:\n - \"tools/*\"\n", + Some(vlt_lock_with_edges(&["workspace~tools+x"])), + false, + ), + ] { + for stray in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", package_json); + if let Some(text) = vlt_json { + write(tmp.path(), VLT_JSON, text); + } + write(tmp.path(), PNPM_WORKSPACE, yaml); + if let Some(text) = &root_lock { + write(tmp.path(), VLT_LOCK, text); + } + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/b/package.json", "{}"); + if stray { + write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + } + let member = tmp.path().join("packages/a"); + let case = format!("{case}, stray: {stray}"); + assert_eq!(code(&member, "npm").await.is_some(), refused, "{case}"); + assert_eq!( + member_stray_lock(&member).await.is_some(), + refused && stray, + "{case}" + ); + } + } + } + /// The nearer of a `vlt.json` root and a `package.json` root governs. #[tokio::test] async fn nearer_of_vlt_and_package_json_roots_is_named() { diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index c2514f32d..95db7b1c9 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -1919,6 +1919,7 @@ mod tests { async fn bun_and_vlt_members_with_stray_lock_are_refused() { const BUN_TEXT: &str = "{\n \"lockfileVersion\": 1\n}\n"; const VLT_TEXT: &str = r#"{"lockfileVersion":1,"options":{},"nodes":{},"edges":{}}"#; + const VLT_PNPM_WORKSPACE_TEXT: &str = r#"{"lockfileVersion":1,"options":{},"nodes":{},"edges":{"workspace~packages+a left-pad":"prod 1.3.0 ~npm~left-pad@1.3.0"}}"#; let pj_workspace = r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#; for (member_lock, member_text, root_files) in [ ( @@ -1956,6 +1957,19 @@ mod tests { ("vlt-lock.json", VLT_TEXT), ], ), + // #1564: vlt 1.4 reads `pnpm-workspace.yaml` `packages:` when + // neither `vlt.json` nor `package.json` declares `workspaces`; + // the root lock's member edge shows vlt 1.4 installed it. + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("package.json", r#"{"name":"root","private":true}"#), + ("vlt.json", r#"{"registries":{}}"#), + ("pnpm-workspace.yaml", "packages:\n - \"packages/*\"\n"), + ("vlt-lock.json", VLT_PNPM_WORKSPACE_TEXT), + ], + ), ] { let (tmp, record) = npm_project().await; tokio::fs::remove_file(tmp.path().join("package-lock.json")) diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ec1c7b265..dc4cdc11b 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -5416,6 +5416,20 @@ mod member_stray_lock_tests { ("vlt-lock.json", "{}"), ][..], ), + // #1564: vlt 1.4's `pnpm-workspace.yaml` `packages:` source, + // shown by the root lock's edge from the member. + ( + "redirect/npm/vlt/basic/expected", + "vlt-lock.json", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + &[ + ("pnpm-workspace.yaml", "packages:\n - packages/*\n"), + ( + "vlt-lock.json", + r#"{"lockfileVersion":1,"options":{},"nodes":{},"edges":{"workspace~packages+a left-pad":"prod 1.3.0 ~npm~left-pad@1.3.0"}}"#, + ), + ][..], + ), ( "redirect/npm/package-lock-v3/basic/expected", "package-lock.json", From a8bec55d0f31e12c20a39c8d43505467dd73f6de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:40:28 +0000 Subject: [PATCH 3/4] Refuse vlt 1.4 pnpm-workspace.yaml members vlt 1.4 lists workspace members from the root pnpm-workspace.yaml packages: when neither vlt.json nor package.json declares workspaces, and installs them from the root vlt-lock.json. socket-patch read only the first two sources, so hosted and vendored scans from such a member rewrote a lock vlt never reads and exited 0, and vendored VEX attested not_affected while vlt installed the unpatched package. The vlt root lookup now reads the third source. vlt 1.3.8 and older ignore the file, so its directories count as members only when the root lock has an edge from one of them, which only vlt 1.4 writes. Hosted, vendored and VEX runs share the lookup. Fixes #1564 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 149 ++++++++++++++---- docs/ecosystems.md | 9 ++ 2 files changed, 131 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 6274d40c4..0b63510b7 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -2,7 +2,8 @@ //! a workspace member reads the member's directory only, while the package //! manager installs from a lock in an ancestor directory. Hosted mode then //! either pins nothing and reports success (pnpm, #590; npm, yarn and Bun -//! `package.json` workspaces, #884; vlt `vlt.json` workspaces, #942) or +//! `package.json` workspaces, #884; vlt `vlt.json` workspaces, #942, and +//! vlt 1.4's `pnpm-workspace.yaml` fallback, #1564) or //! rewrites the member as a lockless project and breaks the workspace //! (cargo, #417; a uv workspace member with Hatch configuration, #1138). //! @@ -23,6 +24,7 @@ use std::path::{Path, PathBuf}; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB, VLT_LOCK}; use crate::formats::governing_locks::{npm_lock_files, NpmLockFamily}; +use crate::formats::pnpm::workspace::read_package_globs; use crate::formats::text::strip_bom; use crate::formats::yarn::berry_gates::YARN_LOCK; use crate::formats::yarn::is_berry_lock; @@ -50,8 +52,8 @@ use super::guidance::{ pub const PNPM_LOCKFILE_ELSEWHERE: &str = "redirect_pnpm_lockfile_elsewhere"; /// Refusal code for an npm, yarn, Bun or vlt workspace member: an ancestor -/// `package.json` (or, for vlt, `vlt.json`) lists the project directory in -/// its `workspaces`, and the workspace's lock lives at that root. Also a uv +/// `package.json` (or, for vlt, `vlt.json` or `pnpm-workspace.yaml`) lists +/// the project directory in its `workspaces`, and the workspace's lock lives at that root. Also a uv /// workspace member (`[tool.uv.workspace] members`, #1138). pub const WORKSPACE_LOCKFILE_ELSEWHERE: &str = "redirect_workspace_lockfile_elsewhere"; @@ -350,8 +352,9 @@ pub(crate) fn member_stray_lock_own_files() -> impl Iterator( } /// The vlt workspace root that installs a member and its locks: the nearer -/// of the `vlt.json` `workspaces` root and the `package.json` `workspaces` -/// root whose `vlt-lock.json` vlt reads (see [`package_json_workspace_root`]). +/// of the `vlt.json` (or `pnpm-workspace.yaml`) root and the `package.json` +/// `workspaces` root whose `vlt-lock.json` vlt reads (see +/// [`package_json_workspace_root`]). async fn vlt_lock_root( root: &Path, package_json_root: &mut Option)>>, ) -> Option<(PathBuf, Vec<&'static str>)> { - let from_vlt_json = vlt_workspace_root(root).await; + let from_vlt_json = vlt_workspace_root(root).await.map(|(ancestor, _)| ancestor); let from_package_json = cached_package_json_root(root, package_json_root) .await .clone() @@ -723,25 +727,26 @@ async fn read_head(path: &Path) -> String { } /// #942: the project directory is a member of a vlt workspace, whose -/// root `vlt.json` lists it under `workspaces` and holds `vlt-lock.json`. -/// vlt reads workspaces only from `vlt.json` and keeps one lock at that -/// root, so a hosted run here would find the member's copy, pin nothing -/// and report success. The nearest ancestor `vlt.json` whose patterns -/// match is the root; one without a lock (never installed) refuses -/// nothing. +/// root `vlt.json` lists it under `workspaces` (or, on vlt 1.4, whose +/// root `pnpm-workspace.yaml` lists it under `packages:`, #1564) and holds +/// `vlt-lock.json`. vlt keeps one lock at that root, so a hosted run here +/// 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)> { - let ancestor = vlt_workspace_root(root).await?; + 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!( "{} is a workspace member with no lockfile of its own: the workspace \ - root {} lists it under \"workspaces\" in {} and installs it from {}, \ + 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.display(), ancestor.display(), - ancestor.join(VLT_JSON).display(), + source.field(), + ancestor.join(source.file()).display(), lock.display(), ancestor.display() ), @@ -749,18 +754,41 @@ async fn vlt_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { Some((ancestor, refusal)) } -/// The nearest ancestor `vlt.json` whose `workspaces` patterns match the -/// project directory, when it holds `vlt-lock.json` (see -/// [`vlt_workspace_refusal`]). -async fn vlt_workspace_root(root: &Path) -> Option { +/// The file a vlt workspace root lists its members in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VltWorkspaceSource { + /// `vlt.json` `workspaces`. + VltJson, + /// `pnpm-workspace.yaml` `packages:`, which vlt 1.4 reads when neither + /// `vlt.json` nor `package.json` has a `workspaces` field (#1564). + PnpmWorkspace, +} + +impl VltWorkspaceSource { + fn file(self) -> &'static str { + match self { + Self::VltJson => VLT_JSON, + Self::PnpmWorkspace => PNPM_WORKSPACE, + } + } + + fn field(self) -> &'static str { + match self { + Self::VltJson => "workspaces", + Self::PnpmWorkspace => "packages", + } + } +} + +/// 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. +async fn vlt_workspace_root(root: &Path) -> Option<(PathBuf, VltWorkspaceSource)> { let canonical = tokio::fs::canonicalize(root) .await .unwrap_or_else(|_| root.to_path_buf()); for ancestor in canonical.ancestors().skip(1) { - let Ok(text) = read_regular_to_string(&ancestor.join(VLT_JSON)).await else { - continue; - }; - let Some(groups) = vlt_workspace_patterns(&text) else { + let Some((groups, source)) = vlt_workspace_groups(ancestor).await else { continue; }; let Ok(rel) = canonical.strip_prefix(ancestor) else { @@ -782,14 +810,81 @@ async fn vlt_workspace_root(root: &Path) -> Option { } continue; } - if !vlt_lists_member(ancestor, &groups, &rel).await { + let listed = match source { + VltWorkspaceSource::VltJson => vlt_lists_member(ancestor, &groups, &rel).await, + VltWorkspaceSource::PnpmWorkspace => { + groups + .iter() + .any(|group| workspaces_include_as(group, &rel, WorkspacesDialect::VLT)) + && vlt_lock_reads_pnpm_workspace(ancestor, &groups).await + } + }; + if !listed { continue; } - return Some(ancestor.to_path_buf()); + return Some((ancestor.to_path_buf(), source)); } None } +/// The workspace pattern groups vlt reads at the directory `dir`, and +/// their file: `vlt.json` `workspaces` when it has the field; otherwise, +/// when `package.json` has no `workspaces` field either (that fallback is +/// [`package_json_workspace_root`]'s), the `packages:` of +/// `pnpm-workspace.yaml`, which vlt 1.4 reads (#1564) without its `.` +/// root entries, as long as a positive pattern remains. +async fn vlt_workspace_groups(dir: &Path) -> Option<(Vec>, VltWorkspaceSource)> { + if let Ok(text) = read_regular_to_string(&dir.join(VLT_JSON)).await { + if let Some(groups) = vlt_workspace_patterns(&text) { + return Some((groups, VltWorkspaceSource::VltJson)); + } + } + let yaml = read_regular_to_string(&dir.join(PNPM_WORKSPACE)) + .await + .ok()?; + if package_json_declares_workspaces(dir).await { + return None; + } + let globs: Vec = read_package_globs(&yaml) + .ok()?? + .into_iter() + .filter(|glob| glob != "." && glob != "./") + .collect(); + globs + .iter() + .any(|glob| !glob.starts_with('!')) + .then(|| (vec![globs], VltWorkspaceSource::PnpmWorkspace)) +} + +/// Whether `/package.json` has a `workspaces` field, whatever its +/// value: vlt keys its precedence on the field. An unreadable or +/// unparseable manifest has none. +async fn package_json_declares_workspaces(dir: &Path) -> bool { + read_regular_to_string(&dir.join("package.json")) + .await + .ok() + .and_then(|text| serde_json::from_str::(strip_bom(&text)).ok()) + .is_some_and(|doc| doc.get("workspaces").is_some()) +} + +/// Whether the vlt that wrote `root`'s `vlt-lock.json` read its +/// `pnpm-workspace.yaml` `packages:` `groups` (#1564). Only vlt 1.4 and +/// later do; older vlt installs each directory the file lists as its own +/// project. vlt records an edge from every member it installed that has a +/// dependency, so a lock with an edge from a listed directory is 1.4's, +/// and one without is older vlt's (a member with no dependency has +/// nothing to patch). A lock that cannot be read keeps the refusing side. +async fn vlt_lock_reads_pnpm_workspace(root: &Path, groups: &[Vec]) -> bool { + let Some(importers) = vlt_lock_workspace_dirs(root).await else { + return true; + }; + importers.iter().any(|dir| { + groups + .iter() + .any(|group| workspaces_include_as(group, dir, WorkspacesDialect::VLT)) + }) +} + /// Whether vlt lists the member `rel` under the `workspaces` `groups` of /// the root `root`, which holds `vlt-lock.json` (#1542). vlt globs each /// group on its own. vlt 1.3 and older vlt read a `!` pattern differently diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 9d6762c2a..065223fd1 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -683,6 +683,15 @@ when every dependency is optional (0.0.0-30 … 1.0.4 install none from the lock optional dependency (a plain `vlt install` keeps the link to the removed vendored directory) or a link still into the vendored directory. +**Workspace members.** vlt installs a workspace member from the root's `vlt-lock.json` +and ignores any lock in the member, so a hosted or vendored run from a member is refused +(`redirect_workspace_lockfile_elsewhere`, `vendor_lockfile_missing`) and names the root +to run from. The root lists its members in `vlt.json` `workspaces`; when that field is +absent, in `package.json` `workspaces`; and from vlt 1.4, when both are absent, in the +`packages:` of `pnpm-workspace.yaml`. vlt 1.3.8 and older ignore `pnpm-workspace.yaml`, +so its directories count as members only when the root lock has an edge from one of them, +which only vlt 1.4 writes (#1564). + **Agent mode.** `apply` and `rollback` patch every store copy of a `name@version` (legacy and tilde DepIDs, peer and modifier variants, transitive-only packages) and replace each file instead of writing through it, so vlt 1.2's shared store (hardlinked on From 3608d88c3c9c3e2d4b197c93cfef2f976fbd109e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:53:02 +0000 Subject: [PATCH 4/4] Walk past an uninstalled pnpm workspace file A pnpm-workspace.yaml with no vlt-lock.json beside it, such as a vendored pnpm sub-monorepo, is not shown to be vlt's, so it no longer stops the search for an outer vlt.json root that installs the member. Also shares one helper for vlt's per-group glob check and fixes the refusal code's doc. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 0b63510b7..e612a7de2 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -52,9 +52,10 @@ use super::guidance::{ pub const PNPM_LOCKFILE_ELSEWHERE: &str = "redirect_pnpm_lockfile_elsewhere"; /// Refusal code for an npm, yarn, Bun or vlt workspace member: an ancestor -/// `package.json` (or, for vlt, `vlt.json` or `pnpm-workspace.yaml`) lists -/// the project directory in its `workspaces`, and the workspace's lock lives at that root. Also a uv -/// workspace member (`[tool.uv.workspace] members`, #1138). +/// `package.json` (or, for vlt, `vlt.json`) lists the project directory in +/// its `workspaces` (or, for vlt 1.4, `pnpm-workspace.yaml` in its +/// `packages:`, #1564), and the workspace's lock lives at that root. Also a +/// uv workspace member (`[tool.uv.workspace] members`, #1138). pub const WORKSPACE_LOCKFILE_ELSEWHERE: &str = "redirect_workspace_lockfile_elsewhere"; /// Refusal code for a pnpm workspace member with its own lock whose @@ -800,11 +801,12 @@ async fn vlt_workspace_root(root: &Path) -> Option<(PathBuf, VltWorkspaceSource) .collect(); if !ancestor.join(VLT_LOCK).is_file() { // Never installed, so no lock tells the vlt versions apart: a - // root the current one lists the member under governs it and - // refuses nothing; otherwise an outer root may (#1542). - if groups - .iter() - .any(|group| workspaces_include_as(group, &rel, WorkspacesDialect::VLT)) + // `vlt.json` root the current one lists the member under + // governs it and refuses nothing; otherwise an outer root may + // (#1542). A `pnpm-workspace.yaml` without a vlt lock is not + // shown to be vlt's at all (#1564), so the walk goes on. + if source == VltWorkspaceSource::VltJson + && vlt_groups_list(&groups, &rel, WorkspacesDialect::VLT) { return None; } @@ -813,9 +815,7 @@ async fn vlt_workspace_root(root: &Path) -> Option<(PathBuf, VltWorkspaceSource) let listed = match source { VltWorkspaceSource::VltJson => vlt_lists_member(ancestor, &groups, &rel).await, VltWorkspaceSource::PnpmWorkspace => { - groups - .iter() - .any(|group| workspaces_include_as(group, &rel, WorkspacesDialect::VLT)) + vlt_groups_list(&groups, &rel, WorkspacesDialect::VLT) && vlt_lock_reads_pnpm_workspace(ancestor, &groups).await } }; @@ -878,11 +878,17 @@ async fn vlt_lock_reads_pnpm_workspace(root: &Path, groups: &[Vec]) -> b let Some(importers) = vlt_lock_workspace_dirs(root).await else { return true; }; - importers.iter().any(|dir| { - groups - .iter() - .any(|group| workspaces_include_as(group, dir, WorkspacesDialect::VLT)) - }) + importers + .iter() + .any(|dir| vlt_groups_list(groups, dir, WorkspacesDialect::VLT)) +} + +/// Whether vlt, reading patterns as `dialect`, lists the directory `rel` +/// under any of the workspace `groups` (it globs each group on its own). +fn vlt_groups_list(groups: &[Vec], rel: &[String], dialect: WorkspacesDialect) -> bool { + groups + .iter() + .any(|group| workspaces_include_as(group, rel, dialect)) } /// Whether vlt lists the member `rel` under the `workspaces` `groups` of @@ -895,11 +901,7 @@ async fn vlt_lock_reads_pnpm_workspace(root: &Path, groups: &[Vec]) -> b /// other directory only older vlt lists, is older vlt's. A lock that /// cannot be read keeps the wider reading. async fn vlt_lists_member(root: &Path, groups: &[Vec], rel: &[String]) -> bool { - let lists = |dir: &[String], dialect| { - groups - .iter() - .any(|group| workspaces_include_as(group, dir, dialect)) - }; + let lists = |dir: &[String], dialect| vlt_groups_list(groups, dir, dialect); let current = lists(rel, WorkspacesDialect::VLT); let older = lists(rel, WorkspacesDialect::VLT_PRE_1_3); if current == older { @@ -2816,6 +2818,34 @@ mod tests { } } + /// #1564: a nearer `pnpm-workspace.yaml` with no `vlt-lock.json` beside + /// it (a vendored pnpm sub-monorepo) is not shown to be vlt's, so an + /// outer `vlt.json` root that lists the member still refuses it. + #[tokio::test] + async fn vlt_uninstalled_pnpm_workspace_yaml_walks_on() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", r#"{"private":true}"#); + write( + tmp.path(), + VLT_JSON, + r#"{"workspaces":"third_party/foo/packages/*"}"#, + ); + write(tmp.path(), VLT_LOCK, "{}"); + write(tmp.path(), "third_party/foo/package.json", "{}"); + write( + tmp.path(), + &format!("third_party/foo/{PNPM_WORKSPACE}"), + "packages:\n - packages/*\n", + ); + write(tmp.path(), "third_party/foo/packages/a/package.json", "{}"); + let member = tmp.path().join("third_party/foo/packages/a"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .expect("the outer vlt.json root governs the member"); + assert_eq!(refused.code, WORKSPACE_LOCKFILE_ELSEWHERE); + assert!(refused.message.contains(VLT_JSON), "{}", refused.message); + } + /// The nearer of a `vlt.json` root and a `package.json` root governs. #[tokio::test] async fn nearer_of_vlt_and_package_json_roots_is_named() {