From a19d6e1186b84c21f91476988f1380535b75e647 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:23:15 +0000 Subject: [PATCH 1/5] Start fix for #1584 Assisted-by: Claude Code:claude-opus-5-5 From 3188ed65143d297b59407f886a69db943323b08a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:41:22 +0000 Subject: [PATCH 2/5] Refuse vlt stray locks under vlt's project root vlt installs whatever project its root lookup finds: the nearest ancestor vlt.json, else the outermost directory with a package.json or node_modules (stopping at .git, never reading $HOME). A directory with no vlt.json of its own is never a project of its own, whether or not a workspaces field lists it. socket-patch only treated a nested vlt-lock.json as stray when the directory was a listed workspace member. Scans from an unlisted nested directory, a member listed through a `..` glob, or a vlt 1.3.8 pnpm-workspace.yaml directory rewrote a lock vlt never reads, exited 0, and VEX attested not_affected while the install stayed unpatched. Hosted and vendored scans now refuse such a directory (redirect_workspace_lockfile_elsewhere / vendor_lockfile_missing) and name the root to run from, and VEX reads the ignored lock as absent. As for listed members, a root that was never installed (no vlt-lock.json) governs nothing. Fixes #1584 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 276 ++++++++++++++++-- .../src/vendor/npm_flavor.rs | 40 +++ .../socket-patch-core/src/vex/discover/mod.rs | 18 ++ 3 files changed, 308 insertions(+), 26 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index bd625948e..ad6ec1755 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -338,7 +338,7 @@ const STRAY_LOCK_FAMILIES: [(NpmLockFamily, &str); 3] = [ pub(crate) fn member_stray_lock_own_files() -> impl Iterator { npm_lock_files() .chain(EXTRA_OWN_LOCKS) - .chain(["rush.json", VLT_JSON]) + .chain(["rush.json", VLT_JSON, ".git"]) } /// The project directory is a workspace member whose own locks are all @@ -355,7 +355,10 @@ pub(crate) fn member_stray_lock_own_files() -> impl Iterator Option { } let mut package_json_root = None; let mut governing: Option<(PathBuf, Vec<&'static str>, &str)> = None; + // Set when vlt governs through its project root rather than a + // workspace listing (#1584). + let mut vlt_project = false; for (family, manager) in STRAY_LOCK_FAMILIES { let files = family.files(); if !own.iter().any(|name| files.contains(name)) { @@ -392,7 +398,20 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { if root.join(VLT_JSON).exists() { return None; } - vlt_lock_root(root, &mut package_json_root).await + match vlt_lock_root(root, &mut package_json_root).await { + Some(found) => Some(found), + // Listed or not, a directory with no `vlt.json` is + // installed from vlt's project root; as for a listed + // member, a root never installed governs nothing. + None => { + let project = vlt_project_root(root, vlt_home().as_deref()); + let own_dir = + std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf()); + vlt_project = governing.is_none(); + (project != own_dir && project.join(VLT_LOCK).is_file()) + .then(|| (project, vec![VLT_LOCK])) + } + } } _ => cached_package_json_root(root, &mut package_json_root) .await @@ -409,14 +428,27 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { governing.get_or_insert((ancestor, root_locks, manager)); } let (ancestor, root_locks, manager) = governing?; - let detail = format!( - "{} is a member of the {manager} workspace rooted at {}: {manager} installs it from {} \ - and ignores its own {}, so a lock rewritten here would never be installed", - root.display(), - ancestor.display(), - join_paths(&ancestor, &root_locks), - join_paths(root, &own) - ); + let detail = if vlt_project { + format!( + "{} has no {VLT_JSON} of its own, so it is inside the vlt project rooted at {}: \ + vlt installs that project from {} and ignores {}, so a lock rewritten here would \ + never be installed (a {VLT_JSON} in {} makes it a vlt project of its own)", + root.display(), + ancestor.display(), + join_paths(&ancestor, &root_locks), + join_paths(root, &own), + root.display() + ) + } else { + format!( + "{} is a member of the {manager} workspace rooted at {}: {manager} installs it from {} \ + and ignores its own {}, so a lock rewritten here would never be installed", + root.display(), + ancestor.display(), + join_paths(&ancestor, &root_locks), + join_paths(root, &own) + ) + }; Some(MemberStrayLock { root: ancestor, ignored: own, @@ -462,6 +494,43 @@ async fn vlt_lock_root( Some((ancestor, vec![VLT_LOCK])) } +/// vlt's project root for a run from `dir`: `find("project")` in +/// `@vltpkg/vlt-json` (the same in vlt 1.3.8 and 1.4), whose directory is +/// the one vlt installs. The walk up from `dir` returns the nearest +/// directory holding a `vlt.json`; failing that, the outermost one holding +/// a `package.json` or `node_modules` (or `dir` itself). It ends after a +/// directory holding `.git`, and before `home`, which it never reads. +/// vlt keys none of this on a `workspaces` field, so a directory it does +/// not list is installed from the root too (#1584). +fn vlt_project_root(dir: &Path, home: Option<&Path>) -> PathBuf { + let dir = std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()); + let mut last_known = dir.as_path(); + for ancestor in dir.ancestors() { + if Some(ancestor) == home { + break; + } + if ancestor.join(VLT_JSON).exists() { + return ancestor.to_path_buf(); + } + if ancestor.join("package.json").exists() || ancestor.join("node_modules").exists() { + last_known = ancestor; + } + if ancestor.join(".git").exists() { + break; + } + } + last_known.to_path_buf() +} + +/// The home directory vlt's project walk stops below: Node's +/// `os.homedir()`, `HOME` on Unix and `USERPROFILE` on Windows. +fn vlt_home() -> Option { + let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(var) + .filter(|home| !home.is_empty()) + .map(PathBuf::from) +} + /// The independent npm-family project below `scan_root` whose /// `node_modules` holds the crawled copy at `copy` (#1534): the deepest /// directory between `scan_root` (exclusive) and the copy's first @@ -971,8 +1040,9 @@ async fn package_json_declares_workspaces(dir: &Path) -> bool { /// 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 +/// later do; older vlt does not list those directories as members (it +/// still installs the project root from them, which +/// [`member_stray_lock`] reads through [`vlt_project_root`], #1584). 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. @@ -1926,9 +1996,12 @@ mod tests { assert_eq!(member_stray_lock(&member).await, None); std::fs::remove_file(member.join("bun.lock")).unwrap(); std::fs::remove_file(tmp.path().join("bun.lock")).unwrap(); + // vlt is different (#1584): with no `vlt.json` of its own the + // member is not its own vlt project, whatever `workspaces` lists, + // so vlt installs the root and its lock stays stray. write(tmp.path(), "vlt-lock.json", "{}"); write(tmp.path(), "packages/b/vlt-lock.json", "{}"); - assert_eq!(member_stray_lock(&member).await, None); + assert!(member_stray_lock(&member).await.is_some()); // Under a classic yarn.lock alone the member is listed, and its own // npm lock is not npm's to ignore. std::fs::remove_file(tmp.path().join("vlt-lock.json")).unwrap(); @@ -2623,10 +2696,12 @@ mod tests { "{case}" ); assert_eq!(member_stray_lock(&member).await.is_some(), stray, "{case}"); - // `packages/c` is not listed: its own lock governs it. + // `packages/c` is not listed, but with no `vlt.json` of + // its own vlt still installs the root from there, so + // its own lock is stray as well (#1584). let other = tmp.path().join("packages/c"); - assert_eq!(code(&other, "npm").await, None, "{case}"); - assert!(member_stray_lock(&other).await.is_none(), "{case}"); + assert_eq!(code(&other, "npm").await.is_some(), stray, "{case}"); + assert_eq!(member_stray_lock(&other).await.is_some(), stray, "{case}"); } } } @@ -2639,7 +2714,8 @@ mod tests { /// 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. + /// reading. A stray lock of the member's own is refused either way + /// (#1584). #[tokio::test] async fn vlt_negated_member_reads_the_root_lock() { let lock = |from: &[&str]| { @@ -2695,15 +2771,14 @@ mod tests { ); 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}" - ); + // Excluded or not, vlt installs the root from a directory with + // no `vlt.json` of its own, so a lock there is stray (#1584). + write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + assert!(code(&member, "npm").await.is_some(), "{case}"); + assert!(member_stray_lock(&member).await.is_some(), "{case}"); } } @@ -3066,16 +3141,165 @@ mod tests { } let member = tmp.path().join("packages/a"); let case = format!("{case}, stray: {stray}"); - assert_eq!(code(&member, "npm").await.is_some(), refused, "{case}"); + // A stray lock is refused whether or not the root lists the + // member: vlt installs its project root, the root here, from + // every directory below it with no `vlt.json` (#1584). A + // root never installed governs nothing. + let stray_governed = stray && root_lock.is_some(); + assert_eq!( + code(&member, "npm").await.is_some(), + refused || stray_governed, + "{case}" + ); assert_eq!( member_stray_lock(&member).await.is_some(), - refused && stray, + stray_governed, "{case}" ); } } } + /// #1584: vlt installs from its project root (`find("project")` in + /// `@vltpkg/vlt-json`): the nearest ancestor `vlt.json`, else the + /// outermost directory holding a `package.json` or `node_modules`, + /// stopping at a `.git` and before the home directory. A directory below + /// that root with no `vlt.json` of its own is never its own project, + /// listed by a `workspaces` field or not, so its `vlt-lock.json` is + /// stray. + #[tokio::test] + async fn vlt_project_root_governs_unlisted_directories() { + for (case, vlt_json, root_files, dir) in [ + ( + "unlisted, vlt.json config only", + Some(r#"{"config":{}}"#), + vec![], + "tools/x", + ), + ( + "unlisted, vlt.json lists others", + Some(r#"{"workspaces":"apps/*"}"#), + vec![], + "tools/x", + ), + ( + "`..` glob", + Some(r#"{"workspaces":["packages/../other/*"]}"#), + vec![], + "other/o", + ), + ( + "`./` and `..` glob", + Some(r#"{"workspaces":["./packages/../other/*"]}"#), + vec![], + "other/o", + ), + ( + "`*/..` glob", + Some(r#"{"workspaces":["packages/*/../a"]}"#), + vec!["packages/b/package.json"], + "packages/a", + ), + ("no vlt.json", None, vec![], "tools/x"), + ( + "vlt 1.3.8 pnpm-workspace.yaml dir", + None, + vec![PNPM_WORKSPACE], + "packages/a", + ), + ] { + 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); + } + for rel in root_files { + let text = if rel == PNPM_WORKSPACE { + "packages:\n - \"packages/*\"\n" + } else { + "{}" + }; + write(tmp.path(), rel, text); + } + write(tmp.path(), VLT_LOCK, &vlt_lock_with_edges(&["file~_d"])); + write(tmp.path(), &format!("{dir}/package.json"), "{}"); + write(tmp.path(), &format!("{dir}/{VLT_LOCK}"), "{}"); + let project = tmp.path().join(dir); + + let stray = member_stray_lock(&project) + .await + .unwrap_or_else(|| panic!("{case}: the lock is stray")); + assert_eq!(stray.root, tmp.path(), "{case}"); + assert_eq!(stray.ignored, vec![VLT_LOCK], "{case}"); + let refused = refusal(&ProjectView::Disk(&project), &[candidate("npm")], true) + .await + .unwrap_or_else(|| panic!("{case}: refused")); + assert_eq!(refused.code, WORKSPACE_LOCKFILE_ELSEWHERE, "{case}"); + assert!( + refused + .message + .contains(&tmp.path().join(VLT_LOCK).display().to_string()) + && refused.message.contains(VLT_JSON) + && refused.message.contains("nothing was written"), + "{case}: {}", + refused.message + ); + assert_eq!(code(tmp.path(), "npm").await, None, "{case}"); + + // Its own vlt.json makes it a project of its own. + write(tmp.path(), &format!("{dir}/{VLT_JSON}"), "{}"); + assert_eq!(member_stray_lock(&project).await, None, "{case}"); + assert_eq!(code(&project, "npm").await, None, "{case}"); + } + } + + /// #1584: the walk for vlt's project root keeps the outermost + /// `package.json` or `node_modules` directory, stops at a `.git` + /// (after reading that directory) and before the home directory. + #[test] + fn vlt_project_root_walk_bounds() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + let dir = base.join("repo/tools/x"); + write(&dir, "package.json", "{}"); + assert_eq!(vlt_project_root(&dir, None), dir); + + // A `node_modules` directory marks a candidate as well as a + // `package.json`, and the outermost candidate wins. + std::fs::create_dir_all(base.join("repo/tools/node_modules")).unwrap(); + write(base, "repo/package.json", "{}"); + assert_eq!(vlt_project_root(&dir, None), base.join("repo")); + + // `.git` ends the walk after its own directory is read. + std::fs::create_dir_all(base.join("repo/.git")).unwrap(); + write(base, "package.json", "{}"); + assert_eq!(vlt_project_root(&dir, None), base.join("repo")); + std::fs::create_dir_all(base.join("repo/tools/.git")).unwrap(); + assert_eq!(vlt_project_root(&dir, None), base.join("repo/tools")); + std::fs::create_dir_all(dir.join(".git")).unwrap(); + assert_eq!(vlt_project_root(&dir, None), dir); + for git in ["repo/.git", "repo/tools/.git", "repo/tools/x/.git"] { + std::fs::remove_dir(base.join(git)).unwrap(); + } + assert_eq!(vlt_project_root(&dir, None), base.to_path_buf()); + + // The home directory is never read: the walk stops below it. + assert_eq!( + vlt_project_root(&dir, Some(&base.join("repo"))), + base.join("repo/tools") + ); + write(base, "repo/vlt.json", "{}"); + assert_eq!( + vlt_project_root(&dir, Some(&base.join("repo"))), + base.join("repo/tools") + ); + + // The nearest `vlt.json` wins over any candidate, at any depth. + assert_eq!(vlt_project_root(&dir, None), base.join("repo")); + write(base, "repo/tools/x/vlt.json", "{}"); + assert_eq!(vlt_project_root(&dir, None), dir); + } + /// #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. diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 95db7b1c9..e1115e5d1 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -1970,6 +1970,46 @@ mod tests { ("vlt-lock.json", VLT_PNPM_WORKSPACE_TEXT), ], ), + // #1584: vlt's project root is the nearest ancestor `vlt.json`, + // so a directory below one that no `workspaces` field lists is + // installed from the root as well, as is a member a `..` glob + // lists, and the vlt 1.3.8 `pnpm-workspace.yaml` directory. + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("package.json", r#"{"name":"root","private":true}"#), + ("vlt.json", r#"{"registries":{}}"#), + ("vlt-lock.json", VLT_TEXT), + ], + ), + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("vlt.json", r#"{"workspaces":["other/../packages/*"]}"#), + ("vlt-lock.json", VLT_TEXT), + ], + ), + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("package.json", r#"{"name":"root","private":true}"#), + ("pnpm-workspace.yaml", "packages:\n - \"packages/*\"\n"), + ("vlt-lock.json", VLT_TEXT), + ], + ), + // Without any `vlt.json`, vlt's root is the outermost directory + // holding a `package.json`. + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("package.json", r#"{"name":"root","private":true}"#), + ("vlt-lock.json", VLT_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 0c02a4ee5..9f7eb79d9 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -5488,6 +5488,24 @@ mod member_stray_lock_tests { ), ][..], ), + // #1584: vlt installs its project root (the nearest ancestor + // `vlt.json`, else the outermost `package.json`) from a + // directory no `workspaces` field lists, too. + ( + "redirect/npm/vlt/basic/expected", + "vlt-lock.json", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + &[("vlt.json", r#"{"config":{}}"#), ("vlt-lock.json", "{}")][..], + ), + ( + "redirect/npm/vlt/basic/expected", + "vlt-lock.json", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + &[ + ("package.json", r#"{"private":true}"#), + ("vlt-lock.json", "{}"), + ][..], + ), ( "redirect/npm/package-lock-v3/basic/expected", "package-lock.json", From 363e4d9df0f8d217f9884073ac23e97c58c4a121 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:42:15 +0000 Subject: [PATCH 3/5] Document vlt project-root stray lock refusal Describe in CLI_CONTRACT.md the #1584 case of the workspace refusal: a directory with no vlt.json of its own whose vlt project root is another, installed directory. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 3756e7abf..d23c9a4b9 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1478,7 +1478,7 @@ command's integration tests check its output. | `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 -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partialFailure`. | | `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 `error.code` (`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]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; yarn berry (picomatch) also reads regex groups (`packages/(a|b)`; a group without `|` only in a pattern fast-glob deems dynamic, or in a `!` pattern), the regex quantifiers `?` / `+` after a group, `+` after a class and `+` inside a group, POSIX classes (`[[:alpha:]]`) and `..` segments in a static pattern or after a static prefix (`packages/a/../b` lists `packages/b`; literal in a `!` pattern); a group alternative holding `/` (`(packages/ui|apps/web)`) is matched across components, and a `?` or `+` quantifying such a group fails closed (the pattern lists every directory, and excludes none as a `!` pattern) (#1490); a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or a `bun.lockb` alone without the trailing `cNfGvRsN` config-version extension, its binary twin, #1492); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) 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 `error.code` (`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]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; yarn berry (picomatch) also reads regex groups (`packages/(a|b)`; a group without `|` only in a pattern fast-glob deems dynamic, or in a `!` pattern), the regex quantifiers `?` / `+` after a group, `+` after a class and `+` inside a group, POSIX classes (`[[:alpha:]]`) and `..` segments in a static pattern or after a static prefix (`packages/a/../b` lists `packages/b`; literal in a `!` pattern); a group alternative holding `/` (`(packages/ui|apps/web)`) is matched across components, and a `?` or `+` quantifying such a group fails closed (the pattern lists every directory, and excludes none as a `!` pattern) (#1490); a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or a `bun.lockb` alone without the trailing `cNfGvRsN` config-version extension, its binary twin, #1492); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134), or, listed or not, when vlt's project root for the directory is another directory holding `vlt-lock.json` (vlt, #1584: the nearest ancestor `vlt.json`, else the outermost directory holding a `package.json` or `node_modules`, the walk ending at a directory holding `.git` and below the home directory, as vlt's own `find("project")` does; the message names that root and says a `vlt.json` in the directory makes it a project of its own); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) 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 `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. 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. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. | | `eject_refused` | top-level `error.code` (`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. | From 5550e0ec7b6453ceef99ba2c270cfc24e2f427c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:23:22 +0000 Subject: [PATCH 4/5] Let vlt's project root win over listings Follow-ups from review of the #1584 fix: - A listed vlt member holding its own .git (a submodule) is its own vlt project, so its lock is no longer refused as stray. - When a vlt.json sits between the member and the listing workspace root, the refusal now names that nearer root, which is the one vlt installs, and says "vlt project root" instead of "workspace root". - The walk resolves symlinks and `..` like vlt's own cwd does. - The message names only the vlt lock as ignored. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 118 +++++++++++++++--- .../src/vendor/npm_flavor.rs | 7 +- .../socket-patch-core/src/vex/discover/mod.rs | 6 +- 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index ad6ec1755..54a1e8825 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -317,6 +317,9 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal pub(crate) struct MemberStrayLock { /// The workspace root whose lock installs the member. pub(crate) root: PathBuf, + /// What [`Self::root`] is, for the "run from" hint: `workspace root`, + /// or `vlt project root` when no workspace lists the member (#1584). + pub(crate) root_kind: &'static str, /// The member's own lock files (root-relative names), every one of /// them ignored. pub(crate) ignored: Vec<&'static str>, @@ -388,6 +391,7 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { // Set when vlt governs through its project root rather than a // workspace listing (#1584). let mut vlt_project = false; + let mut root_kind = "workspace root"; for (family, manager) in STRAY_LOCK_FAMILIES { let files = family.files(); if !own.iter().any(|name| files.contains(name)) { @@ -398,17 +402,29 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { if root.join(VLT_JSON).exists() { return None; } + // vlt installs its project root from here, whatever lists + // the directory (#1584). A `.git` here makes the directory + // that root; a project root nearer than the listing + // workspace root is the one vlt installs. + let own_dir = canonical_or_absolute(root); + let project = vlt_project_root(&own_dir, vlt_home().as_deref()); + if project == own_dir { + return None; + } match vlt_lock_root(root, &mut package_json_root).await { - Some(found) => Some(found), - // Listed or not, a directory with no `vlt.json` is - // installed from vlt's project root; as for a listed - // member, a root never installed governs nothing. - None => { - let project = vlt_project_root(root, vlt_home().as_deref()); - let own_dir = - std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf()); - vlt_project = governing.is_none(); - (project != own_dir && project.join(VLT_LOCK).is_file()) + Some((ancestor, locks)) + if project == ancestor || !project.starts_with(&ancestor) => + { + Some((ancestor, locks)) + } + // As for a listed member, a root never installed + // governs nothing. + _ => { + vlt_project = true; + root_kind = "vlt project root"; + project + .join(VLT_LOCK) + .is_file() .then(|| (project, vec![VLT_LOCK])) } } @@ -436,7 +452,7 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { root.display(), ancestor.display(), join_paths(&ancestor, &root_locks), - join_paths(root, &own), + join_paths(root, &[VLT_LOCK]), root.display() ) } else { @@ -451,6 +467,7 @@ pub(crate) async fn member_stray_lock(root: &Path) -> Option { }; Some(MemberStrayLock { root: ancestor, + root_kind, ignored: own, detail, }) @@ -503,7 +520,7 @@ async fn vlt_lock_root( /// vlt keys none of this on a `workspaces` field, so a directory it does /// not list is installed from the root too (#1584). fn vlt_project_root(dir: &Path, home: Option<&Path>) -> PathBuf { - let dir = std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()); + let dir = canonical_or_absolute(dir); let mut last_known = dir.as_path(); for ancestor in dir.ancestors() { if Some(ancestor) == home { @@ -522,6 +539,14 @@ fn vlt_project_root(dir: &Path, home: Option<&Path>) -> PathBuf { last_known.to_path_buf() } +/// `path` with symlinks and `..` resolved, as Node's `process.cwd()` is, +/// like the sibling walks; the absolute path when it cannot be resolved. +fn canonical_or_absolute(path: &Path) -> PathBuf { + std::fs::canonicalize(path) + .or_else(|_| std::path::absolute(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + /// The home directory vlt's project walk stops below: Node's /// `os.homedir()`, `HOME` on Unix and `USERPROFILE` on Windows. fn vlt_home() -> Option { @@ -674,10 +699,10 @@ async fn member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { let refusal = Refusal { code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), message: format!( - "{}; run socket-patch from {} (the workspace root); nothing was \ - written", + "{}; run socket-patch from {} (the {}); nothing was written", stray.detail, - stray.root.display() + stray.root.display(), + stray.root_kind ), }; Some((stray.root, refusal)) @@ -1958,7 +1983,9 @@ mod tests { /// #1097 (review on #1480): only the locks whose package manager lists /// the member govern it. The refusal names those alone, and a member's - /// own lock is stray only when its own manager lists the member. + /// own lock is stray only when its own manager lists the member (vlt + /// aside, which installs its project root from any directory without a + /// `vlt.json`, #1584). #[tokio::test] async fn only_the_locks_whose_manager_lists_the_member_govern_it() { const CLASSIC: &str = "# yarn lockfile v1\n"; @@ -1988,7 +2015,7 @@ mod tests { // reads, not a stray. write(tmp.path(), "packages/b/package-lock.json", "{}"); assert_eq!(member_stray_lock(&member).await, None); - // Likewise Bun 1.3 and vlt (no `vlt.json` workspaces). + // Likewise Bun 1.3. std::fs::remove_file(member.join("package-lock.json")).unwrap(); std::fs::remove_file(tmp.path().join("package-lock.json")).unwrap(); write(tmp.path(), "bun.lock", BUN_13); @@ -3229,7 +3256,12 @@ mod tests { let stray = member_stray_lock(&project) .await .unwrap_or_else(|| panic!("{case}: the lock is stray")); - assert_eq!(stray.root, tmp.path(), "{case}"); + assert_eq!( + stray.root, + std::fs::canonicalize(tmp.path()).unwrap(), + "{case}" + ); + assert_eq!(stray.root_kind, "vlt project root", "{case}"); assert_eq!(stray.ignored, vec![VLT_LOCK], "{case}"); let refused = refusal(&ProjectView::Disk(&project), &[candidate("npm")], true) .await @@ -3253,13 +3285,61 @@ mod tests { } } + /// #1584: vlt's project root decides over a workspace listing. A listed + /// member holding `.git` (a submodule) is its own vlt project, and a + /// `vlt.json` between the member and the listing root is the project + /// vlt installs, so that is the root named. + #[tokio::test] + async fn vlt_project_root_overrides_the_listing_root() { + let tmp = tempfile::tempdir().unwrap(); + let base = std::fs::canonicalize(tmp.path()).unwrap(); + write(&base, "package.json", r#"{"private":true}"#); + write(&base, VLT_JSON, r#"{"workspaces":"packages/**"}"#); + write(&base, VLT_LOCK, "{}"); + write(&base, "packages/a/package.json", "{}"); + write(&base, "packages/a/vlt-lock.json", "{}"); + let member = base.join("packages/a"); + let listed = member_stray_lock(&member).await.expect("listed member"); + assert_eq!(listed.root, base); + assert_eq!(listed.root_kind, "workspace root"); + + std::fs::create_dir_all(member.join(".git")).unwrap(); + assert_eq!(member_stray_lock(&member).await, None); + assert_eq!(code(&member, "npm").await, None); + std::fs::remove_dir(member.join(".git")).unwrap(); + + // A config-only vlt.json nearer than the listing root. + write(&base, "packages/vlt.json", r#"{"config":{}}"#); + write(&base, "packages/package.json", "{}"); + write(&base, &format!("packages/{VLT_LOCK}"), "{}"); + let nearer = member_stray_lock(&member).await.expect("stray"); + assert_eq!(nearer.root, base.join("packages")); + assert_eq!(nearer.root_kind, "vlt project root"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .expect("refused"); + assert!( + refused.message.contains(&format!( + "run socket-patch from {} (the vlt project root)", + base.join("packages").display() + )), + "{}", + refused.message + ); + // Never installed, the nearer root governs nothing. + std::fs::remove_file(base.join("packages").join(VLT_LOCK)).unwrap(); + assert_eq!(member_stray_lock(&member).await, None); + } + /// #1584: the walk for vlt's project root keeps the outermost /// `package.json` or `node_modules` directory, stops at a `.git` /// (after reading that directory) and before the home directory. #[test] fn vlt_project_root_walk_bounds() { let tmp = tempfile::tempdir().unwrap(); - let base = tmp.path(); + // A `.git` at the base keeps the walk inside the temp dir. + let base = &std::fs::canonicalize(tmp.path()).unwrap(); + std::fs::create_dir_all(base.join(".git")).unwrap(); let dir = base.join("repo/tools/x"); write(&dir, "package.json", "{}"); assert_eq!(vlt_project_root(&dir, None), dir); diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index e1115e5d1..b85078841 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -416,9 +416,10 @@ pub(crate) async fn member_stray_lock_refusal( Some(( "vendor_lockfile_missing", format!( - "{}; vendor from {} (the workspace root)", + "{}; vendor from {} (the {})", stray.detail, - stray.root.display() + stray.root.display(), + stray.root_kind ), )) } @@ -2047,7 +2048,7 @@ mod tests { }; assert_eq!(code, "vendor_lockfile_missing", "{member_lock}"); assert!( - detail.contains("workspace") + (detail.contains("workspace") || detail.contains("vlt project")) && detail.contains("ignores") && detail.contains(member_lock), "{member_lock}: {detail}" diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 9f7eb79d9..341124d91 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1477,9 +1477,10 @@ async fn ignore_member_stray_locks(ctx: &mut DiscoverCtx<'_>, out: &mut Discover rel, format!( "{}, so the Socket references in {rel} are not attested; run socket-patch \ - from {} (the workspace root)", + from {} (the {})", stray.detail, - stray.root.display() + stray.root.display(), + stray.root_kind ), ); } @@ -5481,6 +5482,7 @@ mod member_stray_lock_tests { "vlt-lock.json", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &[ + ("package.json", r#"{"private":true}"#), ("pnpm-workspace.yaml", "packages:\n - packages/*\n"), ( "vlt-lock.json", From 20e3ae501668d4830cae999ea5a31cd1be2a0fdf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:43:41 +0000 Subject: [PATCH 5/5] Match vlt's home stop on resolved paths The vlt project-root walk compared canonicalized ancestors against the raw HOME / USERPROFILE value. On Windows canonical paths carry a `\\?\` prefix, and on any OS a symlinked or `..`-spelled home never matched, so the walk could climb into the home directory and pick the wrong vlt project root. Resolve the home path the same way. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 18 ++++++++++++++++-- 1 file changed, 16 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 54a1e8825..a39cddffa 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -521,9 +521,12 @@ async fn vlt_lock_root( /// not list is installed from the root too (#1584). fn vlt_project_root(dir: &Path, home: Option<&Path>) -> PathBuf { let dir = canonical_or_absolute(dir); + // Resolved like the ancestors, so the stop matches on Windows (`\\?\`) + // and through a symlinked home (Bugbot on #1658). + let home = home.map(canonical_or_absolute); let mut last_known = dir.as_path(); for ancestor in dir.ancestors() { - if Some(ancestor) == home { + if Some(ancestor) == home.as_deref() { break; } if ancestor.join(VLT_JSON).exists() { @@ -3363,11 +3366,22 @@ mod tests { } assert_eq!(vlt_project_root(&dir, None), base.to_path_buf()); - // The home directory is never read: the walk stops below it. + // The home directory is never read: the walk stops below it, also + // when home is spelled through a symlink or `..`. assert_eq!( vlt_project_root(&dir, Some(&base.join("repo"))), base.join("repo/tools") ); + assert_eq!( + vlt_project_root(&dir, Some(&base.join("repo/tools/../../repo"))), + base.join("repo/tools") + ); + #[cfg(unix)] + { + let link = base.join("home-link"); + std::os::unix::fs::symlink(base.join("repo"), &link).unwrap(); + assert_eq!(vlt_project_root(&dir, Some(&link)), base.join("repo/tools")); + } write(base, "repo/vlt.json", "{}"); assert_eq!( vlt_project_root(&dir, Some(&base.join("repo"))),