From a4205d389d33e08f0d131e6c7e4cdb7d626b0bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 18:24:39 +0000 Subject: [PATCH 1/4] Start fix for #1071, #942 Assisted-by: Claude Code:claude-opus-5-5 From 2b0d6770f094b74ef6c631f1ac2a0f227a6a6367 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 18:35:36 +0000 Subject: [PATCH 2/4] Refuse hosted runs from vlt and brace-glob members A hosted scan or get run from a workspace member must refuse when the workspace's lock lives at an ancestor root, or it pins nothing, prints "Switched 0 packages" and exits 0 while the next install brings in the unpatched package. Two layouts slipped through that check: - npm, yarn and Bun roots whose workspaces patterns use brace sets or character classes (packages/{a,b}, packages/[a-c]). The member matcher only knew *, ? and **, so it never recognised the member (#1071). - vlt workspaces, declared in vlt.json and locked by the root's vlt-lock.json. Only package.json workspaces were checked (#942). The member matcher now expands braces (nested sets, {1..3} and {a..c} sequences) and matches [abc], [a-c], [!a] and [^a] classes, and a vlt walk refuses a member listed by the nearest matching vlt.json whose root holds vlt-lock.json. When both a package.json root and a vlt.json root match, the nearer one is named. Fixes #1071 Fixes #942 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/in_process_redirect_pnpm.rs | 105 ++++ .../src/hosted/governing_root.rs | 488 +++++++++++++++++- 3 files changed, 570 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b02fa44fa..7ab3f93b8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1274,7 +1274,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | -| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | +| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. | | `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | 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 47937792d..40dd481cd 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1635,6 +1635,111 @@ async fn hosted_scan_from_package_json_workspace_member_refuses() { } } +/// A workspace member `packages/a` holding the patched package, under a +/// root whose `manifest` (`package.json` or `vlt.json`) declares +/// `workspaces` and whose lock is `lock_name`. +fn write_workspace_member( + root: &Path, + manifest: &str, + manifest_text: &str, + lock_name: &str, +) -> std::path::PathBuf { + std::fs::write(root.join(manifest), manifest_text).unwrap(); + if manifest != "package.json" { + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "private": true }"#, + ) + .unwrap(); + } + std::fs::write(root.join(lock_name), format!("# root lock {lock_name}\n")).unwrap(); + let member = root.join("packages/a"); + let pkg = member.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + member.join("package.json"), + format!( + r#"{{ "name": "a", "version": "1.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + member +} + +/// #1071: npm, yarn and Bun resolve `workspaces` with full glob syntax, so +/// a root listing `packages/{a,b}` or `packages/[a-c]` governs +/// `packages/a`. The member matcher compared `{` and `[` literally, so a +/// hosted run from the member pinned nothing and exited 0. +#[tokio::test] +#[serial] +async fn hosted_scan_from_brace_or_class_glob_workspace_member_refuses() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + for pattern in [ + "packages/{a,b}", + "packages/[a-c]", + "{apps,packages}/*", + "packages/[!b]", + ] { + let tmp = tempfile::tempdir().unwrap(); + let member = write_workspace_member( + tmp.path(), + "package.json", + &format!(r#"{{ "name": "root", "private": true, "workspaces": ["{pattern}"] }}"#), + "package-lock.json", + ); + let lock = tmp.path().join("package-lock.json"); + let before = std::fs::read_to_string(&lock).unwrap(); + let (code, doc) = run_hosted_json(&member, &server.uri()); + assert_refused_workspace_lock_elsewhere(pattern, code, &doc, &lock, &before, &member); + } +} + +/// #942: vlt reads its workspaces from `vlt.json` and keeps one +/// `vlt-lock.json` at that root, so a hosted run from a member found the +/// member's copy, read no lock, pinned nothing and exited 0. It now +/// refuses and names the vlt workspace root, for every `workspaces` shape +/// vlt accepts (a string, an array, an object of groups). +#[tokio::test] +#[serial] +async fn hosted_scan_from_vlt_workspace_member_refuses() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + for workspaces in [ + r#""packages/*""#, + r#"["packages/*"]"#, + r#"{ "apps": "apps/*", "libs": ["packages/{a,b}"] }"#, + ] { + let tmp = tempfile::tempdir().unwrap(); + let member = write_workspace_member( + tmp.path(), + "vlt.json", + &format!(r#"{{ "workspaces": {workspaces} }}"#), + "vlt-lock.json", + ); + let lock = tmp.path().join("vlt-lock.json"); + let before = std::fs::read_to_string(&lock).unwrap(); + let (code, doc) = run_hosted_json(&member, &server.uri()); + assert_refused_workspace_lock_elsewhere(workspaces, code, &doc, &lock, &before, &member); + assert!( + doc["error"] + .as_str() + .unwrap_or_default() + .contains("vlt.json"), + "{workspaces}: the error names vlt.json: {doc}" + ); + } +} + fn assert_refused_workspace_lock_elsewhere( case: &str, code: Option, diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index c646adc4c..653b0ab2e 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -2,7 +2,7 @@ //! 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) or rewrites the member as a lockless +//! `package.json` workspaces, #884; vlt `vlt.json` workspaces, #942) or rewrites the member as a lockless //! project and breaks the workspace (cargo, #417). //! //! [`refusal`] spots these layouts before any takeover or write, so the run @@ -38,9 +38,9 @@ use super::guidance::{ /// member) or a configured `lockfile-dir`. pub const PNPM_LOCKFILE_ELSEWHERE: &str = "redirect_pnpm_lockfile_elsewhere"; -/// Refusal code for an npm, yarn or Bun workspace member: an ancestor -/// `package.json` lists the project directory in its `workspaces`, and the -/// workspace's lock lives at that root. +/// 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. pub const WORKSPACE_LOCKFILE_ELSEWHERE: &str = "redirect_workspace_lockfile_elsewhere"; /// Refusal code for a pnpm workspace member with its own lock whose @@ -50,6 +50,7 @@ pub const PNPM_SETTINGS_ELSEWHERE: &str = "redirect_pnpm_settings_elsewhere"; const PNPM_LOCK: &str = "pnpm-lock.yaml"; const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; +const VLT_JSON: &str = "vlt.json"; /// npm-family locks that, present in the project directory, make it its /// own lock root: the existing rewriters handle it. @@ -90,7 +91,10 @@ pub async fn refusal( let workspace = if has_own_npm_family_lock(root) { None } else { - package_json_workspace_refusal(root).await + 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); @@ -333,6 +337,96 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal None } +/// #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. +async fn vlt_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { + 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(patterns) = vlt_workspace_patterns(&text) else { + continue; + }; + let Ok(rel) = canonical.strip_prefix(ancestor) else { + continue; + }; + let rel: Vec = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + if !workspaces_include(&patterns, &rel) { + continue; + } + let lock = ancestor.join(VLT_LOCK); + if !lock.is_file() { + return None; + } + 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 {}, \ + 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(), + lock.display(), + ancestor.display() + ), + }; + return Some((ancestor.to_path_buf(), refusal)); + } + None +} + +/// The refusal of the nearer (deeper) of two governing roots. +fn nearer_root( + a: Option<(PathBuf, Refusal)>, + b: Option<(PathBuf, Refusal)>, +) -> Option<(PathBuf, Refusal)> { + match (a, b) { + (Some(a), Some(b)) => Some(if b.0.starts_with(&a.0) && b.0 != a.0 { + b + } else { + a + }), + (a, b) => a.or(b), + } +} + +/// 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> { + fn strings(value: &serde_json::Value, out: &mut Vec) { + match value { + serde_json::Value::String(s) => out.push(s.clone()), + serde_json::Value::Array(list) => { + out.extend(list.iter().filter_map(|v| v.as_str()).map(str::to_string)) + } + _ => {} + } + } + let text = vlt_json.strip_prefix('\u{feff}').unwrap_or(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) +} + /// The `workspaces` patterns of a `package.json`: the array form (npm, /// yarn, Bun) or the object form's `packages` array (yarn classic's /// `nohoist` shape, Bun's catalogs shape). `None` when the field is absent @@ -358,54 +452,236 @@ fn workspace_patterns(package_json: &str) -> Option> { /// Whether the member path (`rel`, relative to the workspace root, one /// entry per component) matches a `workspaces` pattern and no later -/// `!`-negated one. A pattern is a `/`-separated glob: `*` and `?` match -/// within one component, `**` matches any number of components. +/// `!`-negated one. A pattern is a `/`-separated glob in the grammar npm +/// (minimatch), yarn and Bun share: brace sets (`{a,b}`, nested, and +/// `{1..3}` / `{a..c}` sequences) expand first, then `*`, `?` and +/// character classes (`[abc]`, `[a-c]`, `[!a]`, `[^a]`) match within one +/// component and `**` matches any number of components (#1071). fn workspaces_include(patterns: &[String], rel: &[String]) -> bool { if rel.is_empty() { return false; } + let rel: Vec> = rel.iter().map(|c| c.chars().collect()).collect(); let mut included = false; for pattern in patterns { let (negated, pattern) = match pattern.strip_prefix('!') { Some(rest) => (true, rest), None => (false, pattern.as_str()), }; - let segments: Vec<&str> = pattern - .trim() - .split(['/', '\\']) - .filter(|s| !s.is_empty() && *s != ".") - .collect(); - if segments.is_empty() { - continue; - } - if path_glob_matches(&segments, rel) { + let matched = expand_braces(pattern.trim()).iter().any(|alternative| { + let segments: Vec> = alternative + .split(['/', '\\']) + .filter(|s| !s.is_empty() && *s != ".") + .map(|s| s.chars().collect()) + .collect(); + !segments.is_empty() && path_glob_matches(&segments, &rel) + }); + if matched { included = !negated; } } included } -fn path_glob_matches(pattern: &[&str], path: &[String]) -> bool { +/// Cap on the alternatives one pattern expands to, so a pathological +/// sequence (`{1..1000000}`) cannot stall the run. +const MAX_BRACE_EXPANSIONS: usize = 4096; + +/// The brace expansion of a glob, as minimatch's `brace-expansion` does it: +/// the first `{...}` group holding a top-level `,` or a `x..y[..step]` +/// sequence is replaced by each alternative, recursively. A group with +/// neither, and an unbalanced `{`, stay literal. +fn expand_braces(pattern: &str) -> Vec { + let mut out = Vec::new(); + expand_braces_into(pattern, &mut out); + out +} + +fn expand_braces_into(pattern: &str, out: &mut Vec) { + if out.len() >= MAX_BRACE_EXPANSIONS { + return; + } + for (open, _) in pattern.match_indices('{') { + let Some(close) = matching_brace(pattern, open) else { + continue; + }; + let body = &pattern[open + 1..close]; + let alternatives = split_top_level_commas(body); + let alternatives = if alternatives.len() > 1 { + alternatives + } else if let Some(sequence) = brace_sequence(body) { + sequence + } else { + continue; + }; + let (prefix, suffix) = (&pattern[..open], &pattern[close + 1..]); + for alternative in alternatives { + expand_braces_into(&format!("{prefix}{alternative}{suffix}"), out); + if out.len() >= MAX_BRACE_EXPANSIONS { + return; + } + } + return; + } + out.push(pattern.to_string()); +} + +/// The byte index of the `}` closing the `{` at `open`. +fn matching_brace(pattern: &str, open: usize) -> Option { + let mut depth = 0usize; + for (i, c) in pattern[open..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(open + i); + } + } + _ => {} + } + } + None +} + +fn split_top_level_commas(body: &str) -> Vec { + let mut parts = Vec::new(); + let (mut depth, mut start) = (0usize, 0usize); + for (i, c) in body.char_indices() { + match c { + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(body[start..i].to_string()); + start = i + 1; + } + _ => {} + } + } + parts.push(body[start..].to_string()); + parts +} + +/// A `{x..y}` or `{x..y..step}` sequence body: integers (zero-padded when +/// either end is) or single characters. +fn brace_sequence(body: &str) -> Option> { + let parts: Vec<&str> = body.split("..").collect(); + let (from, to, step) = match parts.as_slice() { + [from, to] => (*from, *to, None), + [from, to, step] => (*from, *to, Some(*step)), + _ => return None, + }; + let step = match step { + Some(step) => step.parse::().ok()?.unsigned_abs().max(1), + None => 1, + }; + let (start, end, width, as_char) = + if let (Ok(a), Ok(b)) = (from.parse::(), to.parse::()) { + let padded = |s: &str| { + s.trim_start_matches('-').len() > 1 && s.trim_start_matches('-').starts_with('0') + }; + let width = if padded(from) || padded(to) { + from.len().max(to.len()) + } else { + 0 + }; + (a, b, width, false) + } else { + let (mut a, mut b) = (from.chars(), to.chars()); + let (Some(a), None, Some(b), None) = (a.next(), a.next(), b.next(), b.next()) else { + return None; + }; + (a as i64, b as i64, 0, true) + }; + let mut out = Vec::new(); + let mut n = start; + loop { + out.push(if as_char { + char::from_u32(u32::try_from(n).ok()?)?.to_string() + } else { + format!("{n:0width$}") + }); + if n == end || out.len() >= MAX_BRACE_EXPANSIONS { + break; + } + let next = if start <= end { + n.checked_add(step as i64)? + } else { + n.checked_sub(step as i64)? + }; + if (start <= end && next > end) || (start > end && next < end) { + break; + } + n = next; + } + Some(out) +} + +fn path_glob_matches(pattern: &[Vec], path: &[Vec]) -> bool { match pattern.split_first() { None => path.is_empty(), - Some((&"**", rest)) => (0..=path.len()).any(|skip| path_glob_matches(rest, &path[skip..])), + Some((first, rest)) if first.as_slice() == ['*', '*'] => { + (0..=path.len()).any(|skip| path_glob_matches(rest, &path[skip..])) + } Some((first, rest)) => path.split_first().is_some_and(|(head, tail)| { - segment_glob_matches(first.as_bytes(), head.as_bytes()) && path_glob_matches(rest, tail) + segment_glob_matches(first, head) && path_glob_matches(rest, tail) }), } } -fn segment_glob_matches(pattern: &[u8], name: &[u8]) -> bool { +fn segment_glob_matches(pattern: &[char], name: &[char]) -> bool { match pattern.split_first() { None => name.is_empty(), - Some((b'*', rest)) => { - (0..=name.len()).any(|skip| segment_glob_matches(rest, &name[skip..])) - } - Some((b'?', rest)) => !name.is_empty() && segment_glob_matches(rest, &name[1..]), + Some(('*', rest)) => (0..=name.len()).any(|skip| segment_glob_matches(rest, &name[skip..])), + Some(('?', rest)) => !name.is_empty() && segment_glob_matches(rest, &name[1..]), + Some(('[', rest)) => match char_class(rest) { + Some((class, after)) => name + .split_first() + .is_some_and(|(c, tail)| class.matches(*c) && segment_glob_matches(after, tail)), + None => name.first() == Some(&'[') && segment_glob_matches(rest, &name[1..]), + }, Some((c, rest)) => name.first() == Some(c) && segment_glob_matches(rest, &name[1..]), } } +/// A parsed `[...]` character class. +struct CharClass { + negated: bool, + ranges: Vec<(char, char)>, +} + +impl CharClass { + fn matches(&self, c: char) -> bool { + self.ranges.iter().any(|&(lo, hi)| lo <= c && c <= hi) != self.negated + } +} + +/// The class after a `[`, and the pattern after its closing `]`. A `!` or +/// `^` first negates it; a `]` right after that is a member. `None` when +/// the class never closes (the `[` is then literal). +fn char_class(pattern: &[char]) -> Option<(CharClass, &[char])> { + let (negated, mut i) = match pattern.first() { + Some('!' | '^') => (true, 1), + _ => (false, 0), + }; + let mut ranges = Vec::new(); + let first = i; + while i < pattern.len() { + let c = pattern[i]; + if c == ']' && i > first { + return Some((CharClass { negated, ranges }, &pattern[i + 1..])); + } + if pattern.get(i + 1) == Some(&'-') && pattern.get(i + 2).is_some_and(|&h| h != ']') { + ranges.push((c, pattern[i + 2])); + i += 3; + } else { + ranges.push((c, c)); + i += 1; + } + } + None +} + async fn npmrc_lockfile_dir(root: &Path) -> Option { let npmrc = read_regular_to_string(&root.join(".npmrc")).await.ok()?; npmrc_top_level_value(&npmrc, "lockfile-dir") @@ -1026,6 +1302,170 @@ mod tests { assert!(!workspaces_include(&pats(&["*"]), &[])); } + /// #1071: npm (minimatch), yarn and Bun expand brace sets and match + /// character classes in `workspaces`. + #[test] + fn workspaces_patterns_expand_braces_and_match_classes() { + let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); + let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let yes = |p: &str, r: &str| assert!(workspaces_include(&pats(&[p]), &rel(r)), "{p} ~ {r}"); + let no = + |p: &str, r: &str| assert!(!workspaces_include(&pats(&[p]), &rel(r)), "{p} !~ {r}"); + // Brace sets, nested, spanning separators, and sequences. + yes("packages/{a,b}", "packages/a"); + yes("packages/{a,b}", "packages/b"); + no("packages/{a,b}", "packages/c"); + yes("{apps,packages}/*", "apps/web"); + yes("packages/{a,{b,c}x}", "packages/cx"); + no("packages/{a,{b,c}x}", "packages/c"); + yes("{packages/a,tools/*}", "tools/t"); + yes("packages/pkg-{1..3}", "packages/pkg-2"); + no("packages/pkg-{1..3}", "packages/pkg-4"); + yes("packages/{a..c}", "packages/b"); + yes("packages/v{01..10}", "packages/v07"); + yes("packages/{,x}a", "packages/a"); + // A brace group with no comma or range is literal, as in minimatch. + yes("packages/{a}", "packages/{a}"); + no("packages/{a}", "packages/a"); + yes("packages/{a,b", "packages/{a,b"); + // Character classes: sets, ranges, negation, a literal `]` first. + yes("packages/[a-c]", "packages/b"); + no("packages/[a-c]", "packages/d"); + yes("packages/[ab]x", "packages/bx"); + yes("packages/[!b]", "packages/a"); + no("packages/[!b]", "packages/b"); + yes("packages/[^b]", "packages/c"); + yes("packages/[]a]", "packages/]"); + yes("packages/[a-c]*", "packages/core"); + // An unclosed class is a literal `[`. + yes("packages/[a", "packages/[a"); + no("packages/[a", "packages/a"); + // Non-ASCII names match one character per `?` and class. + yes("packages/?", "packages/é"); + yes("packages/[é]", "packages/é"); + // Negation applies to the expanded alternatives too. + assert!(!workspaces_include( + &pats(&["packages/*", "!packages/{b,c}"]), + &rel("packages/b") + )); + } + + /// #942: vlt's `workspaces` in `vlt.json` is a string, an array or an + /// object of named groups, each a string or an array. + #[test] + fn vlt_workspace_patterns_read_every_shape() { + assert_eq!( + vlt_workspace_patterns(r#"{"workspaces":"packages/*"}"#), + Some(vec!["packages/*".to_string()]) + ); + assert_eq!( + vlt_workspace_patterns("\u{feff}{\"workspaces\":[\"a/*\",\"b\"]}"), + Some(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() + ]) + ); + assert_eq!(vlt_workspace_patterns(r#"{"registries":{}}"#), None); + assert_eq!(vlt_workspace_patterns("not json"), None); + } + + /// #942: a vlt workspace member has no lock; the root's `vlt-lock.json` + /// governs it. The root, a directory the root does not list and a + /// never-installed root refuse nothing. + #[tokio::test] + async fn vlt_workspace_member_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", r#"{"private":true}"#); + write( + tmp.path(), + "vlt.json", + r#"{"workspaces":{"apps":"apps/*","libs":["packages/{a,b}"]}}"#, + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/c/package.json", "{}"); + write(tmp.path(), "apps/web/package.json", "{}"); + let member = tmp.path().join("packages/a"); + // Never installed: no lock anywhere, nothing to refuse. + assert_eq!(code(&member, "npm").await, None); + + write(tmp.path(), VLT_LOCK, "{}"); + let refusal = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .expect("a vlt workspace member must be refused"); + assert_eq!(refusal.code, WORKSPACE_LOCKFILE_ELSEWHERE); + assert!( + refusal.message.contains(VLT_LOCK) + && refusal.message.contains("vlt.json") + && refusal.message.contains("nothing was written"), + "{}", + refusal.message + ); + assert_eq!( + code(&tmp.path().join("apps/web"), "npm").await.as_deref(), + Some(WORKSPACE_LOCKFILE_ELSEWHERE) + ); + assert_eq!(code(&tmp.path().join("packages/c"), "npm").await, None); + assert_eq!(code(tmp.path(), "npm").await, None); + assert_eq!(code(&member, "pypi").await, None); + + // A member with its own lock is its own root. + write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + assert_eq!(code(&member, "npm").await, None); + } + + /// A `vlt-lock.json` at a `package.json` `workspaces` root governs no + /// member through that field (vlt reads only `vlt.json`), and a + /// `package-lock.json` at a `vlt.json` root governs none through + /// `vlt.json` (npm reads only `package.json`). + #[tokio::test] + async fn vlt_and_package_json_workspaces_need_their_own_lock() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), VLT_LOCK, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + assert_eq!(code(&tmp.path().join("packages/a"), "npm").await, None); + + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", r#"{"private":true}"#); + write(tmp.path(), "vlt.json", r#"{"workspaces":"packages/*"}"#); + write(tmp.path(), "package-lock.json", "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + assert_eq!(code(&tmp.path().join("packages/a"), "npm").await, None); + } + + /// 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() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["apps/**"]}"#, + ); + write(tmp.path(), "package-lock.json", "{}"); + write(tmp.path(), "apps/vlt.json", r#"{"workspaces":["web"]}"#); + write(tmp.path(), "apps/package.json", "{}"); + write(tmp.path(), "apps/vlt-lock.json", "{}"); + write(tmp.path(), "apps/web/package.json", "{}"); + let refusal = refusal( + &ProjectView::Disk(&tmp.path().join("apps/web")), + &[candidate("npm")], + true, + ) + .await + .expect("refused"); + assert!(refusal.message.contains(VLT_LOCK), "{}", refusal.message); + } + #[test] fn workspace_patterns_reads_both_field_shapes() { assert_eq!( From 05f91dce6c8b00ff13840b7c978f7e70b1a07e92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 18:37:28 +0000 Subject: [PATCH 3/4] Wrap governing_root module doc to 80 columns Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/hosted/governing_root.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 653b0ab2e..8291faa1b 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -2,8 +2,9 @@ //! 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 rewrites the member as a lockless -//! project and breaks the workspace (cargo, #417). +//! `package.json` workspaces, #884; vlt `vlt.json` workspaces, #942) or +//! rewrites the member as a lockless project and breaks the workspace +//! (cargo, #417). //! //! [`refusal`] spots these layouts before any takeover or write, so the run //! fails closed and names the directory to run from. It also refuses a From a73915127882e07e5272fae8c8ac9b44bb498687 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:12:56 +0000 Subject: [PATCH 4/4] Refuse vlt members of package.json workspaces vlt reads the root package.json "workspaces" field when the root vlt.json declares none, and installs those members from the root vlt-lock.json. A hosted run from such a member still pinned nothing and exited 0, because the package.json walk only counted npm, yarn and Bun locks. It now also counts vlt-lock.json when vlt.json has no "workspaces" field (or no vlt.json exists), matching vlt's resolveWSConfig precedence. Refs #942 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/hosted/governing_root.rs | 59 ++++++++++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7ab3f93b8..4e65e59cf 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1274,7 +1274,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | -| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | +| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. | | `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 8291faa1b..a98e71652 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -302,11 +302,17 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal if !workspaces_include(&patterns, &rel) { continue; } - let locks: Vec<&str> = WORKSPACE_ROOT_LOCKS + let mut locks: Vec<&str> = WORKSPACE_ROOT_LOCKS .iter() .copied() .filter(|name| ancestor.join(name).is_file()) .collect(); + // vlt falls back to `package.json` `workspaces` when the root's + // `vlt.json` declares none (Bugbot on #1073), so its lock there + // governs the member too. + if ancestor.join(VLT_LOCK).is_file() && !vlt_json_declares_workspaces(ancestor).await { + locks.push(VLT_LOCK); + } if locks.is_empty() { // Rush keeps its locks under common/config: the rewriters own // a run from the Rush root. @@ -389,6 +395,18 @@ async fn vlt_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { None } +/// 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`. +/// An unreadable or unparseable file counts as declaring none. +async fn vlt_json_declares_workspaces(dir: &Path) -> bool { + read_regular_to_string(&dir.join(VLT_JSON)) + .await + .ok() + .and_then(|text| vlt_workspace_patterns(&text)) + .is_some() +} + /// The refusal of the nearer (deeper) of two governing roots. fn nearer_root( a: Option<(PathBuf, Refusal)>, @@ -1419,25 +1437,52 @@ mod tests { assert_eq!(code(&member, "npm").await, None); } - /// A `vlt-lock.json` at a `package.json` `workspaces` root governs no - /// member through that field (vlt reads only `vlt.json`), and a - /// `package-lock.json` at a `vlt.json` root governs none through - /// `vlt.json` (npm reads only `package.json`). + /// Bugbot on #1073: vlt falls back to `package.json` `workspaces` when + /// the root's `vlt.json` has no `workspaces` field, so its + /// `vlt-lock.json` there governs the member. With the field present, + /// vlt ignores `package.json` `workspaces`, and a `package-lock.json` + /// at a `vlt.json` root governs nothing through `vlt.json` (npm reads + /// only `package.json`). #[tokio::test] - async fn vlt_and_package_json_workspaces_need_their_own_lock() { + async fn vlt_reads_package_json_workspaces_only_without_vlt_json_ones() { + for vlt_json in [None, Some(r#"{"registries":{}}"#)] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/{a,b}"]}"#, + ); + if let Some(text) = vlt_json { + write(tmp.path(), VLT_JSON, text); + } + write(tmp.path(), VLT_LOCK, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + let refusal = refusal( + &ProjectView::Disk(&tmp.path().join("packages/a")), + &[candidate("npm")], + true, + ) + .await + .unwrap_or_else(|| panic!("{vlt_json:?}: member must be refused")); + assert_eq!(refusal.code, WORKSPACE_LOCKFILE_ELSEWHERE); + assert!(refusal.message.contains(VLT_LOCK), "{}", refusal.message); + } + + // vlt.json's own `workspaces` field wins and does not list the member. let tmp = tempfile::tempdir().unwrap(); write( tmp.path(), "package.json", r#"{"private":true,"workspaces":["packages/*"]}"#, ); + write(tmp.path(), VLT_JSON, r#"{"workspaces":"tools/*"}"#); write(tmp.path(), VLT_LOCK, "{}"); write(tmp.path(), "packages/a/package.json", "{}"); assert_eq!(code(&tmp.path().join("packages/a"), "npm").await, None); let tmp = tempfile::tempdir().unwrap(); write(tmp.path(), "package.json", r#"{"private":true}"#); - write(tmp.path(), "vlt.json", r#"{"workspaces":"packages/*"}"#); + write(tmp.path(), VLT_JSON, r#"{"workspaces":"packages/*"}"#); write(tmp.path(), "package-lock.json", "{}"); write(tmp.path(), "packages/a/package.json", "{}"); assert_eq!(code(&tmp.path().join("packages/a"), "npm").await, None);