Skip to content

Commit a3e5f30

Browse files
committed
Scope the Deno project crawl to deno.lock
In project mode, crawl_all walked the whole JSR cache, so scan, agent apply and VEX saw every JSR package any project on the machine had cached. A project with a deno.lock now gets only the packages its lock's jsr section records, each looked up in the cache; a lock with no jsr section records none. --global, --global-prefix and projects without a usable deno.lock still walk the cache. On a 3,000-package cache with 140 locked packages the crawl drops from 48 ms to 7.5 ms (debug build). Fixes #1216. Assisted-by: Claude Code:claude-opus-5-5
1 parent 487b965 commit a3e5f30

1 file changed

Lines changed: 172 additions & 8 deletions

File tree

‎crates/socket-patch-core/src/crawlers/deno_crawler.rs‎

Lines changed: 172 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
//! scannable JSR-shaped trees, and (b) any future Deno that
2929
//! adopts a stable scope/name/version layout (or a third-party
3030
//! tool that materializes JSR packages this way) gets picked
31-
//! up automatically.
31+
//! up automatically. In a project with a `deno.lock`, only the
32+
//! lock's `jsr` entries are looked up in that layout; the cache is
33+
//! not walked.
3234
//!
3335
//! In the meantime, `socket-patch scan --global --ecosystems
3436
//! deno --global-prefix <path>` is what real users would invoke
@@ -84,16 +86,44 @@ impl DenoCrawler {
8486
}
8587
}
8688

87-
/// Crawl JSR cache(s) and return every `pkg:jsr/...` package
88-
/// present. JSR cache layout is
89+
/// Crawl JSR cache(s) and return the `pkg:jsr/...` packages present.
90+
/// JSR cache layout is
8991
/// `<root>/@<scope>/<name>/<version>/<package contents>`.
92+
///
93+
/// A local project with a `deno.lock` only gets the JSR packages its
94+
/// lock records, each looked up in the cache ([`jsr_lock_scope`],
95+
/// [`locate`]), so a package another project left in the cache is
96+
/// never crawled and the cache tree is not walked. Everything else
97+
/// (`--global`, `--global-prefix`, no or unreadable `deno.lock`) walks
98+
/// the whole cache.
9099
pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec<CrawledPackage> {
91100
let mut packages = Vec::new();
92101
let mut seen = HashSet::new();
93102

94103
let cache_paths = self.get_jsr_cache_paths(options).await.unwrap_or_default();
104+
if cache_paths.is_empty() {
105+
return packages;
106+
}
107+
let scope = if options.global || options.global_prefix.is_some() {
108+
None
109+
} else {
110+
jsr_lock_scope(&options.cwd).await
111+
};
95112
for cache_path in &cache_paths {
96-
scan_jsr_cache(cache_path, &mut seen, &mut packages).await;
113+
match &scope {
114+
Some(locked) => {
115+
for purl in locked {
116+
if seen.contains(purl) {
117+
continue;
118+
}
119+
if let Some(pkg) = locate(cache_path, purl).await {
120+
seen.insert(pkg.purl.clone());
121+
packages.push(pkg);
122+
}
123+
}
124+
}
125+
None => scan_jsr_cache(cache_path, &mut seen, &mut packages).await,
126+
}
97127
}
98128

99129
packages
@@ -126,13 +156,13 @@ impl Default for DenoCrawler {
126156
}
127157

128158
/// The cache directory of JSR `purl` under `jsr_cache_path`, reported
129-
/// under `purl`: the lookup behind [`DenoCrawler::find_by_purls`]. `None`
130-
/// for a non-`pkg:jsr/` purl, an unsafe coordinate, or a version directory
131-
/// that is not there.
159+
/// under `purl`: the one lookup behind [`DenoCrawler::find_by_purls`] and
160+
/// the `deno.lock`-scoped crawl. `None` for a non-`pkg:jsr/` purl, an
161+
/// unsafe coordinate, or a version directory that is not there.
132162
async fn locate(jsr_cache_path: &Path, purl: &str) -> Option<CrawledPackage> {
133163
let ((scope, name), version) = crate::utils::purl::parse_jsr_purl(purl)?;
134164
// SECURITY: scope/name/version come straight from the (untrusted)
135-
// manifest PURL and are joined onto the cache
165+
// manifest PURL or project `deno.lock` and are joined onto the cache
136166
// root below. A real JSR coordinate is a single path segment, so reject
137167
// any that could traverse out of the cache (`..`/`.`, a separator,
138168
// NUL). The parser percent-decodes components, so these guards see the
@@ -159,6 +189,24 @@ async fn locate(jsr_cache_path: &Path, purl: &str) -> Option<CrawledPackage> {
159189
})
160190
}
161191

