Fix npm crawler missing relocated dependency stores (#359, #362) - #365
Mikola Lysenko (mikolalysenko) wants to merge 12 commits into
Conversation
Assisted-by: Claude Code:claude-opus-5-5
With npm's install-strategy=linked, or a pnpm virtualStoreDir moved away from node_modules/.pnpm, transitive dependencies live only in a store the crawler never walked: it matched stores by fixed name and skipped every other hidden dir. apply, scan and vendor then reported those packages as not installed and left them unpatched. The crawler now walks npm's node_modules/.store (including scoped entries one level down) and the pnpm store that .modules.yaml records, in scan, apply's resolver and the peer-copy fan-out. A recorded store outside the project, such as pnpm's global virtual store shared by other projects, is still left alone. Assisted-by: Claude Code:claude-opus-5-5
Covers #359 (npm install-strategy=linked, transitive dep only in node_modules/.store: apply, rollback and vendor) and #362 (pnpm virtualStoreDir at .vstore and node_modules/.custom: apply and rollback), each checking that Node loads the patched copy. Both fail on main with package_not_installed. Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
The fan-out that patches every peer-variant copy looked up a relocated pnpm store from any ancestor directory. For a project nested inside another pnpm project, that could pick the outer project's store and patch copies this project never loads. A relocated store now counts only when the copy being patched sits inside it. Assisted-by: Claude Code:claude-opus-5-5
npm 9.0-9.3 ignore install-strategy=linked and install the hoisted tree, so the npm 9.0.0 compatibility cell has no .store to test. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Relative cwd walks global pnpm store
- Added a check to reject empty importer paths, preventing strip_prefix from succeeding on absolute global store paths when cwd is '.'
Or push these changes by commenting:
@cursor push af6d4cb22e
Preview (af6d4cb22e)
diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs
@@ -310,6 +310,9 @@
let text = crate::utils::fs::read_regular_to_string_sync(&nm.join(PNPM_MODULES_YAML)).ok()?;
let recorded = parse_modules_yaml_virtual_store_dir(&text)?;
let importer = normalize_lexically(nm.parent()?);
+ if importer.as_os_str().is_empty() {
+ return None;
+ }
let store = normalize_lexically(&nm.join(recorded));
if store == normalize_lexically(&nm.join(".pnpm")) || store == normalize_lexically(nm) {
return None;
@@ -1088,7 +1091,11 @@
/// Inside a store entry (`store_entry`) a link is a dependency edge into
/// a sibling entry, whose own visit records that copy, so only a real
/// directory there matches.
- fn visit_resolver_dir(nm_path: PathBuf, store_entry: bool, pending: &[Target]) -> ResolverVisit {
+ fn visit_resolver_dir(
+ nm_path: PathBuf,
+ store_entry: bool,
+ pending: &[Target],
+ ) -> ResolverVisit {
let listing = list_dir_sync(&nm_path);
let probe_filter = ProbeFilter::new(&listing);
let matched = pendingYou can send follow-ups to the cloud agent here.
|
[agent] Two new issues share this PR's root cause (a dependency store dir that isn't on the hard-coded list gets dropped by the hidden-entry skip): #366 (Bun isolated linker, Generated by Claude Code |
On macOS the temp dir sits under the /var -> /private/var link, so the peer-variant fan-out from a direct dep's importer link only matched the relocated store on the canonical chain and reported the copy as /private/var/..., unlike the other store layouts. Containment is now also checked canonically, so the store is kept as the caller spelled it. The regression test reaches its project through a linked ancestor so this is covered on every platform. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HcXxpY7X3MC84EEfA8jws6
With the CLI's default `--cwd .` the importer is the empty relative
path, and `strip_prefix("")` accepts every path, so an absolute
`virtualStoreDir` such as pnpm's global virtual store passed the
in-project check and was crawled (and would be patched in place).
The store must now sit strictly below the importer by plain child
names: no root, prefix or `..` component.
Reported by Cursor Bugbot on #365.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HcXxpY7X3MC84EEfA8jws6
|
BugBot review Generated by Claude Code |
The Windows test helper shells out to `cmd /C mklink /J`, which reads a `/` inside a path (`[email protected]/node_modules/is-number`) as a switch, so the linked-store and relocated-store tests failed on Windows with "Invalid switch". Rebuild both paths from their components first so every separator is `\`. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HcXxpY7X3MC84EEfA8jws6
|
[agent] Generated by Claude Code |
|
BugBot review Generated by Claude Code |
Brings in the v5 consolidation (#277). Keeps main's rewritten CHANGELOG and re-adds this branch's Fixed entry, and points the virtualStoreDir reader at strip_bom's new home in utils::serde. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
Old pnpm records virtualStoreDir as an absolute path. With the default --cwd . the importer is the empty path, which is never a prefix of an absolute store, so a store inside the project was skipped and its transitive packages stayed package_not_installed. An absolute store is now also compared with both sides canonicalized, which also covers a project reached through a linked ancestor. A store that resolves outside the project, such as pnpm's global virtual store, is still refused. Reported by Cursor Bugbot on #365. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
pnpm's default store, node_modules/.pnpm, is walked by name. An old or Windows pnpm can record it in .modules.yaml as an absolute path, and from --cwd . (or through a linked ancestor) that spelling missed the lexical default-location check. The relocated-store reader then accepted it too, so every store copy was reported twice. The check now compares the importer-relative tail, so any spelling of the default store, or of node_modules itself, is skipped. Reported by Cursor Bugbot on #365. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 80f4a71. Configure here.
|
[burn-down agent] Ready for review on Generated by Claude Code |

LLM Description written by Claude Code:claude-opus-5-5
Fixes #359
Refs #362
Summary
Agent mode now finds transitive npm packages in two install layouts it used to miss. Before this change,
scan,apply,rollbackandvendorreported those packagespackage_not_installedand left them unpatched, andscan --applystill exited 0.npm
install-strategy=linked(npm install-strategy=linked: transitive packages under node_modules/.store are "not installed", and scan --apply exits 0 leaving them unpatched #359): npm keeps every package innode_modules/.store/<name>@<version>-<hash>/node_modules/<name>. Scoped packages sit one level deeper, at.store/@scope/<leaf>@…. The crawler now walks that store in all three places that look for installed copies:gather_node_modules);apply/rollback's resolver (nested_node_modules_of);find_store_peer_variant_copies). npm hashes the dependency graph into the key, so onename@versioncan have several copies.It uses the existing store-entry policy: real dirs only, and links inside an entry are dependency edges.
decode_npm_store_entry_namereads the advertised name and version. It cuts the hash by its fixed 22-char length, because the base64url hash can contain-itself.pnpm
virtualStoreDir(Agent mode ignores pnpm's virtualStoreDir: transitive dependencies are reported package_not_installed with a custom virtualStoreDir or the global virtual store #362, custom-dir half): pnpm records a relocated virtual store innode_modules/.modules.yaml. That's JSON on pnpm 10+ and YAML before, relative tonode_modules, or absolute on old pnpm.relocated_pnpm_virtual_store_syncreads it, and the same three walks treat the recorded dir like.pnpm. That covers a store next tonode_modules(.vstore) as well as one under a custom hidden name inside it.Not walked on purpose: a recorded store outside the project, or one reached through a link. In practice that's pnpm's global virtual store (
enableGlobalVirtualStore,<store-dir>/v10/links). Every project on the machine loads those files, so patching them in place would patch the other projects too, which is Agent-mode apply and rollback on a pnpm project with enableGlobalVirtualStore patch (and unpatch) every other project that shares the store #361's bug. The peer-copy fan-out also only uses a relocated store that holds the copy being patched, so an enclosing project's store is never touched.--cwd .(9708b3d).--cwd .or through a linked ancestor (6c155c6).node_modules/.pnpmis skipped by its importer-relative tail, however it is spelled, so it is never walked twice (80f4a71).The last three items came from Bugbot reviews.
Docs:
docs/ecosystems.md("npm: whichnode_modulestrees are crawled") lists the stores that are walked and the global-store exclusion. CHANGELOG has aFixedentry.Root cause
npm_crawler.rsrecognized isolated-layout stores only by fixed names:.pnpm, pnpm ≤3.registry.*, and.vlt. Every other hiddennode_moduleschild hit the generic hidden-entry skip. npm's.storeand any relocated pnpm store are the only home of transitive dependencies, so those packages were invisible. Both issues come from that one name check, and one store-discovery change fixes both.Scope
#366 (Bun
node_modules/.bun), #373 (Denonode_modules/.deno) and #405 (hosted VEX attesting from the pin while the.buncopy is unpatched) share this root cause. They are not in this PR, to keep it reviewable..bunentry names need their own decoding:+<hash>peer suffixes, and hostedhttp+++…entries.Refs #362, notFixes. The global-virtual-store half of #362 is left for #361, because making<store>/linksvisible without per-project isolation would widen #361's cross-project patching. I've explained this on #362, which stays open for that part.Merge with post-#277
main(1eb28a7)The v5 consolidation (#277) rewrote CHANGELOG. I kept main's version and re-added this PR's
Fixedbullet. I also pointed the.modules.yamlreader atstrip_bom's new home,utils::serde, becausepackage_json::detectwas removed. No other conflicts.Test evidence
.store: scan, resolver, peer fan-outcrawlers::npm_crawler::tests::test_npm_linked_store_transitive_packages_are_found-/_, prerelease, scoped, shrinkwrap dir)test_decode_npm_store_entry_nameinstall-strategy=linked, is-odd → is-number:applypatches the copy Node loads,rollbackrestores it,vendorrewires the lock,vendor --revertis byte-exacte2e_vendor_npm_build::npm_linked_strategy_transitive_package_is_patched_rolled_back_and_vendoredapply failed)virtualStoreDiras JSON../.vstore, YAML absolute path, YAML'.custom': scan, resolver, peer fan-outtest_pnpm_relocated_virtual_store_dir_is_walkedtest_pnpm_virtual_store_dir_outside_project_is_ignored--cwd .: an absolute outside store is not crawled; in-project stores, relative and absolute, arescan_pnpm_relocated_store_cwd_e2e(3 tests)test_absolute_in_project_store_is_walked_through_any_spelling.pnpm/node_modulesis not a relocated store (no double walk)test_absolute_default_store_is_not_a_relocated_storetest_path_below_accepts_only_plain_childrentest_enclosing_projects_relocated_store_is_not_a_peer_variant_sourcevirtualStoreDir: .vstoreandnode_modules/.custom:applypatches the copy Node loads,rollbackrestores ite2e_vendor_pnpm_build::pnpm_agent_apply_patches_a_transitive_dep_in_a_relocated_virtual_storeLocal results on 80f4a71, merged with main 2463257:
cargo test -p socket-patch-core --lib -- npm_crawler: 62 passed.scan_pnpm_relocated_store_cwd_e2e: 3 passed.cargo clippy --workspace --all-features -- -D warnings: clean.cargo fmt --all -- --check: the only diffs in touched files (visit_resolver_dir's signature,oracle.rsimports) are byte-identical onmain. CI runs no fmt gate.cargo test --workspace --all-features --no-fail-faston 1eb28a7: 9,337 passed and 12 failed. All 12 are write-failure or permission tests that fail only because the sandbox runs as root, the same set reported on the other agent PRs. None are in crawler, npm or pnpm code.CI on 80f4a71: all workflows are green (CI, npm, pnpm, Bun, vlt, Audit), and Bugbot reports no new issues. Earlier, on 6c155c6, the vlt legs failed between about 03:05 and 03:20 UTC. The cause was
vlt ciitself getting401fromapi.socket.dev/v0/purl, which is outside this diff, and the vlt legs passed again after that window.🤖 Generated with Claude Code
Generated by Claude Code