diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 42032f9ff..e612a7de2 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; @@ -51,8 +53,9 @@ 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 -/// workspace member (`[tool.uv.workspace] members`, #1138). +/// 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 @@ -350,8 +353,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 +728,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 +755,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 { @@ -772,24 +801,96 @@ async fn vlt_workspace_root(root: &Path) -> Option { .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; } continue; } - if !vlt_lists_member(ancestor, &groups, &rel).await { + let listed = match source { + VltWorkspaceSource::VltJson => vlt_lists_member(ancestor, &groups, &rel).await, + VltWorkspaceSource::PnpmWorkspace => { + vlt_groups_list(&groups, &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| 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 /// 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 @@ -800,11 +901,7 @@ async fn vlt_workspace_root(root: &Path) -> Option { /// 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 { @@ -2530,6 +2627,225 @@ 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}" + ); + } + } + } + + /// #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() { 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", 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