Skip to content

Fix Deno global npm tools missed by -g (#444) - #1719

Merged
Mikola Lysenko (mikolalysenko) merged 9 commits into
mainfrom
agent/fix-deno-global-npm-cache
Oct 26, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 9 commits into
mainfrom
agent/fix-deno-global-npm-cache

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Refs #444

What it does

deno install -g tools load their npm dependencies from Deno's shared npm
cache, $DENO_DIR/npm/<registry-host>/<name>/<version>/ (scoped:
@scope/name/<version>/, extra peer-dependency copies: <version>_<n>/).
socket-patch's global mode never looked there. scan -g reported success
with zero findings, apply -g / rollback -g reported
package_not_installed, and no --global-prefix value could reach the copy
Deno actually runs.

After this PR:

  • --global adds every $DENO_DIR/npm/<registry-host> dir (all except
    jsr.io) as an npm root. The Deno roots come after the npm, pnpm, yarn,
    bun and macOS fallback roots. DENO_DIR resolution is shared with the
    Deno crawler: the env var, else the per-platform cache dir.
  • Registries served below a path (https://host/api/npm/), which Deno stores as nested dirs npm/<host>/api/npm/, are found too (verified with Deno 2.9.7).
  • A --global-prefix naming such a registry dir is read in Deno's layout
    instead of as a node_modules tree.
  • find_by_purls returns every copy (<version> first, then each
    <version>_<n>), each confirmed by its package.json. A name with
    capitals, which Deno stores under an encoded _<...> dir, is matched
    against those dirs by package.json identity. Scan reads one copy per
    purl.
  • The Deno (JSR) crawler no longer reads a Deno npm-cache prefix as JSR (it
    turned @types/node/20.0.0 into a bogus pkg:jsr/ purl).
  • CLI_CONTRACT.md documents the new global root.

Scan, apply, rollback and vex all reach the npm crawler through
get_node_modules_paths, crawl_all and find_by_purls, so all of them
get the change.

Root cause

NpmCrawler::get_global_node_modules_paths only knew node_modules-shaped
global roots, and every root went through the node_modules walker.

Detecting the layout. A root counts as a Deno npm-cache root only if:

  • some ancestor dir is named npm;
  • it isn't node_modules or jsr.io;
  • at least one package dir is in Deno's layout: no package.json of its own (a node_modules package dir always has one), plus either Deno's registry.json or a version dir holding a package.json. Deno skips registry.json when a deno.lock already pins the tarball, so the version dirs alone have to count.

-g walks down from $DENO_DIR/npm (up to 8 levels) to every such dir, and below one keeps walking its children that aren't packages, plus the non-version subdirs of those that are (a Deno package dir holds only version dirs), so registries served below a path (https://host/api/npm/ → npm/<host>/api/npm/, as Artifactory, CodeArtifact and GitLab serve) are found too. A relative prefix is judged by its absolute path. Both cases were checked with Deno 2.9.7: apply -g patched a lock-installed npmjs copy and a path-registry copy, and the tool ran the patched code from each.

Why Refs, not Fixes

The main defect (Deno global npm tools invisible to -g and to every
prefix) is fixed. Two parts of #444 are left out:

  • The Deno 2.9 per-tool bin/.<tool>/node_modules decoy still reports
    applied if a user points --global-prefix at it by hand. -g now finds
    the real cache copy, so the workaround that led there is no longer needed.
    Refusing that prefix needs Deno-version-specific detection, best done as
    its own change.
  • --global-prefix $DENO_DIR and --global-prefix $DENO_DIR/npm (the parent
    dirs) still aren't expanded; the registry dir itself works.

Related, unchanged: a Deno project without nodeModulesDir (local mode)
also runs npm packages from this cache, and local mode doesn't crawl it.

Test evidence

Red on main, green here:

  • crawler_npm_e2e::deno_npm_cache_prefix_crawls_name_version_layout (scan
    over the registry-dir prefix found nothing)
  • crawler_npm_e2e::deno_npm_cache_find_by_purls_returns_every_copy (apply,
    rollback and vex lookup: plain, scoped, _1 copy, identity mismatch,
    absent)
  • crawler_npm_e2e::global_mode_includes_deno_npm_cache (-g with
    DENO_DIR)
  • ecosystem_dispatch_e2e::rollback_dispatch_deno_npm_cache_global and
    ..._global_prefix (CLI rollback restores the cached copy; on main
    both reported the package not installed)

Other new tests:

  • deno_npm_cache_find_by_purls_rejects_traversal
  • ..._records_each_copy_once
  • ..._resolves_encoded_capital_name
  • non_deno_root_under_npm_dir_keeps_node_modules_walk
  • deno_crawler::tests::crawl_all_skips_deno_npm_cache_prefix

Real Deno 2.9.7 repro from the issue (deno install -g of a tool importing
npm:is-odd@3.0.1, local manifest whose patch appends
console.log('SP-PATCHED')):

  • socket-patch apply -g --offline --json → success, applied
  • the tool then printed SP-PATCHED
  • rollback -g → rolledBack: 1
  • the tool then ran unpatched again

On main the same apply -g reports package_not_installed.

Local checks:

  • cargo fmt --all -- --check: clean
  • cargo clippy --workspace --all-features -- -D warnings: clean
  • cargo test --workspace --all-features: 234 suites pass. 11 tests fail
    identically on origin/main in this sandbox: 9 write-failure tests that
    rely on read-only dirs (the sandbox runs as root), and 2
    e2e_bun_lockb tests that fetch a blob from the real patch API, which the
    sandbox proxy blocks. CI runs them unprivileged and with network.
  • Wrappers (npm/, pypi/, gem/) only dispatch to the binary and need no
    change.

Per-issue checklist

https://claude.ai/code/session_01XrEsEQZ2tQzsX2cz5QKhX3


Note

Medium Risk
Changes global package discovery and path resolution for apply/rollback; mistakes could patch wrong dirs or miss installs, but behavior is gated on Deno-specific layout heuristics with broad test coverage.

Overview
Global scan/apply/rollback/vex now treat Deno's shared npm cache ($DENO_DIR/npm/<registry>/…) as npm roots alongside existing node_modules-style globals. --global walks every registry dir under that tree (skipping jsr.io, including path-shaped registries like <host>/api/npm), and --global-prefix pointing at a registry dir is interpreted in Deno's <name>/<version>/ layout instead of as a node_modules tree.

The npm crawler adds layout detection (registry.json or version dirs without a package-level package.json), dedicated gather/resolve paths for peer-dependency copies (<version>_<n>), and package.json-based matching for encoded capitalized package names. The Deno JSR crawler returns no paths when the prefix is a Deno npm-cache registry, so scoped npm packages are not misreported as pkg:jsr/….

CLI_CONTRACT.md documents the new global root behavior. Coverage is extended with crawler unit/e2e tests and CLI rollback dispatch tests for --global and --global-prefix.

Reviewed by Cursor Bugbot for commit 40ef0c1. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
`deno install -g` tools load their npm dependencies from Deno's
shared npm cache, $DENO_DIR/npm/<registry>/<name>/<version>/. 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/<registry> dir as an npm
root, and a --global-prefix naming one is read the same way. Each
copy, including Deno's <version>_<n> 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
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
<name>/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 <version>_<n>
copies whose <version> dir is already there. The CLI contract
documents the new global root.

Refs #444

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko Mikola Lysenko (mikolalysenko) changed the title Fix Deno global npm tools missed by scan -g (#444) Fix Deno global npm tools missed by -g (#444) Oct 25, 2026
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 25, 2026 01:15
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 25, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review by the burn-down agent.

  • Head: 28fc6217df
  • CI: 38/38 non-skipped checks green (50 skipped), no main-wide failures
  • Bugbot: reviewed 28fc621, no findings; no open review threads
  • Mergeable, no CHANGELOG.md change
  • Worth a look: the Deno cache-layout detection in deno_crawler.rs / npm_crawler.rs (only treats a root as Deno's npm cache where Deno wrote it, so a project under ~/src/npm keeps its node_modules).

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review by the burn-down agent.

  • Head: 28fc6217
  • CI: 38/38 non-skipped checks green (50 skipped), no main-wide failures
  • Bugbot: reviewed 28fc6217, no findings; no open review threads
  • Mergeable against main; no CHANGELOG.md change
  • Reviewer focus: the Deno npm-cache root detection in npm_crawler.rs (only dirs directly under npm/ in DENO_DIR) and the JSR crawler now skipping that layout.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does. deno install -g tools load npm deps from Deno's shared cache, $DENO_DIR/npm/<registry>/<name>/<version>/, which -g never looked at, so scan -g found nothing and apply -g/rollback -g said package_not_installed. This PR adds those registry dirs as npm global roots, reads them in Deno's <name>/<version>[_n] layout, and stops the JSR crawler reading the same dir as bogus pkg:jsr/ purls.

Risk: low-medium. Additive and limited to global mode / a --global-prefix that is detected as Deno's cache (dir directly under npm/, not jsr.io/node_modules, holding a Deno registry.json). Ordinary node_modules roots short-circuit on the name check before any extra I/O. Worth knowing: apply -g now edits Deno's shared cache, so the patch also reaches local Deno projects that run from that cache (that's the copy Deno executes, so this is the intended behavior).

Look here

Verified

  • Read the full diff. Path traversal: purl name/scope go through the existing is_safe_npm_component gate before the new root.join(dir_key); version dirs come from directory listings, and every hit is confirmed by its package.json.
  • Ran cargo test -p socket-patch-core --test crawler_npm_e2e deno: 7/7 pass. With origin/main's crawler sources and the PR's tests, 5 of 7 fail (the two that pass are the traversal and non-Deno-root guards, which are meant to pass on main too).
  • CI: 38 passed, 50 skipped (matrix/coverage), 0 failed; ci-ok and clippy green. Bugbot: no issues. CHANGELOG.md untouched.

Changes I made: none.

Open questions: none blocking. The PR is Refs #444 (not Fixes) on purpose: the Deno 2.9 per-tool bin/.<tool>/node_modules decoy prefix and the parent-dir prefixes ($DENO_DIR, $DENO_DIR/npm) are left for a follow-up.

Auto-merge is armed, so approving sends this straight to the merge queue.


Generated by Claude Code

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 26, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] The merge queue dropped this PR at 15:40 UTC, and the failure isn't from this change. In queue run 40348796795, the yarn-classic 1.22.22 job (runner depot-r2nt1h245n) stalled in its Cache cargo step from 15:24 to 15:40 and was cancelled before any test ran. ci-ok failed only because of that cancellation, and no test failed. The earlier queue run on this head (40346916241) passed, and so did PR CI on 28fc621 (38 passed, 50 skipped). Same pattern as #1660 (Depot runners lost mid-job). I haven't pushed anything, because no code change would help here. The PR is still approved and mergeable, but auto-merge was turned off when it left the queue, so it needs to be queued again.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

CI janitor: removed this PR from the merge queue at 15:40 UTC because its merge_group run was hung. yarn-classic 1.22.22 (job) sat in the Cache cargo (Swatinem/rust-cache restore) step for 14+ minutes. That job normally takes ~2-3 minutes, and 12 entries were waiting behind it. I cancelled the run. Nothing in this PR caused it: the head is approved, ci-ok is green and the PR is mergeable. I couldn't re-add it from this session, so the final-review routine should put it back in the queue.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[final reviewer] Re-added to the merge queue at 15:50 UTC, as the CI janitor asked. The 15:40 removal was a hung Cache cargo restore in yarn-classic 1.22.22 (run 40348796795), not this change. Head 28fc621 is unchanged and approved, and ci-ok/clippy are green. This is its one re-add.


Generated by Claude Code

Resolve the crawl_all_sync conflict with the relocated Yarn
pnpm-linker store walk (#859): Deno npm cache roots keep their own
layout reader, and node_modules roots keep the new store walk.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-core/src/crawlers/npm_crawler.rs
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/<host>/api/npm/<name>/<version>/. 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
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

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
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-core/src/crawlers/npm_crawler.rs
One host can serve a registry at its root and another below a path,
which Deno stores as <host>/<pkg>/ beside <host>/api/npm/<pkg>/. 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
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-core/src/crawlers/npm_crawler.rs Outdated
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
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-core/src/crawlers/npm_crawler.rs Outdated
The previous change walked only the grandchildren of a registry dir,
so a registry one segment below a root registry on the same host
(<host>/npm/<pkg>/) 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
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final reviewer: re-added to the merge queue at 17:50 UTC on 40ef0c1. It is approved (Tanmay182003), ci-ok and clippy are green on this head, it is mergeable, and it has 0 unresolved review threads. Approvals stay valid across later pushes, so no re-review is needed. The earlier evictions were at 15:40 (the hung rust-cache restore that #1730 caps) and 16:17, and the burn-down agent has pushed fixes since.


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 40ef0c1. Configure here.

Merged via the queue into main with commit 8b32765 Oct 26, 2026
56 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the agent/fix-deno-global-npm-cache branch October 26, 2026 18:00
// other subdirs are walked.
for child in children.into_iter().filter(|name| !name.starts_with('@')) {
let child = dir.join(child);
if is_deno_package_dir(&child) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] A one-segment path registry is still missed when the root registry on the same host has cached a package named like that segment. Deno lays out both https://host/ and https://host/npm/ under $DENO_DIR/npm/host/, so host/npm/ holds the root registry's package npm (host/npm/10.9.0/) and the nested registry's packages (host/npm/foo/1.0.0/). Here is_deno_package_dir(host/npm) is true, so only host/npm/foo is queued. holds_deno_packages(host/npm/foo) is false because its children are version dirs that have a package.json, so host/npm never becomes a root. -g scan and apply then leave every package from https://host/npm/ unpatched with no warning. global_mode_finds_one_segment_registry_under_root_registry doesn't cache a package npm at the host root, so it doesn't cover this.

Fix: when a child is a package dir, also check its non-version subdirs with holds_deno_packages (ignoring digit-named dirs). If they hold packages, push the child itself as a registry root, the same as the non-package branch does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants