Skip to content

Commit bd4bd19

Browse files
mikolalysenkoclaude
andcommitted
fix(npm): discover pnpm virtual-store packages — transitive deps were invisible to apply and scan
Under pnpm's isolated linker a transitive-only dependency lives solely inside node_modules/.pnpm/<entry>/node_modules/<name>; the crawler skipped .pnpm as a hidden dir and never traversed symlinked packages, so apply reported package_not_installed for packages that were installed and runtime-loaded, and scan never sent them to the patch API. Confirmed empirically on pnpm 7, 8, 9, 10, 11, and 12-rc (2026-08-18 matrix). - find_by_purls: probe .pnpm store entries (real dirs only, root install wins via BFS order); entries whose dir name decodes as name@version are filtered against pending targets, undecodable names ride a conservative fallback so truncated/hashed dirs stay probeable. - crawl_all: inventory the virtual store after the root pass; identity re-reads are skipped for already-seen name@version entries, bundled deps inside store entries still walk. - One shared store-entry enumerator; scan helpers parameterized by a ScanPolicy bit instead of a parallel copy. - Multi-version installs of one package now individually discoverable. Tests: hand-built pnpm-shaped farm (transitive, multi-version, scoped, decoys, truncated-name fallback, decoder units) + a real-PATH-pnpm transitive apply e2e with CoW inode proofs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent da58dee commit bd4bd19

3 files changed

Lines changed: 960 additions & 19 deletions

File tree

crates/socket-patch-cli/tests/in_process_alternate_installers.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,163 @@ async fn pnpm_install_then_apply_patches_file() {
299299
assert_patched(&real, &patched, &before_hash, &after_hash);
300300
}
301301

302+
// ---------------------------------------------------------------------------
303+
// pnpm isolated linker: transitive-only dependency in the virtual store
304+
// ---------------------------------------------------------------------------
305+
306+
/// Under pnpm's isolated linker a *transitive-only* dependency has no
307+
/// importer-root entry at all: `node_modules/<dep>` does not exist, and the
308+
/// only physical install lives at `node_modules/.pnpm/<x>/node_modules/<dep>`
309+
/// — runtime-loaded, yet invisible to any walk that skips the hidden `.pnpm`
310+
/// virtual store (apply reported `package_not_installed` on pnpm 7–12).
311+
/// mkdirp@0.5.5 depends on minimist, giving a real pnpm install with exactly
312+
/// that shape. Apply must resolve minimist inside the store and patch the
313+
/// canonical file — while a sibling project sharing the same store stays
314+
/// pristine: the file is hardlink-imported, so only a CoW break (not an
315+
/// in-place write) keeps the store and every other consumer untouched.
316+
#[tokio::test]
317+
#[serial]
318+
async fn pnpm_transitive_only_dep_apply_patches_virtual_store() {
319+
if !has("pnpm") {
320+
println!("SKIP: pnpm not on PATH");
321+
return;
322+
}
323+
324+
let tmp = tempfile::tempdir().unwrap();
325+
let store = tmp.path().join("store");
326+
327+
let stage_project = |name: &str| -> std::path::PathBuf {
328+
let proj = tmp.path().join(name);
329+
std::fs::create_dir_all(&proj).unwrap();
330+
std::fs::write(
331+
proj.join("package.json"),
332+
format!(
333+
r#"{{ "name": "{name}", "version": "0.0.0", "dependencies": {{ "mkdirp": "0.5.5" }} }}"#
334+
),
335+
)
336+
.unwrap();
337+
proj
338+
};
339+
let proj_a = stage_project("pnpm-iso-a");
340+
let proj_b = stage_project("pnpm-iso-b");
341+
342+
for proj in [&proj_a, &proj_b] {
343+
// Both projects share one store; hardlink import (instead of the
344+
// APFS-clone default) makes each project file share the store
345+
// file's inode — the layout the CoW assertions below are about.
346+
// CLI flags, not project `.npmrc`: pnpm 11 no longer reads these
347+
// settings from `.npmrc` (silently — config get returns undefined).
348+
let out = pm_command("pnpm", &["npm_config_"])
349+
.args([
350+
"install",
351+
"--silent",
352+
"--no-frozen-lockfile",
353+
"--store-dir",
354+
store.to_str().unwrap(),
355+
"--config.package-import-method=hardlink",
356+
])
357+
.current_dir(proj)
358+
.stdout(std::process::Stdio::piped())
359+
.stderr(std::process::Stdio::piped())
360+
.output()
361+
.expect("pnpm install");
362+
if !out.status.success() {
363+
println!(
364+
"SKIP: pnpm install failed: {}",
365+
String::from_utf8_lossy(&out.stderr)
366+
);
367+
return;
368+
}
369+
}
370+
371+
// Premise: minimist is transitive-only — no importer-root entry (not
372+
// even a symlink). If pnpm ever hoisted it, this test would silently
373+
// stop exercising the virtual-store path and must say so.
374+
assert!(
375+
std::fs::symlink_metadata(proj_a.join("node_modules/minimist")).is_err(),
376+
"pnpm test premise broken: minimist appeared at the importer root; \
377+
the transitive-only virtual-store path is not being exercised"
378+
);
379+
380+
// The lock-resolved minimist lives next to the real mkdirp inside its
381+
// own store entry; canonicalize resolves that (possibly symlinked)
382+
// sibling to its physical store home.
383+
let locate = |proj: &Path| -> std::path::PathBuf {
384+
let mkdirp_real =
385+
std::fs::canonicalize(proj.join("node_modules/mkdirp")).expect("canonicalize mkdirp");
386+
std::fs::canonicalize(mkdirp_real.parent().unwrap().join("minimist"))
387+
.expect("minimist must be installed beside mkdirp in its store entry")
388+
};
389+
let minimist_a = locate(&proj_a);
390+
assert!(
391+
minimist_a.components().any(|c| c.as_os_str() == ".pnpm"),
392+
"premise: minimist's canonical home must be inside the virtual store: {minimist_a:?}"
393+
);
394+
let meta: serde_json::Value =
395+
serde_json::from_slice(&std::fs::read(minimist_a.join("package.json")).unwrap()).unwrap();
396+
let version = meta["version"].as_str().expect("version field").to_string();
397+
398+
let target = minimist_a.join("index.js");
399+
let original = std::fs::read(&target).expect("read minimist index.js");
400+
let before_hash = git_sha256(&original);
401+
let mut patched = original.clone();
402+
patched.extend_from_slice(b"\n// SOCKET-PATCH-PNPM-TRANSITIVE-MARKER\n");
403+
let after_hash = git_sha256(&patched);
404+
405+
// The sibling project's canonical copy of the same file.
406+
let target_b = locate(&proj_b).join("index.js");
407+
assert_eq!(
408+
std::fs::read(&target_b).unwrap(),
409+
original,
410+
"both projects must start from identical store-imported bytes"
411+
);
412+
#[cfg(unix)]
413+
{
414+
use std::os::unix::fs::MetadataExt;
415+
assert_eq!(
416+
std::fs::metadata(&target).unwrap().ino(),
417+
std::fs::metadata(&target_b).unwrap().ino(),
418+
"hardlink-import premise: both projects' copies must share the store inode"
419+
);
420+
}
421+
422+
let socket = proj_a.join(".socket");
423+
write_manifest(
424+
&socket,
425+
&format!("pkg:npm/minimist@{version}"),
426+
&before_hash,
427+
&after_hash,
428+
);
429+
let blobs = socket.join("blobs");
430+
std::fs::create_dir_all(&blobs).unwrap();
431+
std::fs::write(blobs.join(&after_hash), &patched).unwrap();
432+
433+
let code = apply_run(default_apply(&proj_a)).await;
434+
assert_eq!(
435+
code, 0,
436+
"apply must resolve the transitive-only dep inside .pnpm and succeed"
437+
);
438+
assert_patched(&target, &patched, &before_hash, &after_hash);
439+
440+
// CoW safety: the sibling project sharing the store is untouched, and
441+
// the patched file no longer shares the store inode.
442+
let after_b = std::fs::read(&target_b).unwrap();
443+
assert_eq!(
444+
git_sha256(&after_b),
445+
before_hash,
446+
"sibling project sharing the store must keep the original bytes"
447+
);
448+
#[cfg(unix)]
449+
{
450+
use std::os::unix::fs::MetadataExt;
451+
assert_ne!(
452+
std::fs::metadata(&target).unwrap().ino(),
453+
std::fs::metadata(&target_b).unwrap().ino(),
454+
"apply must break the hardlink (CoW) instead of writing through the store inode"
455+
);
456+
}
457+
}
458+
302459
// ---------------------------------------------------------------------------
303460
// Monorepo workspace (npm workspaces)
304461
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)