192+
/// The `pkg:jsr/` purl of every key of `<cwd>/deno.lock`'s `jsr` section
193+
/// (`@<scope>/<name>@<version>`), in lock order. A lock without a `jsr`
194+
/// section records no JSR package (`Some` of nothing). `None` when there
195+
/// is no readable `deno.lock`, it is not JSON, or it carries no `version`
196+
/// (not a lock Deno wrote); the crawl then walks the whole cache.
197+
async fn jsr_lock_scope(cwd: &Path) -> Option<Vec<String>> {
198+
let text = crate::utils::fs::read_regular_to_string(&cwd.join("deno.lock"))
199+
.await
200+
.ok()?;
201+
let lock: Value = serde_json::from_str(&text).ok()?;
202+
lock.get("version")?;
203+
Some(
204+
lock_section(&lock, "jsr")
205+
.map(|jsr| jsr.keys().map(|key| format!("pkg:jsr/{key}")).collect())
206+
.unwrap_or_default(),
207+
)
208+
}
209+
162210
/// The `section` (`npm` or `jsr`) package map of a parsed `deno.lock`, in
163211
/// any lockfile version: top-level in versions 4 and 5, `packages.<section>`
164212
/// in version 3 and `<section>.packages` in version 2. The one reader of
@@ -521,6 +569,122 @@ mod tests {
521569
assert!(crawler.crawl_all(&opts).await.is_empty());
522570
}
523571

