diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b42c5cedc..dffb11f69 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1480,7 +1480,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; for Bun and vlt, `*`, `?` and `**` never match a directory whose name starts with `.` (`packages/*` does not list `packages/.tools`, #1542, #1631) unless the pattern spells the dot (`packages/.tools`, `packages/.*`), while a `!` pattern still excludes one; 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; for Bun and vlt, `*`, `?` and `**` never match a directory whose name starts with `.` (`packages/*` does not list `packages/.tools`, #1542, #1631) unless the pattern spells the dot (`packages/.tools`, `packages/.*`), while a `!` pattern still excludes one; 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. | diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 12ee745c7..df54df69d 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>, @@ -338,7 +341,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 +358,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; + 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)) { @@ -392,7 +402,32 @@ 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 + // 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((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])) + } + } } _ => cached_package_json_root(root, &mut package_json_root) .await @@ -409,16 +444,30 @@ 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, &[VLT_LOCK]), + 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, + root_kind, ignored: own, detail, }) @@ -462,6 +511,54 @@ 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 = 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.as_deref() { + 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() +} + +/// `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 { + 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 @@ -605,10 +702,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)) @@ -971,8 +1068,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. @@ -1888,7 +1986,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"; @@ -1918,7 +2018,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); @@ -1926,9 +2026,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 +2726,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 +2744,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 +2801,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}"); } } @@ -3113,16 +3218,229 @@ 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, + 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 + .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: 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(); + // 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); + + // 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, 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"))), + 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..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 ), )) } @@ -1970,6 +1971,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")) @@ -2007,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 6ef311ece..4e0aae2e0 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1514,9 +1514,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 ), ); } @@ -5518,6 +5519,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", @@ -5525,6 +5527,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",