From 5133649a6b03a9b64e48835cc91279357cf1f5e7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:43:23 -0400 Subject: [PATCH 1/3] Start yarn classic member-dir fix (#691) Co-Authored-By: Claude Opus 5.5 (1M context) From 1c19f0ac412ccf15ba267d46f615696e598f7a24 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:00:24 -0400 Subject: [PATCH 2/3] Warn on yarn classic member-dir vendored installs Vendored yarn classic wiring resolves `file:./.socket/vendor/...` tarballs, and yarn 1 reads a relative `file:` path from the directory it runs in. In a workspaces project, every cold-cache yarn command run from a member directory then fails with "Tarball is not in network". No relative spelling installs from both the root and a member (measured on yarn 1.22.22). Vendored runs in such a project now warn `yarn_classic_workspace_member_install_risk` and name the remedy (install from the workspace root, or use hosted mode). The limitation is documented. Whether to also ship the tarball into each member is open on the issue. Refs #691 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 +- .../socket-patch-cli/src/commands/vendor.rs | 33 ++++---- .../tests/covgap_commands_vendor.rs | 46 +++++++++++ crates/socket-patch-core/src/vendor/mod.rs | 78 +++++++++++++++++++ docs/ecosystems.md | 10 +++ 5 files changed, 157 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..ad96ed246 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -538,7 +538,10 @@ the model is **not uniform** today: vlt.json `workspaces` — a glob, a list, or named groups of either — or vlt <= 0.0.0-12's `vlt-workspaces.json`; vlt's declaration wins over the others and vlt never reads package.json `workspaces`). One repo-root invocation discovers every member. A member that is itself a - workspace root is recursed into (bounded depth). + workspace root is recursed into (bounded depth). Vendored yarn classic wiring is root-relative (`file:./.socket/vendor/…`), which yarn 1 + reads relative to the directory it runs in: a cold-cache yarn command run from a member + directory cannot fetch it, and the run warns `yarn_classic_workspace_member_install_risk` + (#691; see docs/ecosystems.md). - **cwd-only (single project):** gem, pypi, composer. The crawler inspects only the project rooted at `--cwd` (pypi first takes the env the project's manager records: PDM's `.pdm-python` interpreter, meaning its venv or, for a base interpreter, PEP 582 `__pypackages__//lib`, and uv's `UV_PROJECT_ENVIRONMENT`. Otherwise it looks at `$VIRTUAL_ENV`, `/.venv` / `venv`, then a Poetry project's out-of-tree virtualenv(s) under Poetry's `virtualenvs.path`; composer at the vendor tree); it does **not** descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 29bcad9cd..7e47e23be 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -893,28 +893,33 @@ fn format_revert_install_hint(cmd: &str) -> String { ) } -/// Run-level advisory shared by the `vendor` command and the scan-driven +/// Run-level advisories shared by the `vendor` command and the scan-driven /// vendor step: warn (once, at the envelope level — not per package) when /// the project's classic `yarn.lock` carries vendored wiring that a stray -/// yarn 2+ install would silently drop. The probe is state-based (it reads -/// the on-disk lockfile), so callers invoke it unconditionally at -/// envelope-finalize time — unwired projects and fully-reverted runs stay -/// silent, and dry runs report the risk that already exists on disk. +/// yarn 2+ install would silently drop, or that installs run from a +/// workspace member directory cannot fetch (#691). The probes are +/// state-based (they read the on-disk lockfile), so callers invoke them +/// unconditionally at envelope-finalize time — unwired projects and +/// fully-reverted runs stay silent, and dry runs report the risk that +/// already exists on disk. pub(crate) fn note_classic_migration_risk( env: &mut Envelope, project_root: &Path, common: &GlobalArgs, ) { - let Some(w) = vendor::yarn_classic_berry_migration_risk(project_root) else { - return; - }; - if !common.silent && !common.json { - eprintln!("Warning: {}", w.detail); + let warnings = [ + vendor::yarn_classic_berry_migration_risk(project_root), + vendor::yarn_classic_workspace_member_risk(project_root), + ]; + for w in warnings.into_iter().flatten() { + if !common.silent && !common.json { + eprintln!("Warning: {}", w.detail); + } + env.warnings.push(RunWarning { + code: w.code.to_string(), + detail: w.detail, + }); } - env.warnings.push(RunWarning { - code: w.code.to_string(), - detail: w.detail, - }); } /// The usage error for `vendor` under global scope, or `None` for a diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index 2c7b2c61e..0b0e964e8 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -1212,6 +1212,52 @@ async fn vendor_state_write_failure_reports_failed_event() { /// advisory. Covers npm's `package-lock.json` and yarn classic's `yarn.lock` /// (the issue's report), both written through the shared group commit. #[cfg(unix)] +/// #691: yarn 1 resolves the vendored `file:./.socket/vendor/…` tarball +/// against the directory it runs in, so in a workspaces project every +/// cold-cache install run from a member directory fails once vendoring +/// wires one. The vendor run must say so (and say nothing for a project +/// without workspaces). +#[test] +fn yarn_classic_workspaces_vendor_warns_about_member_dir_installs() { + const CODE: &str = "yarn_classic_workspace_member_install_risk"; + let yarn_lock = format!( + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + left-pad@^1.3.0:\n version \"1.3.0\"\n resolved \"{REG_RESOLVED}\"\n \ + integrity {REG_INTEGRITY}\n" + ); + for workspaces in [true, false] { + let fx = npm_fixture(); + std::fs::remove_file(fx.lock_path()).unwrap(); + let manifest = if workspaces { + std::fs::create_dir_all(fx.root().join("a")).unwrap(); + std::fs::write( + fx.root().join("a/package.json"), + br#"{"name":"a","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}"#, + ) + .unwrap(); + r#"{"name":"fixture","version":"1.0.0","private":true,"workspaces":["a"]}"# + } else { + r#"{"name":"fixture","version":"1.0.0","private":true,"dependencies":{"left-pad":"^1.3.0"}}"# + }; + std::fs::write(fx.root().join("package.json"), manifest).unwrap(); + std::fs::write(fx.root().join("yarn.lock"), &yarn_lock).unwrap(); + + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "{env:#}"); + assert!( + std::fs::read_to_string(fx.root().join("yarn.lock")) + .unwrap() + .contains("resolved \"file:./.socket/vendor/"), + "{env:#}" + ); + let warned = env["warnings"] + .as_array() + .is_some_and(|ws| ws.iter().any(|w| w["code"] == CODE)); + assert_eq!(warned, workspaces, "workspaces={workspaces}: {env:#}"); + } +} + #[tokio::test] async fn vendor_refuses_a_symlinked_lock_instead_of_replacing_it() { let yarn_lock = format!( diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index d37d81f64..3e98a6f6a 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -197,6 +197,47 @@ pub fn yarn_classic_berry_migration_risk(project_root: &Path) -> Option …`, which runs there) fails with "Tarball is not +/// in network and can not be located in cache", whether or not the member +/// depends on the patched package. No relative spelling works from both +/// (measured on yarn 1.7.0, 1.10.1 and 1.22.22). Returns the warning when +/// `yarn.lock` is classic with such a `file:./` resolution and the root +/// `package.json` declares workspaces. State-based, like +/// [`yarn_classic_berry_migration_risk`]. +pub fn yarn_classic_workspace_member_risk(project_root: &Path) -> Option { + let lock = read_regular_to_string_sync(&project_root.join("yarn.lock")).ok()?; + if !lock.contains("# yarn lockfile v1") || !lock.contains("\"file:./.socket/vendor/") { + return None; + } + let manifest = read_regular_to_string_sync(&project_root.join("package.json")).ok()?; + let manifest: serde_json::Value = serde_json::from_str(crate::formats::text::strip_bom(&manifest)).ok()?; + let workspaces = manifest.get("workspaces")?; + let globs = workspaces + .as_array() + .or_else(|| workspaces.get("packages").and_then(serde_json::Value::as_array))?; + if globs.is_empty() { + return None; + } + Some(VendorWarning::new( + "yarn_classic_workspace_member_install_risk", + "yarn.lock is yarn-classic (v1) in a workspaces project and its vendored entries \ + resolve `file:./.socket/vendor/…` tarballs, which yarn 1 looks up relative to the \ + directory it runs in: on a cold yarn cache, `yarn install`, `yarn add` or `yarn \ + workspace …` run from a workspace member directory fails (\"Tarball is not \ + in network\"). Run `yarn install` from the workspace root (which also warms the \ + cache for later member-directory commands), or patch this project with `--mode \ + hosted`.", + )) +} + /// Whether a root `package.json` text pins yarn classic through corepack's /// `packageManager: yarn@1…`, which makes a stray yarn 2+ (berry) install /// refuse instead of migrating a classic `yarn.lock` and dropping its @@ -2032,6 +2073,43 @@ mod berry_migration_risk_tests { assert!(probe_with_timeout(tmp.path(), &fifo).is_some()); } + /// #691: yarn 1 resolves a relative `file:` tarball in `resolved` + /// against the directory it runs in, not the one holding yarn.lock, so + /// in a workspaces project every cold-cache install run from a member + /// directory fails once vendoring wires one. Measured on yarn 1.7.0, + /// 1.10.1 and 1.22.22; no relative spelling installs from both. + #[test] + fn issue_691_wired_classic_workspaces_warn_about_member_dir_installs() { + for workspaces in [r#"["a","b"]"#, r#"{"packages":["packages/*"]}"#] { + let pkg = format!(r#"{{"name":"root","private":true,"workspaces":{workspaces}}}"#); + let tmp = project(Some(WIRED_V1), Some(&pkg)); + let w = yarn_classic_workspace_member_risk(tmp.path()).expect("must warn"); + assert_eq!(w.code, "yarn_classic_workspace_member_install_risk"); + assert!( + w.detail.contains("member directory") && w.detail.contains("workspace root"), + "detail names the trap and the remedy: {}", + w.detail + ); + } + // No workspaces, empty workspaces, an unwired or berry lock, or + // no manifest: nothing installs from a member directory through + // our wiring. + for (lock, pkg) in [ + (Some(WIRED_V1), Some(r#"{"name":"x"}"#)), + (Some(WIRED_V1), Some(r#"{"name":"x","workspaces":[]}"#)), + (Some(WIRED_V1), Some(r#"{"name":"x","workspaces":{"packages":[]}}"#)), + (Some(WIRED_V1), None), + (Some(UNWIRED_V1), Some(r#"{"name":"x","workspaces":["a"]}"#)), + (None, Some(r#"{"name":"x","workspaces":["a"]}"#)), + ] { + let tmp = project(lock, pkg); + assert!( + yarn_classic_workspace_member_risk(tmp.path()).is_none(), + "{lock:?} {pkg:?}" + ); + } + } + #[test] fn malformed_or_missing_package_json_still_warns() { // Fail toward warning: an unreadable pin must not silently vouch diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 759d7d182..863328bc9 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -177,6 +177,16 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. vendored wiring alike, so the packages install unpatched. A run that leaves such a pin warns (`redirect_yarn_classic_berry_migration_risk` / `yarn_classic_berry_migration_risk`) unless `package.json` pins yarn classic through `"packageManager": "yarn@1…"`. +- **yarn classic workspaces, vendored** — vendored mode wires + `resolved "file:./.socket/vendor/…"`, relative to the workspace root. Yarn 1 looks a + relative `file:` tarball up from the directory it runs in, so on a cold yarn cache + `yarn install`, `yarn add` or `yarn workspace …` run from a member directory + fails ("Tarball is not in network and can not be located in cache"), whether or not the + member depends on the patched package (yarn 1.7.0, 1.10.1 and 1.22.22; no relative + spelling installs from both). Installs from the workspace root work, and warm the cache + for later member-directory commands. A vendored run in such a project warns + `yarn_classic_workspace_member_install_risk`; hosted mode, which pins an absolute URL, + is not affected. - **yarn classic git dependencies** — yarn 1 fetches a git pattern (`git+https:`, `git+ssh:`, `git:`, `ssh:`, a `….git` url, or a bare `https://github.com//`) with git, using the lock entry's `resolved` as the remote, so a rewritten `resolved` From 7cf1e0f7941062680ac186bb85ea7eb103f93699 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 20:49:57 -0400 Subject: [PATCH 3/3] Gate symlink lock test back under cfg(unix) for Windows (#691) The #691 test was inserted between the #627 test's doc comment and its #[cfg(unix)] attribute, so the gate (and the #627 doc) moved onto the new portable yarn test and left vendor_refuses_a_symlinked_lock_instead_of_replacing_it, which calls std::os::unix::fs::symlink, ungated. Windows test builds failed with E0433. Move the new test above the #627 doc so each test keeps its own attributes, and rustfmt the new probe. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/covgap_commands_vendor.rs | 16 ++++++++-------- crates/socket-patch-core/src/vendor/mod.rs | 16 +++++++++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index e2e63f009..83350e097 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -1194,14 +1194,6 @@ async fn vendor_state_write_failure_reports_failed_event() { ); } -/// #627: a symlinked lockfile (a lock shared with another checkout) is -/// refused like hosted mode refuses it — `redirect_symlinked_file_unsupported`, -/// exit 1 — instead of being renamed over: the link survives, its target -/// keeps the pre-run bytes, and no ledger names wiring that was never -/// written. A dry run predicts it with a `vendor_would_refuse_symlinked_file` -/// advisory. Covers npm's `package-lock.json` and yarn classic's `yarn.lock` -/// (the issue's report), both written through the shared group commit. -#[cfg(unix)] /// #691: yarn 1 resolves the vendored `file:./.socket/vendor/…` tarball /// against the directory it runs in, so in a workspaces project every /// cold-cache install run from a member directory fails once vendoring @@ -1248,6 +1240,14 @@ fn yarn_classic_workspaces_vendor_warns_about_member_dir_installs() { } } +/// #627: a symlinked lockfile (a lock shared with another checkout) is +/// refused like hosted mode refuses it — `redirect_symlinked_file_unsupported`, +/// exit 1 — instead of being renamed over: the link survives, its target +/// keeps the pre-run bytes, and no ledger names wiring that was never +/// written. A dry run predicts it with a `vendor_would_refuse_symlinked_file` +/// advisory. Covers npm's `package-lock.json` and yarn classic's `yarn.lock` +/// (the issue's report), both written through the shared group commit. +#[cfg(unix)] #[tokio::test] async fn vendor_refuses_a_symlinked_lock_instead_of_replacing_it() { let yarn_lock = format!( diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 7861e6744..a28448ad4 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -225,11 +225,14 @@ pub fn yarn_classic_workspace_member_risk(project_root: &Path) -> Option