From 6fc25ddb288011fbfbe02f685b9077394359f87b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:44:27 -0400 Subject: [PATCH 1/3] Start v5 blocker fix (uv-workspace-member) Empty commit to open the draft PR. Co-Authored-By: Claude Opus 5.5 (1M context) From 56d1f6dde127e583cb7500a6b5192a91c47c9cf8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:25:08 -0400 Subject: [PATCH 2/3] Refuse scans from a uv workspace member dir A scan run from a uv workspace member never saw the root uv.lock. A member with any Hatch configuration (the hatchling backend that `uv init --package` scaffolds before uv 0.8, a hatch.toml) was then rewritten as a lockless Hatch project in both modes, exit 0. The root uv.lock went stale, `uv sync --frozen` installed the unpatched release, and vendored vex attested it not_affected. A directory with a pyproject.toml but no Python lock of its own, listed by the nearest ancestor `[tool.uv.workspace] members` (minus `exclude`), is now refused before anything is written: hosted with `redirect_workspace_lockfile_elsewhere` (the governing-root pre-check), vendored with `pypi_uv_workspace_unsupported`, the code a run from the workspace root already gets. The message names the workspace root and its lock. Fixes #1138 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/mode_migration_pypi.rs | 113 +++++++++++ .../src/hosted/governing_root.rs | 13 +- crates/socket-patch-core/src/utils/mod.rs | 1 + .../src/utils/uv_workspace.rs | 181 ++++++++++++++++++ crates/socket-patch-core/src/vendor/pypi.rs | 11 ++ docs/testing/uv-compatibility.md | 7 + 7 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/uv_workspace.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..602c2ec8f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1325,7 +1325,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 -- `)` (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 `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 `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]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; 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]`); 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` but no Python lock of its own (`uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `pylock*.toml`, a script lock), and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), so uv installs it from that root's `uv.lock`; 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_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-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 9296c6884..737f13997 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -2002,3 +2002,116 @@ async fn pipenv_hosted_to_vendored_names_the_unpatched_requirements() { "the takeover names requirements.txt as an unpatched install source: {env:#}" ); } + +// ── #1138: a uv workspace member ───────────────────────────────────────── + +/// A uv workspace (root `pyproject.toml` with `[tool.uv.workspace]` and its +/// `uv.lock`) whose member `packages/a` carries `member`'s Hatch +/// configuration; `six` 1.16.0 is installed in the run's venv. Returns the +/// member directory. +fn stage_uv_workspace_member(ws: &Path, member: &[(&str, &str)]) -> std::path::PathBuf { + std::fs::write( + ws.join("pyproject.toml"), + "[project]\nname = \"root\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"a\"]\n\n[tool.uv.workspace]\nmembers = [\"packages/*\"]\n\n[tool.uv.sources]\na = { workspace = true }\n", + ) + .unwrap(); + std::fs::write( + ws.join("uv.lock"), + "version = 1\nrequires-python = \">=3.9\"\n\n[manifest]\nmembers = [\"a\", \"root\"]\n", + ) + .unwrap(); + let dir = ws.join("packages/a"); + for (rel, text) in member { + let path = dir.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + // The venv `run_raw` points at (beside the member) holds six 1.16.0. + let site = dir.join("../empty-venv").join(if cfg!(windows) { + "Lib/site-packages" + } else { + "lib/python3.11/site-packages" + }); + let dist_info = site.join("six-1.16.0.dist-info"); + std::fs::create_dir_all(&dist_info).unwrap(); + std::fs::write( + dist_info.join("METADATA"), + "Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n", + ) + .unwrap(); + std::fs::write(site.join("six.py"), ORIG).unwrap(); + dir +} + +/// Every file under `root` outside `.socket/` and the test venv (relative +/// path → bytes). +fn tree(root: &Path) -> std::collections::BTreeMap> { + let mut out = std::collections::BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(); + if path + .components() + .any(|c| c.as_os_str() == ".socket" || c.as_os_str() == "empty-venv") + { + continue; + } + if path.is_dir() { + stack.push(path); + } else { + out.insert(rel, std::fs::read(&path).unwrap()); + } + } + } + out +} + +/// #1138: a scan from a uv workspace member whose own files are Hatch-shaped +/// (the hatchling backend `uv init --package` scaffolds before uv 0.8, or a +/// `hatch.toml`) used to rewrite the member as a lockless Hatch project in +/// both modes, exit 0 `success`, while the root `uv.lock` went stale and +/// `uv sync --frozen` installed the unpatched release. Both modes must fail +/// closed, name the workspace root and write nothing. +#[tokio::test] +async fn uv_workspace_hatch_member_is_refused_in_both_modes() { + const HATCHLING: &str = "[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n"; + const PLAIN: &str = "[project]\nname = \"a\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n"; + for member in [ + &[("pyproject.toml", HATCHLING)][..], + &[ + ("pyproject.toml", PLAIN), + ("hatch.toml", "[envs.default]\n"), + ][..], + ] { + let (_tmp, ws) = project(); + let dir = stage_uv_workspace_member(&ws, member); + let before = tree(&ws); + + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&dir, &server); + assert_eq!(code, 1, "hosted: {env:#}"); + assert_eq!( + env["error"]["code"], "redirect_workspace_lockfile_elsewhere", + "hosted: {env:#}" + ); + let message = env["error"]["message"].as_str().unwrap_or_default(); + assert!(message.contains("uv workspace"), "{message}"); + assert_eq!(tree(&ws), before, "hosted wrote nothing"); + + stage_manifest(&dir); + let (code, env) = run_cli(&dir, &["vendor"], &[]); + assert_ne!(code, 0, "vendored: {env:#}"); + assert!( + env.to_string().contains("pypi_uv_workspace_unsupported"), + "vendored: {env:#}" + ); + assert_eq!(tree(&ws), before, "vendored wrote nothing"); + } +} diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index f64e55052..d2c18ec54 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -4,7 +4,7 @@ //! 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). +//! (cargo, #417; a uv workspace member with Hatch configuration, #1138). //! //! [`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 @@ -44,7 +44,8 @@ pub const PNPM_LOCKFILE_ELSEWHERE: &str = "redirect_pnpm_lockfile_elsewhere"; /// Refusal code for an npm, yarn, Bun or vlt workspace member: an ancestor /// `package.json` (or, for vlt, `vlt.json`) lists the project directory in -/// its `workspaces`, and the workspace's lock lives at that root. +/// its `workspaces`, and the workspace's lock lives at that root. Also a uv +/// workspace member (`[tool.uv.workspace] members`, #1138). pub const WORKSPACE_LOCKFILE_ELSEWHERE: &str = "redirect_workspace_lockfile_elsewhere"; /// Refusal code for a pnpm workspace member with its own lock whose @@ -86,6 +87,14 @@ pub async fn refusal( return Some(refusal); } } + if candidates.iter().any(|c| c.dep.ecosystem == "pypi") { + if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(root).await { + return Some(Refusal { + code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), + message: crate::utils::uv_workspace::member_detail(root, &workspace), + }); + } + } if candidates.iter().any(|c| c.dep.ecosystem == "npm") { let workspace = if has_own_npm_family_lock(root) { member_stray_lock_refusal(root).await diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index cf4d79805..17e8fd632 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -32,6 +32,7 @@ pub mod socket_dir; pub mod target; pub(crate) mod toml_edit_ext; pub mod uri; +pub(crate) mod uv_workspace; pub(crate) mod workspace_globs; pub mod hatch; diff --git a/crates/socket-patch-core/src/utils/uv_workspace.rs b/crates/socket-patch-core/src/utils/uv_workspace.rs new file mode 100644 index 000000000..3def46948 --- /dev/null +++ b/crates/socket-patch-core/src/utils/uv_workspace.rs @@ -0,0 +1,181 @@ +//! The uv workspace that governs a member directory from above (#1138). +//! +//! In a uv workspace, `uv.lock` lives at the workspace root and governs +//! every member listed by the root `pyproject.toml`'s +//! `[tool.uv.workspace] members` globs (minus its `exclude` globs). A run +//! whose project directory is such a member reads only the member: its +//! Python flavor routing never sees the ancestor lock, and a member with +//! any Hatch configuration (the hatchling build backend `uv init --package` +//! scaffolds before uv 0.8, a `hatch.toml`, a `[tool.hatch.*]` table) was +//! rewritten as a lockless Hatch project. The root `uv.lock` then went +//! stale, `uv sync --frozen` installed the unpatched release, and vendored +//! VEX attested it `not_affected`. +//! +//! Both modes refuse the layout instead (hosted through the governing-root +//! pre-check, vendored in the PyPI flavor routing), the way they already +//! refuse a run from the workspace root itself. + +use std::path::{Path, PathBuf}; + +use toml_edit::{DocumentMut, Item}; + +use crate::formats::governing_locks::PYPI_TOOL_LOCKS; +use crate::utils::fs::read_regular_to_string; +use crate::utils::workspace_globs::glob_matches; + +/// The uv workspace that governs `dir`: `Some(root)` when `dir` holds a +/// `pyproject.toml` but no Python lock of its own (no `uv.lock`, +/// `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `pylock*.toml` or script +/// lock), and the nearest ancestor `pyproject.toml` declaring +/// `[tool.uv.workspace]` lists it as a member. As in uv, the nearest +/// workspace decides: one that does not list `dir` (or excludes it) +/// governs nothing here, and no outer root is consulted. +pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { + let dir = tokio::fs::canonicalize(dir) + .await + .unwrap_or_else(|_| dir.to_path_buf()); + if tokio::fs::metadata(dir.join("pyproject.toml")) + .await + .is_err() + { + return None; + } + for lock in PYPI_TOOL_LOCKS { + if tokio::fs::metadata(dir.join(lock)).await.is_ok() { + return None; + } + } + if crate::utils::python_lock::python_lock_paths(&dir).is_ok_and(|locks| !locks.is_empty()) { + return None; + } + for ancestor in dir.ancestors().skip(1) { + let Ok(text) = read_regular_to_string(&ancestor.join("pyproject.toml")).await else { + continue; + }; + let Ok(doc) = text.parse::() else { + continue; + }; + let Some(workspace) = doc + .get("tool") + .and_then(Item::as_table_like) + .and_then(|tool| tool.get("uv")) + .and_then(Item::as_table_like) + .and_then(|uv| uv.get("workspace")) + .and_then(Item::as_table_like) + else { + continue; + }; + let rel: Vec = dir + .strip_prefix(ancestor) + .ok()? + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + let globs = |key: &str| -> Vec { + workspace + .get(key) + .and_then(Item::as_array) + .into_iter() + .flatten() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect() + }; + let member = globs("members") + .iter() + .any(|pattern| glob_matches(pattern, &rel)) + && !globs("exclude") + .iter() + .any(|pattern| glob_matches(pattern, &rel)); + return member.then(|| ancestor.to_path_buf()); + } + None +} + +/// The one-line detail both modes refuse a governed member with: names the +/// workspace root, the lock it installs from (or that it has none yet), and +/// that nothing was written. +pub(crate) fn member_detail(member: &Path, root: &Path) -> String { + let lock = root.join("uv.lock"); + let installs = if lock.is_file() { + format!("installs it from {}", lock.display()) + } else { + format!( + "will install it from a uv.lock at {} (none yet)", + root.display() + ) + }; + format!( + "{} is a member of the uv workspace rooted at {}, which {installs}; a run here \ + reads only the member, so rewriting it would leave that lock stale and installs \ + from it unpatched, and socket-patch does not patch uv workspaces from their root \ + yet either; nothing was written", + member.display(), + root.display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, text: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + const ROOT: &str = "[project]\nname = \"root\"\nversion = \"0.1.0\"\n\n[tool.uv.workspace]\nmembers = [\"packages/*\"]\nexclude = [\"packages/skip\"]\n"; + const MEMBER: &str = "[project]\nname = \"a\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n"; + + #[tokio::test] + async fn listed_member_without_its_own_lock_is_governed() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + write(&root, "pyproject.toml", ROOT); + write(&root, "uv.lock", "version = 1\n"); + for member in ["packages/a", "packages/skip", "tools/x"] { + write(&root, &format!("{member}/pyproject.toml"), MEMBER); + } + assert_eq!( + governing_uv_workspace(&root.join("packages/a")).await, + Some(root.clone()) + ); + // Excluded, or not listed: not a member. + assert_eq!( + governing_uv_workspace(&root.join("packages/skip")).await, + None + ); + assert_eq!(governing_uv_workspace(&root.join("tools/x")).await, None); + // The workspace root itself is not governed from above. + assert_eq!(governing_uv_workspace(&root).await, None); + // A member with a lock of its own is its own project. + write(&root, "packages/a/poetry.lock", ""); + assert_eq!(governing_uv_workspace(&root.join("packages/a")).await, None); + // A directory with no pyproject.toml is not a uv project. + write(&root, "packages/b/requirements.txt", "six==1.16.0\n"); + assert_eq!(governing_uv_workspace(&root.join("packages/b")).await, None); + } + + #[tokio::test] + async fn the_nearest_workspace_decides() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + write(&root, "pyproject.toml", ROOT); + // A nested workspace that does not list the member stops the walk. + write( + &root, + "packages/a/pyproject.toml", + "[project]\nname = \"a\"\n\n[tool.uv.workspace]\nmembers = [\"libs/*\"]\n", + ); + write(&root, "packages/a/tools/t/pyproject.toml", MEMBER); + assert_eq!( + governing_uv_workspace(&root.join("packages/a/tools/t")).await, + None + ); + write(&root, "packages/a/libs/l/pyproject.toml", MEMBER); + assert_eq!( + governing_uv_workspace(&root.join("packages/a/libs/l")).await, + Some(root.join("packages/a")) + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 73822f574..213f9d653 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -405,6 +405,17 @@ async fn detect_pypi_flavor( None => {} } + // #1138: a uv workspace member installs from the workspace root's + // uv.lock, which this run cannot see; routing it by its own files would + // rewrite it as a lockless (Hatch) project and leave that lock stale. + if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(project_root).await + { + return Err(( + "pypi_uv_workspace_unsupported", + crate::utils::uv_workspace::member_detail(project_root, &workspace), + )); + } + let pyproject_text = read_regular_to_string(&project_root.join("pyproject.toml")) .await .ok(); diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 5448481c0..df8147fe8 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -146,6 +146,13 @@ frozen, locked, and ordinary installation outcomes separately where supported. vendored `uv.lock` / `pyproject.toml` writer (including the appended `[manifest]` and `[package.metadata]` fragments and their revert) keep the file's convention. +- uv workspaces are refused from the root (`pypi_uv_workspace_unsupported` / + `redirect_uv_project_unsupported`) and from a member directory: a member + with no Python lock of its own, listed by the nearest ancestor + `[tool.uv.workspace] members`, installs from the root's `uv.lock`, so both + modes refuse it before writing (`redirect_workspace_lockfile_elsewhere` + hosted, `pypi_uv_workspace_unsupported` vendored) instead of rewriting a + Hatch-configured member as a lockless Hatch project (#1138). - A script lock requires its paired script and a valid PEP 723 metadata block. Missing metadata or an incompatible existing source is reported before either file is rewritten. From d0a87ca50b587942e8b36c8b91205b6b0792b1c3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 15:14:01 -0400 Subject: [PATCH 3/3] Follow uv workspace discovery for member scans Two gaps in the #1138 member check: - An ancestor pyproject.toml with a [project] table and no workspace ends uv's walk: the directory is nested in a standalone project (its tests or examples), and uv does not install it from an outer workspace. The walk kept climbing, so a recursive `members` glob could refuse a project uv treats as standalone. - A lock left in a listed member (uv.lock, poetry.lock, pdm.lock, Pipfile.lock, a pylock) exempted it, but uv still installs the member from the root's uv.lock and never reads those files, so the stray lock was rewritten and the root lock went stale. The member is now refused whatever locks it holds, and the vendored check runs before flavor routing. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/utils/uv_workspace.rs | 67 ++++++++++++++----- crates/socket-patch-core/src/vendor/pypi.rs | 22 +++--- docs/testing/uv-compatibility.md | 9 +-- 4 files changed, 67 insertions(+), 33 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 602c2ec8f..accf6727f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1325,7 +1325,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 -- `)` (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 `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 `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]`); 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` but no Python lock of its own (`uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `pylock*.toml`, a script lock), and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), so uv installs it from that root's `uv.lock`; 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]`); 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_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/utils/uv_workspace.rs b/crates/socket-patch-core/src/utils/uv_workspace.rs index 3def46948..eb3284ff2 100644 --- a/crates/socket-patch-core/src/utils/uv_workspace.rs +++ b/crates/socket-patch-core/src/utils/uv_workspace.rs @@ -19,17 +19,20 @@ use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item}; -use crate::formats::governing_locks::PYPI_TOOL_LOCKS; use crate::utils::fs::read_regular_to_string; use crate::utils::workspace_globs::glob_matches; /// The uv workspace that governs `dir`: `Some(root)` when `dir` holds a -/// `pyproject.toml` but no Python lock of its own (no `uv.lock`, -/// `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `pylock*.toml` or script -/// lock), and the nearest ancestor `pyproject.toml` declaring -/// `[tool.uv.workspace]` lists it as a member. As in uv, the nearest -/// workspace decides: one that does not list `dir` (or excludes it) -/// governs nothing here, and no outer root is consulted. +/// `pyproject.toml` and the nearest ancestor `pyproject.toml` declaring +/// `[tool.uv.workspace]` lists it as a member. Discovery follows uv's: the +/// nearest workspace decides (one that does not list `dir`, or excludes +/// it, governs nothing here and no outer root is consulted), and an +/// ancestor `pyproject.toml` with a `[project]` table but no workspace +/// ends the walk (`dir` is nested in a standalone project, e.g. its tests +/// or examples). A member's own lock files do not make it standalone: uv +/// installs a listed member from the workspace root's `uv.lock` and never +/// reads a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock +/// left in the member directory. pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { let dir = tokio::fs::canonicalize(dir) .await @@ -40,14 +43,6 @@ pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { { return None; } - for lock in PYPI_TOOL_LOCKS { - if tokio::fs::metadata(dir.join(lock)).await.is_ok() { - return None; - } - } - if crate::utils::python_lock::python_lock_paths(&dir).is_ok_and(|locks| !locks.is_empty()) { - return None; - } for ancestor in dir.ancestors().skip(1) { let Ok(text) = read_regular_to_string(&ancestor.join("pyproject.toml")).await else { continue; @@ -63,6 +58,9 @@ pub(crate) async fn governing_uv_workspace(dir: &Path) -> Option { .and_then(|uv| uv.get("workspace")) .and_then(Item::as_table_like) else { + if doc.contains_key("project") { + return None; + } continue; }; let rel: Vec = dir @@ -148,9 +146,14 @@ mod tests { assert_eq!(governing_uv_workspace(&root.join("tools/x")).await, None); // The workspace root itself is not governed from above. assert_eq!(governing_uv_workspace(&root).await, None); - // A member with a lock of its own is its own project. + // A lock left in the member does not make it standalone: uv still + // installs it from the root's uv.lock. write(&root, "packages/a/poetry.lock", ""); - assert_eq!(governing_uv_workspace(&root.join("packages/a")).await, None); + write(&root, "packages/a/uv.lock", "version = 1\n"); + assert_eq!( + governing_uv_workspace(&root.join("packages/a")).await, + Some(root.clone()) + ); // A directory with no pyproject.toml is not a uv project. write(&root, "packages/b/requirements.txt", "six==1.16.0\n"); assert_eq!(governing_uv_workspace(&root.join("packages/b")).await, None); @@ -178,4 +181,34 @@ mod tests { Some(root.join("packages/a")) ); } + + /// As in uv, a standalone project (`[project]`, no workspace) between + /// the directory and a workspace root that would list it ends the walk; + /// an ancestor `pyproject.toml` with only tool configuration does not. + #[tokio::test] + async fn an_intermediate_project_is_a_boundary() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + write( + &root, + "pyproject.toml", + "[project]\nname = \"root\"\n\n[tool.uv.workspace]\nmembers = [\"**\"]\n", + ); + write(&root, "app/pyproject.toml", "[project]\nname = \"app\"\n"); + write(&root, "app/examples/demo/pyproject.toml", MEMBER); + assert_eq!( + governing_uv_workspace(&root.join("app/examples/demo")).await, + None + ); + write( + &root, + "tools/pyproject.toml", + "[tool.ruff]\nline-length = 100\n", + ); + write(&root, "tools/x/pyproject.toml", MEMBER); + assert_eq!( + governing_uv_workspace(&root.join("tools/x")).await, + Some(root.clone()) + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 213f9d653..042425b30 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -277,6 +277,17 @@ async fn detect_pypi_flavor( project_root: &Path, target: Option<(&str, &str)>, ) -> Result<(PypiFlavor, Vec), (&'static str, String)> { + // #1138: a uv workspace member installs from the workspace root's + // uv.lock, which this run cannot see; routing it by its own files would + // rewrite it as a lockless (Hatch) project, or rewrite a lock left in + // the member that uv never reads, and leave the root lock stale. + if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(project_root).await + { + return Err(( + "pypi_uv_workspace_unsupported", + crate::utils::uv_workspace::member_detail(project_root, &workspace), + )); + } let exists = |name: &str| { let p = project_root.join(name); async move { tokio::fs::metadata(&p).await.is_ok() } @@ -405,17 +416,6 @@ async fn detect_pypi_flavor( None => {} } - // #1138: a uv workspace member installs from the workspace root's - // uv.lock, which this run cannot see; routing it by its own files would - // rewrite it as a lockless (Hatch) project and leave that lock stale. - if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(project_root).await - { - return Err(( - "pypi_uv_workspace_unsupported", - crate::utils::uv_workspace::member_detail(project_root, &workspace), - )); - } - let pyproject_text = read_regular_to_string(&project_root.join("pyproject.toml")) .await .ok(); diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index df8147fe8..021c103a7 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -148,10 +148,11 @@ frozen, locked, and ordinary installation outcomes separately where supported. file's convention. - uv workspaces are refused from the root (`pypi_uv_workspace_unsupported` / `redirect_uv_project_unsupported`) and from a member directory: a member - with no Python lock of its own, listed by the nearest ancestor - `[tool.uv.workspace] members`, installs from the root's `uv.lock`, so both - modes refuse it before writing (`redirect_workspace_lockfile_elsewhere` - hosted, `pypi_uv_workspace_unsupported` vendored) instead of rewriting a + listed by the nearest ancestor `[tool.uv.workspace] members` (no + standalone `[project]` in between) installs from the root's `uv.lock`, + whatever locks sit in the member, so both modes refuse it before writing + (`redirect_workspace_lockfile_elsewhere` hosted, + `pypi_uv_workspace_unsupported` vendored) instead of rewriting a Hatch-configured member as a lockless Hatch project (#1138). - A script lock requires its paired script and a valid PEP 723 metadata block. Missing metadata or an incompatible existing source is reported before either