diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b9921c653..734b3af34 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -592,7 +592,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 c9adc6951..2bb95b2d0 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -865,28 +865,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 a18e4450b..83350e097 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -1194,6 +1194,52 @@ async fn vendor_state_write_failure_reports_failed_event() { ); } +/// #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:#}"); + } +} + /// #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 diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 4fcf8aad9..a28448ad4 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -204,6 +204,50 @@ 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 @@ -2061,6 +2105,46 @@ 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 13b9f4f49..2517bcc95 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -263,6 +263,16 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: 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`