572+
// ── deno.lock-scoped project crawl ─────────────────────────────
573+
574+
/// A project at a fresh tempdir with `deno.lock` = `lock`, and a
575+
/// `DENO_DIR` whose JSR cache holds `@std/path@0.220.0` (locked in the
576+
/// fixtures below) plus `@std/fs@1.0.0` and `@other/x@2.0.0` (never
577+
/// locked). Returns the purls `crawl_all` reports, sorted.
578+
async fn crawl_project_with_lock(lock: &str, global: bool) -> Vec<String> {
579+
let project = tempfile::tempdir().unwrap();
580+
tokio::fs::write(project.path().join("deno.lock"), lock)
581+
.await
582+
.unwrap();
583+
let deno_home = tempfile::tempdir().unwrap();
584+
let jsr = deno_home.path().join("npm").join("jsr.io");
585+
stage(&jsr, "@std", "path", "0.220.0").await;
586+
stage(&jsr, "@std", "fs", "1.0.0").await;
587+
stage(&jsr, "@other", "x", "2.0.0").await;
588+
let _g = EnvGuard::set("DENO_DIR", deno_home.path().to_str().unwrap());
589+
590+
let opts = CrawlerOptions {
591+
cwd: project.path().to_path_buf(),
592+
global,
593+
global_prefix: None,
594+
};
595+
let mut purls: Vec<String> = DenoCrawler
596+
.crawl_all(&opts)
597+
.await
598+
.into_iter()
599+
.map(|p| p.purl)
600+
.collect();
601+
purls.sort();
602+
purls
603+
}
604+
605+
const WHOLE_CACHE: [&str; 3] = [
606+
"pkg:jsr/@other/x@2.0.0",
607+
"pkg:jsr/@std/fs@1.0.0",
608+
"pkg:jsr/@std/path@0.220.0",
609+
];
610+
611+
#[tokio::test]
612+
#[serial_test::serial]
613+
async fn crawl_all_local_lock_scopes_to_locked_jsr_packages() {
614+
// v4/v5 (top-level `jsr`) and v3 (`packages.jsr`): only the locked
615+
// package is reported; the other cached packages are not.
616+
for lock in [
617+
r#"{"version":"5","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"#,
618+
r#"{"version":"4","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"#,
619+
r#"{"version":"3","packages":{"jsr":{"@std/path@0.220.0":{"integrity":"a"}}}}"#,
620+
] {
621+
assert_eq!(
622+
crawl_project_with_lock(lock, false).await,
623+
vec!["pkg:jsr/@std/path@0.220.0"],
624+
"{lock}"
625+
);
626+
}
627+
}
628+
629+
#[tokio::test]
630+
#[serial_test::serial]
631+
async fn crawl_all_local_lock_without_jsr_reports_nothing() {
632+
// An npm-only project resolves no JSR package at all.
633+
let lock = r#"{"version":"4","npm":{"left-pad@1.3.0":{"integrity":"x"}}}"#;
634+
assert!(crawl_project_with_lock(lock, false).await.is_empty());
635+
}
636+
637+
#[tokio::test]
638+
#[serial_test::serial]
639+
async fn crawl_all_local_lock_skips_uncached_and_unsafe_entries() {
640+
// A locked package missing from the cache, and a key whose version
641+
// would traverse out of it, are skipped; the cached one is found.
642+
let lock = r#"{"version":"4","jsr":{
643+
"@std/path@0.220.0":{},
644+
"@std/path@9.9.9":{},
645+
"@std/fs@../../@other/x/2.0.0":{}
646+
}}"#;
647+
assert_eq!(
648+
crawl_project_with_lock(lock, false).await,
649+
vec!["pkg:jsr/@std/path@0.220.0"]
650+
);
651+
}
652+
653+
#[tokio::test]
654+
#[serial_test::serial]
655+
async fn crawl_all_walks_without_a_usable_lock_or_in_global_mode() {
656+
let scoped = r#"{"version":"4","jsr":{"@std/path@0.220.0":{}}}"#;
657+
// `--global` ignores the project's lock.
658+
assert_eq!(crawl_project_with_lock(scoped, true).await, WHOLE_CACHE);
659+
// Not JSON, or JSON without a `version` (no lock Deno wrote): walk.
660+
assert_eq!(
661+
crawl_project_with_lock("not json", false).await,
662+
WHOLE_CACHE
663+
);
664+
assert_eq!(crawl_project_with_lock("{}", false).await, WHOLE_CACHE);
665+
}
666+
667+
#[tokio::test]
668+
#[serial_test::serial]
669+
async fn crawl_all_local_without_lock_walks_the_cache() {
670+
// A `deno.json`-only project has no resolution to scope by.
671+
let project = tempfile::tempdir().unwrap();
672+
tokio::fs::write(project.path().join("deno.json"), b"{}")
673+
.await
674+
.unwrap();
675+
let deno_home = tempfile::tempdir().unwrap();
676+
let jsr = deno_home.path().join("npm").join("jsr.io");
677+
stage(&jsr, "@std", "path", "0.220.0").await;
678+
stage(&jsr, "@std", "fs", "1.0.0").await;
679+
let _g = EnvGuard::set("DENO_DIR", deno_home.path().to_str().unwrap());
680+
let opts = CrawlerOptions {
681+
cwd: project.path().to_path_buf(),
682+
global: false,
683+
global_prefix: None,
684+
};
685+
assert_eq!(DenoCrawler.crawl_all(&opts).await.len(), 2);
686+
}
687+
524688
/// The shared section reader answers every lockfile version for both
525689
/// callers: the crawl's `jsr` scope and VEX discovery's `npm` keys.
526690
#[test]

0 commit comments

Comments
 (0)