Skip to main content

deps_deno/
registry.rs

1//! JSR registry client and the scheme-dispatching Deno registry facade (D3).
2//!
3//! A Deno `imports` map mixes two registries in one file: `jsr:` specifiers resolve
4//! against the JSR API (this module's [`JsrRegistry`]), and `npm:` specifiers reuse the
5//! existing [`deps_npm::NpmRegistry`] unchanged. [`DenoRegistry`] is the single
6//! `deps_core::Registry` implementation the ecosystem exposes; every method splits the
7//! scheme off the incoming (already scheme-qualified, per D2) [`PackageName`] and
8//! delegates to whichever half owns it.
9
10use crate::specifier::{Scheme, is_dot_prefixed, split_scheme, split_scoped};
11use crate::types::{DenoMetadata, JsrPackage, JsrVersion};
12use deps_core::{
13    DepsError, FreshnessSettings, HttpCache, Metadata, PackageName, Registry, Result, Version,
14    VersionReq, lsp_helpers::warn_rejected_value,
15};
16use deps_npm::NpmRegistry;
17use serde::Deserialize;
18use std::any::Any;
19use std::collections::HashMap;
20use std::sync::Arc;
21
22const JSR_BASE: &str = "https://jsr.io";
23const JSR_API_BASE: &str = "https://api.jsr.io";
24
25/// Display name for the JSR registry used in not-found error messages.
26pub const REGISTRY: &str = "jsr";
27
28/// Returns the URL for a JSR package's page on jsr.io.
29///
30/// Display link only, never fetched by this process — unlike `meta_json_url` (a fetch
31/// sink), so it is deliberately not gated against a `.`/`..` scope or name segment (see
32/// [`deps_core::is_dot_segment`]'s doc for the fetch-sink-vs-display-link scope split, #379).
33/// `DenoFormatter::package_url` (the sole caller) already rejects a malformed/unscoped `jsr:`
34/// specifier before reaching here (#378/#380 follow-up); this function itself is unchanged.
35#[must_use]
36pub fn jsr_package_url(scope: &str, name: &str) -> String {
37    format!(
38        "{JSR_BASE}/@{}/{}",
39        urlencoding::encode(scope),
40        urlencoding::encode(name)
41    )
42}
43
44fn meta_json_url(base: &str, scope: &str, name: &str) -> String {
45    format!(
46        "{base}/@{}/{}/meta.json",
47        urlencoding::encode(scope),
48        urlencoding::encode(name)
49    )
50}
51
52/// Upper bound on how many results [`JsrRegistry::search`] fetches from the wire before
53/// reordering/truncating to the caller's requested `limit`, for a scope-qualified query
54/// (N1). Bounds the request even if `limit` itself is large; JSR's search API returns
55/// `total` (well beyond this) but a scope-qualified completion query never needs more than
56/// a small over-fetch to find the exact-scope match within.
57const MAX_SCOPE_SEARCH_OVERFETCH: usize = 40;
58
59/// Splits a scope-qualified search query (`"@scope/pkg-prefix"`) into `(scope,
60/// pkg_prefix)`. `pkg_prefix` may be empty if the caller hasn't typed a package-name
61/// character yet (`"@std/"`). Returns `None` for an unscoped query (no leading `@`) or one
62/// with no `/` yet (`"@std"` — still typing the scope, nothing to split on).
63fn split_scope_query(query: &str) -> Option<(&str, &str)> {
64    let after_at = query.strip_prefix('@')?;
65    after_at.split_once('/')
66}
67
68/// Extracts the scope portion of a [`JsrPackage`]'s already scheme-qualified `name`
69/// (`"jsr:@scope/pkg"`), or `""` if the name is unexpectedly not in that shape.
70fn package_scope(pkg: &JsrPackage) -> &str {
71    pkg.name
72        .as_str()
73        .strip_prefix("jsr:@")
74        .and_then(|s| s.split_once('/'))
75        .map_or("", |(scope, _)| scope)
76}
77
78/// Converts a 404 response into `DepsError::PackageNotFound`, passing through any other
79/// error unchanged. Mirrors `deps-npm`'s `not_found_or` (`deps-npm/src/registry.rs`).
80fn not_found_or(err: DepsError, full_name: &str) -> DepsError {
81    if matches!(err, DepsError::HttpStatus { status: 404, .. }) {
82        DepsError::PackageNotFound {
83            package: full_name.to_string(),
84            registry: REGISTRY,
85        }
86    } else {
87        err
88    }
89}
90
91/// Builds the error for a scheme-qualified name that could not be routed: an unknown or
92/// missing scheme reaching a fetch method, or a scheme-qualified name with nothing after
93/// it (`"npm:"`) — the latter is not merely defensive:
94/// [`partial_name_range`](crate::specifier::partial_name_range) (#310) deliberately treats
95/// a bare `"npm:"`/`"jsr:"` as a completion-eligible in-progress
96/// dependency name while the user is mid-keystroke, so `rest.is_empty()` reaches this
97/// facade's `npm:` arm in normal use (M2) and must be rejected here rather than turned
98/// into a GET against the bare npm registry base URL.
99fn unroutable(name: &PackageName) -> DepsError {
100    DepsError::PackageNotFound {
101        package: name.to_string(),
102        registry: "deno",
103    }
104}
105
106/// One JSR package version entry inside `meta.json`'s `versions` object.
107#[derive(Deserialize, Default)]
108#[serde(rename_all = "camelCase")]
109struct MetaVersionEntry {
110    #[serde(default)]
111    yanked: bool,
112    #[serde(default)]
113    created_at: Option<String>,
114}
115
116/// The subset of `https://jsr.io/@{scope}/{pkg}/meta.json` this client needs.
117#[derive(Deserialize)]
118struct MetaJson {
119    versions: HashMap<String, MetaVersionEntry>,
120}
121
122/// One search result inside `https://api.jsr.io/packages?query=`'s `items` array.
123#[derive(Deserialize)]
124struct SearchItem {
125    scope: String,
126    name: String,
127    #[serde(default)]
128    description: Option<String>,
129    #[serde(rename = "latestVersion", default)]
130    latest_version: Option<String>,
131    #[serde(rename = "githubRepository", default)]
132    github_repository: Option<GithubRepository>,
133}
134
135#[derive(Deserialize)]
136struct GithubRepository {
137    owner: String,
138    name: String,
139}
140
141#[derive(Deserialize)]
142struct SearchResponse {
143    items: Vec<SearchItem>,
144}
145
146/// Client for the JSR registry (`jsr.io` for package metadata, `api.jsr.io` for search).
147///
148/// Both endpoints are keyless (live-verified 2026-08-24) and are fetched through the
149/// shared `HttpCache`, so no TTL tuning is needed (NFR-001): JSR sends a strong `ETag`,
150/// and `HttpCache` revalidates via `If-None-Match` on every call regardless of the
151/// `Cache-Control: no-cache, no-store` header JSR also sends.
152#[derive(Clone)]
153pub struct JsrRegistry {
154    cache: Arc<HttpCache>,
155    base: String,
156    api_base: String,
157}
158
159impl JsrRegistry {
160    /// Creates a new JSR registry client with the given HTTP cache.
161    #[must_use]
162    pub fn new(cache: Arc<HttpCache>) -> Self {
163        Self::with_bases(cache, JSR_BASE.to_string(), JSR_API_BASE.to_string())
164    }
165
166    fn with_bases(cache: Arc<HttpCache>, base: String, api_base: String) -> Self {
167        Self {
168            cache,
169            base,
170            api_base,
171        }
172    }
173
174    /// Fetches all versions of `@{scope}/{name}` from `meta.json`.
175    ///
176    /// Returns versions sorted newest-first (per `Registry::get_versions`' contract) —
177    /// `meta.json`'s `versions` is a JSON *object*, and `serde_json` does not preserve
178    /// insertion order here (`preserve_order` is enabled only for `deps-composer`), so an
179    /// explicit semver-descending sort is required (C2).
180    ///
181    /// `published_at` is populated directly from this same response's per-version
182    /// `createdAt` field — no extra request, unlike npm (D10).
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the HTTP request fails, the response is not valid UTF-8/JSON,
187    /// or the package does not exist (mapped to `DepsError::PackageNotFound`). Also
188    /// returns `DepsError::PackageNotFound` up front, before any request is made, if
189    /// `scope` or `name` starts with `.` (S-L1) — such a segment would otherwise let
190    /// `url::Url::parse` normalize the request path away from the intended
191    /// `/@scope/name/meta.json` shape.
192    ///
193    /// # Examples
194    ///
195    /// ```no_run
196    /// # use deps_deno::registry::JsrRegistry;
197    /// # use deps_core::HttpCache;
198    /// # use std::sync::Arc;
199    /// # #[tokio::main]
200    /// # async fn main() {
201    /// let cache = Arc::new(HttpCache::new());
202    /// let registry = JsrRegistry::new(cache);
203    ///
204    /// let versions = registry.get_versions("std", "fs").await.unwrap();
205    /// assert!(!versions.is_empty());
206    /// # }
207    /// ```
208    pub async fn get_versions(&self, scope: &str, name: &str) -> Result<Vec<JsrVersion>> {
209        let full_name = format!("@{scope}/{name}");
210        // S-L1: a dot-prefixed segment must be rejected before it ever reaches
211        // `meta_json_url` — `url::Url::parse` decodes percent-encoding before dot-segment
212        // normalization, so encoding alone cannot prevent `..`/`.` from collapsing the
213        // path away from the intended `/@scope/name` shape. This is the single choke point
214        // for every `JsrRegistry::get_versions` caller (`DenoRegistry::get_versions`,
215        // `get_versions_with`, `get_latest_matching`).
216        if is_dot_prefixed(scope) || is_dot_prefixed(name) {
217            warn_rejected_value("is_dot_prefixed", "jsr meta.json request URL", &full_name);
218            return Err(DepsError::PackageNotFound {
219                package: full_name,
220                registry: REGISTRY,
221            });
222        }
223        let url = meta_json_url(&self.base, scope, name);
224        let data = self
225            .cache
226            .get_cached(&url)
227            .await
228            .map_err(|e| not_found_or(e, &full_name))?;
229        parse_meta_json(&data)
230    }
231
232    /// Searches JSR for packages matching `query`.
233    ///
234    /// If `query` is scope-qualified (`"@scope/pkg-prefix"`), the scope is split off
235    /// *before* hitting the wire and only the package-name portion is sent as the search
236    /// text (N1): JSR's search API ranks purely by text relevance and ignores the scope
237    /// portion of a scoped query entirely — live-verified 2026-08-24, `query=@std/fs`
238    /// buries the exact `std/fs` match 17th of 20 results, and a `scope=` query parameter
239    /// is accepted but has no effect on ranking. Results are then reordered so an exact
240    /// scope match sorts first (a stable sort, so JSR's own relevance ranking is preserved
241    /// within each group), restoring the ordering a caller completing a scoped specifier —
242    /// the common case for JSR — actually needs.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the HTTP request fails or the response is not valid JSON.
247    ///
248    /// # Examples
249    ///
250    /// ```no_run
251    /// # use deps_deno::registry::JsrRegistry;
252    /// # use deps_core::HttpCache;
253    /// # use std::sync::Arc;
254    /// # #[tokio::main]
255    /// # async fn main() {
256    /// let cache = Arc::new(HttpCache::new());
257    /// let registry = JsrRegistry::new(cache);
258    ///
259    /// let results = registry.search("fs", 10).await.unwrap();
260    /// assert!(!results.is_empty());
261    /// # }
262    /// ```
263    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<JsrPackage>> {
264        let Some((scope, pkg_prefix)) = split_scope_query(query) else {
265            return self.fetch_search(query, limit).await;
266        };
267
268        // No package-name text typed yet (`"@std/"`): search on the scope itself instead
269        // of an empty string.
270        let text_query = if pkg_prefix.is_empty() {
271            scope
272        } else {
273            pkg_prefix
274        };
275        // R1: `usize::clamp(min, max)` panics if `min > max`, which a plain
276        // `.clamp(limit, MAX_SCOPE_SEARCH_OVERFETCH)` would do for any `limit` above the
277        // cap. `.min(MAX_SCOPE_SEARCH_OVERFETCH.max(limit))` can never invert: the upper
278        // bound passed to `min` is itself widened to `limit` whenever `limit` exceeds the
279        // cap, so this degrades to "no overfetch, just use `limit`" instead of panicking.
280        let fetch_limit = limit
281            .saturating_mul(4)
282            .min(MAX_SCOPE_SEARCH_OVERFETCH.max(limit));
283
284        let mut results = self.fetch_search(text_query, fetch_limit).await?;
285        results.sort_by_key(|p| !package_scope(p).eq_ignore_ascii_case(scope));
286        results.truncate(limit);
287        Ok(results)
288    }
289
290    /// Issues the raw `api.jsr.io/packages?query=` request with no scope-aware
291    /// post-processing. Used directly for an unscoped query, and as the underlying fetch
292    /// for [`Self::search`]'s scope-qualified path.
293    async fn fetch_search(&self, query: &str, limit: usize) -> Result<Vec<JsrPackage>> {
294        let url = format!(
295            "{}/packages?query={}&limit={}",
296            self.api_base,
297            urlencoding::encode(query),
298            limit
299        );
300        let data = self.cache.get_cached(&url).await?;
301        parse_search_response(&data)
302    }
303}
304
305/// Parses `meta.json`'s `versions` object into a newest-first `Vec<JsrVersion>` (C2).
306fn parse_meta_json(data: &[u8]) -> Result<Vec<JsrVersion>> {
307    let meta: MetaJson = deps_core::parse_json_checked(data)?;
308
309    let mut versions_with_parsed: Vec<(JsrVersion, node_semver::Version)> = meta
310        .versions
311        .into_iter()
312        .filter_map(|(version, entry)| {
313            let parsed = node_semver::Version::parse(&version).ok()?;
314            let published_at = entry
315                .created_at
316                .as_deref()
317                .and_then(deps_core::PublishTime::parse_rfc3339);
318            Some((
319                JsrVersion {
320                    version: version.into(),
321                    yanked: entry.yanked,
322                    published_at,
323                },
324                parsed,
325            ))
326        })
327        .collect();
328
329    versions_with_parsed.sort_unstable_by(|a, b| b.1.cmp(&a.1));
330    Ok(versions_with_parsed.into_iter().map(|(v, _)| v).collect())
331}
332
333/// Parses `api.jsr.io/packages`'s search response into `JsrPackage`s, each already
334/// scheme-qualified as `"jsr:@scope/name"` (D3).
335fn parse_search_response(data: &[u8]) -> Result<Vec<JsrPackage>> {
336    let response: SearchResponse = deps_core::parse_json_checked(data)?;
337
338    Ok(response
339        .items
340        .into_iter()
341        .map(|item| {
342            let repository = item
343                .github_repository
344                .map(|repo| format!("https://github.com/{}/{}", repo.owner, repo.name));
345            let description = item.description.filter(|d| !d.is_empty());
346            JsrPackage {
347                name: PackageName::new(format!("jsr:@{}/{}", item.scope, item.name)),
348                description,
349                repository,
350                documentation: None,
351                latest_version: item.latest_version.unwrap_or_default().into(),
352            }
353        })
354        .collect())
355}
356
357/// The `deps_core::Registry` implementation for Deno manifests (D3).
358///
359/// A dispatching facade holding a [`JsrRegistry`] plus a [`deps_npm::NpmRegistry`],
360/// routed by the scheme carried inside every incoming [`PackageName`].
361pub struct DenoRegistry {
362    jsr: JsrRegistry,
363    npm: NpmRegistry,
364}
365
366impl DenoRegistry {
367    /// Creates a new Deno registry facade, building both halves from the same
368    /// `Arc<HttpCache>` (M1) and a private `NpmRegistry` instance.
369    ///
370    /// This dedupes plain cached GETs — the abbreviated packument `get_versions` fetches,
371    /// and the JSR endpoints — between `package.json` and `deno.json` for the same npm
372    /// package. It does **not** dedupe the *separate* full-packument fetch npm's own
373    /// freshness path (`fetch_publish_times`) issues when `freshness.enabled`: that path
374    /// deliberately bypasses `HttpCache`'s entry map (`deps-npm/src/registry.rs`) and is
375    /// memoized in a per-`NpmRegistry`-instance `DashMap`, and this constructor builds its
376    /// own private `NpmRegistry` rather than sharing `NpmEcosystem`'s — so with freshness
377    /// on, that one extra request is still duplicated for a package appearing in both
378    /// manifests. Use [`Self::with_npm`] instead to avoid this (N4/#312).
379    #[must_use]
380    pub fn new(cache: Arc<HttpCache>) -> Self {
381        Self::with_npm(Arc::clone(&cache), NpmRegistry::new(cache))
382    }
383
384    /// Creates a new Deno registry facade sharing an existing [`NpmRegistry`] instance for
385    /// its `npm:`-scheme half, instead of building a private one (N4/#312).
386    ///
387    /// `NpmRegistry` is cheaply `Clone` (its `HttpCache` and freshness-path publish-time
388    /// map are both `Arc`-wrapped internally), so passing a clone of the same instance
389    /// registered for the standalone npm ecosystem shares not just plain cached GETs
390    /// (already covered by `cache`) but also the freshness path's full-packument fetch and
391    /// its publish-time cache, for a package appearing in both `package.json` and
392    /// `deno.json` (an `npm:`-specifier dependency). This is what `deps-lsp`'s ecosystem
393    /// registration does when both the `npm` and `deno` features are enabled.
394    #[must_use]
395    pub fn with_npm(cache: Arc<HttpCache>, npm: NpmRegistry) -> Self {
396        Self {
397            jsr: JsrRegistry::new(cache),
398            npm,
399        }
400    }
401}
402
403impl Registry for DenoRegistry {
404    fn get_versions<'a>(
405        &'a self,
406        name: &'a PackageName,
407    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
408        Box::pin(async move {
409            match split_scheme(name.as_str()) {
410                Some((Scheme::Jsr, rest)) => {
411                    let (scope, pkg) = split_scoped(rest).ok_or_else(|| unroutable(name))?;
412                    let versions = self.jsr.get_versions(scope, pkg).await?;
413                    Ok(versions
414                        .into_iter()
415                        .map(|v| Box::new(v) as Box<dyn Version>)
416                        .collect())
417                }
418                Some((Scheme::Npm, rest)) => {
419                    if rest.is_empty() {
420                        return Err(unroutable(name));
421                    }
422                    let bare = PackageName::new(rest);
423                    // S3: `NpmRegistry` has an *inherent* `get_versions` that shadows the
424                    // trait method and silently drops `get_versions_with`'s freshness
425                    // semantics if called via plain method syntax — UFCS forces the trait
426                    // method.
427                    Registry::get_versions(&self.npm, &bare).await
428                }
429                None => Err(unroutable(name)),
430            }
431        })
432    }
433
434    fn get_versions_with<'a>(
435        &'a self,
436        name: &'a PackageName,
437        freshness: FreshnessSettings,
438    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
439        Box::pin(async move {
440            match split_scheme(name.as_str()) {
441                Some((Scheme::Npm, rest)) => {
442                    if rest.is_empty() {
443                        return Err(unroutable(name));
444                    }
445                    let bare = PackageName::new(rest);
446                    Registry::get_versions_with(&self.npm, &bare, freshness).await
447                }
448                // JSR's `meta.json` already carries `createdAt` in the same response
449                // `get_versions` fetches (D10) — no separate freshness request needed.
450                _ => self.get_versions(name).await,
451            }
452        })
453    }
454
455    fn get_latest_matching<'a>(
456        &'a self,
457        name: &'a PackageName,
458        req: &'a VersionReq,
459    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn Version>>>> {
460        Box::pin(async move {
461            match split_scheme(name.as_str()) {
462                Some((Scheme::Jsr, rest)) => {
463                    let (scope, pkg) = split_scoped(rest).ok_or_else(|| unroutable(name))?;
464                    let versions = self.jsr.get_versions(scope, pkg).await?;
465                    let parsed_req = node_semver::Range::parse(req.as_str())
466                        .map_err(|e| DepsError::InvalidVersionReq(e.to_string()))?;
467                    Ok(versions
468                        .into_iter()
469                        .find(|v| {
470                            node_semver::Version::parse(&v.version)
471                                .is_ok_and(|ver| parsed_req.satisfies(&ver) && !v.yanked)
472                        })
473                        .map(|v| Box::new(v) as Box<dyn Version>))
474                }
475                Some((Scheme::Npm, rest)) => {
476                    if rest.is_empty() {
477                        return Err(unroutable(name));
478                    }
479                    let bare = PackageName::new(rest);
480                    Registry::get_latest_matching(&self.npm, &bare, req).await
481                }
482                None => Err(unroutable(name)),
483            }
484        })
485    }
486
487    fn search<'a>(
488        &'a self,
489        query: &'a str,
490        limit: usize,
491    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn Metadata>>>> {
492        Box::pin(async move {
493            match split_scheme(query) {
494                Some((Scheme::Jsr, rest)) => {
495                    let packages = self.jsr.search(rest, limit).await?;
496                    Ok(packages
497                        .into_iter()
498                        .map(|p| Box::new(p) as Box<dyn Metadata>)
499                        .collect())
500                }
501                Some((Scheme::Npm, rest)) => {
502                    let results = Registry::search(&self.npm, rest, limit).await?;
503                    Ok(results
504                        .into_iter()
505                        .map(|m| {
506                            let prefixed = PackageName::new(format!("npm:{}", m.name()));
507                            Box::new(DenoMetadata::new(prefixed, m)) as Box<dyn Metadata>
508                        })
509                        .collect())
510                }
511                // No scheme prefix on the query: never guess which registry to search.
512                None => Ok(vec![]),
513            }
514        })
515    }
516
517    fn select_latest_matching(
518        &self,
519        versions: &[Box<dyn Version>],
520        req: &VersionReq,
521    ) -> Option<usize> {
522        // Name-free and pure `node_semver`: correct for both JSR (which mandates semver)
523        // and npm version strings, so npm's implementation covers both without a
524        // downcast. This deliberately includes npm's #338 wildcard fallback: an
525        // all-yanked JSR package (like an all-deprecated npm package) resolves to its
526        // newest yanked version rather than `None`/"Unknown package" — not a bug to
527        // "fix" by special-casing JSR here, since the alternative reintroduces #338
528        // for JSR specifically.
529        self.npm.select_latest_matching(versions, req)
530    }
531
532    fn as_any(&self) -> &dyn Any {
533        self
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    use deps_core::test_util::capture_tracing_output_async;
542    use std::assert_matches;
543
544    #[test]
545    fn test_jsr_package_url() {
546        assert_eq!(jsr_package_url("std", "fs"), "https://jsr.io/@std/fs");
547    }
548
549    // --- S-L2: URL-encoding regressions, mirroring deps-npm's package_url/versions_url tests ---
550
551    #[test]
552    fn test_jsr_package_url_encodes_malicious_scope() {
553        let url = jsr_package_url("evil)[pkg](https://evil.example", "x");
554        assert!(!url.contains(')'));
555        assert!(!url.contains('('));
556        assert!(!url.contains('['));
557        assert!(!url.contains(']'));
558    }
559
560    #[test]
561    fn test_jsr_package_url_encodes_malicious_name_segment() {
562        let url = jsr_package_url("std", "evil)[pkg](https://evil.example");
563        assert!(!url.contains(')'));
564        assert!(!url.contains('('));
565    }
566
567    #[test]
568    fn test_jsr_package_url_encodes_newline_and_percent() {
569        let url = jsr_package_url("evil\n<%", "pkg");
570        assert!(!url.contains('\n'));
571        assert!(!url.contains('<'));
572        assert!(url.contains("%25"));
573    }
574
575    #[test]
576    fn test_meta_json_url_encodes_malicious_segments() {
577        // S-L1 trap: the original version of this test asserted `!url.contains("/../")`
578        // on the *raw* string, which passed even while the bug was live, because
579        // `urlencoding::encode` never puts a literal `/../` into the raw string — the
580        // collapse happens later, inside `url::Url::parse`'s dot-segment normalization
581        // (which runs *after* percent-decoding). This test only exercises `meta_json_url`
582        // (unchanged by the S-L1 fix, which gates `JsrRegistry::get_versions` instead), so
583        // it does not itself prove the fix works — see
584        // `test_jsr_registry_get_versions_rejects_dot_prefixed_package_segment` and its
585        // siblings below for that. It still asserts on the parsed path, not the raw
586        // string, so it stays a meaningful check of `meta_json_url`'s own encoding.
587        let url = meta_json_url(JSR_BASE, "evil/../secret?x=1#frag", "pkg");
588        assert!(!url.contains('?'));
589        assert!(!url.contains('#'));
590        let parsed = url::Url::parse(&url).unwrap();
591        assert_eq!(
592            parsed.path(),
593            "/@evil%2F..%2Fsecret%3Fx%3D1%23frag/pkg/meta.json"
594        );
595    }
596
597    /// #365 regression sweep: exercises the real production pair (`is_dot_prefixed` gate +
598    /// `meta_json_url` sink) against the shared adversarial input set (varying scope, then
599    /// name), guarding against a 6th recurrence of #337's defect class.
600    #[test]
601    fn test_meta_json_url_dot_segment_sweep() {
602        deps_core::test_util::assert_dot_segment_gated_or_contained(
603            |seg| (!is_dot_prefixed(seg)).then(|| meta_json_url(JSR_BASE, seg, "pkg")),
604            "jsr.io",
605            "/@",
606        );
607        deps_core::test_util::assert_dot_segment_gated_or_contained(
608            |seg| (!is_dot_prefixed(seg)).then(|| meta_json_url(JSR_BASE, "scope", seg)),
609            "jsr.io",
610            "/@",
611        );
612    }
613
614    #[test]
615    fn test_parse_meta_json_sorts_newest_first() {
616        // C2: the raw object order below is deliberately NOT sorted, mirroring the live
617        // `meta.json` shape (verified 2026-08-24) where `1.0.19` precedes `0.200.0`.
618        let json = r#"{
619  "versions": {
620    "1.0.19": {"createdAt": "2025-07-01T07:43:44Z"},
621    "0.200.0": {"createdAt": "2024-04-24T06:44:45Z"},
622    "1.0.24": {"createdAt": "2026-05-26T09:57:22Z"},
623    "0.229.0": {"yanked": true, "createdAt": "2024-04-29T17:22:46Z"},
624    "1.0.9": {"createdAt": "2025-01-10T08:22:54Z"}
625  }
626}"#;
627
628        let versions = parse_meta_json(json.as_bytes()).unwrap();
629        let strings: Vec<&str> = versions.iter().map(|v| v.version.as_str()).collect();
630        assert_eq!(
631            strings,
632            vec!["1.0.24", "1.0.19", "1.0.9", "0.229.0", "0.200.0"]
633        );
634    }
635
636    #[test]
637    fn test_parse_meta_json_yanked_and_published_at() {
638        let json = r#"{
639  "versions": {
640    "1.0.0": {"createdAt": "2024-01-01T00:00:00Z"},
641    "0.9.0": {"yanked": true, "createdAt": "2023-01-01T00:00:00Z"}
642  }
643}"#;
644
645        let versions = parse_meta_json(json.as_bytes()).unwrap();
646        let v1 = versions.iter().find(|v| v.version == "1.0.0").unwrap();
647        assert!(!v1.yanked);
648        assert!(v1.published_at.is_some());
649
650        let v09 = versions.iter().find(|v| v.version == "0.9.0").unwrap();
651        assert!(v09.yanked);
652    }
653
654    #[test]
655    fn test_parse_meta_json_skips_non_semver_keys() {
656        let json = r#"{
657  "versions": {
658    "1.0.0": {"createdAt": "2024-01-01T00:00:00Z"},
659    "not-a-version": {"createdAt": "2024-01-01T00:00:00Z"}
660  }
661}"#;
662
663        let versions = parse_meta_json(json.as_bytes()).unwrap();
664        assert_eq!(versions.len(), 1);
665        assert_eq!(versions[0].version, "1.0.0");
666    }
667
668    #[test]
669    fn test_parse_meta_json_nesting_at_max_depth_accepted() {
670        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
671        let json = format!(
672            r#"{{"versions": {{}}, "extra": {}1{}}}"#,
673            "[".repeat(depth - 1),
674            "]".repeat(depth - 1)
675        );
676        assert!(parse_meta_json(json.as_bytes()).is_ok());
677    }
678
679    #[test]
680    fn test_parse_meta_json_nesting_over_max_depth_rejected() {
681        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
682        let json = format!(
683            r#"{{"versions": {{}}, "extra": {}1{}}}"#,
684            "[".repeat(depth),
685            "]".repeat(depth)
686        );
687        assert!(parse_meta_json(json.as_bytes()).is_err());
688    }
689
690    #[test]
691    fn test_parse_search_response_prefixes_name_with_scheme_and_maps_github_repo() {
692        let json = r#"{
693  "items": [
694    {
695      "scope": "std",
696      "name": "fs",
697      "description": "File system utilities",
698      "latestVersion": "1.0.24",
699      "githubRepository": {"owner": "denoland", "name": "std"}
700    }
701  ]
702}"#;
703
704        let packages = parse_search_response(json.as_bytes()).unwrap();
705        assert_eq!(packages.len(), 1);
706        assert_eq!(packages[0].name, "jsr:@std/fs");
707        assert_eq!(
708            packages[0].description,
709            Some("File system utilities".to_string())
710        );
711        assert_eq!(
712            packages[0].repository,
713            Some("https://github.com/denoland/std".to_string())
714        );
715        assert_eq!(packages[0].latest_version, "1.0.24");
716    }
717
718    #[test]
719    fn test_parse_search_response_nesting_at_max_depth_accepted() {
720        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
721        let json = format!(
722            r#"{{"items": [], "extra": {}1{}}}"#,
723            "[".repeat(depth - 1),
724            "]".repeat(depth - 1)
725        );
726        assert!(parse_search_response(json.as_bytes()).is_ok());
727    }
728
729    #[test]
730    fn test_parse_search_response_nesting_over_max_depth_rejected() {
731        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
732        let json = format!(
733            r#"{{"items": [], "extra": {}1{}}}"#,
734            "[".repeat(depth),
735            "]".repeat(depth)
736        );
737        assert!(parse_search_response(json.as_bytes()).is_err());
738    }
739
740    #[test]
741    fn test_parse_search_response_empty_description_becomes_none() {
742        let json = r#"{
743  "items": [
744    {"scope": "anabranch", "name": "fs", "description": "", "latestVersion": "0.3.1"}
745  ]
746}"#;
747
748        let packages = parse_search_response(json.as_bytes()).unwrap();
749        assert_eq!(packages[0].description, None);
750        assert_eq!(packages[0].repository, None);
751    }
752
753    #[test]
754    fn test_not_found_or_maps_404() {
755        let err = DepsError::HttpStatus {
756            url: "https://jsr.io/@std/fs/meta.json".into(),
757            status: 404,
758        };
759        let result = not_found_or(err, "@std/fs");
760        assert_matches!(
761            result,
762            DepsError::PackageNotFound { package, registry }
763                if package == "@std/fs" && registry == REGISTRY
764        );
765    }
766
767    #[test]
768    fn test_not_found_or_passes_through_non_404() {
769        let err = DepsError::HttpStatus {
770            url: "https://jsr.io/@std/fs/meta.json".into(),
771            status: 500,
772        };
773        let result = not_found_or(err, "@std/fs");
774        assert_matches!(result, DepsError::HttpStatus { status: 500, .. });
775    }
776
777    // --- M2/#312: "npm:" alone (partial_name_range's in-progress name, #310) must not
778    // become a GET against the bare npm registry base URL ---
779
780    /// An `NpmRegistry` pointed at an address nothing listens on, so any request it
781    /// actually issues fails with a connection error rather than silently succeeding —
782    /// proof that the empty-name guard short-circuits before any network call.
783    fn unreachable_npm(cache: Arc<HttpCache>) -> NpmRegistry {
784        NpmRegistry::with_registry_base(cache, "http://127.0.0.1:1".to_string())
785    }
786
787    #[tokio::test]
788    async fn test_deno_registry_get_versions_rejects_empty_npm_name() {
789        let cache = Arc::new(HttpCache::new());
790        let npm = unreachable_npm(Arc::clone(&cache));
791        let registry = DenoRegistry::with_npm(cache, npm);
792
793        let Err(err) = Registry::get_versions(&registry, &PackageName::new("npm:")).await else {
794            panic!("expected an error for an empty npm: name");
795        };
796        assert_matches!(
797            err,
798            DepsError::PackageNotFound {
799                registry: "deno",
800                ..
801            }
802        );
803    }
804
805    #[tokio::test]
806    async fn test_deno_registry_get_versions_with_rejects_empty_npm_name() {
807        let cache = Arc::new(HttpCache::new());
808        let npm = unreachable_npm(Arc::clone(&cache));
809        let registry = DenoRegistry::with_npm(cache, npm);
810
811        let Err(err) = Registry::get_versions_with(
812            &registry,
813            &PackageName::new("npm:"),
814            FreshnessSettings::default(),
815        )
816        .await
817        else {
818            panic!("expected an error for an empty npm: name");
819        };
820        assert_matches!(
821            err,
822            DepsError::PackageNotFound {
823                registry: "deno",
824                ..
825            }
826        );
827    }
828
829    #[tokio::test]
830    async fn test_deno_registry_get_latest_matching_rejects_empty_npm_name() {
831        let cache = Arc::new(HttpCache::new());
832        let npm = unreachable_npm(Arc::clone(&cache));
833        let registry = DenoRegistry::with_npm(cache, npm);
834
835        let Err(err) = Registry::get_latest_matching(
836            &registry,
837            &PackageName::new("npm:"),
838            &VersionReq::new("*"),
839        )
840        .await
841        else {
842            panic!("expected an error for an empty npm: name");
843        };
844        assert_matches!(
845            err,
846            DepsError::PackageNotFound {
847                registry: "deno",
848                ..
849            }
850        );
851    }
852
853    /// #341: the `npm:` arm forwards the bare name straight to `NpmRegistry::get_versions`
854    /// via `Registry::get_versions` (UFCS), so npm's own dot-segment guard is reached
855    /// without any deno-side change — `unreachable_npm` proves this by construction: a
856    /// dot-segment name must fail before any network call, not merely fail eventually.
857    #[tokio::test]
858    async fn test_deno_registry_get_versions_rejects_dot_segment_npm_package() {
859        let cache = Arc::new(HttpCache::new());
860        let npm = unreachable_npm(Arc::clone(&cache));
861        let registry = DenoRegistry::with_npm(cache, npm);
862
863        let Err(err) = Registry::get_versions(&registry, &PackageName::new("npm:@a/..")).await
864        else {
865            panic!("expected an error for a dot-segment npm: package name");
866        };
867        assert!(err.is_not_found());
868    }
869
870    #[tokio::test]
871    async fn test_deno_registry_get_versions_dispatches_jsr_via_mock() {
872        let mut server = mockito::Server::new_async().await;
873        let cache = Arc::new(HttpCache::new());
874        let jsr = JsrRegistry::with_bases(Arc::clone(&cache), server.url(), server.url());
875        let registry = DenoRegistry {
876            jsr,
877            npm: NpmRegistry::new(cache),
878        };
879
880        let mock = server
881            .mock("GET", "/@std/fs/meta.json")
882            .with_status(200)
883            .with_body(r#"{"versions": {"1.0.0": {"createdAt": "2024-01-01T00:00:00Z"}}}"#)
884            .create_async()
885            .await;
886
887        let versions = Registry::get_versions(&registry, &PackageName::new("jsr:@std/fs"))
888            .await
889            .unwrap();
890
891        assert_eq!(versions.len(), 1);
892        assert_eq!(versions[0].version_string(), "1.0.0");
893        mock.assert_async().await;
894    }
895
896    // --- S-L1: dot-prefixed JSR segment must be rejected before building the URL ---
897
898    /// `JsrRegistry` pointed at an address nothing listens on, so any request it actually
899    /// issues fails with a connection error rather than silently succeeding — proof that
900    /// the dot-prefix guard short-circuits before any network call.
901    fn unreachable_jsr(cache: Arc<HttpCache>) -> JsrRegistry {
902        JsrRegistry::with_bases(
903            cache,
904            "http://127.0.0.1:1".to_string(),
905            "http://127.0.0.1:1".to_string(),
906        )
907    }
908
909    #[tokio::test]
910    async fn test_jsr_registry_get_versions_rejects_dot_prefixed_package_segment() {
911        // The exploitable case: a `..`/`.` *package* segment would otherwise let
912        // `url::Url::parse` normalize the request path away from `/@scope/pkg/meta.json`
913        // to an unrelated URL (e.g. `/meta.json`). Must fail closed with `PackageNotFound`
914        // instead of reaching the network.
915        let registry = unreachable_jsr(Arc::new(HttpCache::new()));
916
917        let err = registry.get_versions("std", "..").await.unwrap_err();
918        assert_matches!(
919            err,
920            DepsError::PackageNotFound {
921                registry: REGISTRY,
922                ..
923            }
924        );
925
926        let err = registry.get_versions("std", ".").await.unwrap_err();
927        assert_matches!(
928            err,
929            DepsError::PackageNotFound {
930                registry: REGISTRY,
931                ..
932            }
933        );
934    }
935
936    #[tokio::test]
937    async fn test_jsr_registry_get_versions_rejects_dot_prefixed_scope_segment() {
938        // Defense in depth: a dot-prefixed *scope* segment cannot actually collapse the
939        // URL (the literal `@` prefix makes it e.g. `@..`, not a dot-segment), but it is
940        // still rejected directly by the `is_dot_prefixed` gate on principle.
941        let registry = unreachable_jsr(Arc::new(HttpCache::new()));
942
943        let err = registry.get_versions("..", "pkg").await.unwrap_err();
944        assert_matches!(
945            err,
946            DepsError::PackageNotFound {
947                registry: REGISTRY,
948                ..
949            }
950        );
951    }
952
953    #[tokio::test]
954    async fn test_jsr_registry_get_versions_rejection_logs_warn_rejected_value() {
955        // N1: the two tests above only prove the `Err` return value, not that
956        // `warn_rejected_value` actually fires from this fetch-sink gate.
957        let registry = unreachable_jsr(Arc::new(HttpCache::new()));
958        let output = capture_tracing_output_async(async {
959            let _ = registry.get_versions("std", "..").await;
960        })
961        .await;
962        assert!(output.contains("is_dot_prefixed"), "output was: {output}");
963        assert!(
964            output.contains("jsr meta.json request URL"),
965            "output was: {output}"
966        );
967    }
968
969    #[tokio::test]
970    async fn test_deno_registry_get_versions_rejects_dot_prefixed_jsr_package_via_mock() {
971        // End-to-end through the `Registry` trait dispatch: `jsr:@scope/..` must return
972        // `PackageNotFound` without ever issuing the `meta.json` request.
973        let mut server = mockito::Server::new_async().await;
974        let cache = Arc::new(HttpCache::new());
975        let jsr = JsrRegistry::with_bases(Arc::clone(&cache), server.url(), server.url());
976        let registry = DenoRegistry {
977            jsr,
978            npm: NpmRegistry::new(cache),
979        };
980
981        let mock = server
982            .mock("GET", mockito::Matcher::Any)
983            .expect(0)
984            .create_async()
985            .await;
986
987        let Err(err) = Registry::get_versions(&registry, &PackageName::new("jsr:@std/..")).await
988        else {
989            panic!("expected an error for a dot-prefixed jsr: package segment");
990        };
991        assert_matches!(
992            err,
993            DepsError::PackageNotFound {
994                registry: REGISTRY,
995                ..
996            }
997        );
998
999        let Err(err) = Registry::get_versions_with(
1000            &registry,
1001            &PackageName::new("jsr:@std/.."),
1002            FreshnessSettings::default(),
1003        )
1004        .await
1005        else {
1006            panic!("expected an error for a dot-prefixed jsr: package segment");
1007        };
1008        assert_matches!(
1009            err,
1010            DepsError::PackageNotFound {
1011                registry: REGISTRY,
1012                ..
1013            }
1014        );
1015
1016        let Err(err) = Registry::get_latest_matching(
1017            &registry,
1018            &PackageName::new("jsr:@std/.."),
1019            &VersionReq::new("*"),
1020        )
1021        .await
1022        else {
1023            panic!("expected an error for a dot-prefixed jsr: package segment");
1024        };
1025        assert_matches!(
1026            err,
1027            DepsError::PackageNotFound {
1028                registry: REGISTRY,
1029                ..
1030            }
1031        );
1032
1033        mock.assert_async().await;
1034    }
1035
1036    // --- N1: scope-aware JSR search ---
1037
1038    #[test]
1039    fn test_split_scope_query() {
1040        assert_eq!(split_scope_query("@std/fs"), Some(("std", "fs")));
1041        assert_eq!(split_scope_query("@std/f"), Some(("std", "f")));
1042        assert_eq!(split_scope_query("@std/"), Some(("std", "")));
1043        assert_eq!(split_scope_query("@std"), None);
1044        assert_eq!(split_scope_query("fs"), None);
1045    }
1046
1047    #[test]
1048    fn test_package_scope_extracts_from_prefixed_name() {
1049        let pkg = JsrPackage {
1050            name: PackageName::new("jsr:@std/fs"),
1051            description: None,
1052            repository: None,
1053            documentation: None,
1054            latest_version: deps_core::ConcreteVersion::new(""),
1055        };
1056        assert_eq!(package_scope(&pkg), "std");
1057    }
1058
1059    #[tokio::test]
1060    async fn test_jsr_registry_search_scoped_query_sends_only_package_name_segment() {
1061        // N1: JSR's search API ranks purely by text relevance and ignores the scope
1062        // portion of a scoped query, so the scope must never reach `query=` on the wire.
1063        let mut server = mockito::Server::new_async().await;
1064        let registry =
1065            JsrRegistry::with_bases(Arc::new(HttpCache::new()), server.url(), server.url());
1066
1067        let mock = server
1068            .mock("GET", "/packages")
1069            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
1070                "query".into(),
1071                "fs".into(),
1072            )]))
1073            .with_status(200)
1074            .with_body(r#"{"items": [{"scope": "std", "name": "fs", "latestVersion": "1.0.24"}]}"#)
1075            .create_async()
1076            .await;
1077
1078        let results = registry.search("@std/fs", 5).await.unwrap();
1079
1080        assert_eq!(results.len(), 1);
1081        assert_eq!(results[0].name, "jsr:@std/fs");
1082        mock.assert_async().await;
1083    }
1084
1085    #[tokio::test]
1086    async fn test_jsr_registry_search_scoped_query_reorders_exact_scope_match_first() {
1087        // N1: the common case (`jsr:@std/fs`) must surface the exact-scope match first,
1088        // not buried behind unrelated packages that merely share the text query.
1089        let mut server = mockito::Server::new_async().await;
1090        let registry =
1091            JsrRegistry::with_bases(Arc::new(HttpCache::new()), server.url(), server.url());
1092
1093        let body = r#"{"items": [
1094            {"scope": "other", "name": "fs", "latestVersion": "1.0.0"},
1095            {"scope": "another", "name": "fs-utils", "latestVersion": "3.0.0"},
1096            {"scope": "std", "name": "fs", "latestVersion": "2.0.0"}
1097        ]}"#;
1098        let mock = server
1099            .mock("GET", "/packages")
1100            .match_query(mockito::Matcher::Any)
1101            .with_status(200)
1102            .with_body(body)
1103            .create_async()
1104            .await;
1105
1106        let results = registry.search("@std/fs", 2).await.unwrap();
1107
1108        assert_eq!(results.len(), 2);
1109        assert_eq!(results[0].name, "jsr:@std/fs");
1110        mock.assert_async().await;
1111    }
1112
1113    #[tokio::test]
1114    async fn test_jsr_registry_search_unscoped_query_unaffected() {
1115        let mut server = mockito::Server::new_async().await;
1116        let registry =
1117            JsrRegistry::with_bases(Arc::new(HttpCache::new()), server.url(), server.url());
1118
1119        let mock = server
1120            .mock("GET", "/packages")
1121            .match_query(mockito::Matcher::UrlEncoded("query".into(), "fs".into()))
1122            .with_status(200)
1123            .with_body(r#"{"items": [{"scope": "std", "name": "fs", "latestVersion": "1.0.24"}]}"#)
1124            .create_async()
1125            .await;
1126
1127        let results = registry.search("fs", 5).await.unwrap();
1128
1129        assert_eq!(results.len(), 1);
1130        mock.assert_async().await;
1131    }
1132
1133    #[tokio::test]
1134    async fn test_jsr_registry_search_scoped_query_limit_above_overfetch_cap_does_not_panic() {
1135        // R1: `limit > MAX_SCOPE_SEARCH_OVERFETCH` used to invert a `usize::clamp`'s
1136        // min/max and panic. Must degrade to using `limit` directly instead.
1137        let mut server = mockito::Server::new_async().await;
1138        let registry =
1139            JsrRegistry::with_bases(Arc::new(HttpCache::new()), server.url(), server.url());
1140
1141        let mock = server
1142            .mock("GET", "/packages")
1143            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
1144                "limit".into(),
1145                "50".into(),
1146            )]))
1147            .with_status(200)
1148            .with_body(r#"{"items": [{"scope": "std", "name": "fs", "latestVersion": "1.0.24"}]}"#)
1149            .create_async()
1150            .await;
1151
1152        let results = registry.search("@std/fs", 50).await.unwrap();
1153
1154        assert_eq!(results.len(), 1);
1155        mock.assert_async().await;
1156    }
1157
1158    #[tokio::test]
1159    async fn test_deno_registry_with_npm_shares_freshness_cache_across_instances() {
1160        // #312: DenoRegistry::with_npm must hold the caller-supplied NpmRegistry rather
1161        // than building its own, so a package appearing in both package.json (the
1162        // standalone npm ecosystem) and deno.json (an `npm:`-specifier dependency) shares
1163        // one freshness-path publish-time cache instead of refetching the full packument
1164        // per ecosystem instance.
1165        let mut server = mockito::Server::new_async().await;
1166        let base = server.url();
1167        let http_cache = Arc::new(HttpCache::new());
1168        let shared_npm = NpmRegistry::with_registry_base(Arc::clone(&http_cache), base);
1169
1170        let abbrev_mock = server
1171            .mock("GET", "/widget")
1172            .match_header("accept", "application/vnd.npm.install-v1+json")
1173            .with_status(200)
1174            .with_body(r#"{"versions": {"1.0.0": {}}}"#)
1175            .expect(2)
1176            .create_async()
1177            .await;
1178        let full_mock = server
1179            .mock("GET", "/widget")
1180            .match_header("accept", "application/json")
1181            .with_status(200)
1182            .with_body(r#"{"time": {"1.0.0": "2020-01-01T00:00:00Z"}}"#)
1183            .expect(1)
1184            .create_async()
1185            .await;
1186
1187        // Simulates package.json's standalone npm ecosystem instance.
1188        let npm_side = shared_npm.clone();
1189        let from_npm = Registry::get_versions_with(
1190            &npm_side,
1191            &PackageName::new("widget"),
1192            FreshnessSettings::default(),
1193        )
1194        .await
1195        .unwrap();
1196        assert!(from_npm[0].published_at().is_some());
1197
1198        // Simulates deno.json's `npm:widget` dependency, sharing the SAME NpmRegistry
1199        // instance via `with_npm` — the full-packument fetch above must not repeat.
1200        let deno_registry = DenoRegistry::with_npm(Arc::clone(&http_cache), shared_npm);
1201        let from_deno = Registry::get_versions_with(
1202            &deno_registry,
1203            &PackageName::new("npm:widget"),
1204            FreshnessSettings::default(),
1205        )
1206        .await
1207        .unwrap();
1208        assert!(from_deno[0].published_at().is_some());
1209
1210        abbrev_mock.assert_async().await;
1211        full_mock.assert_async().await;
1212    }
1213
1214    #[tokio::test]
1215    #[ignore] // requires network access
1216    async fn test_deno_registry_get_versions_dispatches_npm_live() {
1217        // Exercises the npm arm's dispatch through `DenoRegistry::new`'s private
1218        // `NpmRegistry` end-to-end against the real registry; mockable dispatch via a
1219        // shared instance is covered by
1220        // `test_deno_registry_with_npm_shares_freshness_cache_across_instances` above.
1221        let cache = Arc::new(HttpCache::new());
1222        let registry = DenoRegistry::new(cache);
1223        let versions = Registry::get_versions(&registry, &PackageName::new("npm:react"))
1224            .await
1225            .unwrap();
1226        assert!(!versions.is_empty());
1227    }
1228
1229    // --- Live registry verification (real network, run explicitly with `--ignored`) ---
1230    // Per `.claude/rules/continuous-improvement.md`'s Registry Integration Gate: confirms
1231    // this crate's parsing matches the actual live JSR response shape, not just the JSON
1232    // samples quoted in the architecture plan.
1233
1234    #[tokio::test]
1235    #[ignore]
1236    async fn test_live_jsr_get_versions_std_fs() {
1237        let registry = JsrRegistry::new(Arc::new(HttpCache::new()));
1238        let versions = registry.get_versions("std", "fs").await.unwrap();
1239
1240        assert!(!versions.is_empty());
1241        // 1.0.24 is JSR's `@std/fs` latest as of 2026-08-24; live registry only ever adds
1242        // new versions above it, so a look-up here is a floor, not a fixed hit.
1243        assert!(versions.iter().any(|v| v.version == "1.0.24"));
1244        // At least one known-yanked version (0.229.0) must round-trip its yanked flag.
1245        assert!(versions.iter().any(|v| v.version == "0.229.0" && v.yanked));
1246        // Sorted newest-first (C2).
1247        let parsed: Vec<node_semver::Version> = versions
1248            .iter()
1249            .map(|v| node_semver::Version::parse(&v.version).unwrap())
1250            .collect();
1251        assert!(parsed.windows(2).all(|w| w[0] >= w[1]));
1252    }
1253
1254    #[tokio::test]
1255    #[ignore]
1256    async fn test_live_jsr_search_fs() {
1257        let registry = JsrRegistry::new(Arc::new(HttpCache::new()));
1258        let results = registry.search("fs", 5).await.unwrap();
1259
1260        assert!(!results.is_empty());
1261        assert!(results.iter().all(|p| p.name.as_str().starts_with("jsr:@")));
1262    }
1263
1264    #[tokio::test]
1265    #[ignore]
1266    async fn test_live_jsr_search_scoped_query_ranks_exact_scope_first() {
1267        // N1: live-verified 2026-08-24 that an unfixed scoped query buries `@std/fs`
1268        // ~17th of 20 results; this must now come back first.
1269        let registry = JsrRegistry::new(Arc::new(HttpCache::new()));
1270        let results = registry.search("@std/fs", 5).await.unwrap();
1271
1272        assert!(!results.is_empty());
1273        assert_eq!(results[0].name, "jsr:@std/fs");
1274    }
1275
1276    #[tokio::test]
1277    #[ignore]
1278    async fn test_live_jsr_get_versions_missing_package_is_not_found() {
1279        let registry = JsrRegistry::new(Arc::new(HttpCache::new()));
1280        let err = registry
1281            .get_versions("this-scope-does-not-exist-12345", "nope")
1282            .await
1283            .unwrap_err();
1284        assert_matches!(err, DepsError::PackageNotFound { .. });
1285    }
1286}