diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 6c719c56f..b06632bb5 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -2770,7 +2770,7 @@ async fn hosted_scan_from_npm_member_with_stray_lock_refuses() { } } -/// #1101 (Bun), #1134 (vlt): a workspace member holding a stray +/// #1101 (Bun), #1134 / #1542 (vlt): a workspace member holding a stray /// `bun.lock` / `bun.lockb` / `vlt-lock.json` of its own (one its manager /// never reads; members install from the root lock) used to skip the /// #884 / #942 refusal. `scan` and `get` pinned the ignored member lock @@ -2804,6 +2804,19 @@ async fn hosted_scan_from_bun_or_vlt_member_with_stray_lock_refuses() { "vlt-lock.json", vlt_text.as_str(), ), + // #1542: vlt lists members through extglobs too. + ( + "vlt-lock.json", + Some(r#"{"workspaces":["packages/+(a|b)"]}"#), + "vlt-lock.json", + vlt_text.as_str(), + ), + ( + "vlt-lock.json", + Some(r#"{"workspaces":["packages/@(a|b)"]}"#), + "vlt-lock.json", + vlt_text.as_str(), + ), ] { let tmp = tempfile::tempdir().unwrap(); let member = write_package_json_workspace(tmp.path(), root_lock, false); diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 57c755073..42032f9ff 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -32,10 +32,11 @@ use crate::utils::fs::{ read_regular_to_string_sync, }; use crate::utils::pnpm_workspace::governing_workspace_file; -use crate::utils::workspace_globs::{workspaces_include, workspaces_include_as, WorkspacesDialect}; +use crate::utils::workspace_globs::{workspaces_include_as, WorkspacesDialect}; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; use crate::vendor::lock_inventory::ProjectView; +use crate::vendor::vlt_lock_text::{self, DepIdKind, LockSniff}; use crate::vendor::{bun_lock_text, bun_lockb}; use super::engine::{Candidate, Refusal}; @@ -592,6 +593,9 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat /// is older Bun's; 1.3.0/1.3.1 never list an excluded one. A list that /// cannot be read keeps the wider pre-1.3 reading. async fn lock_lists_member(root: &Path, lock: &str, patterns: &[String], rel: &[String]) -> bool { + if lock == VLT_LOCK { + return vlt_lists_member(root, &[patterns.to_vec()], rel).await; + } let dialect = lock_dialect(root, lock).await; if !workspaces_include_as(patterns, rel, dialect) { return false; @@ -680,6 +684,8 @@ async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { WorkspacesDialect::BUN_PRE_1_3 } } + // [`lock_lists_member`] reads a vlt lock through + // [`vlt_lists_member`], which also weighs vlt before 1.3. VLT_LOCK => WorkspacesDialect::VLT, _ => WorkspacesDialect::NPM, } @@ -754,7 +760,7 @@ async fn vlt_workspace_root(root: &Path) -> Option { let Ok(text) = read_regular_to_string(&ancestor.join(VLT_JSON)).await else { continue; }; - let Some(patterns) = vlt_workspace_patterns(&text) else { + let Some(groups) = vlt_workspace_patterns(&text) else { continue; }; let Ok(rel) = canonical.strip_prefix(ancestor) else { @@ -764,17 +770,87 @@ async fn vlt_workspace_root(root: &Path) -> Option { .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect(); - if !workspaces_include(&patterns, &rel) { + 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)) + { + return None; + } continue; } - if !ancestor.join(VLT_LOCK).is_file() { - return None; + if !vlt_lists_member(ancestor, &groups, &rel).await { + continue; } return Some(ancestor.to_path_buf()); } None } +/// 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 +/// ([`WorkspacesDialect::VLT`], [`WorkspacesDialect::VLT_PRE_1_3`]) but +/// write the same lock, so for a directory only one of them lists, the +/// lock decides: vlt records an edge from every member it installed that +/// has a dependency, so a lock with an edge from the member, or from any +/// 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 current = lists(rel, WorkspacesDialect::VLT); + let older = lists(rel, WorkspacesDialect::VLT_PRE_1_3); + if current == older { + return current; + } + let Some(importers) = vlt_lock_workspace_dirs(root).await else { + return true; + }; + let older_lock = importers.iter().any(|dir| { + lists(dir, WorkspacesDialect::VLT_PRE_1_3) && !lists(dir, WorkspacesDialect::VLT) + }); + // A directory only vlt 1.3 lists (`!!x`) is a member unless the lock + // shows older vlt; one only older vlt lists is a member only then. + if current { + !older_lock || importers.iter().any(|dir| dir == rel) + } else { + older_lock + } +} + +/// The workspace directories `root`'s `vlt-lock.json` holds edges from, as +/// `/`-separated components. `None` when the lock cannot be read. +async fn vlt_lock_workspace_dirs(root: &Path) -> Option>> { + let text = read_regular_to_string(&root.join(VLT_LOCK)).await.ok()?; + let LockSniff::Readable(lock) = vlt_lock_text::sniff_lock(&text) else { + return None; + }; + let Some(edges) = lock.edges() else { + return Some(Vec::new()); + }; + Some( + edges + .keys() + .filter_map(|key| vlt_lock_text::split_dep_id(key.split_once(' ')?.0)) + .filter(|id| id.kind == DepIdKind::Workspace) + .map(|id| { + id.first + .split(['/', '\\']) + .filter(|part| !part.is_empty() && *part != ".") + .map(str::to_string) + .collect() + }) + .collect(), + ) +} + /// Whether `/vlt.json` has a `workspaces` field. vlt keys its /// precedence on the field: with it, `package.json` `workspaces` is /// ignored; without it (or without the file), vlt reads `package.json`. @@ -802,10 +878,10 @@ fn nearer_root( } } -/// The `workspaces` patterns of a `vlt.json`: a string, an array, or an -/// object of named groups whose values are a string or an array. `None` -/// when the field is absent or the file does not parse. -fn vlt_workspace_patterns(vlt_json: &str) -> Option> { +/// The `workspaces` pattern groups of a `vlt.json`: a string or an array +/// (one group), or an object of named groups whose values are a string or +/// an array. `None` when the field is absent or the file does not parse. +fn vlt_workspace_patterns(vlt_json: &str) -> Option>> { fn strings(value: &serde_json::Value, out: &mut Vec) { match value { serde_json::Value::String(s) => out.push(s.clone()), @@ -818,12 +894,15 @@ fn vlt_workspace_patterns(vlt_json: &str) -> Option> { let text = strip_bom(vlt_json); let doc: serde_json::Value = serde_json::from_str(text).ok()?; let field = doc.get("workspaces")?; - let mut out = Vec::new(); - match field { - serde_json::Value::Object(groups) => groups.values().for_each(|v| strings(v, &mut out)), - other => strings(other, &mut out), - } - Some(out) + let group = |value| { + let mut out = Vec::new(); + strings(value, &mut out); + out + }; + Some(match field { + serde_json::Value::Object(groups) => groups.values().map(group).collect(), + other => vec![group(other)], + }) } /// The `workspaces` patterns of a `package.json`: the array form (npm, @@ -2012,7 +2091,10 @@ mod tests { let json = r#"{"workspaces":["a/*"]}"#; for bom in ["", "\u{feff}"] { let text = format!("{bom}{json}"); - assert_eq!(vlt_workspace_patterns(&text), Some(vec!["a/*".to_string()])); + assert_eq!( + vlt_workspace_patterns(&text), + Some(vec![vec!["a/*".to_string()]]) + ); assert_eq!(workspace_patterns(&text), Some(vec!["a/*".to_string()])); } let text = format!("\u{feff}\u{feff}{json}"); @@ -2026,18 +2108,17 @@ mod tests { fn vlt_workspace_patterns_read_every_shape() { assert_eq!( vlt_workspace_patterns(r#"{"workspaces":"packages/*"}"#), - Some(vec!["packages/*".to_string()]) + Some(vec![vec!["packages/*".to_string()]]) ); assert_eq!( vlt_workspace_patterns("\u{feff}{\"workspaces\":[\"a/*\",\"b\"]}"), - Some(vec!["a/*".to_string(), "b".to_string()]) + Some(vec![vec!["a/*".to_string(), "b".to_string()]]) ); assert_eq!( vlt_workspace_patterns(r#"{"workspaces":{"apps":"apps/*","libs":["libs/*","x"]}}"#), Some(vec![ - "apps/*".to_string(), - "libs/*".to_string(), - "x".to_string() + vec!["apps/*".to_string()], + vec!["libs/*".to_string(), "x".to_string()] ]) ); assert_eq!(vlt_workspace_patterns(r#"{"registries":{}}"#), None); @@ -2143,6 +2224,181 @@ mod tests { } } + /// #1542: vlt expands extglobs in `workspaces` (vlt 1.2.0 and 1.3.8 + /// install `packages/a` as a member of `packages/+(a|b)` and + /// `packages/@(a|b)`), so a member listed through one is refused, with + /// or without a stray lock of its own, whether the patterns sit in + /// `vlt.json` or in the `package.json` fallback. + #[tokio::test] + async fn vlt_extglob_member_is_refused() { + for pattern in ["packages/+(a|b)", "packages/@(a|b)"] { + for package_json_fallback in [false, true] { + for stray in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let list = serde_json::json!([pattern]).to_string(); + if package_json_fallback { + write( + tmp.path(), + "package.json", + &format!(r#"{{"private":true,"workspaces":{list}}}"#), + ); + write(tmp.path(), "vlt.json", r#"{"registries":{}}"#); + } else { + write(tmp.path(), "package.json", r#"{"private":true}"#); + write( + tmp.path(), + "vlt.json", + &format!(r#"{{"workspaces":{list}}}"#), + ); + } + write(tmp.path(), VLT_LOCK, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/c/package.json", "{}"); + if stray { + write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + write(tmp.path(), "packages/c/vlt-lock.json", "{}"); + } + let case = format!( + "{pattern}, package.json fallback: {package_json_fallback}, stray: {stray}" + ); + let member = tmp.path().join("packages/a"); + assert_eq!( + code(&member, "npm").await.as_deref(), + Some(WORKSPACE_LOCKFILE_ELSEWHERE), + "{case}" + ); + assert_eq!(member_stray_lock(&member).await.is_some(), stray, "{case}"); + // `packages/c` is not listed: its own lock governs it. + let other = tmp.path().join("packages/c"); + assert_eq!(code(&other, "npm").await, None, "{case}"); + assert!(member_stray_lock(&other).await.is_none(), "{case}"); + } + } + } + } + + /// #1542: vlt 1.3 drops a `!`-excluded directory wherever the `!` + /// pattern sits, while vlt before 1.3 ignores `!` patterns and lists + /// it. Both write the same lock, so the root's `vlt-lock.json` decides: + /// vlt records an edge from every member it installed that has a + /// dependency, so a lock with edges from the excluded member, or from + /// any other directory only older vlt lists, is older vlt's and the + /// member is refused. A lock that cannot be read keeps the wider + /// reading. + #[tokio::test] + async fn vlt_negated_member_reads_the_root_lock() { + let lock = |from: &[&str]| { + 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 ") + ) + }; + for (workspaces, root_lock, refused) in [ + // vlt 1.3: `packages/a` is excluded wherever the `!` sits. + (r#"["!packages/a","packages/*"]"#, lock(&[]), false), + (r#"["packages/*","!packages/a"]"#, lock(&[]), false), + ( + r#"["packages/*","!packages/a"]"#, + lock(&["workspace~packages+b"]), + false, + ), + // A later pattern whose text the `!` matches lists it again. + ( + r#"["packages/*","!packages/a","packages/a"]"#, + lock(&[]), + true, + ), + (r#"["!packages/*","packages/a"]"#, lock(&[]), true), + // vlt 1.2 installed the excluded member (or another one). + ( + r#"["!packages/a","packages/*"]"#, + lock(&["workspace~packages+a"]), + true, + ), + ( + r#"["packages/*","!packages/a","!packages/z"]"#, + lock(&["workspace~packages+z"]), + true, + ), + // An unreadable lock keeps the wider reading. + ( + r#"["!packages/a","packages/*"]"#, + "not json".to_string(), + true, + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", r#"{"private":true}"#); + write( + tmp.path(), + "vlt.json", + &format!(r#"{{"workspaces":{workspaces}}}"#), + ); + write(tmp.path(), VLT_LOCK, &root_lock); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + let member = tmp.path().join("packages/a"); + let case = format!("{workspaces} with {root_lock:?}"); + assert_eq!(code(&member, "npm").await.is_some(), refused, "{case}"); + assert_eq!( + member_stray_lock(&member).await.is_some(), + refused, + "{case}" + ); + } + } + + /// #1542: a nearer, never-installed `vlt.json` that excludes the member + /// with a `!` pattern is not its root, so an outer root that lists it + /// still refuses it. + #[tokio::test] + async fn vlt_excluded_by_an_uninstalled_inner_root_walks_on() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", r#"{"private":true}"#); + write( + tmp.path(), + "vlt.json", + r#"{"workspaces":["inner/packages/*"]}"#, + ); + write(tmp.path(), VLT_LOCK, "{}"); + write(tmp.path(), "inner/package.json", r#"{"private":true}"#); + write( + tmp.path(), + "inner/vlt.json", + r#"{"workspaces":["packages/*","!packages/a"]}"#, + ); + write(tmp.path(), "inner/packages/a/package.json", "{}"); + assert_eq!( + code(&tmp.path().join("inner/packages/a"), "npm") + .await + .as_deref(), + Some(WORKSPACE_LOCKFILE_ELSEWHERE) + ); + } + + /// #1542: vlt globs each named `workspaces` group on its own, so a `!` + /// pattern in one group does not exclude what another group lists. + #[tokio::test] + async fn vlt_negation_stays_in_its_group() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", r#"{"private":true}"#); + write( + tmp.path(), + "vlt.json", + r#"{"workspaces":{"libs":["!packages/a"],"apps":["packages/*"]}}"#, + ); + write(tmp.path(), VLT_LOCK, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + assert_eq!( + code(&tmp.path().join("packages/a"), "npm").await.as_deref(), + Some(WORKSPACE_LOCKFILE_ELSEWHERE) + ); + } + /// #1101: Bun installs every `package.json` workspace member from the /// root's `bun.lock` / `bun.lockb` and never reads a lock inside the /// member, so a stray member Bun lock does not make the member its own diff --git a/crates/socket-patch-core/src/utils/workspace_globs.rs b/crates/socket-patch-core/src/utils/workspace_globs.rs index 896a74fab..4e9346a31 100644 --- a/crates/socket-patch-core/src/utils/workspace_globs.rs +++ b/crates/socket-patch-core/src/utils/workspace_globs.rs @@ -9,7 +9,7 @@ //! ignored. A leading `!` negates; each caller applies negations the way //! its package manager does. pnpm's globber also never lets a wildcard //! match a component that starts with `.` ([`glob_matches_no_dot`]). -//! npm and yarn also match extglobs (`@(a|b)`, `?(..)`, `*(..)`, `+(..)`, +//! npm, yarn and vlt also match extglobs (`@(a|b)`, `?(..)`, `*(..)`, `+(..)`, //! `!(..)`) within one component, and yarn classic ignores `!` patterns; //! [`WorkspacesDialect`] names what each package manager reads (#1097). //! yarn berry's picomatch further reads regex groups (`(a|b)`), POSIX @@ -34,13 +34,16 @@ pub(crate) struct WorkspacesDialect { /// berry), which also take regex groups, POSIX classes and `..` /// segments ([`Picomatch`], #1490). pub(crate) picomatch: bool, + /// `*`, `?` and `**` match a component that starts with `.`. vlt's + /// node-glob does not (#1542). + pub(crate) dot: bool, } /// What a `workspaces` pattern with a leading `!` does. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Negation { /// It excludes what earlier patterns matched; a later pattern can - /// list the directory again (npm, Bun 1.3+, vlt). + /// list the directory again (npm, Bun 1.3+). LastMatchWins, /// It excludes the directory whatever the order (yarn berry's /// globby), and a leading `!(` is an extglob group, not a negation. @@ -48,6 +51,14 @@ pub(crate) enum Negation { /// It is ignored: it neither excludes nor lists anything (yarn /// classic, Bun before 1.3). Ignored, + /// It excludes the directory whatever the order, until a later + /// pattern whose own text it matches lists it again; an even run of + /// leading `!` cancels out (vlt 1.3's `splitNegatedPatterns`, #1542). + Relisted, + /// A leading `!` is not special: `!x` lists only a directory literally + /// named so, and `!(..)` is an extglob group (vlt before 1.3, which + /// globs every pattern as given, #1542). + Literal, } impl WorkspacesDialect { @@ -56,18 +67,21 @@ impl WorkspacesDialect { negation: Negation::LastMatchWins, extglob: true, picomatch: false, + dot: true, }; /// yarn classic (a `yarn.lock` without berry's `__metadata`). pub(crate) const YARN_CLASSIC: Self = Self { negation: Negation::Ignored, extglob: true, picomatch: false, + dot: true, }; /// yarn berry (a `yarn.lock` with `__metadata`). pub(crate) const YARN_BERRY: Self = Self { negation: Negation::Unordered, extglob: true, picomatch: true, + dot: true, }; /// Bun 1.3 and later (a `bun.lock` with `configVersion`, or an /// untagged 1.3.0/1.3.1 lock that leaves an excluded member out, @@ -76,6 +90,7 @@ impl WorkspacesDialect { negation: Negation::LastMatchWins, extglob: false, picomatch: false, + dot: true, }; /// Bun before 1.3: it lists `!`-negated directories anyway. An /// untagged lock reads this way until its workspace list shows it @@ -84,34 +99,48 @@ impl WorkspacesDialect { negation: Negation::Ignored, extglob: false, picomatch: false, + dot: true, }; - /// vlt, as #942 read it. + /// vlt 1.3 and later, probed on 1.3.8 (#1542). pub(crate) const VLT: Self = Self { - negation: Negation::LastMatchWins, - extglob: false, + negation: Negation::Relisted, + extglob: true, + picomatch: false, + dot: false, + }; + /// vlt before 1.3, probed on 1.2.0 (#1542). Both write the same + /// `vlt-lock.json`, so `hosted::governing_root` tells them apart by the + /// members the lock records. + pub(crate) const VLT_PRE_1_3: Self = Self { + negation: Negation::Literal, + extglob: true, picomatch: false, + dot: false, }; /// Every dialect, for a root whose package manager is unknown: it lists /// a directory when any of them does. - pub(crate) const ALL: [Self; 6] = [ + pub(crate) const ALL: [Self; 7] = [ Self::NPM, Self::YARN_CLASSIC, Self::YARN_BERRY, Self::BUN, Self::BUN_PRE_1_3, Self::VLT, + Self::VLT_PRE_1_3, ]; } /// Whether the member path (`rel`, relative to the workspace root, one /// entry per component) matches a `workspaces` pattern and no later -/// `!`-negated one (npm and yarn: the last matching pattern wins), in vlt's -/// dialect. +/// `!`-negated one, in npm's dialect. +#[cfg(test)] pub(crate) fn workspaces_include(patterns: &[String], rel: &[String]) -> bool { - workspaces_include_as(patterns, rel, WorkspacesDialect::VLT) + workspaces_include_as(patterns, rel, WorkspacesDialect::NPM) } -/// [`workspaces_include`] in a given package manager's dialect. +/// Whether the member path (`rel`, relative to the workspace root, one +/// entry per component) is listed by the `workspaces` `patterns`, read in +/// `dialect` (see [`Negation`] for what a `!` pattern does). pub(crate) fn workspaces_include_as( patterns: &[String], rel: &[String], @@ -122,7 +151,7 @@ pub(crate) fn workspaces_include_as( } let matches = |pattern: &str, negated: bool| { let picomatch = dialect.picomatch.then_some(Picomatch { negated }); - pattern_matches(pattern, rel, true, dialect.extglob, picomatch) + pattern_matches(pattern, rel, dialect.dot, dialect.extglob, picomatch) }; match dialect.negation { Negation::LastMatchWins => { @@ -150,6 +179,33 @@ pub(crate) fn workspaces_include_as( Negation::Ignored => patterns .iter() .any(|pattern| !pattern.starts_with('!') && matches(pattern, false)), + Negation::Relisted => { + let mut listed = false; + let mut ignore: Vec<&str> = Vec::new(); + for pattern in patterns { + let bangs = pattern.len() - pattern.trim_start_matches('!').len(); + let pattern = &pattern[bangs..]; + if bangs % 2 == 1 { + ignore.push(pattern); + continue; + } + // minimatch(pattern, ignore): the pattern's own text, read + // as a path, against each earlier `!` glob. + let text: Vec = pattern + .split(['/', '\\']) + .filter(|s| !s.is_empty() && *s != ".") + .map(str::to_string) + .collect(); + ignore.retain(|ign| !pattern_matches(ign, &text, false, true, None)); + listed |= matches(pattern, false); + } + // node-glob's ignore matches dot components. + listed + && !ignore + .iter() + .any(|ign| pattern_matches(ign, rel, true, dialect.extglob, None)) + } + Negation::Literal => patterns.iter().any(|pattern| matches(pattern, false)), } } @@ -996,11 +1052,7 @@ mod tests { // An unclosed group is literal; braces expand around a group. yes("packages/@(a", "packages/@(a"); yes("{apps,packages}/@(a|b)", "apps/b"); - // Without extglobs (Bun, vlt, uv, pnpm) the group is literal. - assert!(!workspaces_include( - &pats(&["packages/@(a|b)"]), - &rel("packages/a") - )); + // Without extglobs (Bun, uv, pnpm) the group is literal. assert!(!glob_matches("packages/@(a|b)", &rel("packages/a"))); assert!(!workspaces_include_as( &pats(&["packages/@(a|b)"]), @@ -1145,7 +1197,8 @@ mod tests { for (dialect, last, first, again) in [ (D::NPM, false, true, true), (D::BUN, false, true, true), - (D::VLT, false, true, true), + (D::VLT, false, false, true), + (D::VLT_PRE_1_3, true, true, true), (D::YARN_BERRY, false, false, false), (D::YARN_CLASSIC, true, true, true), (D::BUN_PRE_1_3, true, true, true), @@ -1230,6 +1283,85 @@ mod tests { )); } + /// #1542: vlt reads `workspaces` with node-glob / minimatch, probed on + /// vlt 1.2.0 and 1.3.8 (`vlt install` then `vlt query ':workspace'` + /// over `packages/{a,b,ab,c,.h}`): extglobs match, and wildcards skip a + /// component that starts with `.`. vlt 1.3 drops a `!` pattern's + /// directories wherever the pattern sits, until a later pattern whose + /// own text it matches lists them again (`splitNegatedPatterns`); vlt + /// before 1.3 reads a leading `!` literally, so `!x` lists nothing and + /// `!(..)` is an extglob. + #[test] + fn vlt_dialects_list_what_vlt_lists() { + use WorkspacesDialect as D; + let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); + let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + // (patterns, directory, vlt 1.3 lists it, vlt 1.2 lists it) + for (patterns, dir, current, pre_1_3) in [ + (&["packages/+(a|b)"][..], "packages/a", true, true), + (&["packages/+(a|b)"], "packages/ab", true, true), + (&["packages/+(a|b)"], "packages/c", false, false), + (&["packages/@(a|b)"], "packages/b", true, true), + (&["packages/@(a|b)"], "packages/ab", false, false), + (&["packages/?(a)b"], "packages/ab", true, true), + (&["packages/*(a)"], "packages/a", true, true), + (&["packages/*(a)"], "packages/b", false, false), + (&["packages/!(a)"], "packages/b", true, true), + (&["packages/!(a)"], "packages/a", false, false), + (&["packages/(a|b)"], "packages/a", false, false), + (&["packages/{a,b}"], "packages/a", true, true), + (&["packages/[ab]"], "packages/b", true, true), + (&["packages/*"], "packages/.h", false, false), + (&["**"], ".gh/x", false, false), + (&["packages/.h"], "packages/.h", true, true), + (&["!packages/a", "packages/*"], "packages/a", false, true), + (&["!packages/a", "packages/*"], "packages/b", true, true), + (&["packages/*", "!packages/a"], "packages/a", false, true), + ( + &["packages/*", "!packages/a", "packages/a"], + "packages/a", + true, + true, + ), + ( + &["packages/*", "!packages/a", "packages/[a]"], + "packages/a", + false, + true, + ), + (&["!packages/*", "packages/a"], "packages/a", true, true), + (&["packages/a", "!packages/*"], "packages/a", false, true), + ( + &["!packages/a", "packages/@(a|b)"], + "packages/a", + false, + true, + ), + ( + &["!packages/@(a|b)", "packages/a"], + "packages/a", + true, + true, + ), + (&["!!packages/a"], "packages/a", true, false), + (&["!(apps)/a"], "packages/a", false, true), + (&["packages/*", "!(apps)/a"], "packages/a", true, true), + ] { + let patterns = pats(patterns); + let dir = rel(dir); + assert_eq!( + workspaces_include_as(&patterns, &dir, D::VLT), + current, + "vlt 1.3: {patterns:?} ~ {dir:?}" + ); + assert_eq!( + workspaces_include_as(&patterns, &dir, D::VLT_PRE_1_3), + pre_1_3, + "vlt 1.2: {patterns:?} ~ {dir:?}" + ); + } + } + #[test] fn pnpm_wildcards_skip_dot_components() { let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index ddce7b4e4..c2514f32d 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -1909,7 +1909,7 @@ mod tests { ); } - /// #1101 (Bun), #1134 (vlt): a workspace member's own `bun.lock`, + /// #1101 (Bun), #1134 / #1542 (vlt): a workspace member's own `bun.lock`, /// `bun.lockb` or `vlt-lock.json` is a lock its manager never reads /// (members install from the workspace root's lock), so vendoring into /// it would wire nothing. The engine and the hosted→vendored takeover @@ -1939,6 +1939,23 @@ mod tests { ("vlt-lock.json", VLT_TEXT), ], ), + // #1542: vlt lists members through extglobs too. + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("vlt.json", r#"{"workspaces":["packages/+(a|b)"]}"#), + ("vlt-lock.json", VLT_TEXT), + ], + ), + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("vlt.json", r#"{"workspaces":["packages/@(a|b)"]}"#), + ("vlt-lock.json", VLT_TEXT), + ], + ), ] { let (tmp, record) = npm_project().await; tokio::fs::remove_file(tmp.path().join("package-lock.json"))