From ca962672da756957b83965cc01e8a2c7cfafb371 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:57:21 +0000 Subject: [PATCH 1/3] Start refactor for #1129 Assisted-by: Claude Code:claude-opus-5-5 From f51cbfd4e2a51f7e985ae64f1a733189f8c38686 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:08:14 +0000 Subject: [PATCH 2/3] Move pnpm modules-dir lookup to pnpm_layout The npm crawler alone knew where pnpm installs a project when modulesDir is set. Its resolver moves to crawlers::pnpm_layout, which answers for a disk root and for a ProjectView (memory and snapshot), and lists a dir both configured and holding .modules.yaml once. The crawler reads its pnpm roots from there; nothing it finds changes. Refs #1129 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/mod.rs | 1 + .../src/crawlers/npm_crawler.rs | 92 +---- .../src/crawlers/pnpm_layout.rs | 340 ++++++++++++++++++ 3 files changed, 349 insertions(+), 84 deletions(-) create mode 100644 crates/socket-patch-core/src/crawlers/pnpm_layout.rs diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index 2c92ec45c..7647d5275 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -17,6 +17,7 @@ pub mod nuget_crawler; #[cfg(test)] pub(crate) mod oracle_support; pub mod pkg_managers; +pub(crate) mod pnpm_layout; pub mod python_crawler; pub mod ruby_crawler; pub mod sbt_evidence; diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index c6f572cad..2a07ae859 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; +use super::pnpm_layout::MODULES_YAML as PNPM_MODULES_YAML; use super::types::{CrawledPackage, CrawlerOptions}; use super::walk_pool::{par_map, run_walk}; use crate::formats::text::strip_bom; @@ -42,7 +43,8 @@ const SKIP_DIRS: &[&str] = &[ /// store and the projects' own `node_modules` hold only links to their /// direct deps. /// -/// - pnpm's `modulesDir` (see [`pnpm_modules_dirs`]): pnpm installs the +/// - pnpm's `modulesDir` (see +/// [`super::pnpm_layout::configured_modules_dirs`]): pnpm installs the /// project there instead of `node_modules`, and from pnpm 10.12 its /// virtual store follows (`/.pnpm`), so nothing of the /// install is under a dir named `node_modules` (#661). @@ -56,91 +58,17 @@ pub(super) fn configured_install_roots(start_path: &Path) -> Vec { if start_path.join("rush.json").is_file() { roots.push(start_path.join("common").join("temp").join("node_modules")); } - roots.extend(pnpm_modules_dirs(start_path)); + roots.extend(super::pnpm_layout::configured_modules_dirs(start_path)); roots.retain(|root| root.is_dir()); let mut seen = HashSet::new(); roots.retain(|root| seen.insert(root.clone())); roots } -/// The project's pnpm `modulesDir` install roots, other than -/// `node_modules` itself: -/// - the configured setting ([`pnpm_modules_dir_setting`]), resolved -/// against the project like pnpm does, and honored only strictly inside -/// it (the value comes from the scanned project and names a tree apply -/// WRITES into; see [`resolve_modules_folder`]); -/// - any direct child dir holding pnpm's `.modules.yaml` install record, -/// which pnpm writes into whatever modules dir it used. That finds an -/// install whose `modulesDir` came from pnpm's global config or the -/// environment, which the project's files do not show. -fn pnpm_modules_dirs(start_path: &Path) -> Vec { - let mut dirs = Vec::new(); - if let Some(dir) = - pnpm_modules_dir_setting(start_path).and_then(|raw| resolve_modules_folder(&[], &raw)) - { - dirs.push(start_path.join(dir)); - } - let Some((entries, _)) = read_dir_entries_sync(start_path) else { - return dirs; - }; - for entry in entries { - let name = entry.file_name(); - if name == OsStr::new("node_modules") || !entry.file_type().is_ok_and(|t| t.is_dir()) { - continue; - } - let dir = start_path.join(name); - if std::fs::symlink_metadata(dir.join(PNPM_MODULES_YAML)).is_ok_and(|m| m.is_file()) { - dirs.push(dir); - } - } - dirs -} - -/// The raw pnpm `modulesDir` setting that applies to the project at -/// `start_path`: `modulesDir:` in the nearest `pnpm-workspace.yaml` at or -/// above it (the workspace's settings file on pnpm 10+, which wins over -/// `.npmrc`), else `modules-dir` from the nearest `.npmrc` at or above it -/// that sets it (pnpm up to 10). Read with -/// [`crate::utils::fs::read_regular_to_string_sync`]: the files belong to -/// the (untrusted) project. -fn pnpm_modules_dir_setting(start_path: &Path) -> Option { - let read = |path: PathBuf| crate::utils::fs::read_regular_to_string_sync(&path).ok(); - let from_workspace = start_path - .ancestors() - .find_map(|dir| read(dir.join("pnpm-workspace.yaml"))) - .and_then(|yaml| { - crate::formats::text::strip_bom(&yaml) - .lines() - .filter_map(crate::formats::pnpm::workspace::top_level_key) - .rfind(|(key, _)| key == "modulesDir") - .map(|(_, value)| unquote_yaml_scalar(value)) - }); - from_workspace - .or_else(|| { - start_path.ancestors().find_map(|dir| { - let npmrc = read(dir.join(".npmrc"))?; - crate::patch::redirect::npmrc::npmrc_top_level_value(&npmrc, "modules-dir") - }) - }) - .filter(|value| !value.is_empty()) -} - -/// A YAML flow scalar's value: quotes removed (`''` is a literal quote -/// inside single quotes), a plain scalar as is. -fn unquote_yaml_scalar(raw: &str) -> String { - if raw.starts_with('"') { - if let Ok(value) = serde_json::from_str::(raw) { - return value; - } - } else if let Some(inner) = raw.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) { - return inner.replace("''", "'"); - } - raw.to_string() -} - /// Whether the installed pnpm tree of the project at `project` keeps its /// virtual store where the crawler does not look: a `.modules.yaml` in -/// `node_modules` or a pnpm modules dir ([`pnpm_modules_dirs`]) records a +/// `node_modules` or a pnpm modules dir +/// ([`super::pnpm_layout::configured_modules_dirs`]) records a /// `virtualStoreDir` outside the project, as pnpm's global virtual store /// (`enableGlobalVirtualStore`) and a `virtualStoreDir` that climbs out /// do. Only direct deps are linked into the project then, so a package @@ -148,7 +76,7 @@ fn unquote_yaml_scalar(raw: &str) -> String { /// and must not be read as absent (#696). `false` with no pnpm install. pub fn pnpm_store_outside_project(project: &Path) -> bool { let mut modules_dirs = vec![project.join("node_modules")]; - modules_dirs.extend(pnpm_modules_dirs(project)); + modules_dirs.extend(super::pnpm_layout::configured_modules_dirs(project)); modules_dirs.iter().any(|nm| { let Ok(text) = crate::utils::fs::read_regular_to_string_sync(&nm.join(PNPM_MODULES_YAML)) else { @@ -242,7 +170,7 @@ fn yarnrc_modules_folder(start_path: &Path) -> Option { /// resolved lexically, and a value that is absolute, drive-qualified, or /// resolves outside the project or to the project itself fails closed — /// the project then discovers nothing there, as before. -fn resolve_modules_folder(project_in_rc_dir: &[String], raw: &str) -> Option { +pub(super) fn resolve_modules_folder(project_in_rc_dir: &[String], raw: &str) -> Option { if raw.starts_with(['/', '\\']) { return None; } @@ -1303,10 +1231,6 @@ fn decode_npm_store_entry_name(entry_name: &str) -> Option<(String, String)> { Some((key[..at].to_string(), version.to_string())) } -/// The `node_modules` child in which pnpm records its install state, -/// including where the virtual store lives. -const PNPM_MODULES_YAML: &str = ".modules.yaml"; - /// The `virtualStoreDir` value of a `.modules.yaml`: JSON on pnpm 10+, /// YAML before (a top-level `virtualStoreDir:` scalar, maybe quoted). fn parse_modules_yaml_virtual_store_dir(text: &str) -> Option { diff --git a/crates/socket-patch-core/src/crawlers/pnpm_layout.rs b/crates/socket-patch-core/src/crawlers/pnpm_layout.rs new file mode 100644 index 000000000..de780ca4d --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/pnpm_layout.rs @@ -0,0 +1,340 @@ +//! Where pnpm keeps a project's install: its modules dirs. +//! +//! pnpm installs into `node_modules` unless `modulesDir` says otherwise +//! (`modulesDir:` in `pnpm-workspace.yaml`, `modules-dir` in `.npmrc` up to +//! pnpm 10), and from pnpm 10.12 its virtual store follows +//! (`/.pnpm`). The npm crawler (crawl roots), the layout +//! detector ([`super::pkg_managers`]) and the vendored router all ask this +//! one module, so they agree on where a pnpm install lives (#1129). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::utils::fs::read_dir_entries_sync; +use crate::vendor::lock_inventory::ProjectView; + +/// The install record pnpm writes into whatever modules dir it used. +pub(crate) const MODULES_YAML: &str = ".modules.yaml"; + +/// The project's pnpm `modulesDir` install roots, other than +/// `node_modules` itself, without repeats: +/// - the configured setting ([`modules_dir_setting`]), resolved against +/// the project like pnpm does, and honored only strictly inside it (the +/// value comes from the scanned project and names a tree apply WRITES +/// into; see [`super::npm_crawler::resolve_modules_folder`]); +/// - any direct child dir holding pnpm's [`MODULES_YAML`] install record. +/// That finds an install whose `modulesDir` came from pnpm's global +/// config or the environment, which the project's files do not show. +pub(crate) fn configured_modules_dirs(project: &Path) -> Vec { + let mut dirs = Vec::new(); + if let Some(dir) = modules_dir_setting(project) + .and_then(|raw| super::npm_crawler::resolve_modules_folder(&[], &raw)) + { + dirs.push(project.join(dir)); + } + let Some((entries, _)) = read_dir_entries_sync(project) else { + return dirs; + }; + for entry in entries { + let name = entry.file_name(); + if name == OsStr::new("node_modules") || !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let dir = project.join(name); + if !dirs.contains(&dir) + && std::fs::symlink_metadata(dir.join(MODULES_YAML)).is_ok_and(|m| m.is_file()) + { + dirs.push(dir); + } + } + dirs +} + +/// Whether the project holds an installed pnpm store: a [`MODULES_YAML`] +/// record or a `.pnpm` virtual store in `node_modules` or in one of its +/// [`configured_modules_dirs`]. The configured dirs are read only when +/// `node_modules` holds neither. +pub(crate) fn installed_store_in(view: &ProjectView<'_>) -> bool { + let holds_store = |dir: &str| { + view.is_file(&format!("{dir}/{MODULES_YAML}")) || view.is_dir(&format!("{dir}/.pnpm")) + }; + holds_store("node_modules") + || configured_modules_dirs_in(view) + .iter() + .any(|d| holds_store(d)) +} + +/// [`installed_store_in`] over the disk. +pub(crate) fn installed_store(project: &Path) -> bool { + installed_store_in(&ProjectView::Disk(project)) +} + +/// [`configured_modules_dirs`] for a view, as `/`-separated paths +/// relative to the project. On disk (and in a snapshot, whose recording +/// this read opts out of, like +/// [`ProjectView::yarn_node_linker`]) it is the disk answer, which also +/// reads the settings files above the project. A memory view is the +/// repository alone: only the project's own `pnpm-workspace.yaml` and +/// `.npmrc` count, plus the children holding an install record. +fn configured_modules_dirs_in(view: &ProjectView<'_>) -> Vec { + let root = match view { + ProjectView::Disk(root) => *root, + ProjectView::Snapshot(snap) => snap.root(), + ProjectView::Memory(project) => { + let mut dirs: Vec = + setting_from(project.text("pnpm-workspace.yaml"), project.text(".npmrc")) + .and_then(|raw| super::npm_crawler::resolve_modules_folder(&[], &raw)) + .into_iter() + .collect(); + for (name, is_dir) in project.children("") { + if is_dir + && name != "node_modules" + && !dirs.contains(&name) + && view.is_file(&format!("{name}/{MODULES_YAML}")) + { + dirs.push(name); + } + } + return dirs; + } + }; + configured_modules_dirs(root) + .iter() + .filter_map(|dir| { + let rel = dir.strip_prefix(root).ok()?; + let parts = rel + .components() + .map(|c| c.as_os_str().to_str()) + .collect::>>()?; + Some(parts.join("/")) + }) + .collect() +} + +/// The raw pnpm `modulesDir` setting that applies to the project: +/// `modulesDir:` in the nearest `pnpm-workspace.yaml` at or above it (the +/// workspace's settings file on pnpm 10+, which wins over `.npmrc`), else +/// `modules-dir` from the nearest `.npmrc` at or above it that sets it +/// (pnpm up to 10). Read with +/// [`crate::utils::fs::read_regular_to_string_sync`]: the files belong to +/// the (untrusted) project. +fn modules_dir_setting(project: &Path) -> Option { + let read = |path: PathBuf| crate::utils::fs::read_regular_to_string_sync(&path).ok(); + project + .ancestors() + .find_map(|dir| read(dir.join("pnpm-workspace.yaml"))) + .and_then(|yaml| workspace_modules_dir(&yaml)) + .or_else(|| { + project.ancestors().find_map(|dir| { + let npmrc = read(dir.join(".npmrc"))?; + npmrc_modules_dir(&npmrc) + }) + }) + .filter(|value| !value.is_empty()) +} + +/// [`modules_dir_setting`] from one `pnpm-workspace.yaml` and one +/// `.npmrc` text. +fn setting_from(workspace_yaml: Option<&str>, npmrc: Option<&str>) -> Option { + workspace_yaml + .and_then(workspace_modules_dir) + .or_else(|| npmrc.and_then(npmrc_modules_dir)) + .filter(|value| !value.is_empty()) +} + +/// The last top-level `modulesDir:` of a `pnpm-workspace.yaml`. +fn workspace_modules_dir(yaml: &str) -> Option { + crate::formats::text::strip_bom(yaml) + .lines() + .filter_map(crate::formats::pnpm::workspace::top_level_key) + .rfind(|(key, _)| key == "modulesDir") + .map(|(_, value)| unquote_yaml_scalar(value)) +} + +/// The top-level `modules-dir` of an `.npmrc`. +fn npmrc_modules_dir(npmrc: &str) -> Option { + crate::patch::redirect::npmrc::npmrc_top_level_value(npmrc, "modules-dir") +} + +/// A YAML flow scalar's value: quotes removed (`''` is a literal quote +/// inside single quotes), a plain scalar as is. +fn unquote_yaml_scalar(raw: &str) -> String { + if raw.starts_with('"') { + if let Ok(value) = serde_json::from_str::(raw) { + return value; + } + } else if let Some(inner) = raw.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) { + return inner.replace("''", "'"); + } + raw.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crawlers::pkg_managers::{ + detect_npm_pkg_manager, pnpm_pnp_layout, pnpm_pnp_layout_in, NpmPkgManager, YarnPnpLoader, + }; + use crate::vendor::lock_inventory::{MemoryEntry, MemoryProject}; + + /// The ways a project tells pnpm to install into `deps/`, with the + /// store each leaves there: the `.npmrc` and `pnpm-workspace.yaml` + /// settings (a `.pnpm` store), and an unconfigured project whose + /// `deps/` holds pnpm's install record (a global-config setting). + const LAYOUTS: [(&str, &str, &str); 4] = [ + ( + ".npmrc", + "node-linker=pnp\nmodules-dir=deps\n", + "deps/.pnpm/x", + ), + ("pnpm-workspace.yaml", "modulesDir: deps\n", "deps/.pnpm/x"), + ( + "pnpm-workspace.yaml", + "modulesDir: './deps'\n", + "deps/.pnpm/x", + ), + ("package.json", "{}", "deps/.modules.yaml"), + ]; + + /// Stage a pnpm `node-linker=pnp` install into `deps/` (the files a + /// real pnpm 10.28 install writes, #1129) in `root`, and the same + /// files in memory. + fn stage(root: &Path, (file, text, store): (&str, &str, &str)) -> MemoryProject { + let mut memory = MemoryProject::new(); + for (rel, text) in [ + (file, text), + (".pnp.cjs", "/* pnp */"), + ("pnpm-lock.yaml", "lockfileVersion: '9.0'\n"), + (store, ""), + ] { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + memory.insert(rel, MemoryEntry::Text(text.into())); + } + memory + } + + /// Every former reader of the pnpm install location answers through + /// this module: the crawler's roots, the layout detector, the PnP + /// carve-out over disk and memory, VEX's loader probe and the + /// vendored router all find the `deps/` store (#1129). Before, all + /// but the crawler probed only `node_modules/`, so the tree read as + /// yarn berry. + #[tokio::test] + async fn every_reader_finds_a_store_in_the_configured_modules_dir() { + for layout in LAYOUTS { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let memory = stage(root, layout); + + assert_eq!( + configured_modules_dirs(root), + vec![root.join("deps")], + "{layout:?}" + ); + assert_eq!( + super::super::npm_crawler::configured_install_roots(root), + vec![root.join("deps")], + "{layout:?}" + ); + assert!(installed_store(root), "{layout:?}"); + assert!(pnpm_pnp_layout(root), "{layout:?}"); + assert!( + pnpm_pnp_layout_in(&ProjectView::Memory(&memory)), + "memory: {layout:?}" + ); + let snapshot = crate::vendor::lock_inventory::DiskSnapshot::new(root); + assert!( + pnpm_pnp_layout_in(&ProjectView::Snapshot(&snapshot)), + "snapshot: {layout:?}" + ); + assert_eq!( + detect_npm_pkg_manager(root), + NpmPkgManager::Pnpm, + "{layout:?}" + ); + assert!(YarnPnpLoader::detect(root).is_none(), "{layout:?}"); + let (code, _) = crate::vendor::npm_flavor::detect_npm_lock_flavor(root) + .await + .unwrap_err(); + assert_eq!(code, "vendor_pnpm_pnp_unsupported", "{layout:?}"); + + // Without the loader it is a plain pnpm install in deps/. + std::fs::remove_file(root.join(".pnp.cjs")).unwrap(); + assert_eq!( + detect_npm_pkg_manager(root), + NpmPkgManager::Pnpm, + "{layout:?}" + ); + } + } + + /// The carve-out stays fail-closed with a configured modules dir: no + /// store there, or a `yarn.lock` beside the loader, keeps the yarn + /// berry reading. + #[test] + fn a_configured_modules_dir_without_a_store_is_not_pnpm() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + stage( + root, + (".npmrc", "modules-dir=deps\n", "deps/x/package.json"), + ); + assert!(!installed_store(root)); + assert_eq!(detect_npm_pkg_manager(root), NpmPkgManager::YarnBerryPnP); + + std::fs::create_dir_all(root.join("deps/.pnpm")).unwrap(); + assert_eq!(detect_npm_pkg_manager(root), NpmPkgManager::Pnpm); + std::fs::write(root.join("yarn.lock"), "").unwrap(); + assert_eq!(detect_npm_pkg_manager(root), NpmPkgManager::YarnBerryPnP); + } + + /// A modules dir both configured and holding an install record is + /// listed once. + #[test] + fn a_configured_dir_holding_the_record_is_listed_once() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let memory = stage(root, (".npmrc", "modules-dir=deps\n", "deps/.modules.yaml")); + assert_eq!(configured_modules_dirs(root), vec![root.join("deps")]); + assert_eq!( + configured_modules_dirs_in(&ProjectView::Memory(&memory)), + vec!["deps"] + ); + assert_eq!( + configured_modules_dirs_in(&ProjectView::Disk(root)), + vec!["deps"] + ); + } + + /// The setting: `pnpm-workspace.yaml` wins over `.npmrc` (even when + /// it is empty), quotes are removed, and the last key wins. + #[test] + fn setting_precedence_and_quoting() { + assert_eq!( + setting_from(Some("modulesDir: a\n"), Some("modules-dir=b\n")).as_deref(), + Some("a") + ); + assert_eq!( + setting_from(None, Some("modules-dir=b\n")).as_deref(), + Some("b") + ); + assert_eq!( + setting_from(Some("modulesDir: ''\n"), Some("modules-dir=b\n")), + None + ); + assert_eq!( + setting_from(Some("packages: []\n"), Some("modules-dir=b\n")).as_deref(), + Some("b") + ); + assert_eq!( + setting_from(Some("modulesDir: a\nmodulesDir: \"it's\"\n"), None).as_deref(), + Some("it's") + ); + assert_eq!( + setting_from(Some("modulesDir: 'it''s'\n"), None).as_deref(), + Some("it's") + ); + } +} From 2e2715bec924b9299deee69dad1d45920a7cf580 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:08:14 +0000 Subject: [PATCH 3/3] Detect pnpm stores in the configured modulesDir detect_npm_pkg_manager and the pnpm Plug'n'Play carve-out probed only node_modules/ for pnpm's store. A node-linker=pnp project with modulesDir set keeps it in /.pnpm, so it was read as yarn berry: apply refused with yarn_pnp_unsupported and the yarn remedy, vendor gave the yarn refusal, and vex read pnpm's loader as yarn's. Both now ask pnpm_layout::installed_store_in, the same modules dirs the crawler crawls, and the literal node_modules probes are gone. Fixes #1129 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_safety_yarn_pnp.rs | 44 +++++++++++++++++++ .../src/crawlers/pkg_managers.rs | 19 ++++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index b2f1042cb..baf98cc35 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -1218,3 +1218,47 @@ fn pnp_loader_under_explicit_pnp_linker_still_refuses() { assert_eq!(envelope_error_code(&env), Some("yarn_pnp_unsupported")); assert_eq!(std::fs::read(&index).unwrap(), ORIGINAL_BYTES); } + +/// #1129: pnpm's `node-linker=pnp` with a custom `modulesDir` keeps its +/// store in `/.pnpm`, not `node_modules/.pnpm`. The layout +/// detector reads the same modules dirs as the crawler, so the tree is +/// pnpm (not yarn berry) and apply patches the copy the crawler finds +/// there instead of refusing with `yarn_pnp_unsupported`. Both settings +/// files spell `modulesDir`. +#[test] +fn pnpm_pnp_with_a_custom_modules_dir_applies() { + for (file, text) in [ + (".npmrc", "node-linker=pnp\nmodules-dir=deps\n"), + ("pnpm-workspace.yaml", "nodeLinker: pnp\nmodulesDir: deps\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"pnpm-pnp-deps","version":"0.0.0","private":true}"#, + ) + .unwrap(); + std::fs::write(cwd.join(file), text).unwrap(); + std::fs::write(cwd.join(".pnp.cjs"), b"// pnpm PnP loader\n").unwrap(); + std::fs::write(cwd.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n").unwrap(); + stage_applicable_package(cwd); + // What pnpm installs with `modulesDir: deps`: the store and the + // package under deps/, nothing under node_modules/. + std::fs::rename(cwd.join("node_modules"), cwd.join("deps")).unwrap(); + std::fs::create_dir_all(cwd.join("deps/.pnpm")).unwrap(); + let index = cwd.join("deps/dummy/index.js"); + + let (code, stdout, stderr) = run(cwd, &["apply", "--json"]); + let env = parse_json_envelope(&stdout); + assert_ne!( + envelope_error_code(&env), + Some("yarn_pnp_unsupported"), + "{file}: pnpm's PnP tree is not yarn berry.\nenvelope: {env}" + ); + assert_eq!( + code, 0, + "{file}: apply patches the deps/ copy.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!(std::fs::read(&index).unwrap(), PATCHED_BYTES, "{file}"); + } +} diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 8088107d2..18f70789d 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -81,7 +81,8 @@ pub enum NpmPkgManager { /// 2. `node_modules/.vlt/` is a directory, or `node_modules/.vlt-lock.json` /// is a file → vlt. /// 3. `bun.lock` or `bun.lockb` (+ `node_modules/`) → bun. -/// 4. `node_modules/.modules.yaml` or `node_modules/.pnpm/` → pnpm. +/// 4. `.modules.yaml` or `.pnpm/` in `node_modules/` or the configured +/// `modulesDir` → pnpm. /// 5. `yarn.lock` (without PnP markers) + `node_modules/` → yarn classic. /// 6. `node_modules/` exists → npm. /// 7. Otherwise → unknown. @@ -140,8 +141,9 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { return NpmPkgManager::Bun; } - // 4. pnpm — markers live inside node_modules/. - if node_modules.join(".modules.yaml").is_file() || node_modules.join(".pnpm").is_dir() { + // 4. pnpm — markers live inside node_modules/ or the configured + // modules dir (`modulesDir`, #1129). + if super::pnpm_layout::installed_store(project_root) { return NpmPkgManager::Pnpm; } @@ -294,9 +296,10 @@ pub fn live_pnp_marker_with( /// verified against a real `pnpm install` with pnpm 10.28.2). The /// reclassification requires ALL of: /// -/// * an installed pnpm store (`node_modules/.modules.yaml` or -/// `node_modules/.pnpm/`) — a bare `pnpm-lock.yaml` left behind in a -/// yarn-berry repo must not escape the refusal; +/// * an installed pnpm store (`.modules.yaml` or `.pnpm/` in +/// `node_modules/` or the configured `modulesDir`, see +/// [`super::pnpm_layout::installed_store_in`]) — a bare `pnpm-lock.yaml` +/// left behind in a yarn-berry repo must not escape the refusal; /// * `pnpm-lock.yaml` at the root — an installed store without pnpm's /// lockfile is not attributable to pnpm's PnP mode; /// * NO `yarn.lock` — a tree carrying both lockfiles alongside the @@ -314,9 +317,9 @@ pub(crate) fn pnpm_pnp_layout(project_root: &Path) -> bool { /// [`pnpm_pnp_layout`] over a [`crate::vendor::lock_inventory::ProjectView`]. pub(crate) fn pnpm_pnp_layout_in(view: &crate::vendor::lock_inventory::ProjectView<'_>) -> bool { - (view.is_file("node_modules/.modules.yaml") || view.is_dir("node_modules/.pnpm")) - && view.is_file("pnpm-lock.yaml") + view.is_file("pnpm-lock.yaml") && !view.is_file("yarn.lock") + && super::pnpm_layout::installed_store_in(view) } /// The yarn Plug'n'Play loader of a project: the text of each loader file