From 838bb2cf1cf09ef25d2b30981d214e728de568b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:24:59 +0000 Subject: [PATCH 1/8] Start fix for #444 Assisted-by: Claude Code:claude-opus-5-5 From ef563debd45e5b741199b0294401dad6ad709011 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:38:13 +0000 Subject: [PATCH 2/8] Find Deno global npm tools in scan -g and apply -g `deno install -g` tools load their npm dependencies from Deno's shared npm cache, $DENO_DIR/npm////. Global mode only looked in npm, pnpm, yarn and bun node_modules roots, so scan -g reported success with no findings, apply -g and rollback -g said the package was not installed, and no --global-prefix could reach the copy Deno runs. Global mode now adds every $DENO_DIR/npm/ dir as an npm root, and a --global-prefix naming one is read the same way. Each copy, including Deno's _ peer-dependency copies, is matched by its package.json, so apply and rollback patch every copy. The JSR crawler no longer misreads such a prefix as JSR packages. Refs #444 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/ecosystem_dispatch_e2e.rs | 56 ++++++ .../src/crawlers/deno_crawler.rs | 30 ++- .../src/crawlers/npm_crawler.rs | 185 +++++++++++++++++- .../tests/crawler_npm_e2e.rs | 147 ++++++++++++++ 4 files changed, 414 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index d2e4dc542..8167aec2b 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -1006,3 +1006,59 @@ fn rollback_dispatch_branch_deno() { }; assert_rollback_restored(root, "deno", &fixture); } + +/// #444: a package `deno install -g` tools load lives in Deno's npm cache, +/// `$DENO_DIR/npm/registry.npmjs.org///`. Both `--global` +/// (which now discovers that cache from `DENO_DIR`) and a `--global-prefix` +/// naming the registry dir must reach it; before the fix each reported the +/// package not installed. +fn deno_npm_cache_fixture( + root: &Path, + scope_args: impl FnOnce(&Path) -> Vec, +) -> RollbackFixture { + let deno_dir = root.join("deno-dir"); + let registry = deno_dir.join("npm").join("registry.npmjs.org"); + let pkg = registry.join("__deno_global_dispatch__").join("1.0.0"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"__deno_global_dispatch__","version":"1.0.0"}"#, + ) + .unwrap(); + let verify_file = pkg.join("index.js"); + std::fs::write(&verify_file, PATCHED).unwrap(); + let purl = "pkg:npm/__deno_global_dispatch__@1.0.0"; + write_rollback_manifest(root, purl, "package/index.js"); + RollbackFixture { + purl: purl.to_string(), + verify_file, + envs: vec![( + "DENO_DIR".to_string(), + deno_dir.to_str().unwrap().to_string(), + )], + scope_args: scope_args(®istry), + } +} + +#[test] +fn rollback_dispatch_deno_npm_cache_global() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_root_package_json(root); + let fixture = deno_npm_cache_fixture(root, |_| vec!["--global".to_string()]); + assert_rollback_restored(root, "npm", &fixture); +} + +#[test] +fn rollback_dispatch_deno_npm_cache_global_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_root_package_json(root); + let fixture = deno_npm_cache_fixture(root, |registry| { + vec![ + "--global-prefix".to_string(), + registry.to_str().unwrap().to_string(), + ] + }); + assert_rollback_restored(root, "npm", &fixture); +} diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 80c2ac948..0b9a1d9e8 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -60,8 +60,9 @@ impl DenoCrawler { /// Get the JSR cache root paths to scan. /// - /// With `--global-prefix`, returns the prefix directly; otherwise - /// resolves `$DENO_DIR/npm/jsr.io/`. + /// With `--global-prefix`, returns the prefix directly (nothing for a + /// registry dir of Deno's npm cache, which holds npm packages); + /// otherwise resolves `$DENO_DIR/npm/jsr.io/`. /// /// In local mode, only returns paths when the cwd looks like a /// Deno project (`deno.json`, `deno.jsonc`, or `deno.lock` @@ -71,6 +72,13 @@ impl DenoCrawler { options: &CrawlerOptions, ) -> Result, std::io::Error> { if let Some(ref custom) = options.global_prefix { + // Deno's npm cache (`$DENO_DIR/npm/registry.npmjs.org`) holds + // npm packages, which the npm crawler reads there; walked as a + // JSR root, its `@scope/name/` dirs would read as + // bogus `pkg:jsr/` purls. + if super::npm_crawler::is_deno_npm_cache_root(custom) { + return Ok(Vec::new()); + } return Ok(vec![custom.clone()]); } if !options.global && !is_deno_project(&options.cwd).await { @@ -304,7 +312,7 @@ async fn is_deno_project(cwd: &Path) -> bool { /// * Linux/other Unix: `$XDG_CACHE_HOME/deno`, else `$HOME/.cache/deno`. /// * Windows: `%LOCALAPPDATA%\deno` (falling back to `~\.cache\deno` /// if LOCALAPPDATA isn't set). -fn deno_dir() -> Option { +pub(super) fn deno_dir() -> Option { if let Ok(d) = std::env::var("DENO_DIR") { if !d.is_empty() { return Some(PathBuf::from(d)); @@ -390,6 +398,22 @@ mod tests { let _b = DenoCrawler::new(); } + /// A `--global-prefix` naming Deno's npm cache registry dir is the npm + /// crawler's (#444): its scoped `@types/node/20.0.0` must not also + /// surface as `pkg:jsr/@types/node@20.0.0`. + #[tokio::test] + async fn crawl_all_skips_deno_npm_cache_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + stage(®istry, "@types", "node", "20.0.0").await; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: Some(registry), + }; + assert!(DenoCrawler.crawl_all(&opts).await.is_empty()); + } + #[tokio::test] async fn crawl_all_empty_cache_returns_empty() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 4069ea988..517cc32d6 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1807,6 +1807,93 @@ fn list_dir_sync(path: &Path) -> Listing { } } +/// The registry dirs of Deno's npm cache, `$DENO_DIR/npm/` +/// (#444). `deno install -g` tools, and Deno projects without a +/// `nodeModulesDir`, load npm packages from there in Deno's own layout +/// (see [`is_deno_npm_cache_root`]), so these are global roots beside the +/// npm, pnpm, yarn and bun `node_modules`. `jsr.io` is skipped: it is the +/// Deno crawler's JSR root, not an npm registry. Sorted, for a stable +/// scan order. +fn deno_npm_cache_roots() -> Vec { + let Some(npm) = super::deno_crawler::deno_dir().map(|dir| dir.join("npm")) else { + return Vec::new(); + }; + let mut roots: Vec = list_dir_sync(&npm) + .entries + .into_iter() + .filter(|entry| !entry.name_str.starts_with('.') && entry.name_str != "jsr.io") + .map(|entry| npm.join(entry.name)) + .filter(|root| is_dir_sync(root)) + .collect(); + roots.sort(); + roots +} + +/// Whether `root` is a registry dir of Deno's npm cache rather than a +/// `node_modules` tree: a dir directly under one named `npm` +/// (`$DENO_DIR/npm/registry.npmjs.org`) that is neither a `node_modules` +/// nor Deno's `jsr.io` root. Every npm, pnpm, yarn and bun root is a +/// `node_modules` dir, so none of them reads as one. Deno lays a package +/// out at `//` (scoped: `@scope/name//`), and each +/// further copy it makes for a different peer-dependency resolution at +/// `_/`. +pub(super) fn is_deno_npm_cache_root(root: &Path) -> bool { + root.file_name() + .is_some_and(|name| name != "node_modules" && name != "jsr.io") + && root + .parent() + .and_then(Path::file_name) + .is_some_and(|parent| parent == "npm") +} + +/// The copy index of the Deno npm cache dir `dir_name` when it holds +/// `version`: 0 for ``, `n` for `_`, `None` for a dir +/// of any other version. +fn deno_npm_copy_index(dir_name: &str, version: &str) -> Option { + let rest = dir_name.strip_prefix(version)?; + if rest.is_empty() { + return Some(0); + } + let digits = rest.strip_prefix('_')?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// The non-hidden subdirectories of `dir`, sorted by name. +fn sorted_subdirs_sync(dir: &Path) -> Vec { + let mut names: Vec = list_dir_sync(dir) + .entries + .into_iter() + .filter(|entry| { + !entry.name_str.starts_with('.') && entry.file_type.is_some_and(|t| t.is_dir()) + }) + .map(|entry| entry.name_str) + .collect(); + names.sort(); + names +} + +/// Every package dir (`` or `@scope/`) of the Deno npm cache +/// registry dir `root`. +fn deno_npm_cache_package_dirs(root: &Path) -> Vec { + let mut dirs = Vec::new(); + for name in sorted_subdirs_sync(root) { + if name.starts_with('@') { + let scope = root.join(&name); + dirs.extend( + sorted_subdirs_sync(&scope) + .into_iter() + .map(|n| scope.join(n)), + ); + } else { + dirs.push(root.join(name)); + } + } + dirs +} + /// The `node_modules` roots a global prefix stands for. pnpm 11+ gives /// every `pnpm add -g` its own install, `$PNPM_HOME/global/v11//`, /// each with its own `node_modules` (and its own `.pnpm` on pnpm 12, or @@ -2356,7 +2443,11 @@ 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) + if is_deno_npm_cache_root(nm_path) { + Self::gather_deno_npm_cache(nm_path) + } else { + Self::gather_node_modules(nm_path, None, false) + } }); let mut packages = Vec::new(); @@ -2428,6 +2519,11 @@ impl NpmCrawler { }); } + if is_deno_npm_cache_root(node_modules_path) { + let root = node_modules_path.to_path_buf(); + return Ok(run_walk(move || Self::resolve_deno_npm_cache(&root, pending)).await); + } + // Both passes run as one walk-pool task: each visited dir is // listed once, and that listing both bounds which targets are // probed there and drives the descent (see @@ -2964,6 +3060,9 @@ impl NpmCrawler { Ok(None) => {} Err(why) => warn_bun_global_dir_undetermined(&why), } + for root in deno_npm_cache_roots() { + add(root); + } // macOS-specific fallback paths #[cfg(target_os = "macos")] @@ -3011,6 +3110,9 @@ impl NpmCrawler { fn node_modules_paths_sync(options: &CrawlerOptions) -> Vec { if options.global || options.global_prefix.is_some() { if let Some(ref custom) = options.global_prefix { + if is_deno_npm_cache_root(custom) { + return vec![custom.clone()]; + } return pnpm_isolated_global_install_roots(custom); } return NpmCrawler.get_global_node_modules_paths(); @@ -3434,6 +3536,87 @@ impl NpmCrawler { }) } + /// Gather every copy in the Deno npm cache registry dir `root` + /// (`/[_]/`, see [`is_deno_npm_cache_root`]), each + /// identified by its own package.json like any other package. + fn gather_deno_npm_cache(root: &Path) -> Vec { + par_map(deno_npm_cache_package_dirs(root), |pkg_dir| { + sorted_subdirs_sync(&pkg_dir) + .into_iter() + .map(|copy| { + let path = pkg_dir.join(copy); + ScanEvent::Package { + identity: read_package_json_sync(&path.join("package.json")), + path, + entry_key: None, + } + }) + .collect::>() + }) + .into_iter() + .flatten() + .collect() + } + + /// [`Self::find_by_purls`] over the Deno npm cache registry dir `root`: + /// every copy of each target, `/` first, then its + /// `_` peer-dependency copies in index order, each kept + /// only when its package.json names the target. Deno stores a name + /// with capitals under an encoded dir, so a target with one that the + /// direct lookup misses is matched against the whole cache by + /// package.json identity. + fn resolve_deno_npm_cache( + root: &Path, + targets: Vec, + ) -> HashMap> { + let is_target = |path: &Path, target: &Target| { + read_package_json_sync(&path.join("package.json")) + .is_some_and(|(name, version)| name == target.dir_key && version == target.version) + }; + let copy = |path: PathBuf, target: &Target| CrawledPackage { + name: target.name.clone(), + version: target.version.clone(), + namespace: target.namespace.clone(), + purl: target.purl.clone(), + path, + }; + let mut result: HashMap> = HashMap::new(); + let mut unmatched = Vec::new(); + for target in targets { + let pkg_dir = root.join(&target.dir_key); + let mut copies: Vec<(u64, String)> = sorted_subdirs_sync(&pkg_dir) + .into_iter() + .filter_map(|dir| Some((deno_npm_copy_index(&dir, &target.version)?, dir))) + .collect(); + copies.sort(); + let found: Vec = copies + .into_iter() + .map(|(_, dir)| pkg_dir.join(dir)) + .filter(|path| is_target(path, &target)) + .map(|path| copy(path, &target)) + .collect(); + if !found.is_empty() { + result.entry(target.purl.clone()).or_default().extend(found); + } else if target.dir_key.bytes().any(|b| b.is_ascii_uppercase()) { + unmatched.push(target); + } + } + if !unmatched.is_empty() { + for pkg_dir in deno_npm_cache_package_dirs(root) { + for dir in sorted_subdirs_sync(&pkg_dir) { + let path = pkg_dir.join(dir); + for target in unmatched.iter().filter(|t| is_target(&path, t)) { + result + .entry(target.purl.clone()) + .or_default() + .push(copy(path.clone(), target)); + } + } + } + } + result + } + /// Replay gathered [`ScanEvent`]s in order against `seen`, exactly as /// the sequential walk's `check_package` calls would have: a package /// is recorded only if its package.json parsed and its PURL is new. diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index dda4d2ca4..f56475ac4 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4272,3 +4272,150 @@ async fn apply_breaks_hardlink_to_shared_store_file() { assert_eq!(target_meta.nlink(), 1); } } + +// ── Deno's npm cache (#444) ──────────────────────────────────── + +/// Stage a package in Deno's npm cache layout: +/// `///package.json`, where `` is the version +/// (or `_` for a peer-dependency copy). `name` may include a +/// `@scope/` prefix. +async fn stage_deno_npm_pkg(registry: &Path, name: &str, dir: &str, version: &str) -> PathBuf { + let pkg_dir = registry.join(name).join(dir); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + let pkg_json = format!(r#"{{"name":"{name}","version":"{version}"}}"#); + tokio::fs::write(pkg_dir.join("package.json"), pkg_json) + .await + .unwrap(); + tokio::fs::write(registry.join(name).join("registry.json"), "{}") + .await + .unwrap(); + pkg_dir +} + +/// The copy each purl resolves to, as paths. +fn found_paths( + found: &HashMap>, + purl: &str, +) -> Vec { + found + .get(purl) + .map(|copies| copies.iter().map(|c| c.path.clone()).collect()) + .unwrap_or_default() +} + +/// #444: a `--global-prefix` naming Deno's npm cache registry dir +/// (`$DENO_DIR/npm/registry.npmjs.org`) is read in Deno's +/// `/` layout, not as a `node_modules` tree, so the scan +/// finds the packages `deno install -g` tools run. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_prefix_crawls_name_version_layout() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(®istry, "is-number", "6.0.0", "6.0.0").await; + stage_deno_npm_pkg(®istry, "@types/node", "20.0.0", "20.0.0").await; + + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: false, + global_prefix: Some(registry.clone()), + }; + let mut purls: Vec = NpmCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + purls.sort(); + assert_eq!( + purls, + vec![ + "pkg:npm/@types/node@20.0.0", + "pkg:npm/is-number@6.0.0", + "pkg:npm/is-odd@3.0.1", + ] + ); +} + +/// #444: apply / rollback / vex resolve a purl in Deno's npm cache to its +/// `/` dir, plus every `_` peer-dependency copy +/// Deno made of it, and to nothing whose package.json says otherwise. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_returns_every_copy() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let plain = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + let copy = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1_1", "3.0.1").await; + stage_deno_npm_pkg(®istry, "is-odd", "3.0.10", "3.0.10").await; + let scoped = stage_deno_npm_pkg(®istry, "@types/node", "20.0.0", "20.0.0").await; + // A dir spelled like the version whose package.json disagrees is not it. + stage_deno_npm_pkg(®istry, "left-pad", "1.3.0", "9.9.9").await; + + let purls = vec![ + "pkg:npm/is-odd@3.0.1".to_string(), + "pkg:npm/%40types/node@20.0.0".to_string(), + "pkg:npm/left-pad@1.3.0".to_string(), + "pkg:npm/absent@1.0.0".to_string(), + ]; + let found = NpmCrawler.find_by_purls(®istry, &purls).await.unwrap(); + assert_eq!( + found_paths(&found, "pkg:npm/is-odd@3.0.1"), + vec![plain, copy] + ); + assert_eq!( + found_paths(&found, "pkg:npm/%40types/node@20.0.0"), + vec![scoped] + ); + assert!(found_paths(&found, "pkg:npm/left-pad@1.3.0").is_empty()); + assert!(found_paths(&found, "pkg:npm/absent@1.0.0").is_empty()); +} + +/// #444: a traversal purl never escapes Deno's npm cache root. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_rejects_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(tmp.path(), "evil", "1.0.0", "1.0.0").await; + + let purls = vec!["pkg:npm/..%2F..%2Fevil@1.0.0".to_string()]; + let found = NpmCrawler.find_by_purls(®istry, &purls).await.unwrap(); + assert!(found.values().all(|copies| copies.is_empty()), "{found:?}"); +} + +/// #444: `-g` discovers `$DENO_DIR/npm/` beside the npm, pnpm, +/// yarn and bun global roots, so `scan -g` sees the packages +/// `deno install -g` tools load. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_includes_deno_npm_cache() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let registry = deno_dir.join("npm").join("registry.npmjs.org"); + let pkg = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + + let prev = std::env::var_os("DENO_DIR"); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let crawled = NpmCrawler.crawl_all(&opts).await; + match prev { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + + assert!(roots.contains(®istry), "{roots:?}"); + assert!( + crawled + .iter() + .any(|p| p.purl == "pkg:npm/is-odd@3.0.1" && p.path == pkg), + "{crawled:?}" + ); +} From 28fc6217dfbe9d2eae8deb0895733319cac027ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:51:41 +0000 Subject: [PATCH 3/8] Only read Deno's cache layout where Deno wrote it A root was read as Deno's npm cache whenever its parent dir was named npm, so a project under ~/src/npm with a custom modules folder lost its node_modules walk, and the JSR crawler skipped JSR trees kept under such a dir. A root now also has to hold Deno's cached /registry.json, and a relative --global-prefix is judged by its absolute path. Deno cache roots now come after the macOS fallback roots, so a copy another global root also holds is still reported there. A purl listed twice records each copy once, the capital-name fallback looks only at Deno's encoded _<...> dirs, and scan skips reading _ copies whose dir is already there. The CLI contract documents the new global root. Refs #444 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 + .../tests/ecosystem_dispatch_e2e.rs | 2 + .../src/crawlers/deno_crawler.rs | 21 ++- .../src/crawlers/npm_crawler.rs | 143 +++++++++++------- .../tests/crawler_npm_e2e.rs | 85 ++++++++++- 5 files changed, 195 insertions(+), 58 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e2bc9b3d9..5bcaf2d2b 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -279,6 +279,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v//node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names. +**Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm////` (scoped: `@scope/name//`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every `$DENO_DIR/npm/` dir (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir directly under one named `npm`, other than `node_modules` and `jsr.io`, holding Deno's cached `/registry.json` for some package) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`_/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix. + **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry their own `redirect` payload and `details.mode: "hosted"` events instead, and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 8167aec2b..b9693c715 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -1025,6 +1025,8 @@ fn deno_npm_cache_fixture( r#"{"name":"__deno_global_dispatch__","version":"1.0.0"}"#, ) .unwrap(); + // Deno caches the package's packument beside its version dirs. + std::fs::write(pkg.parent().unwrap().join("registry.json"), "{}").unwrap(); let verify_file = pkg.join("index.js"); std::fs::write(&verify_file, PATCHED).unwrap(); let purl = "pkg:npm/__deno_global_dispatch__@1.0.0"; diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 0b9a1d9e8..d47a584dd 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -400,18 +400,37 @@ mod tests { /// A `--global-prefix` naming Deno's npm cache registry dir is the npm /// crawler's (#444): its scoped `@types/node/20.0.0` must not also - /// surface as `pkg:jsr/@types/node@20.0.0`. + /// surface as `pkg:jsr/@types/node@20.0.0`. A JSR tree under a dir + /// named `npm` (no `registry.json`) is still walked. #[tokio::test] async fn crawl_all_skips_deno_npm_cache_prefix() { let tmp = tempfile::tempdir().unwrap(); let registry = tmp.path().join("npm").join("registry.npmjs.org"); stage(®istry, "@types", "node", "20.0.0").await; + tokio::fs::write(registry.join("@types/node/registry.json"), b"{}") + .await + .unwrap(); let opts = CrawlerOptions { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(registry), }; assert!(DenoCrawler.crawl_all(&opts).await.is_empty()); + + let mirror = tmp.path().join("npm").join("jsr-mirror"); + stage(&mirror, "@std", "path", "1.0.8").await; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: Some(mirror), + }; + let purls: Vec = DenoCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + assert_eq!(purls, vec!["pkg:jsr/@std/path@1.0.8"]); } #[tokio::test] diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 517cc32d6..f07e924f7 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1808,12 +1808,11 @@ fn list_dir_sync(path: &Path) -> Listing { } /// The registry dirs of Deno's npm cache, `$DENO_DIR/npm/` -/// (#444). `deno install -g` tools, and Deno projects without a -/// `nodeModulesDir`, load npm packages from there in Deno's own layout -/// (see [`is_deno_npm_cache_root`]), so these are global roots beside the -/// npm, pnpm, yarn and bun `node_modules`. `jsr.io` is skipped: it is the -/// Deno crawler's JSR root, not an npm registry. Sorted, for a stable -/// scan order. +/// (#444). `deno install -g` tools load their npm dependencies from there, +/// in Deno's own layout (see [`is_deno_npm_cache_root`]), so these are +/// global roots beside the npm, pnpm, yarn and bun `node_modules`. +/// `jsr.io` is skipped: it is the Deno crawler's JSR root, not an npm +/// registry. Sorted, for a stable scan order. fn deno_npm_cache_roots() -> Vec { let Some(npm) = super::deno_crawler::deno_dir().map(|dir| dir.join("npm")) else { return Vec::new(); @@ -1823,27 +1822,47 @@ fn deno_npm_cache_roots() -> Vec { .into_iter() .filter(|entry| !entry.name_str.starts_with('.') && entry.name_str != "jsr.io") .map(|entry| npm.join(entry.name)) - .filter(|root| is_dir_sync(root)) .collect(); roots.sort(); roots } -/// Whether `root` is a registry dir of Deno's npm cache rather than a -/// `node_modules` tree: a dir directly under one named `npm` -/// (`$DENO_DIR/npm/registry.npmjs.org`) that is neither a `node_modules` -/// nor Deno's `jsr.io` root. Every npm, pnpm, yarn and bun root is a -/// `node_modules` dir, so none of them reads as one. Deno lays a package -/// out at `//` (scoped: `@scope/name//`), and each -/// further copy it makes for a different peer-dependency resolution at -/// `_/`. +/// Whether `root` is a registry dir of Deno's npm cache +/// (`$DENO_DIR/npm/registry.npmjs.org`) rather than a `node_modules` tree: +/// a dir directly under one named `npm`, other than `node_modules` and +/// Deno's `jsr.io` root, that holds Deno's cached packument +/// (`/registry.json`) for at least one package. A `node_modules` +/// tree never has that file, so a project root that merely sits under a +/// dir named `npm` keeps its usual walk. Deno lays a package out at +/// `//` (scoped: `@scope/name//`), and each further +/// copy it makes for a different peer-dependency resolution at +/// `_/`. A relative `--global-prefix` is judged by its +/// absolute spelling. pub(super) fn is_deno_npm_cache_root(root: &Path) -> bool { - root.file_name() + let root = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf()); + let named = root + .file_name() .is_some_and(|name| name != "node_modules" && name != "jsr.io") && root .parent() .and_then(Path::file_name) - .is_some_and(|parent| parent == "npm") + .is_some_and(|parent| parent == "npm"); + named && holds_deno_packument(&root) +} + +/// Whether some package dir of `root` (`` or `@scope/`) holds +/// Deno's `registry.json`, stopping at the first that does. +fn holds_deno_packument(root: &Path) -> bool { + sorted_subdirs_sync(root).into_iter().any(|name| { + let dir = root.join(&name); + if name.starts_with('@') { + sorted_subdirs_sync(&dir) + .into_iter() + .any(|child| dir.join(child).join("registry.json").is_file()) + } else { + dir.join("registry.json").is_file() + } + }) } /// The copy index of the Deno npm cache dir `dir_name` when it holds @@ -3060,9 +3079,6 @@ impl NpmCrawler { Ok(None) => {} Err(why) => warn_bun_global_dir_undetermined(&why), } - for root in deno_npm_cache_roots() { - add(root); - } // macOS-specific fallback paths #[cfg(target_os = "macos")] @@ -3099,6 +3115,12 @@ impl NpmCrawler { } } + // Deno's npm cache, last: a copy another manager's global root + // also holds keeps being reported there. + for root in deno_npm_cache_roots() { + add(root); + } + paths } @@ -3536,15 +3558,26 @@ impl NpmCrawler { }) } - /// Gather every copy in the Deno npm cache registry dir `root` - /// (`/[_]/`, see [`is_deno_npm_cache_root`]), each - /// identified by its own package.json like any other package. + /// Gather every package in the Deno npm cache registry dir `root` + /// (`//`, see [`is_deno_npm_cache_root`]), each + /// identified by its own package.json like any other package. A + /// `_` peer-dependency copy is read only when no + /// `` dir sits beside it: the scan keeps one copy per purl, + /// and that one comes first. fn gather_deno_npm_cache(root: &Path) -> Vec { par_map(deno_npm_cache_package_dirs(root), |pkg_dir| { - sorted_subdirs_sync(&pkg_dir) - .into_iter() - .map(|copy| { - let path = pkg_dir.join(copy); + let dirs = sorted_subdirs_sync(&pkg_dir); + let is_extra_copy = |dir: &String| { + dir.rsplit_once('_').is_some_and(|(base, n)| { + !n.is_empty() + && n.bytes().all(|b| b.is_ascii_digit()) + && dirs.binary_search_by(|d| d.as_str().cmp(base)).is_ok() + }) + }; + dirs.iter() + .filter(|dir| !is_extra_copy(dir)) + .map(|dir| { + let path = pkg_dir.join(dir); ScanEvent::Package { identity: read_package_json_sync(&path.join("package.json")), path, @@ -3561,10 +3594,10 @@ impl NpmCrawler { /// [`Self::find_by_purls`] over the Deno npm cache registry dir `root`: /// every copy of each target, `/` first, then its /// `_` peer-dependency copies in index order, each kept - /// only when its package.json names the target. Deno stores a name - /// with capitals under an encoded dir, so a target with one that the - /// direct lookup misses is matched against the whole cache by - /// package.json identity. + /// only when its package.json names the target and recorded once. + /// Deno stores a name with capitals under an encoded `_<...>` dir at + /// the registry root, so a target with one that the direct lookup + /// misses is matched by package.json identity against those dirs only. fn resolve_deno_npm_cache( root: &Path, targets: Vec, @@ -3573,14 +3606,19 @@ impl NpmCrawler { read_package_json_sync(&path.join("package.json")) .is_some_and(|(name, version)| name == target.dir_key && version == target.version) }; - let copy = |path: PathBuf, target: &Target| CrawledPackage { - name: target.name.clone(), - version: target.version.clone(), - namespace: target.namespace.clone(), - purl: target.purl.clone(), - path, - }; let mut result: HashMap> = HashMap::new(); + let mut record = |path: PathBuf, target: &Target| { + let copies = result.entry(target.purl.clone()).or_default(); + if !copies.iter().any(|c| c.path == path) { + copies.push(CrawledPackage { + name: target.name.clone(), + version: target.version.clone(), + namespace: target.namespace.clone(), + purl: target.purl.clone(), + path, + }); + } + }; let mut unmatched = Vec::new(); for target in targets { let pkg_dir = root.join(&target.dir_key); @@ -3589,27 +3627,28 @@ impl NpmCrawler { .filter_map(|dir| Some((deno_npm_copy_index(&dir, &target.version)?, dir))) .collect(); copies.sort(); - let found: Vec = copies - .into_iter() - .map(|(_, dir)| pkg_dir.join(dir)) - .filter(|path| is_target(path, &target)) - .map(|path| copy(path, &target)) - .collect(); - if !found.is_empty() { - result.entry(target.purl.clone()).or_default().extend(found); - } else if target.dir_key.bytes().any(|b| b.is_ascii_uppercase()) { + let mut found = false; + for (_, dir) in copies { + let path = pkg_dir.join(dir); + if is_target(&path, &target) { + record(path, &target); + found = true; + } + } + if !found && target.dir_key.bytes().any(|b| b.is_ascii_uppercase()) { unmatched.push(target); } } if !unmatched.is_empty() { - for pkg_dir in deno_npm_cache_package_dirs(root) { + for encoded in sorted_subdirs_sync(root) + .into_iter() + .filter(|name| name.starts_with('_')) + { + let pkg_dir = root.join(encoded); for dir in sorted_subdirs_sync(&pkg_dir) { let path = pkg_dir.join(dir); for target in unmatched.iter().filter(|t| is_target(&path, t)) { - result - .entry(target.purl.clone()) - .or_default() - .push(copy(path.clone(), target)); + record(path.clone(), target); } } } diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index f56475ac4..184e3c9e8 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4397,7 +4397,17 @@ async fn global_mode_includes_deno_npm_cache() { let registry = deno_dir.join("npm").join("registry.npmjs.org"); let pkg = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; - let prev = std::env::var_os("DENO_DIR"); + /// Restores `DENO_DIR` however the test ends. + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); std::env::set_var("DENO_DIR", &deno_dir); let opts = CrawlerOptions { cwd: tmp.path().to_path_buf(), @@ -4406,10 +4416,6 @@ async fn global_mode_includes_deno_npm_cache() { }; let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); let crawled = NpmCrawler.crawl_all(&opts).await; - match prev { - Some(v) => std::env::set_var("DENO_DIR", v), - None => std::env::remove_var("DENO_DIR"), - } assert!(roots.contains(®istry), "{roots:?}"); assert!( @@ -4419,3 +4425,72 @@ async fn global_mode_includes_deno_npm_cache() { "{crawled:?}" ); } + +/// #444 review: a root that merely sits under a dir named `npm` (a +/// project checked out at `~/src/npm` with a custom modules folder) has no +/// Deno packument, so it keeps the `node_modules` walk. +#[tokio::test] +#[serial_test::parallel] +async fn non_deno_root_under_npm_dir_keeps_node_modules_walk() { + let tmp = tempfile::tempdir().unwrap(); + let deps = tmp.path().join("npm").join("deps"); + stage_npm_pkg(&deps, "left-pad", "1.3.0").await; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: false, + global_prefix: Some(deps.clone()), + }; + let purls: Vec = NpmCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + assert_eq!(purls, vec!["pkg:npm/left-pad@1.3.0"]); + let found = NpmCrawler + .find_by_purls(&deps, &["pkg:npm/left-pad@1.3.0".to_string()]) + .await + .unwrap(); + assert_eq!(found_paths(&found, "pkg:npm/left-pad@1.3.0").len(), 1); +} + +/// #444 review: a purl listed twice resolves each Deno cache copy once. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_records_each_copy_once() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let plain = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + let purl = "pkg:npm/is-odd@3.0.1".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, &[purl.clone(), purl.clone()]) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![plain]); +} + +/// #444: Deno stores a name with capitals under an encoded `_<...>` dir; +/// the purl still resolves to it by its package.json. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_find_by_purls_resolves_encoded_capital_name() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let pkg = registry.join("_jjjvatsuojxgc3nonu").join("1.3.5"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write( + pkg.join("package.json"), + r#"{"name":"JSONStream","version":"1.3.5"}"#, + ) + .await + .unwrap(); + tokio::fs::write(pkg.parent().unwrap().join("registry.json"), "{}") + .await + .unwrap(); + let purl = "pkg:npm/JSONStream@1.3.5".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, std::slice::from_ref(&purl)) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![pkg]); +} From 3a55fd83767c1aba462b9014bea7b2fe7d70c441 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:42:54 +0000 Subject: [PATCH 4/8] Find Deno caches of registries served below a path Deno stores a registry served below a path (Artifactory, CodeArtifact, GitLab: https://host/api/npm/) as nested dirs under $DENO_DIR/npm, one per path segment, e.g. npm//api/npm///. Only a dir directly under npm/ was recognized, so -g and a --global-prefix naming such a registry still reported its packages not installed. A registry dir now only needs an ancestor named npm plus Deno's registry.json marker, and -g walks down from $DENO_DIR/npm to every dir holding that marker. Refs #444 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/crawlers/npm_crawler.rs | 69 +++++++++++------ .../tests/crawler_npm_e2e.rs | 75 +++++++++++++++++++ 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 76b3ab99c..e60c597c5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -279,7 +279,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v//node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names. -**Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm////` (scoped: `@scope/name//`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every `$DENO_DIR/npm/` dir (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir directly under one named `npm`, other than `node_modules` and `jsr.io`, holding Deno's cached `/registry.json` for some package) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`_/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix. +**Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm////`, where `` is the registry's host, plus one dir per path segment for a registry served below a path (`https://host/api/npm/` → `/api/npm`) (scoped: `@scope/name//`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every such registry dir under `$DENO_DIR/npm` (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir below one named `npm`, other than `node_modules` and `jsr.io`, holding Deno's cached `/registry.json` for some package) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`_/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix. **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 5d9fbb728..43e72c0f1 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1866,35 +1866,62 @@ fn list_dir_sync(path: &Path) -> Listing { } } -/// The registry dirs of Deno's npm cache, `$DENO_DIR/npm/` -/// (#444). `deno install -g` tools load their npm dependencies from there, -/// in Deno's own layout (see [`is_deno_npm_cache_root`]), so these are -/// global roots beside the npm, pnpm, yarn and bun `node_modules`. -/// `jsr.io` is skipped: it is the Deno crawler's JSR root, not an npm -/// registry. Sorted, for a stable scan order. +/// How many levels below `$DENO_DIR/npm` a registry dir may sit. Deno +/// maps a registry URL to `[_]` plus one dir per path segment, +/// so `https://host/api/npm/` lands three levels down; this leaves room +/// for longer paths without walking the cache's package dirs. +const DENO_NPM_REGISTRY_MAX_DEPTH: usize = 8; + +/// The registry dirs of Deno's npm cache (#444): every dir under +/// `$DENO_DIR/npm` that holds Deno's packuments +/// ([`holds_deno_packument`]), `` for a registry at a host's root +/// (`registry.npmjs.org`) and `/` for one served below a +/// path (`https://host/api/npm/` → `/api/npm`, as Artifactory, +/// CodeArtifact and GitLab registries are). `deno install -g` tools load +/// their npm dependencies from there, in Deno's own layout (see +/// [`is_deno_npm_cache_root`]), so these are global roots beside the npm, +/// pnpm, yarn and bun `node_modules`. A registry dir is not descended +/// into (its children are packages), and `jsr.io` is skipped: it is the +/// Deno crawler's JSR root, not an npm registry. Sorted, for a stable +/// scan order. fn deno_npm_cache_roots() -> Vec { let Some(npm) = super::deno_crawler::deno_dir().map(|dir| dir.join("npm")) else { return Vec::new(); }; - let mut roots: Vec = list_dir_sync(&npm) - .entries + let mut roots = Vec::new(); + let mut level: Vec = sorted_subdirs_sync(&npm) .into_iter() - .filter(|entry| !entry.name_str.starts_with('.') && entry.name_str != "jsr.io") - .map(|entry| npm.join(entry.name)) + .filter(|name| name != "jsr.io") + .map(|name| npm.join(name)) .collect(); + for _ in 0..DENO_NPM_REGISTRY_MAX_DEPTH { + let mut next = Vec::new(); + for dir in level { + if holds_deno_packument(&dir) { + roots.push(dir); + } else { + next.extend(sorted_subdirs_sync(&dir).into_iter().map(|n| dir.join(n))); + } + } + if next.is_empty() { + break; + } + level = next; + } roots.sort(); roots } /// Whether `root` is a registry dir of Deno's npm cache -/// (`$DENO_DIR/npm/registry.npmjs.org`) rather than a `node_modules` tree: -/// a dir directly under one named `npm`, other than `node_modules` and -/// Deno's `jsr.io` root, that holds Deno's cached packument -/// (`/registry.json`) for at least one package. A `node_modules` -/// tree never has that file, so a project root that merely sits under a -/// dir named `npm` keeps its usual walk. Deno lays a package out at -/// `//` (scoped: `@scope/name//`), and each further -/// copy it makes for a different peer-dependency resolution at +/// (`$DENO_DIR/npm/registry.npmjs.org`, or a nested +/// `$DENO_DIR/npm//` for a registry served below a path) +/// rather than a `node_modules` tree: a dir below one named `npm`, other +/// than a `node_modules` and Deno's `jsr.io` root, that holds Deno's +/// cached packument (`/registry.json`) for at least one package. A +/// `node_modules` tree never has that file, so a project root that merely +/// sits under a dir named `npm` keeps its usual walk. Deno lays a package +/// out at `//` (scoped: `@scope/name//`), and each +/// further copy it makes for a different peer-dependency resolution at /// `_/`. A relative `--global-prefix` is judged by its /// absolute spelling. pub(super) fn is_deno_npm_cache_root(root: &Path) -> bool { @@ -1903,9 +1930,9 @@ pub(super) fn is_deno_npm_cache_root(root: &Path) -> bool { .file_name() .is_some_and(|name| name != "node_modules" && name != "jsr.io") && root - .parent() - .and_then(Path::file_name) - .is_some_and(|parent| parent == "npm"); + .ancestors() + .skip(1) + .any(|dir| dir.file_name().is_some_and(|name| name == "npm")); named && holds_deno_packument(&root) } diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 184e3c9e8..1b6ac8c8e 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4494,3 +4494,78 @@ async fn deno_npm_cache_find_by_purls_resolves_encoded_capital_name() { .unwrap(); assert_eq!(found_paths(&found, &purl), vec![pkg]); } + +/// #444 (Bugbot): Deno maps a registry URL with a path +/// (`https://host/api/npm/`, as Artifactory, CodeArtifact and GitLab +/// serve) to nested dirs, `$DENO_DIR/npm//api/npm///` +/// (checked against Deno 2.9.7). A `--global-prefix` naming that nested +/// registry dir is still read in Deno's layout. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_nested_registry_prefix_crawls_and_resolves() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp + .path() + .join("npm") + .join("registry.example.com") + .join("api") + .join("npm"); + let pkg = stage_deno_npm_pkg(®istry, "is-odd", "3.0.1", "3.0.1").await; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: false, + global_prefix: Some(registry.clone()), + }; + let purls: Vec = NpmCrawler + .crawl_all(&opts) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + assert_eq!(purls, vec!["pkg:npm/is-odd@3.0.1"]); + let purl = "pkg:npm/is-odd@3.0.1".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, std::slice::from_ref(&purl)) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![pkg]); +} + +/// #444 (Bugbot): `-g` finds a nested registry dir under `$DENO_DIR/npm` +/// beside a plain one. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_includes_nested_deno_npm_registry() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let plain = deno_dir.join("npm").join("registry.npmjs.org"); + let nested = deno_dir.join("npm").join("4873").join("api").join("npm"); + stage_deno_npm_pkg(&plain, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + assert!(roots.contains(&plain), "{roots:?}"); + assert!(roots.contains(&nested), "{roots:?}"); + assert!( + !roots + .iter() + .any(|r| r.ends_with("4873") || r.ends_with("api")), + "intermediate dirs are not roots: {roots:?}" + ); +} From aa27f7878ec8097fce28425726a3be7ed3510cab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:55:35 +0000 Subject: [PATCH 5/8] Find Deno cache copies installed from a lock When a global tool's deno.lock already pins a tarball, Deno downloads only the tarball and writes no registry.json for the package. Such a registry dir was not recognized as Deno's cache, so apply -g left that copy unpatched while the tool kept running it. A registry dir now counts as Deno's cache when some package dir has no package.json of its own and holds either Deno's registry.json or a version dir with a package.json. A node_modules package dir always has its own package.json, so node_modules trees keep their usual walk. Checked with Deno 2.9.7: apply -g patches both the lock-installed copy and a path-registry copy, and the tool runs the patched code. Refs #444 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/crawlers/npm_crawler.rs | 44 +++++++++++++------ .../tests/crawler_npm_e2e.rs | 40 +++++++++++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e60c597c5..52ece9654 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -279,7 +279,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v//node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names. -**Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm////`, where `` is the registry's host, plus one dir per path segment for a registry served below a path (`https://host/api/npm/` → `/api/npm`) (scoped: `@scope/name//`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every such registry dir under `$DENO_DIR/npm` (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir below one named `npm`, other than `node_modules` and `jsr.io`, holding Deno's cached `/registry.json` for some package) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`_/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix. +**Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm////`, where `` is the registry's host, plus one dir per path segment for a registry served below a path (`https://host/api/npm/` → `/api/npm`) (scoped: `@scope/name//`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every such registry dir under `$DENO_DIR/npm` (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir below one named `npm`, other than `node_modules` and `jsr.io`, holding some package dir in Deno's layout: no `package.json` of its own, and a cached `registry.json` or a version dir with a `package.json`) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`_/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix. **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 43e72c0f1..085efa7e5 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1873,8 +1873,8 @@ fn list_dir_sync(path: &Path) -> Listing { const DENO_NPM_REGISTRY_MAX_DEPTH: usize = 8; /// The registry dirs of Deno's npm cache (#444): every dir under -/// `$DENO_DIR/npm` that holds Deno's packuments -/// ([`holds_deno_packument`]), `` for a registry at a host's root +/// `$DENO_DIR/npm` that holds packages in Deno's layout +/// ([`holds_deno_packages`]), `` for a registry at a host's root /// (`registry.npmjs.org`) and `/` for one served below a /// path (`https://host/api/npm/` → `/api/npm`, as Artifactory, /// CodeArtifact and GitLab registries are). `deno install -g` tools load @@ -1897,7 +1897,7 @@ fn deno_npm_cache_roots() -> Vec { for _ in 0..DENO_NPM_REGISTRY_MAX_DEPTH { let mut next = Vec::new(); for dir in level { - if holds_deno_packument(&dir) { + if holds_deno_packages(&dir) { roots.push(dir); } else { next.extend(sorted_subdirs_sync(&dir).into_iter().map(|n| dir.join(n))); @@ -1916,10 +1916,10 @@ fn deno_npm_cache_roots() -> Vec { /// (`$DENO_DIR/npm/registry.npmjs.org`, or a nested /// `$DENO_DIR/npm//` for a registry served below a path) /// rather than a `node_modules` tree: a dir below one named `npm`, other -/// than a `node_modules` and Deno's `jsr.io` root, that holds Deno's -/// cached packument (`/registry.json`) for at least one package. A -/// `node_modules` tree never has that file, so a project root that merely -/// sits under a dir named `npm` keeps its usual walk. Deno lays a package +/// than a `node_modules` and Deno's `jsr.io` root, with at least one +/// package dir in Deno's layout ([`is_deno_package_dir`]). A +/// `node_modules` package dir has its own `package.json`, so a project +/// root that merely sits under a dir named `npm` keeps its usual walk. Deno lays a package /// out at `//` (scoped: `@scope/name//`), and each /// further copy it makes for a different peer-dependency resolution at /// `_/`. A relative `--global-prefix` is judged by its @@ -1933,24 +1933,42 @@ pub(super) fn is_deno_npm_cache_root(root: &Path) -> bool { .ancestors() .skip(1) .any(|dir| dir.file_name().is_some_and(|name| name == "npm")); - named && holds_deno_packument(&root) + named && holds_deno_packages(&root) } -/// Whether some package dir of `root` (`` or `@scope/`) holds -/// Deno's `registry.json`, stopping at the first that does. -fn holds_deno_packument(root: &Path) -> bool { +/// Whether some package dir of `root` (`` or `@scope/`) is laid +/// out the way Deno's npm cache lays one out ([`is_deno_package_dir`]), +/// stopping at the first that is. +fn holds_deno_packages(root: &Path) -> bool { sorted_subdirs_sync(root).into_iter().any(|name| { let dir = root.join(&name); if name.starts_with('@') { sorted_subdirs_sync(&dir) .into_iter() - .any(|child| dir.join(child).join("registry.json").is_file()) + .any(|child| is_deno_package_dir(&dir.join(child))) } else { - dir.join("registry.json").is_file() + is_deno_package_dir(&dir) } }) } +/// Whether `dir` is a package dir of Deno's npm cache: it has no +/// `package.json` of its own (a `node_modules` package dir always does) +/// and holds Deno's cached packument (`registry.json`) or a version dir +/// (a name starting with a digit) with a `package.json`. Deno skips the +/// packument when a `deno.lock` already pins the tarball, so the version +/// dirs alone must count. +fn is_deno_package_dir(dir: &Path) -> bool { + if dir.join("package.json").exists() { + return false; + } + dir.join("registry.json").is_file() + || sorted_subdirs_sync(dir).into_iter().any(|version| { + version.starts_with(|c: char| c.is_ascii_digit()) + && dir.join(version).join("package.json").is_file() + }) +} + /// The copy index of the Deno npm cache dir `dir_name` when it holds /// `version`: 0 for ``, `n` for `_`, `None` for a dir /// of any other version. diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 1b6ac8c8e..2858f4ae2 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4569,3 +4569,43 @@ async fn global_mode_includes_nested_deno_npm_registry() { "intermediate dirs are not roots: {roots:?}" ); } + +/// #444: when a global tool's `deno.lock` already pins the tarball, Deno +/// downloads only the tarball and writes no `registry.json` (checked +/// against Deno 2.9.7). Such a registry dir is still Deno's cache, both +/// under `-g` and as a `--global-prefix`, while a `node_modules` tree +/// whose package dirs have their own `package.json` is not. +#[tokio::test] +#[serial_test::parallel] +async fn deno_npm_cache_without_packument_is_still_found() { + let tmp = tempfile::tempdir().unwrap(); + let registry = tmp.path().join("npm").join("registry.npmjs.org"); + let pkg = registry.join("is-odd").join("3.0.1"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write( + pkg.join("package.json"), + r#"{"name":"is-odd","version":"3.0.1"}"#, + ) + .await + .unwrap(); + let purl = "pkg:npm/is-odd@3.0.1".to_string(); + let found = NpmCrawler + .find_by_purls(®istry, std::slice::from_ref(&purl)) + .await + .unwrap(); + assert_eq!(found_paths(&found, &purl), vec![pkg]); + + // A node_modules-shaped root under a dir named npm, whose package + // ships a version-like subdir with a package.json, keeps its walk. + let deps = tmp.path().join("npm").join("deps"); + stage_npm_pkg(&deps, "rxjs", "7.8.1").await; + stage_npm_pkg(&deps.join("rxjs"), "7.8.1", "0.0.0").await; + let found = NpmCrawler + .find_by_purls(&deps, &["pkg:npm/rxjs@7.8.1".to_string()]) + .await + .unwrap(); + assert_eq!( + found_paths(&found, "pkg:npm/rxjs@7.8.1"), + vec![deps.join("rxjs")] + ); +} From dfe48fcfa59d0befa914f8f207832dbcfbb4d74c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:12:06 +0000 Subject: [PATCH 6/8] Find a path registry under a root registry host One host can serve a registry at its root and another below a path, which Deno stores as // beside /api/npm//. The -g walk stopped at the first dir holding packages, so the nested registry was never crawled. It now keeps walking that dir's children that are not Deno package or scope dirs. Refs #444 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 18 ++++++++-- .../tests/crawler_npm_e2e.rs | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 085efa7e5..bdddf4160 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1880,8 +1880,8 @@ const DENO_NPM_REGISTRY_MAX_DEPTH: usize = 8; /// CodeArtifact and GitLab registries are). `deno install -g` tools load /// their npm dependencies from there, in Deno's own layout (see /// [`is_deno_npm_cache_root`]), so these are global roots beside the npm, -/// pnpm, yarn and bun `node_modules`. A registry dir is not descended -/// into (its children are packages), and `jsr.io` is skipped: it is the +/// pnpm, yarn and bun `node_modules`. Below a registry dir only the +/// children that are not its packages are walked, and `jsr.io` is skipped: it is the /// Deno crawler's JSR root, not an npm registry. Sorted, for a stable /// scan order. fn deno_npm_cache_roots() -> Vec { @@ -1897,10 +1897,22 @@ fn deno_npm_cache_roots() -> Vec { for _ in 0..DENO_NPM_REGISTRY_MAX_DEPTH { let mut next = Vec::new(); for dir in level { + let children = sorted_subdirs_sync(&dir); if holds_deno_packages(&dir) { + // A registry dir's children are its packages, but the same + // host can also serve a registry below a path + // (`//` beside `/api/npm//`): keep + // walking the children that are neither. + next.extend( + children + .into_iter() + .filter(|name| !name.starts_with('@')) + .map(|name| dir.join(name)) + .filter(|child| !is_deno_package_dir(child)), + ); roots.push(dir); } else { - next.extend(sorted_subdirs_sync(&dir).into_iter().map(|n| dir.join(n))); + next.extend(children.into_iter().map(|name| dir.join(name))); } } if next.is_empty() { diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 2858f4ae2..016d2fd40 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4609,3 +4609,38 @@ async fn deno_npm_cache_without_packument_is_still_found() { vec![deps.join("rxjs")] ); } + +/// #444 (Bugbot): one host can hold a registry at its root and another +/// below a path (`npm///` beside `npm//api/npm//`); +/// `-g` finds both, without taking a package dir as a nested registry. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_finds_registry_nested_under_another() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let host = deno_dir.join("npm").join("registry.example.com"); + let nested = host.join("api").join("npm"); + stage_deno_npm_pkg(&host, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(&host, "@types/node", "20.0.0", "20.0.0").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); + assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); +} From 16f3005ab4d87a3534228b8e112270aaff2d122a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:26:59 +0000 Subject: [PATCH 7/8] Find a path registry behind a same-named package When a host serves a registry at its root and another below a path, a cached root package named like the path's first segment (api beside api/npm/) hid the nested registry from -g, because the walk skipped any child that looked like a package. A Deno package dir holds only version dirs, so the walk now continues into every non-version subdir of each child of a registry dir. Refs #444 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 29 +++++++++------- .../tests/crawler_npm_e2e.rs | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index bdddf4160..6357d6562 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1881,7 +1881,8 @@ const DENO_NPM_REGISTRY_MAX_DEPTH: usize = 8; /// their npm dependencies from there, in Deno's own layout (see /// [`is_deno_npm_cache_root`]), so these are global roots beside the npm, /// pnpm, yarn and bun `node_modules`. Below a registry dir only the -/// children that are not its packages are walked, and `jsr.io` is skipped: it is the +/// non-version subdirs of its children are walked (a nested registry +/// can sit there, not a package's copy), and `jsr.io` is skipped: it is the /// Deno crawler's JSR root, not an npm registry. Sorted, for a stable /// scan order. fn deno_npm_cache_roots() -> Vec { @@ -1899,17 +1900,21 @@ fn deno_npm_cache_roots() -> Vec { for dir in level { let children = sorted_subdirs_sync(&dir); if holds_deno_packages(&dir) { - // A registry dir's children are its packages, but the same - // host can also serve a registry below a path - // (`//` beside `/api/npm//`): keep - // walking the children that are neither. - next.extend( - children - .into_iter() - .filter(|name| !name.starts_with('@')) - .map(|name| dir.join(name)) - .filter(|child| !is_deno_package_dir(child)), - ); + // A registry dir's children are its packages, and a Deno + // package dir holds only version dirs (named from a + // digit). The same host can also serve a registry below a + // path (`/api/npm//` beside `//`), + // even under a dir that is also a package (`/api/`): + // keep walking every non-version subdir of a child. + for child in children.into_iter().filter(|name| !name.starts_with('@')) { + let child = dir.join(child); + next.extend( + sorted_subdirs_sync(&child) + .into_iter() + .filter(|name| !name.starts_with(|c: char| c.is_ascii_digit())) + .map(|name| child.join(name)), + ); + } roots.push(dir); } else { next.extend(children.into_iter().map(|name| dir.join(name))); diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 016d2fd40..e732541d6 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4644,3 +4644,37 @@ async fn global_mode_finds_registry_nested_under_another() { let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); } + +/// #444 (Bugbot): a host-root package named like the first segment of a +/// path registry on the same host (`/api//` beside +/// `/api/npm//`) does not hide that registry from `-g`. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_finds_path_registry_behind_same_named_package() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let host = deno_dir.join("npm").join("registry.example.com"); + let nested = host.join("api").join("npm"); + stage_deno_npm_pkg(&host, "api", "1.0.0", "1.0.0").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); + assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); +} From 40ef0c1e09468b6d223198229003327f10c4e378 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:43:02 +0000 Subject: [PATCH 8/8] Find a one-segment path registry on a root host The previous change walked only the grandchildren of a registry dir, so a registry one segment below a root registry on the same host (/npm//) was never checked. A child that is not a Deno package dir is now walked like any other dir; only a child that is a package has its non-version subdirs walked instead. Refs #444 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 30 ++++++++++------- .../tests/crawler_npm_e2e.rs | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 6357d6562..0540dec2d 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1900,20 +1900,26 @@ fn deno_npm_cache_roots() -> Vec { for dir in level { let children = sorted_subdirs_sync(&dir); if holds_deno_packages(&dir) { - // A registry dir's children are its packages, and a Deno - // package dir holds only version dirs (named from a - // digit). The same host can also serve a registry below a - // path (`/api/npm//` beside `//`), - // even under a dir that is also a package (`/api/`): - // keep walking every non-version subdir of a child. + // A registry dir's children are mostly its packages, but the + // same host can also serve a registry below a path + // (`/npm//` or `/api/npm//` beside + // `//`). A child that is not a package is walked + // like any other dir; one that is (a package named like + // the path's first segment, `/api/`) holds only + // version dirs (named from a digit) of its own, so its + // other subdirs are walked. for child in children.into_iter().filter(|name| !name.starts_with('@')) { let child = dir.join(child); - next.extend( - sorted_subdirs_sync(&child) - .into_iter() - .filter(|name| !name.starts_with(|c: char| c.is_ascii_digit())) - .map(|name| child.join(name)), - ); + if is_deno_package_dir(&child) { + next.extend( + sorted_subdirs_sync(&child) + .into_iter() + .filter(|name| !name.starts_with(|c: char| c.is_ascii_digit())) + .map(|name| child.join(name)), + ); + } else { + next.push(child); + } } roots.push(dir); } else { diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index e732541d6..c9de98a80 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -4678,3 +4678,36 @@ async fn global_mode_finds_path_registry_behind_same_named_package() { let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); } + +/// #444 (Bugbot): a registry one path segment below a root registry on the +/// same host (`https://host/npm/` → `/npm//`) is itself a root. +#[tokio::test] +#[serial_test::serial] +async fn global_mode_finds_one_segment_registry_under_root_registry() { + let tmp = tempfile::tempdir().unwrap(); + let deno_dir = tmp.path().join("deno"); + let host = deno_dir.join("npm").join("registry.example.com"); + let nested = host.join("npm"); + stage_deno_npm_pkg(&host, "is-odd", "3.0.1", "3.0.1").await; + stage_deno_npm_pkg(&nested, "is-number", "6.0.0", "6.0.0").await; + + struct DenoDirGuard(Option); + impl Drop for DenoDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("DENO_DIR", v), + None => std::env::remove_var("DENO_DIR"), + } + } + } + let _guard = DenoDirGuard(std::env::var_os("DENO_DIR")); + std::env::set_var("DENO_DIR", &deno_dir); + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let roots = NpmCrawler.get_node_modules_paths(&opts).await.unwrap(); + let deno_roots: Vec<&PathBuf> = roots.iter().filter(|r| r.starts_with(&deno_dir)).collect(); + assert_eq!(deno_roots, vec![&host, &nested], "{roots:?}"); +}