From 67cee3f1918b73f6b4f8bdb92a1d5b68c151b7c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:22:49 +0000 Subject: [PATCH 1/4] Start fix for #859 Assisted-by: Claude Code:claude-opus-5-5 From d94d9048d1a82a82eeb4990f285c7888464651ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:32:51 +0000 Subject: [PATCH 2/4] Find Yarn pnpm stores moved by pnpmStoreFolder With Yarn 4's pnpm linker, pnpmStoreFolder (in .yarnrc.yml, the home rc file or YARN_PNPM_STORE_FOLDER) moves the package store out of node_modules/.store. Agent mode then missed transitive packages in a hidden store folder (reported not installed, left unpatched) and refused them as first-party source in a plain one; a store set from the environment also refused direct dependencies. Resolve the store the way yarn does, walk it like node_modules/.store in scan, apply and the peer fan-out, and judge store links against the project holding yarn.lock. Fixes #859. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_yarn4_pnpm_linker_build.rs | 156 ++++++++++++++++ .../src/crawlers/npm_crawler.rs | 170 +++++++++++++++++- .../src/crawlers/pkg_managers.rs | 90 ++++++++++ .../src/patch/shared_store.rs | 101 +++++++---- .../src/vendor/npm_flavor.rs | 3 + docs/ecosystems.md | 11 +- 6 files changed, 487 insertions(+), 44 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs index 479a99a37..778780850 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs @@ -1131,3 +1131,159 @@ fn yarn4_pnpm_linker_agent_apply_patches_bundled_copy_inside_store_package() { "rollback restores the bundled copy" ); } + +/// #859: `pnpmStoreFolder` moves the pnpm linker's store out of +/// `node_modules`. Agent-mode `apply` must still patch the copy Node loads +/// and `rollback` restore it, whether the store sits in a hidden folder +/// (`.cache/.store`, which the workspace walk skips), a plain one +/// (`store`, whose entries' links were refused as first-party source), or +/// is set through the environment (`YARN_NODE_LINKER` / +/// `YARN_PNPM_STORE_FOLDER`, where even a direct dependency was refused). +#[test] +fn yarn4_pnpm_linker_agent_apply_patches_relocated_store_copies() { + if !has_corepack_pm(yarn_berry()) { + skip!( + "SKIP e2e_yarn4_pnpm_linker_build (agent relocated store): `corepack {}` unavailable", + yarn_berry() + ); + return; + } + + let env_store: &[(&str, &str)] = &[ + ("YARN_NODE_LINKER", "pnpm"), + ("YARN_PNPM_STORE_FOLDER", ".cache/.store"), + ]; + // (cell, yarnrc, environment, store folder, patched package) + let cells: [(&str, String, &[(&str, &str)], &str, &str); 4] = [ + ( + "hidden", + format!("{YARNRC_PNPM}pnpmStoreFolder: .cache/.store\n"), + &[], + ".cache/.store", + "is-number-npm-6.0.0-", + ), + ( + "plain", + format!("{YARNRC_PNPM}pnpmStoreFolder: store\n"), + &[], + "store", + "is-number-npm-6.0.0-", + ), + ( + "env transitive", + "enableGlobalCache: false\n".to_string(), + env_store, + ".cache/.store", + "is-number-npm-6.0.0-", + ), + ( + "env direct", + "enableGlobalCache: false\n".to_string(), + env_store, + ".cache/.store", + "is-odd-npm-3.0.1-", + ), + ]; + for (cell, yarnrc, env, folder, slug) in cells { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + r#"{"name":"yarn4-pnpm-linker-relocated","version":"0.0.0","private":true,"dependencies":{"is-odd":"3.0.1"}}"#, + ) + .unwrap(); + std::fs::write(proj.join(".yarnrc.yml"), &yarnrc).unwrap(); + let global = tmp.path().join("yarn-global"); + let mut yarn_env = vec![("YARN_GLOBAL_FOLDER", global.to_str().unwrap())]; + yarn_env.extend_from_slice(env); + let install = corepack(&proj, yarn_berry(), &["install"], &yarn_env); + if !install.status.success() { + skip!( + "SKIP e2e_yarn4_pnpm_linker_build (agent relocated store, {cell}): fixture \ + `yarn install` failed (registry unreachable?):\n{}", + yarn_berry_common::yarn_output(&install) + ); + return; + } + + // The layout under test: no `.store` in `node_modules`, the + // package's only physical copy in the relocated store. + let nm = proj.join("node_modules"); + assert!( + !nm.join(".store").exists(), + "({cell}) the store must be relocated" + ); + let entry = std::fs::read_dir(proj.join(folder)) + .unwrap_or_else(|e| panic!("({cell}) relocated store {folder}: {e}")) + .map(|e| e.unwrap().path()) + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(slug)) + }) + .unwrap_or_else(|| panic!("({cell}) {folder} must hold a {slug} entry")); + let (name, version) = if slug.starts_with("is-odd") { + ("is-odd", "3.0.1") + } else { + ("is-number", "6.0.0") + }; + let purl = format!("pkg:npm/{name}@{version}"); + let index = entry.join("package").join("index.js"); + let orig = std::fs::read(&index).expect("store copy of index.js"); + assert!(!orig.starts_with(MARKER.as_bytes())); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + stage_patch(&proj, &purl, &orig, &patched); + std::fs::write(proj.join(".socket/blobs").join(git_sha256(&orig)), &orig).unwrap(); + + let socket = |command: &str| { + let mut cmd = hermetic::binary_command(); + cmd.current_dir(&proj); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv, hermetic::Extra::Yarn]); + let args = [ + command, + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ]; + let _fixture = prebuilt_common::prepare_command(&mut cmd, &proj, &args, &[]); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("failed to run socket-patch binary"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + assert_eq!( + out.status.code(), + Some(0), + "({cell}) {command} failed.\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_str::(&stdout).unwrap_or_else(|e| { + panic!("({cell}) {command} --json output is not JSON: {e}\n{stdout}") + }) + }; + + let applied = socket("apply"); + assert_eq!(applied["status"], "success", "({cell}) {applied}"); + assert_eq!(applied["summary"]["applied"], 1, "({cell}) {applied}"); + assert_eq!(std::fs::read(&index).unwrap(), patched, "({cell}) patched"); + + // RUNTIME PROOF: the code the project loads carries the patch. + let resolve = format!( + "process.stdout.write(require('fs').readFileSync(require.resolve('{name}', \ + {{paths: [require('path').dirname(require.resolve('is-odd'))]}})))" + ); + let out = corepack(&proj, yarn_berry(), &["node", "-e", &resolve], env); + assert!( + out.status.success(), + "({cell}) `yarn node` failed:\n{}", + yarn_berry_common::yarn_output(&out) + ); + assert_eq!(out.stdout, patched, "({cell}) the loaded {name} is patched"); + + let rolled_back = socket("rollback"); + assert_eq!(rolled_back["status"], "success", "({cell}) {rolled_back}"); + assert_eq!(std::fs::read(&index).unwrap(), orig, "({cell}) restored"); + } +} diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 4069ea988..a6d560b5c 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1519,6 +1519,39 @@ fn relocated_pnpm_virtual_store_sync(nm: &Path) -> Option { Some(dir) } +/// The store of Yarn's pnpm linker for the project whose `node_modules` +/// is `nm`, when `pnpmStoreFolder` moved it out of `node_modules/.store` +/// (#859): `.cache/.store`, `store`, set in an rc file, the home rc file +/// or `YARN_PNPM_STORE_FOLDER`. Yarn then keeps every package there, at +/// `//package`, and `nm` holds only links to the direct +/// dependencies: the store is the only home of the transitive ones, which +/// a walk of `nm` alone never reaches (or a hidden name hides from the +/// workspace walk). Its entries have the `node_modules/.store` shape, so +/// the same enumeration applies ([`NpmCrawler::list_npm_store_entries_sync`]). +/// +/// Only for a Yarn project (`yarn.lock` beside `nm`) whose active linker +/// is pnpm, and only for a real directory that is not the default +/// location (walked by name) and does not contain the project. Returned +/// canonical. +fn relocated_yarn_pnpm_store_sync(nm: &Path) -> Option { + if nm.file_name() != Some(OsStr::new("node_modules")) { + return None; + } + let project = nm.parent()?; + if !project.join("yarn.lock").is_file() { + return None; + } + let store = super::pkg_managers::active_yarn_pnpm_store(project)?; + let store = std::fs::canonicalize(store).ok()?; + if !is_dir_sync(&store) + || std::fs::canonicalize(project).is_ok_and(|p| p.starts_with(&store)) + || std::fs::canonicalize(nm.join(NPM_LINKED_STORE_NAME)).is_ok_and(|d| d == store) + { + return None; + } + Some(store) +} + /// The entries of pnpm's global virtual store (`enableGlobalVirtualStore`) /// that the importer holding `nm` actually loads (#362). /// @@ -2356,7 +2389,14 @@ impl NpmCrawler { fn crawl_all_sync(options: &CrawlerOptions) -> (Vec, Vec) { let nm_paths = Self::node_modules_paths_sync(options); let gathered: Vec> = par_map(&nm_paths, |nm_path| { - Self::gather_node_modules(nm_path, None, false) + let mut events = Self::gather_node_modules(nm_path, None, false); + // Yarn's relocated pnpm-linker store, after the importer's own + // entries like the stores found by name (#859). + if let Some(store) = relocated_yarn_pnpm_store_sync(nm_path) { + let entries = Self::list_npm_store_entries_sync(&store, true); + events.extend(Self::gather_store_entries(entries)); + } + events }); let mut packages = Vec::new(); @@ -2508,6 +2548,19 @@ impl NpmCrawler { // `node_modules`. let mut level: Vec<(PathBuf, bool)> = vec![(node_modules_path.to_path_buf(), false)]; let mut visited = HashSet::new(); + // The root's relocated Yarn pnpm-linker store (#859), contributed + // by the root's visit like a store found there by name. + let mut yarn_store = relocated_yarn_pnpm_store_sync(node_modules_path).map(|store| { + NestedNodeModules::StoreEntries( + Self::list_npm_store_entries_sync(&store, false) + .into_iter() + .map(|e| StoreEntry { + advertised: e.advertised, + node_modules: e.node_modules, + }) + .collect(), + ) + }); while !level.is_empty() { // A bundled node_modules may link back to an ancestor (or to // another already-visited tree). Keep the first/root-first @@ -2518,9 +2571,14 @@ impl NpmCrawler { let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()); visited.insert((canonical, *store_entry)) }); - let visits: Vec = par_map(level, |(nm_path, store_entry)| { + let mut visits: Vec = par_map(level, |(nm_path, store_entry)| { Self::visit_resolver_dir(nm_path, store_entry, &pending) }); + if let Some(store) = yarn_store.take() { + if let Some(root) = visits.first_mut() { + root.nested.push(store); + } + } let mut next_level: Vec<(PathBuf, bool)> = Vec::new(); for visit in visits { for (index, pkg_path) in visit.matched { @@ -3037,7 +3095,22 @@ impl NpmCrawler { let members = skipped_workspace_member_roots(start_path, &results); results.extend(members); - merge_configured_install_roots(results, configured_install_roots(start_path)) + let mut results = + merge_configured_install_roots(results, configured_install_roots(start_path)); + // A Yarn pnpm-linker store relocated into the project (#859) is + // walked as a store from its project's `node_modules`; the + // `node_modules` of its entries are not importers of their own. + let stores: Vec = results + .iter() + .filter_map(|nm| relocated_yarn_pnpm_store_sync(nm)) + .collect(); + if !stores.is_empty() { + results.retain(|nm| { + let real = std::fs::canonicalize(nm).unwrap_or_else(|_| nm.clone()); + !stores.iter().any(|store| real.starts_with(store)) + }); + } + results } /// Find `node_modules` in subdirectories (for monorepos / workspaces), @@ -4063,6 +4136,34 @@ async fn find_store_peer_variant_copies_reusing( stores.push((StoreLayout::Pnpm, store)); } } + // Yarn's pnpm-linker store relocated by `pnpmStoreFolder` (#859): the + // project's `node_modules` names no such store, its settings do. Only + // a store holding the primary itself counts, as above. + if let Some(canonical) = canonical_pkg.clone() { + let projects: Vec = chains + .into_iter() + .flatten() + .flat_map(|start| { + start + .ancestors() + .skip(1) + .map(|dir| dir.join("node_modules")) + }) + .collect(); + let yarn_stores = run_walk(move || { + projects + .iter() + .filter_map(|nm| relocated_yarn_pnpm_store_sync(nm)) + .filter(|store| canonical.starts_with(store)) + .collect::>() + }) + .await; + for store in yarn_stores { + if seen_stores.insert(store.clone()) { + stores.push((StoreLayout::NpmLinked, store)); + } + } + } if stores.is_empty() { return Vec::new(); } @@ -7402,6 +7503,69 @@ mod tests { ); } + /// #859: `pnpmStoreFolder` moves Yarn 4's pnpm-linker store out of + /// `node_modules`, to a hidden folder (`.cache/.store`, which the + /// workspace walk skips) or a plain one (`store`, whose entries' + /// `node_modules` the walk would otherwise take for importers). Either + /// way the transitive copies live only there: scan, the resolver and + /// the peer fan-out must find each at its `package` dir, once. + #[tokio::test] + async fn test_yarn4_pnpm_linker_relocated_store_transitive_packages_are_found() { + for folder in [".cache/.store", "store"] { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = root.join(folder); + std::fs::write(root.join("yarn.lock"), "").unwrap(); + std::fs::write( + root.join(".yarnrc.yml"), + format!("nodeLinker: pnpm\npnpmStoreFolder: {folder}\n"), + ) + .unwrap(); + + let odd_entry = store.join("is-odd-npm-3.0.1-3e1a2b4c5d"); + write_pkg(&odd_entry.join("package"), "is-odd", "3.0.1"); + link_own_package(&odd_entry, "is-odd"); + let number_entry = store.join("is-number-npm-6.0.0-9f8e7d6c5b"); + let number = number_entry.join("package"); + write_pkg(&number, "is-number", "6.0.0"); + link_own_package(&number_entry, "is-number"); + let twin_entry = store.join("is-number-npm-6.0.0-0a1b2c3d4e"); + let number_twin = twin_entry.join("package"); + write_pkg(&number_twin, "is-number", "6.0.0"); + link_own_package(&twin_entry, "is-number"); + link_dir(&number, &odd_entry.join("node_modules/is-number")); + std::fs::create_dir_all(&nm).unwrap(); + link_dir(&odd_entry.join("package"), &nm.join("is-odd")); + + assert_store_copies_found( + &root, + &["pkg:npm/is-number@6.0.0", "pkg:npm/is-odd@3.0.1"], + &[ + ( + "pkg:npm/is-number@6.0.0", + vec![number.clone(), number_twin.clone()], + ), + ("pkg:npm/is-odd@3.0.1", vec![nm.join("is-odd")]), + ], + &number, + vec![number_twin.clone()], + ) + .await; + + // An inactive linker leaves the folder alone. + std::fs::write( + root.join(".yarnrc.yml"), + format!("nodeLinker: node-modules\npnpmStoreFolder: {folder}\n"), + ) + .unwrap(); + assert!( + relocated_yarn_pnpm_store_sync(&nm).is_none(), + "{folder}: node-modules linker" + ); + } + } + /// #496 review: a Yarn 4 store entry's `package` dir carries the /// package's bundled dependencies in `package/node_modules`, which Node /// loads in preference to the regular store copy. The entry reaches diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index dae80740e..ba15db503 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -171,6 +171,9 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { #[derive(Debug, Clone)] pub(crate) struct YarnEnv { pub node_linker: Option, + /// `YARN_PNPM_STORE_FOLDER`, which moves the pnpm linker's store the + /// way an rc file's `pnpmStoreFolder` does (#859). + pub pnpm_store_folder: Option, pub rc_filename: String, pub home: Option, /// The variables `${NAME}` references read; `None` reads this @@ -199,6 +202,7 @@ impl YarnEnv { // environment (each one overwrites the setting) but the first // `yarn_rc_filename` (`getRcFilename` returns on a match). node_linker: yarn_env_key(std::env::vars_os(), "YARN_NODE_LINKER", true), + pnpm_store_folder: yarn_env_key(std::env::vars_os(), "YARN_PNPM_STORE_FOLDER", true), rc_filename: yarn_env_key(std::env::vars_os(), "YARN_RC_FILENAME", false) .unwrap_or_else(|| ".yarnrc.yml".to_string()), home: crate::utils::fs::home_dir(), @@ -262,6 +266,7 @@ impl YarnEnv { pub(crate) fn repository_only() -> Self { Self { node_linker: None, + pnpm_store_folder: None, rc_filename: ".yarnrc.yml".to_string(), home: None, vars: Some(Default::default()), @@ -366,6 +371,52 @@ pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option< .or_else(|| env.home_node_linker()) } +/// The folder yarn berry's pnpm linker keeps its store in for the project +/// at `project_root` (#859), as yarn resolves `pnpmStoreFolder`: +/// `YARN_PNPM_STORE_FOLDER` when set (relative to the project, where yarn +/// runs), else the nearest rc file at or above the project that sets it +/// (relative to that file's folder), else the home folder's rc file +/// (relative to the home folder), else the default +/// `/node_modules/.store`. `None` when the value cannot be +/// expanded or expands to nothing: yarn refuses to run on it, so no store +/// is admitted. The path is lexical; callers canonicalize it. +pub(crate) fn yarn_pnpm_store_folder_in(project_root: &Path, env: &YarnEnv) -> Option { + let expand = |raw: &str| { + crate::formats::yarn::rc::expand_env(raw, &mut |name| Ok(env.var(name))) + .ok() + .filter(|v| !v.is_empty()) + }; + let start = std::path::absolute(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + if let Some(raw) = &env.pnpm_store_folder { + return Some(start.join(expand(raw)?)); + } + let from_rc = |dir: &Path| { + let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(&env.rc_filename)).ok()?; + crate::formats::yarn::rc::raw_setting(&rc, "pnpmStoreFolder") + .filter(|raw| !raw.is_empty()) + .map(|raw| expand(&raw).map(|value| dir.join(value))) + }; + let found = start + .ancestors() + .find_map(from_rc) + .or_else(|| env.home.as_deref().and_then(from_rc)); + match found { + Some(store) => store, + None => Some(start.join("node_modules").join(".store")), + } +} + +/// The store of an active yarn berry pnpm-linker install of the project at +/// `project_root` ([`yarn_pnpm_store_folder_in`]), or `None` when the +/// linker yarn resolves there ([`yarn_node_linker_in`]) is not `pnpm`. +pub(crate) fn active_yarn_pnpm_store(project_root: &Path) -> Option { + let env = YarnEnv::for_project(project_root); + if yarn_node_linker_in(project_root, &env).as_deref() != Some("pnpm") { + return None; + } + yarn_pnpm_store_folder_in(project_root, &env) +} + /// The linker that decides whether a PnP loader is live: yarn 1 has no /// `nodeLinker` (its PnP mode is `installConfig.pnp` in package.json, and /// it reads neither `.yarnrc.yml` nor `YARN_NODE_LINKER`), so a loader @@ -775,10 +826,49 @@ mod tests { ); } + /// #859: `pnpmStoreFolder` resolves the way yarn resolves it: the + /// environment against the project, else the nearest rc file that sets + /// it against that file's folder, else the home rc file against the + /// home folder, else `node_modules/.store`; an unexpandable value + /// admits no store. + #[test] + fn yarn_pnpm_store_folder_resolution() { + let d = tempfile::tempdir().unwrap(); + let (root, home) = (d.path().join("repo"), d.path().join("home")); + let member = root.join("packages").join("a"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + let mut env = bare_env(None); + let store = |env: &YarnEnv| yarn_pnpm_store_folder_in(&member, env); + + assert_eq!( + store(&env), + Some(member.join("node_modules").join(".store")) + ); + env.home = Some(home.clone()); + std::fs::write(home.join(".yarnrc.yml"), "pnpmStoreFolder: s\n").unwrap(); + assert_eq!(store(&env), Some(home.join("s")), "home rc"); + std::fs::write(root.join(".yarnrc.yml"), "pnpmStoreFolder: .cache/.store\n").unwrap(); + assert_eq!(store(&env), Some(root.join(".cache/.store")), "ancestor rc"); + std::fs::write(member.join(".yarnrc.yml"), "nodeLinker: pnpm\n").unwrap(); + assert_eq!( + store(&env), + Some(root.join(".cache/.store")), + "nearest that sets it" + ); + std::fs::write(member.join(".yarnrc.yml"), "pnpmStoreFolder: \"${S:-x}\"\n").unwrap(); + assert_eq!(store(&env), Some(member.join("x")), "fallback"); + std::fs::write(member.join(".yarnrc.yml"), "pnpmStoreFolder: \"${S}\"\n").unwrap(); + assert_eq!(store(&env), None, "unset variable"); + env.pnpm_store_folder = Some("from-env".into()); + assert_eq!(store(&env), Some(member.join("from-env")), "environment"); + } + /// A [`YarnEnv`] with no home folder and the default rc file name. fn bare_env(node_linker: Option<&str>) -> YarnEnv { YarnEnv { node_linker: node_linker.map(str::to_string), + pnpm_store_folder: None, rc_filename: ".yarnrc.yml".into(), home: None, vars: Some(Default::default()), diff --git a/crates/socket-patch-core/src/patch/shared_store.rs b/crates/socket-patch-core/src/patch/shared_store.rs index 9737885fa..3634b0816 100644 --- a/crates/socket-patch-core/src/patch/shared_store.rs +++ b/crates/socket-patch-core/src/patch/shared_store.rs @@ -323,53 +323,31 @@ fn linked_source_of(pkg_path: &Path, real: PathBuf) -> Option { /// workspace carrying `pnpmStoreFolder: packages`) can never admit a /// workspace member: /// -/// * the project is a Yarn project: a `yarn.lock` at or above it; -/// * the active linker is pnpm: the nearest `.yarnrc.yml` at or above the -/// project that sets `nodeLinker` sets it to `pnpm`; -/// * `` is the `pnpmStoreFolder` of the nearest `.yarnrc.yml` that -/// sets it, resolved against that file's directory as Yarn does, and does -/// not contain the project (`pnpmStoreFolder: .`); +/// * the project is a Yarn project: the nearest folder at or above +/// `node_modules` holding a `yarn.lock`. A link found inside a store +/// entry's own `node_modules` (a transitive dependency) is judged +/// against that project, not against the store entry (#859); +/// * the active linker is pnpm and `` is its `pnpmStoreFolder`, as +/// yarn resolves both: the environment, then the nearest rc file at or +/// above the project, then the home rc file +/// ([`crate::crawlers::pkg_managers::active_yarn_pnpm_store`]); `` +/// does not contain the project (`pnpmStoreFolder: .`); /// * `real` is exactly `//package`, where `` is Yarn's /// slug of a registry locator ([`is_yarn_copy_slug`]): the layout /// Yarn gives a hard (installed) package, never a workspace. fn in_yarn_pnpm_store(node_modules: &Path, real: &Path) -> bool { - let Some(project) = node_modules.parent() else { - return false; - }; - if !project + let Some(project) = node_modules .ancestors() - .any(|dir| dir.join("yarn.lock").is_file()) - { + .skip(1) + .find(|dir| dir.join("yarn.lock").is_file()) + else { return false; - } - let rcs: Vec<(&Path, String)> = project - .ancestors() - .filter_map(|dir| { - let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(".yarnrc.yml")); - rc.ok().map(|rc| (dir, rc)) - }) - .collect(); - // Yarn expands `${…}` with the project's injected dotenv files applied - // (#1611). - let env = crate::crawlers::pkg_managers::YarnEnv::for_project(project); - let nearest = |key: &str| { - rcs.iter().find_map(|(dir, rc)| { - // The value yarn uses (`${…}` expanded, #1100); one it cannot - // expand admits nothing. - crate::formats::yarn::rc::setting(rc, key, &|name| env.var(name)) - .map(|setting| (*dir, setting.value.unwrap_or_default())) - }) }; - if nearest("nodeLinker").is_none_or(|(_, linker)| linker != "pnpm") { - return false; - } - let Some((rc_dir, value)) = nearest("pnpmStoreFolder").filter(|(_, v)| !v.is_empty()) else { + let Some(store) = crate::crawlers::pkg_managers::active_yarn_pnpm_store(project) else { return false; }; - let (Ok(store), Ok(project)) = ( - std::fs::canonicalize(rc_dir.join(value)), - std::fs::canonicalize(project), - ) else { + let (Ok(store), Ok(project)) = (std::fs::canonicalize(store), std::fs::canonicalize(project)) + else { return false; }; if project.starts_with(&store) { @@ -922,6 +900,53 @@ mod tests { ); } + /// #859: a transitive dependency of a relocated Yarn pnpm store is a + /// link inside a store entry's own `node_modules` + /// (`/is-odd-…/node_modules/is-number`) to a sibling entry's + /// `package` dir. It is judged against the project holding the + /// `yarn.lock`, not the store entry, so it is not first-party source. + #[cfg(unix)] + #[tokio::test] + async fn relocated_yarn_pnpm_store_transitive_link_is_not_refused() { + use std::os::unix::fs::symlink; + for folder in ["store", ".cache/.store"] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("yarn.lock"), "").unwrap(); + std::fs::write( + root.join(".yarnrc.yml"), + format!("nodeLinker: pnpm\npnpmStoreFolder: {folder}\n"), + ) + .unwrap(); + let store = root.join(folder); + let number = store.join("is-number-npm-6.0.0-0123456789").join("package"); + std::fs::create_dir_all(&number).unwrap(); + let odd_nm = store + .join("is-odd-npm-3.0.1-0123456789") + .join("node_modules"); + std::fs::create_dir_all(&odd_nm).unwrap(); + symlink(&number, odd_nm.join("is-number")).unwrap(); + assert_eq!( + shared_store_of(&odd_nm.join("is-number")).await, + None, + "{folder}" + ); + // Not without an active pnpm linker. + std::fs::write( + root.join(".yarnrc.yml"), + format!("pnpmStoreFolder: {folder}\n"), + ) + .unwrap(); + assert_eq!( + shared_store_of(&odd_nm.join("is-number")) + .await + .map(|s| s.kind), + Some(SharedStoreKind::LinkedSource), + "{folder}: PnP linker" + ); + } + } + /// The review reproduction: an npm workspace whose stray `.yarnrc.yml` /// names its `packages/` dir as a pnpm store. npm ignores the file, and /// `node_modules/left-pad` is the first-party member `packages/foo/package`. diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index c6130fb2a..022211041 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -1165,6 +1165,7 @@ mod tests { touch(tmp.path(), ".yarnrc.yml", "enableGlobalCache: false\n").await; let env = YarnEnv { node_linker: None, + pnpm_store_folder: None, rc_filename: ".yarnrc.yml".into(), home: Some(home.path().to_path_buf()), vars: Some(Default::default()), @@ -1181,6 +1182,7 @@ mod tests { touch(tmp.path(), ".yarnrc.ci.yml", "nodeLinker: node-modules\n").await; let env = YarnEnv { node_linker: None, + pnpm_store_folder: None, rc_filename: ".yarnrc.ci.yml".into(), home: None, vars: Some(Default::default()), @@ -1213,6 +1215,7 @@ mod tests { touch(tmp.path(), ".yarnrc.yml", &rc).await; let env = YarnEnv { node_linker: env_linker, + pnpm_store_folder: None, rc_filename: ".yarnrc.yml".into(), home: None, vars: Some([("SOCKET_PATCH_T_SECRET".to_string(), "secret".to_string())].into()), diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 60a2a8fce..b49e22216 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -504,7 +504,10 @@ moved it to, as recorded in `node_modules/.modules.yaml`), vlt's `node_modules/.vlt`, Bun's isolated-linker store `node_modules/.bun`, Deno's isolated `nodeModulesDir` store `node_modules/.deno`, and `node_modules/.store`, written by npm's `install-strategy=linked` and by -Yarn 4's pnpm linker (where each entry's `package/` dir is the copy). +Yarn 4's pnpm linker (where each entry's `package/` dir is the copy), or +the folder a Yarn `pnpmStoreFolder` setting moved that store to (from the +nearest `.yarnrc.yml` that sets it, the home `.yarnrc.yml`, or +`YARN_PNPM_STORE_FOLDER`, while the active linker is `pnpm`). pnpm 7–12 keep the entry of a removed or upgraded-away package for a while after `pnpm remove` or an upgrade (pnpm 7–11 for up to 7 days, pnpm 12 until `pnpm prune`). Scans skip such an entry when the current @@ -548,8 +551,10 @@ vendored and the local source is skipped with `vendor_workspace_member_skipped`) Links into a store inside a `node_modules` tree, including a workspace member's link into the root `node_modules/.pnpm`, are patched as usual. So are links into Yarn's pnpm-linker store relocated outside `node_modules`, but only for an -active Yarn pnpm install (a `yarn.lock`, and `nodeLinker: pnpm` with -`pnpmStoreFolder` in the nearest `.yarnrc.yml`), only to the +active Yarn pnpm install (a `yarn.lock`, and `nodeLinker: pnpm` with a +`pnpmStoreFolder`, each read from the environment, the nearest +`.yarnrc.yml` that sets it, or the home `.yarnrc.yml`, as Yarn reads +them), only to the `//package` directory of a package Yarn installs as a copy (`npm:`, peer-instantiated `virtual:`, a `file:` tarball, `patch:`, a URL or git; never `workspace:`, `portal:` or `link:`), and only when that store From 32d935e392bbf706d3cb0f91356d9278e408fd06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:34:08 +0000 Subject: [PATCH 3/4] Skip relocated-store e2e before yarn 4.8 pnpmStoreFolder arrived in yarn 4.8.0; older releases in the CI matrix refuse it as an unrecognized setting. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_yarn4_pnpm_linker_build.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs index 778780850..c1a32baf3 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs @@ -1149,6 +1149,22 @@ fn yarn4_pnpm_linker_agent_apply_patches_relocated_store_copies() { return; } + // `pnpmStoreFolder` arrived in yarn 4.8.0; older releases refuse it as + // an unrecognized setting, so there is nothing to relocate. + let minor = yarn_berry() + .trim_start_matches("yarn@") + .split('.') + .nth(1) + .and_then(|m| m.parse::().ok()); + if minor.is_some_and(|m| m < 8) { + println!( + "SKIP e2e_yarn4_pnpm_linker_build (agent relocated store): {} predates \ + pnpmStoreFolder (4.8.0)", + yarn_berry() + ); + return; + } + let env_store: &[(&str, &str)] = &[ ("YARN_NODE_LINKER", "pnpm"), ("YARN_PNPM_STORE_FOLDER", ".cache/.store"), From ff09166ebfc9f9b359d38c2fb1bf6e7eee7ef508 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:15:23 +0000 Subject: [PATCH 4/4] Keep relocated Yarn store inside the project Review fixes for #859: - walk a relocated store only when it lies inside the project, as for a relocated pnpm virtual store: a home rc's folder may be shared by other projects; - keep the project's spelling of store paths instead of the canonical one (macOS /private/var, Windows verbatim paths); - a workspace member's node_modules uses the root project's store; - an empty nearest pnpmStoreFolder admits nothing, as before; - walk each store once per scan, dedupe the fan-out's ancestors and share the store-entry mapping. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 178 +++++++++++------- .../src/crawlers/pkg_managers.rs | 4 +- 2 files changed, 112 insertions(+), 70 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index a6d560b5c..cde06377b 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1519,37 +1519,53 @@ fn relocated_pnpm_virtual_store_sync(nm: &Path) -> Option { Some(dir) } -/// The store of Yarn's pnpm linker for the project whose `node_modules` -/// is `nm`, when `pnpmStoreFolder` moved it out of `node_modules/.store` -/// (#859): `.cache/.store`, `store`, set in an rc file, the home rc file -/// or `YARN_PNPM_STORE_FOLDER`. Yarn then keeps every package there, at -/// `//package`, and `nm` holds only links to the direct -/// dependencies: the store is the only home of the transitive ones, which -/// a walk of `nm` alone never reaches (or a hidden name hides from the -/// workspace walk). Its entries have the `node_modules/.store` shape, so -/// the same enumeration applies ([`NpmCrawler::list_npm_store_entries_sync`]). +/// The store of Yarn's pnpm linker for the project `nm` belongs to, when +/// `pnpmStoreFolder` moved it out of `node_modules/.store` (#859): +/// `.cache/.store`, `store`, set in an rc file, the home rc file or +/// `YARN_PNPM_STORE_FOLDER`. Yarn then keeps every package there, at +/// `//package`, and the `node_modules` dirs hold only links +/// to the direct dependencies: the store is the only home of the +/// transitive ones, which a walk of `nm` alone never reaches (or a hidden +/// name hides from the workspace walk). Its entries have the +/// `node_modules/.store` shape, so the same enumeration applies +/// ([`NpmCrawler::list_npm_store_entries_sync`]). /// -/// Only for a Yarn project (`yarn.lock` beside `nm`) whose active linker -/// is pnpm, and only for a real directory that is not the default -/// location (walked by name) and does not contain the project. Returned -/// canonical. +/// The project is the nearest folder at or above `nm`'s parent holding a +/// `yarn.lock` (a workspace member's `node_modules` uses the root's +/// store), and its active linker must be pnpm. Like a relocated pnpm +/// virtual store ([`relocated_pnpm_virtual_store_sync`]), only a store +/// strictly inside the project, reached through real directories, counts: +/// one elsewhere (a home rc's folder) may be shared with other projects, +/// and patching it would patch them too. `None` for the default location, +/// which the walks find by name. The path keeps the project's spelling. fn relocated_yarn_pnpm_store_sync(nm: &Path) -> Option { - if nm.file_name() != Some(OsStr::new("node_modules")) { - return None; - } - let project = nm.parent()?; - if !project.join("yarn.lock").is_file() { + if nm.file_name() != Some(OsStr::new("node_modules")) || !is_dir_sync(nm) { return None; } + let project = nm + .parent()? + .ancestors() + .find(|dir| dir.join("yarn.lock").is_file())?; let store = super::pkg_managers::active_yarn_pnpm_store(project)?; - let store = std::fs::canonicalize(store).ok()?; - if !is_dir_sync(&store) - || std::fs::canonicalize(project).is_ok_and(|p| p.starts_with(&store)) - || std::fs::canonicalize(nm.join(NPM_LINKED_STORE_NAME)).is_ok_and(|d| d == store) + let (Ok(real_store), Ok(real_project)) = + (std::fs::canonicalize(store), std::fs::canonicalize(project)) + else { + return None; + }; + let below = path_below(&real_project, &real_store)?; + if below == Path::new("node_modules").join(NPM_LINKED_STORE_NAME) + || std::fs::canonicalize(nm.join(NPM_LINKED_STORE_NAME)).is_ok_and(|d| d == real_store) { return None; } - Some(store) + let mut dir = project.to_path_buf(); + for component in below.components() { + dir.push(component); + if !std::fs::symlink_metadata(&dir).is_ok_and(|m| m.is_dir()) { + return None; + } + } + Some(dir) } /// The entries of pnpm's global virtual store (`enableGlobalVirtualStore`) @@ -1731,6 +1747,16 @@ struct StoreEntry { } impl StoreEntry { + /// The entries of a store enumeration, keeping what each advertises. + fn from_dirs(dirs: Vec) -> Vec { + dirs.into_iter() + .map(|e| StoreEntry { + advertised: e.advertised, + node_modules: e.node_modules, + }) + .collect() + } + fn pnpm(entries: Vec<(String, PathBuf)>) -> Vec { entries .into_iter() @@ -2388,11 +2414,21 @@ impl NpmCrawler { fn crawl_all_sync(options: &CrawlerOptions) -> (Vec, Vec) { let nm_paths = Self::node_modules_paths_sync(options); - let gathered: Vec> = par_map(&nm_paths, |nm_path| { + // Yarn's relocated pnpm-linker store (#859), walked once, from the + // first root of its project, after that root's own entries like + // the stores found by name. + let mut seen_stores = HashSet::new(); + let roots: Vec<(&PathBuf, Option)> = nm_paths + .iter() + .map(|nm| { + let store = relocated_yarn_pnpm_store_sync(nm) + .filter(|store| seen_stores.insert(store.clone())); + (nm, store) + }) + .collect(); + let gathered: Vec> = par_map(roots, |(nm_path, store)| { let mut events = Self::gather_node_modules(nm_path, None, false); - // Yarn's relocated pnpm-linker store, after the importer's own - // entries like the stores found by name (#859). - if let Some(store) = relocated_yarn_pnpm_store_sync(nm_path) { + if let Some(store) = store { let entries = Self::list_npm_store_entries_sync(&store, true); events.extend(Self::gather_store_entries(entries)); } @@ -2551,15 +2587,9 @@ impl NpmCrawler { // The root's relocated Yarn pnpm-linker store (#859), contributed // by the root's visit like a store found there by name. let mut yarn_store = relocated_yarn_pnpm_store_sync(node_modules_path).map(|store| { - NestedNodeModules::StoreEntries( - Self::list_npm_store_entries_sync(&store, false) - .into_iter() - .map(|e| StoreEntry { - advertised: e.advertised, - node_modules: e.node_modules, - }) - .collect(), - ) + NestedNodeModules::StoreEntries(StoreEntry::from_dirs( + Self::list_npm_store_entries_sync(&store, false), + )) }); while !level.is_empty() { // A bundled node_modules may link back to an ancestor (or to @@ -2847,13 +2877,9 @@ impl NpmCrawler { return Vec::new(); } let store = nm_path.join(&entry.name); - let entries = Self::list_pnpm_shaped_store_entries_sync(&store, layout, false, false) - .into_iter() - .map(|e| StoreEntry { - advertised: e.advertised, - node_modules: e.node_modules, - }) - .collect(); + let entries = StoreEntry::from_dirs(Self::list_pnpm_shaped_store_entries_sync( + &store, layout, false, false, + )); return vec![NestedNodeModules::StoreEntries(entries)]; } // vlt's store has the same transitive-only-home property: every @@ -2872,13 +2898,10 @@ impl NpmCrawler { if !entry.file_type.is_some_and(|ft| ft.is_dir()) { return Vec::new(); } - let entries = Self::list_npm_store_entries_sync(&nm_path.join(&entry.name), false) - .into_iter() - .map(|e| StoreEntry { - advertised: e.advertised, - node_modules: e.node_modules, - }) - .collect(); + let entries = StoreEntry::from_dirs(Self::list_npm_store_entries_sync( + &nm_path.join(&entry.name), + false, + )); return vec![NestedNodeModules::StoreEntries(entries)]; } // A pnpm virtual store relocated by `virtualStoreDir`, found @@ -2893,13 +2916,7 @@ impl NpmCrawler { let entries = reachable_pnpm_global_virtual_store_entries_sync(nm_path); return vec![NestedNodeModules::StoreEntries(entries)]; }; - let entries = Self::list_pnpm_store_entries_sync(&store, false) - .into_iter() - .map(|e| StoreEntry { - advertised: e.advertised, - node_modules: e.node_modules, - }) - .collect(); + let entries = StoreEntry::from_dirs(Self::list_pnpm_store_entries_sync(&store, false)); return vec![NestedNodeModules::StoreEntries(entries)]; } // pnpm <=3: the virtual store is a hidden `.` dir @@ -3103,6 +3120,7 @@ impl NpmCrawler { let stores: Vec = results .iter() .filter_map(|nm| relocated_yarn_pnpm_store_sync(nm)) + .filter_map(|store| std::fs::canonicalize(store).ok()) .collect(); if !stores.is_empty() { results.retain(|nm| { @@ -4140,21 +4158,22 @@ async fn find_store_peer_variant_copies_reusing( // project's `node_modules` names no such store, its settings do. Only // a store holding the primary itself counts, as above. if let Some(canonical) = canonical_pkg.clone() { - let projects: Vec = chains - .into_iter() - .flatten() - .flat_map(|start| { - start - .ancestors() - .skip(1) - .map(|dir| dir.join("node_modules")) - }) - .collect(); + let mut projects: Vec = Vec::new(); + for start in chains.into_iter().flatten() { + for dir in start.ancestors().skip(1) { + let nm = dir.join("node_modules"); + if !projects.contains(&nm) { + projects.push(nm); + } + } + } let yarn_stores = run_walk(move || { projects .iter() .filter_map(|nm| relocated_yarn_pnpm_store_sync(nm)) - .filter(|store| canonical.starts_with(store)) + .filter(|store| { + std::fs::canonicalize(store).is_ok_and(|real| canonical.starts_with(real)) + }) .collect::>() }) .await; @@ -7513,7 +7532,8 @@ mod tests { async fn test_yarn4_pnpm_linker_relocated_store_transitive_packages_are_found() { for folder in [".cache/.store", "store"] { let tmp = tempfile::tempdir().unwrap(); - let root: PathBuf = tmp.path().components().collect(); + let root: PathBuf = tmp.path().join("proj").components().collect(); + std::fs::create_dir_all(&root).unwrap(); let nm = root.join("node_modules"); let store = root.join(folder); std::fs::write(root.join("yarn.lock"), "").unwrap(); @@ -7553,6 +7573,26 @@ mod tests { ) .await; + // A workspace member's `node_modules` uses the root's store. + let member_nm = root.join("packages").join("a").join("node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + assert_eq!( + relocated_yarn_pnpm_store_sync(&member_nm), + Some(store.clone()), + "{folder}: member" + ); + // A store outside the project may be shared: never walked. + std::fs::write( + root.join(".yarnrc.yml"), + "nodeLinker: pnpm\npnpmStoreFolder: ../shared\n", + ) + .unwrap(); + std::fs::create_dir_all(root.join("../shared")).unwrap(); + assert!( + relocated_yarn_pnpm_store_sync(&nm).is_none(), + "{folder}: outside the project" + ); + // An inactive linker leaves the folder alone. std::fs::write( root.join(".yarnrc.yml"), diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index ba15db503..419e9e5d3 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -392,8 +392,8 @@ pub(crate) fn yarn_pnpm_store_folder_in(project_root: &Path, env: &YarnEnv) -> O } let from_rc = |dir: &Path| { let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(&env.rc_filename)).ok()?; + // An empty value is still the nearest setting: it admits nothing. crate::formats::yarn::rc::raw_setting(&rc, "pnpmStoreFolder") - .filter(|raw| !raw.is_empty()) .map(|raw| expand(&raw).map(|value| dir.join(value))) }; let found = start @@ -860,6 +860,8 @@ mod tests { assert_eq!(store(&env), Some(member.join("x")), "fallback"); std::fs::write(member.join(".yarnrc.yml"), "pnpmStoreFolder: \"${S}\"\n").unwrap(); assert_eq!(store(&env), None, "unset variable"); + std::fs::write(member.join(".yarnrc.yml"), "pnpmStoreFolder: ''\n").unwrap(); + assert_eq!(store(&env), None, "empty nearest setting"); env.pnpm_store_folder = Some("from-env".into()); assert_eq!(store(&env), Some(member.join("from-env")), "environment"); }