Skip to main content

npm_utils/
registry.rs

1//! npm registry interaction: tarball URLs, package metadata, and version
2//! resolution against a semver range.
3
4use crate::download;
5use crate::package_json::spec::{Range, Spec};
6use semver::Version;
7use serde_json::Value;
8
9/// How much of a packument to request from the registry.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
11pub enum PackumentDetail {
12    /// The abbreviated install document (`application/vnd.npm.install-v1+json`) — much smaller and
13    /// faster, but it omits each version's `license`.
14    #[default]
15    Abbreviated,
16    /// The full document, including `license`.
17    Full,
18}
19
20impl PackumentDetail {
21    /// The `Accept` header for this detail level (`None` = the registry's default JSON).
22    fn accept(self) -> Option<&'static str> {
23        match self {
24            PackumentDetail::Abbreviated => Some("application/vnd.npm.install-v1+json"),
25            PackumentDetail::Full => None,
26        }
27    }
28}
29
30/// An npm-compatible registry. Defaults to the public registry and the abbreviated packument.
31pub struct Registry {
32    pub base_url: String,
33    /// How much of each packument to fetch. Abbreviated by default (faster); `Full` includes
34    /// per-version `license`.
35    pub detail: PackumentDetail,
36}
37
38impl Default for Registry {
39    fn default() -> Self {
40        Self {
41            base_url: "https://registry.npmjs.org".to_string(),
42            detail: PackumentDetail::Abbreviated,
43        }
44    }
45}
46
47/// A resolved package version: the exact version, the tarball to fetch, and the
48/// registry's `dist.integrity` SRI for that tarball (when the packument publishes one).
49///
50/// `#[non_exhaustive]` so further fields can be added without a breaking change — this
51/// type is only ever *constructed* inside the crate; callers receive and read it.
52#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct Resolved {
55    pub name: String,
56    pub version: Version,
57    pub tarball_url: String,
58    /// The registry's Subresource-Integrity hash (`sha512-<base64>`), when the packument
59    /// carries one — verified against the downloaded bytes before extraction. `None` for a
60    /// synthesized tarball URL or a packument entry without `dist.integrity`.
61    pub integrity: Option<String>,
62    /// The version's declared license, normalized to a single SPDX-ish string from the
63    /// packument's `license` string / legacy `{ "type": … }` object / `licenses[]` array.
64    /// `None` when the packument declares none. Carried so a generated lockfile can record
65    /// it for license/compliance tooling (npm's own lockfiles do the same).
66    pub license: Option<String>,
67}
68
69/// A dependency the audit resolution *skipped* rather than resolved — a non-registry spec
70/// (git / path / tarball / `workspace:` / `link:` / alias to one of those) or an optional
71/// dependency that failed to resolve — so an audit can report what it did **not** check.
72///
73/// `#[non_exhaustive]` like [`Resolved`]: constructed only inside the crate, read by callers.
74#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub struct Omission {
77    pub name: String,
78    /// The spec/range text that couldn't be followed — verbatim for a manifest root; a parsed
79    /// range's display form for a transitive edge (the verbatim text is gone after parsing);
80    /// empty when no spec applies (e.g. the `workspaces` marker).
81    pub spec: String,
82    /// Prose for reports: e.g. `git dependency`, `local path`, `remote tarball`,
83    /// `workspace: protocol`, `optional dependency failed to fetch: <cause>`.
84    pub reason: String,
85}
86
87impl Omission {
88    pub(crate) fn new(
89        name: impl Into<String>,
90        spec: impl Into<String>,
91        reason: impl Into<String>,
92    ) -> Omission {
93        Omission {
94            name: name.into(),
95            spec: spec.into(),
96            reason: reason.into(),
97        }
98    }
99}
100
101impl std::fmt::Display for Omission {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        if self.spec.is_empty() {
104            write!(f, "{} ({})", self.name, self.reason)
105        } else {
106            write!(f, "{} ({}: {})", self.name, self.spec, self.reason)
107        }
108    }
109}
110
111/// A single npm registry search result (from the `/-/v1/search` endpoint): the package name, its
112/// latest published version, and the descriptive metadata the registry indexes.
113///
114/// `#[non_exhaustive]` like [`Resolved`] — constructed only inside the crate, read by callers.
115#[derive(Debug, Clone)]
116#[non_exhaustive]
117pub struct SearchResult {
118    pub name: String,
119    /// The latest published version the registry indexes for this package.
120    pub version: String,
121    /// The package's `description`, when it publishes one.
122    pub description: Option<String>,
123    /// The package's keywords (empty when none are published).
124    pub keywords: Vec<String>,
125    /// Publication date of this version (RFC 3339), when the registry reports it.
126    pub date: Option<String>,
127    /// The package homepage (`links.homepage`), when published.
128    pub homepage: Option<String>,
129}
130
131impl Registry {
132    /// The public npm registry (`https://registry.npmjs.org`).
133    pub fn npm() -> Self {
134        Self::default()
135    }
136
137    /// A registry at a custom base URL (e.g. a private mirror).
138    pub fn with_base_url(base_url: impl Into<String>) -> Self {
139        Self {
140            base_url: base_url.into(),
141            ..Self::default()
142        }
143    }
144
145    /// Set how much of each packument to fetch (e.g. [`PackumentDetail::Full`] to capture license).
146    pub fn with_detail(mut self, detail: PackumentDetail) -> Self {
147        self.detail = detail;
148        self
149    }
150
151    /// Conventional tarball URL for an exact `version`. Handles scoped names:
152    /// `@scope/pkg` → `<base>/@scope/pkg/-/pkg-<version>.tgz`.
153    pub fn tarball_url(&self, name: &str, version: &str) -> String {
154        let unscoped = name.rsplit('/').next().unwrap_or(name);
155        format!("{}/{}/-/{}-{}.tgz", self.base_url, name, unscoped, version)
156    }
157
158    /// Fetch the package metadata document ("packument").
159    pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
160        // Scoped names are URL-encoded in the path: `@scope/pkg` → `@scope%2fpkg`.
161        let encoded = match name.strip_prefix('@') {
162            Some(rest) => format!("@{}", rest.replacen('/', "%2f", 1)),
163            None => name.to_string(),
164        };
165        let url = format!("{}/{}", self.base_url, encoded);
166        let bytes = download::fetch_with_accept(&url, self.detail.accept())?;
167        Ok(serde_json::from_slice(&bytes)?)
168    }
169
170    /// Resolve the newest published version of `name` matching the `range`.
171    pub fn resolve(
172        &self,
173        name: &str,
174        range: &Range,
175    ) -> Result<Resolved, Box<dyn std::error::Error + Send + Sync>> {
176        let doc = self.packument(name)?;
177        let (version, tarball, integrity, license) = select_version(&doc, range)
178            .ok_or_else(|| format!("no published version of {name} matches {range}"))?;
179        let tarball_url = tarball.unwrap_or_else(|| self.tarball_url(name, &version.to_string()));
180        Ok(Resolved {
181            name: name.to_string(),
182            version,
183            tarball_url,
184            integrity,
185            license,
186        })
187    }
188
189    /// Search the registry for `query`, returning up to `limit` results ranked by the registry's own
190    /// relevance score (npm's `/-/v1/search` endpoint). `limit` is clamped to the registry's
191    /// `1..=250` window. Each result carries the package's *latest* version and indexed metadata, not
192    /// a range resolution — follow up with [`resolve`](Self::resolve) to pin a version.
193    pub fn search(
194        &self,
195        query: &str,
196        limit: usize,
197    ) -> Result<Vec<SearchResult>, Box<dyn std::error::Error + Send + Sync>> {
198        let size = limit.clamp(1, 250);
199        let url = format!(
200            "{}/-/v1/search?text={}&size={size}",
201            self.base_url,
202            encode_query(query)
203        );
204        let bytes = download::fetch(&url)?;
205        let doc: Value = serde_json::from_slice(&bytes)?;
206        Ok(parse_search(&doc))
207    }
208
209    /// Resolve the transitive dependency graph of `roots` into a **flat** set — one
210    /// version per package name (the npm v3+ `node_modules` layout). Each package's
211    /// `dependencies` are read straight from the registry metadata (no tarball
212    /// extraction), every child resolved to its newest matching version, and the set
213    /// de-duplicated by name. Cyclic graphs terminate (a name is resolved once).
214    /// Returns the packages sorted by name. Packuments are prefetched concurrently
215    /// (bounded, level by level); resolution order, version selection, errors, and
216    /// output are deterministic and identical to a sequential walk.
217    ///
218    /// MVP limitation: a single version per package name. Two *incompatible*
219    /// requirements on the same package — a genuine conflict npm would resolve by
220    /// nesting — is reported as an error rather than silently mis-resolved;
221    /// [`resolve_tree_nested`](Self::resolve_tree_nested) is the conflict-tolerant
222    /// sibling that keeps a version per disagreeing range instead.
223    pub fn resolve_tree(
224        &self,
225        roots: &[(String, Range)],
226    ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
227        self.resolve_tree_observed(roots, |_| {})
228    }
229
230    /// [`resolve_tree`](Self::resolve_tree) with the walk's progress observer ([`ResolveEvent`])
231    /// — the lockfile writer and the `node_modules` installer drive their `[resolve]` tasks from
232    /// it. Resolution behavior is identical to [`resolve_tree`](Self::resolve_tree), which is
233    /// this with a no-op observer.
234    pub(crate) fn resolve_tree_observed<O>(
235        &self,
236        roots: &[(String, Range)],
237        on_event: O,
238    ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
239    where
240        O: Fn(ResolveEvent<'_>) + Sync,
241    {
242        self.resolve_walk(
243            &required_roots(roots),
244            |name| self.packument(name),
245            on_event,
246            FLAT_INSTALL,
247        )
248        .map(|(packages, _omissions)| packages)
249    }
250
251    /// Resolve the transitive dependency graph of `roots` the way npm's **nested**
252    /// `node_modules` does: a requirement an already-resolved version satisfies reuses it
253    /// (npm's dedupe), and requirements that disagree keep one resolved version *per range*
254    /// instead of erroring like [`resolve_tree`](Self::resolve_tree). Returns the packages
255    /// sorted by name, then version — possibly several versions of one name.
256    ///
257    /// This is the resolution an *audit* wants — a nested-compatible package/version set, **not**
258    /// npm's placement algorithm: no hoisting, no peer-dependency handling, and no os/cpu
259    /// filtering (every platform's optional deps are included — their advisories matter
260    /// regardless of the auditing machine). `optionalDependencies` are traversed alongside
261    /// `dependencies`; non-registry specs (git / path / tarball / `workspace:` / `link:`) and
262    /// optional deps that fail to resolve are skipped — this method drops the [`Omission`]
263    /// records, the crate-internal observed variant returns them. Installs keep
264    /// [`resolve_tree`](Self::resolve_tree)'s flat one-version-per-name guarantee.
265    /// Packuments are prefetched concurrently (bounded, level by level); the result is
266    /// deterministic and identical to a sequential walk.
267    pub fn resolve_tree_nested(
268        &self,
269        roots: &[(String, Range)],
270    ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
271        self.resolve_tree_nested_observed(&required_roots(roots), |_| {})
272            .map(|(packages, _omissions)| packages)
273    }
274
275    /// [`resolve_tree_nested`](Self::resolve_tree_nested) with a progress observer and the
276    /// skipped dependencies reported. `on_event` sees the walk's [`ResolveEvent`]s: packument
277    /// fetches beginning and ending on the prefetch pool's worker threads (hence the `Sync`
278    /// bound), and each resolved package on the calling thread — the CLI's `audit` verb drives
279    /// its `[resolve]` task from them. Roots carry an `optional` flag (an
280    /// `optionalDependencies` root may fail to resolve without failing the walk). Resolution
281    /// behavior (walk order, nested semantics, errors, sorting) is identical to
282    /// [`resolve_tree_nested`](Self::resolve_tree_nested), which is this with a no-op observer,
283    /// required roots, and the omissions dropped.
284    pub(crate) fn resolve_tree_nested_observed<O>(
285        &self,
286        roots: &[(String, Range, bool)],
287        on_event: O,
288    ) -> Result<(Vec<Resolved>, Vec<Omission>), Box<dyn std::error::Error + Send + Sync>>
289    where
290        O: Fn(ResolveEvent<'_>) + Sync,
291    {
292        self.resolve_walk(roots, |name| self.packument(name), on_event, NESTED_AUDIT)
293    }
294
295    /// [`resolve_tree`](Self::resolve_tree) with an injectable packument source — a test-only
296    /// seam ([`resolve_tree`] itself routes through
297    /// [`resolve_tree_observed`](Self::resolve_tree_observed), so nothing outside the tests
298    /// calls this). Passes the same [`FLAT_INSTALL`] policy as the public method.
299    #[cfg(test)]
300    fn resolve_tree_from<F>(
301        &self,
302        roots: &[(String, Range)],
303        get_packument: F,
304    ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
305    where
306        F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
307    {
308        self.resolve_walk(&required_roots(roots), get_packument, |_| {}, FLAT_INSTALL)
309            .map(|(packages, _omissions)| packages)
310    }
311
312    /// [`resolve_tree_nested`](Self::resolve_tree_nested) with an injectable packument source —
313    /// a test-only seam ([`resolve_tree_nested`] itself routes through
314    /// [`resolve_tree_nested_observed`](Self::resolve_tree_nested_observed), so nothing outside
315    /// the tests calls this). Passes the same [`NESTED_AUDIT`] policy as the public method, so
316    /// offline tests exercise the real audit walk.
317    #[cfg(test)]
318    fn resolve_tree_nested_from<F>(
319        &self,
320        roots: &[(String, Range)],
321        get_packument: F,
322    ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
323    where
324        F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
325    {
326        self.resolve_walk(&required_roots(roots), get_packument, |_| {}, NESTED_AUDIT)
327            .map(|(packages, _omissions)| packages)
328    }
329
330    /// The shared graph walk. `policy.nested: false` errors on a version conflict (one version
331    /// per name — an installable flat tree); `true` resolves the conflicting range to its own
332    /// version instead, as npm would by nesting a second copy. Under the audit policy the walk
333    /// also traverses `optionalDependencies` and records what it cannot follow as [`Omission`]s
334    /// — non-registry specs, and optional edges whose fetch or version selection fails — where
335    /// the install policy keeps every failure fatal. `on_event` reports fetch begin/done from
336    /// the prefetch workers and each resolution on this thread (a deduped requirement does not
337    /// tick); it returns `()` and cannot affect control flow. Terminates on any graph: each
338    /// iteration either reuses an already-resolved satisfying version, records a
339    /// `(name, version)` pair that wasn't there before, or skips.
340    ///
341    /// The walk proceeds in rounds: each takes the whole BFS frontier, prefetches its packuments
342    /// concurrently ([`prefetch_packuments`]), and then processes the entries in their original
343    /// FIFO order — the exact sequence the one-at-a-time walk consumed, merely chunked — so
344    /// version selection, dedupe, errors, omissions, and output are identical to a sequential
345    /// walk.
346    fn resolve_walk<F, O>(
347        &self,
348        roots: &[(String, Range, bool)],
349        get_packument: F,
350        on_event: O,
351        policy: WalkPolicy,
352    ) -> Result<(Vec<Resolved>, Vec<Omission>), Box<dyn std::error::Error + Send + Sync>>
353    where
354        F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
355        O: Fn(ResolveEvent<'_>) + Sync,
356    {
357        use std::collections::{HashMap, VecDeque};
358        let mut packuments: HashMap<String, Value> = HashMap::new();
359        let mut fetch_errors: HashMap<String, String> = HashMap::new();
360        let mut resolved: HashMap<String, Vec<Resolved>> = HashMap::new();
361        let mut omissions: Vec<Omission> = Vec::new();
362        let mut count = 0usize;
363        let mut queue: VecDeque<(String, Range, bool)> = roots.iter().cloned().collect();
364
365        while !queue.is_empty() {
366            let batch: Vec<(String, Range, bool)> = queue.drain(..).collect();
367            prefetch_packuments(
368                &batch,
369                &mut packuments,
370                &mut fetch_errors,
371                !policy.skip_unsupported,
372                &get_packument,
373                &on_event,
374            )?;
375            for (name, range, optional) in batch {
376                if let Some(existing) = resolved.get(&name) {
377                    if existing.iter().any(|r| range.matches(&r.version)) {
378                        continue; // already resolved to a satisfying version — dedup
379                    }
380                    if !policy.nested {
381                        return Err(format!(
382                            "version conflict for `{name}`: resolved {} but also required \
383                             `{range}` (flat node_modules install resolves one version per \
384                             package)",
385                            existing[0].version
386                        )
387                        .into());
388                    }
389                    // Nested: fall through and resolve this range's own version. The pick can't
390                    // duplicate an existing one — a satisfying existing version was handled above.
391                }
392                // A name whose prefetch failed: fatal on a required edge, an omission on an
393                // optional one — npm likewise tolerates an optional dep that fails to resolve.
394                if let Some(cause) = fetch_errors.get(&name) {
395                    if policy.skip_unsupported && optional {
396                        push_unique(
397                            &mut omissions,
398                            Omission::new(
399                                &name,
400                                range.to_string(),
401                                format!("optional dependency failed to fetch: {cause}"),
402                            ),
403                        );
404                        continue;
405                    }
406                    return Err(cause.clone().into());
407                }
408                // The prefetch has already cached every batch name; kept as a defensive fallback.
409                if !packuments.contains_key(&name) {
410                    let doc = get_packument(&name)?;
411                    packuments.insert(name.clone(), doc);
412                }
413                let doc = &packuments[&name];
414                let (version, tarball, integrity, license) = match select_version(doc, &range) {
415                    Some(selected) => selected,
416                    None if policy.skip_unsupported && optional => {
417                        push_unique(
418                            &mut omissions,
419                            Omission::new(
420                                &name,
421                                range.to_string(),
422                                "optional dependency: no published version matches",
423                            ),
424                        );
425                        continue;
426                    }
427                    None => {
428                        return Err(format!("no published version of {name} matches {range}").into())
429                    }
430                };
431                let deps = dependencies_of(doc, &version, policy.include_optional);
432                let tarball_url =
433                    tarball.unwrap_or_else(|| self.tarball_url(&name, &version.to_string()));
434                for (dep_name, dep_spec, dep_optional) in deps {
435                    if policy.skip_unsupported {
436                        // Non-registry child specs become omissions; a child of an optional
437                        // package is itself optional (npm skips the whole optional subtree).
438                        let action = classify_dep(&dep_name, &dep_spec).map_err(|e| {
439                            format!(
440                                "{name}@{version} dependency `{dep_name}`: unsupported version \
441                                 {dep_spec:?}: {e}"
442                            )
443                        })?;
444                        match action {
445                            EdgeAction::Resolve {
446                                name: target,
447                                range: dep_range,
448                            } => queue.push_back((target, dep_range, optional || dep_optional)),
449                            EdgeAction::Omit(omission) => push_unique(&mut omissions, omission),
450                        }
451                    } else {
452                        // Transitive deps routinely use npm `||`/space ranges; parse the full
453                        // grammar.
454                        let dep_range = Range::parse(&dep_spec).map_err(|e| {
455                            format!(
456                                "{name}@{version} dependency `{dep_name}`: unsupported version \
457                                 {dep_spec:?}: {e}"
458                            )
459                        })?;
460                        queue.push_back((dep_name, dep_range, false));
461                    }
462                }
463                let package = Resolved {
464                    name,
465                    version,
466                    tarball_url,
467                    integrity,
468                    license,
469                };
470                count += 1;
471                on_event(ResolveEvent::Resolved {
472                    count,
473                    package: &package,
474                });
475                resolved
476                    .entry(package.name.clone())
477                    .or_default()
478                    .push(package);
479            }
480        }
481        let mut out: Vec<Resolved> = resolved.into_values().flatten().collect();
482        out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
483        omissions.sort_by(|a, b| (&a.name, &a.spec).cmp(&(&b.name, &b.spec)));
484        Ok((out, omissions))
485    }
486}
487
488/// What the graph walk does with the dependency classes an install can't or shouldn't follow;
489/// see [`FLAT_INSTALL`] and [`NESTED_AUDIT`].
490#[derive(Clone, Copy)]
491struct WalkPolicy {
492    /// Keep one version per disagreeing range (npm's nesting) instead of erroring on a conflict.
493    nested: bool,
494    /// Traverse `optionalDependencies` alongside `dependencies`.
495    include_optional: bool,
496    /// Record non-registry specs and failed optional edges as [`Omission`]s instead of aborting.
497    skip_unsupported: bool,
498}
499
500/// The install walk: flat, `dependencies` only, every failure aborts.
501const FLAT_INSTALL: WalkPolicy = WalkPolicy {
502    nested: false,
503    include_optional: false,
504    skip_unsupported: false,
505};
506
507/// The audit walk: nested, optional deps included, unauditable edges recorded as omissions.
508const NESTED_AUDIT: WalkPolicy = WalkPolicy {
509    nested: true,
510    include_optional: true,
511    skip_unsupported: true,
512};
513
514/// Adapt plain `(name, range)` roots to required (non-optional) walk edges.
515fn required_roots(roots: &[(String, Range)]) -> Vec<(String, Range, bool)> {
516    roots
517        .iter()
518        .map(|(name, range)| (name.clone(), range.clone(), false))
519        .collect()
520}
521
522/// A progress event from the shared graph walk
523/// ([`Registry::resolve_tree_nested_observed`]). `FetchBegin`/`FetchDone` fire on the prefetch
524/// pool's worker threads — cross-name order is nondeterministic, but per name Begin precedes
525/// Done, and Done fires on success *and* failure ("no longer in flight"). `Resolved` fires on
526/// the calling thread once per newly resolved package version (a deduped requirement does not
527/// tick), never concurrently with fetch events — the walk alternates prefetch rounds with
528/// sequential processing.
529pub(crate) enum ResolveEvent<'a> {
530    FetchBegin {
531        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
532        name: &'a str,
533    },
534    FetchDone {
535        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
536        name: &'a str,
537    },
538    Resolved {
539        /// The running total — the walk's own count, pinned by the determinism tests;
540        /// renderers keep their own counters.
541        #[allow(dead_code)]
542        count: usize,
543        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
544        package: &'a Resolved,
545    },
546}
547
548/// Record `omission` unless an identical one (same name, spec, reason) is already recorded —
549/// the same unsupported dep reached from several parents reports once.
550fn push_unique(omissions: &mut Vec<Omission>, omission: Omission) {
551    if !omissions.contains(&omission) {
552        omissions.push(omission);
553    }
554}
555
556/// One classified dependency edge under the audit policy ([`classify_dep`]).
557pub(crate) enum EdgeAction {
558    /// A registry-resolvable edge — for an `npm:` alias, `name` is the alias **target**.
559    Resolve { name: String, range: Range },
560    /// An edge the audit resolution cannot follow, recorded rather than resolved.
561    Omit(Omission),
562}
563
564/// Classify one dependency edge for the audit walk: registry ranges (and `npm:` aliases to
565/// registry ranges) resolve; git / path / tarball / `workspace:` / `link:` specs — and aliases
566/// to them — are omissions. A malformed registry range is a hard error (fail clearly), never an
567/// omission. The `workspace:`/`link:` prefixes are checked before [`Spec::parse`], which has no
568/// variants for them (`link:../x` would misread as a git shorthand).
569pub(crate) fn classify_dep(
570    name: &str,
571    spec: &str,
572) -> Result<EdgeAction, Box<dyn std::error::Error + Send + Sync>> {
573    let spec = spec.trim();
574    if spec.starts_with("workspace:") {
575        return Ok(EdgeAction::Omit(Omission::new(
576            name,
577            spec,
578            "workspace: protocol",
579        )));
580    }
581    if spec.starts_with("link:") {
582        return Ok(EdgeAction::Omit(Omission::new(
583            name,
584            spec,
585            "link: protocol",
586        )));
587    }
588    match Spec::parse(spec) {
589        Spec::Git { .. } => Ok(EdgeAction::Omit(Omission::new(
590            name,
591            spec,
592            "git dependency",
593        ))),
594        Spec::Tarball(_) => Ok(EdgeAction::Omit(Omission::new(
595            name,
596            spec,
597            "remote tarball",
598        ))),
599        Spec::Path(_) => Ok(EdgeAction::Omit(Omission::new(name, spec, "local path"))),
600        Spec::Alias {
601            name: target,
602            spec: inner,
603        } => match *inner {
604            // The alias target is what gets installed — resolve and audit it.
605            Spec::Registry(range) => Ok(EdgeAction::Resolve {
606                name: target,
607                range: Range::parse(&range)?,
608            }),
609            _ => Ok(EdgeAction::Omit(Omission::new(
610                name,
611                spec,
612                "npm: alias to a non-registry target",
613            ))),
614        },
615        Spec::Registry(range) => Ok(EdgeAction::Resolve {
616            name: name.to_string(),
617            range: Range::parse(&range)?,
618        }),
619    }
620}
621
622/// How many packuments [`prefetch_packuments`] fetches concurrently per round. The shared
623/// agent's per-host idle pool ([`crate::download`]) is sized to match — keep the two in sync.
624const PACKUMENT_CONCURRENCY: usize = 8;
625
626/// One prefetched packument outcome (a private alias keeping the slot type readable).
627type FetchedPackument = Result<Value, Box<dyn std::error::Error + Send + Sync>>;
628
629/// Fetch the packuments a round's `batch` needs — the distinct names not already in
630/// `packuments` (nor already recorded as failed), in first-appearance order — with up to
631/// [`PACKUMENT_CONCURRENCY`] threads, and insert them in that same order.
632///
633/// Failure handling follows the walk policy. `fail_fast` (the install walk): the first failure
634/// in batch order aborts with the original error — exactly the sequential walk's first hit.
635/// Otherwise (the audit walk) every failure is recorded in `fetch_errors` and the walk decides
636/// per entry: fatal for a required edge, an [`Omission`] for an optional one. A failed name is
637/// never re-fetched — later rounds see it in `fetch_errors` and skip it in the missing set — so
638/// every name is fetched exactly once regardless of outcome. Each attempted fetch is bracketed
639/// by [`ResolveEvent::FetchBegin`]/[`ResolveEvent::FetchDone`], emitted on the worker thread.
640///
641/// No over-fetch on success paths: a name enters the walk's `resolved` map only via the
642/// fetch-and-select path, so every resolved name is already cached and a dedupe-bound batch
643/// entry never lands in the missing set.
644fn prefetch_packuments<F, O>(
645    batch: &[(String, Range, bool)],
646    packuments: &mut std::collections::HashMap<String, Value>,
647    fetch_errors: &mut std::collections::HashMap<String, String>,
648    fail_fast: bool,
649    get_packument: &F,
650    on_event: &O,
651) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
652where
653    F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
654    O: Fn(ResolveEvent<'_>) + Sync,
655{
656    use std::collections::HashSet;
657    use std::sync::atomic::{AtomicUsize, Ordering};
658    use std::sync::Mutex;
659
660    let mut seen: HashSet<&str> = HashSet::new();
661    let missing: Vec<&str> = batch
662        .iter()
663        .map(|(name, _, _)| name.as_str())
664        .filter(|name| {
665            !packuments.contains_key(*name)
666                && !fetch_errors.contains_key(*name)
667                && seen.insert(*name)
668        })
669        .collect();
670    if missing.is_empty() {
671        return Ok(());
672    }
673
674    // One slot per missing name; workers claim indices through the atomic counter, so each slot
675    // is written exactly once. Relaxed suffices: the counter only partitions indices, and result
676    // visibility is ordered by each slot's Mutex and the scope's join.
677    let results: Vec<Mutex<Option<FetchedPackument>>> =
678        missing.iter().map(|_| Mutex::new(None)).collect();
679    let next = AtomicUsize::new(0);
680    std::thread::scope(|scope| {
681        for _ in 0..missing.len().min(PACKUMENT_CONCURRENCY) {
682            scope.spawn(|| loop {
683                let i = next.fetch_add(1, Ordering::Relaxed);
684                let Some(name) = missing.get(i) else { break };
685                on_event(ResolveEvent::FetchBegin { name });
686                let result = get_packument(name); // fetched before the slot lock is taken
687                on_event(ResolveEvent::FetchDone { name });
688                *results[i]
689                    .lock()
690                    .expect("no panic while holding a slot lock") = Some(result);
691            });
692        }
693    });
694
695    for (name, slot) in missing.iter().zip(results) {
696        let result = slot
697            .into_inner()
698            .expect("a panicking worker re-panics out of the scope before this runs")
699            .expect("every index below missing.len() was claimed and filled");
700        match result {
701            Ok(doc) => {
702                packuments.insert((*name).to_string(), doc);
703            }
704            Err(e) if fail_fast => return Err(e),
705            Err(e) => {
706                fetch_errors.insert((*name).to_string(), e.to_string());
707            }
708        }
709    }
710    Ok(())
711}
712
713/// The fields [`select_version`] extracts for the newest matching version:
714/// `(version, dist.tarball, dist.integrity, license)`.
715type SelectedVersion = (Version, Option<String>, Option<String>, Option<String>);
716
717/// Pick the newest version in a packument's `versions` map that satisfies the `range`,
718/// returning it with the `dist.tarball` URL, the `dist.integrity` SRI, and the declared
719/// `license` the registry advertises (each `None` if absent). Factored out for unit testing
720/// without network access.
721fn select_version(doc: &Value, range: &Range) -> Option<SelectedVersion> {
722    let versions = doc.get("versions")?.as_object()?;
723    let mut best: Option<SelectedVersion> = None;
724    for (ver_str, meta) in versions {
725        let Ok(ver) = Version::parse(ver_str) else {
726            continue;
727        };
728        if !range.matches(&ver) {
729            continue;
730        }
731        if best.as_ref().map(|(b, ..)| ver > *b).unwrap_or(true) {
732            let dist = meta.get("dist");
733            let string_at = |key: &str| {
734                dist.and_then(|d| d.get(key))
735                    .and_then(|v| v.as_str())
736                    .map(str::to_string)
737            };
738            best = Some((
739                ver,
740                string_at("tarball"),
741                string_at("integrity"),
742                license_of(meta),
743            ));
744        }
745    }
746    best
747}
748
749/// Normalize a packument version entry's license to a single SPDX-ish string. npm uses a
750/// `license` string today; older packages used a `{ "type": … }` object or a
751/// `licenses: [{ "type": … }]` array — collapse all three (joining a multi-entry array with
752/// `" OR "`), returning `None` when none is declared.
753fn license_of(meta: &Value) -> Option<String> {
754    crate::package_json::normalize_license(meta)
755}
756
757/// The npm dependency-spec → `VersionReq` parser lives in the [`crate::package_json`] module
758/// (the package-spec grammar); re-exported here for back-compat as `registry::version_req`.
759pub use crate::package_json::spec::version_req;
760
761/// The `dependencies` — and, when `include_optional`, the `optionalDependencies` — of a
762/// specific version, read from a packument, as `(name, spec, optional)` triples. Both maps ride
763/// inline in each version's metadata (the abbreviated install packument included), so the
764/// transitive walk discovers children without extracting any tarball. An `optionalDependencies`
765/// entry overrides a same-name `dependencies` entry **in place** (npm applies optional entries
766/// over regular ones), keeping one triple per name in a deterministic order.
767fn dependencies_of(
768    doc: &Value,
769    version: &Version,
770    include_optional: bool,
771) -> Vec<(String, String, bool)> {
772    let meta = doc.get("versions").and_then(|v| v.get(version.to_string()));
773    let read = |key: &str| -> Vec<(String, String)> {
774        meta.and_then(|m| m.get(key))
775            .and_then(|d| d.as_object())
776            .map(|map| {
777                map.iter()
778                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
779                    .collect()
780            })
781            .unwrap_or_default()
782    };
783    let mut out: Vec<(String, String, bool)> = read("dependencies")
784        .into_iter()
785        .map(|(name, spec)| (name, spec, false))
786        .collect();
787    if include_optional {
788        for (name, spec) in read("optionalDependencies") {
789            match out.iter_mut().find(|(existing, ..)| *existing == name) {
790                Some(entry) => *entry = (name, spec, true),
791                None => out.push((name, spec, true)),
792            }
793        }
794    }
795    out
796}
797
798/// Parse the registry's `/-/v1/search` response into [`SearchResult`]s, skipping any malformed
799/// entry. The response shape is `{ "objects": [ { "package": { name, version, description,
800/// keywords, date, links: { homepage } } }, … ] }`. Factored out for unit testing without network.
801fn parse_search(doc: &Value) -> Vec<SearchResult> {
802    let Some(objects) = doc.get("objects").and_then(Value::as_array) else {
803        return Vec::new();
804    };
805    objects
806        .iter()
807        .filter_map(|obj| {
808            let pkg = obj.get("package")?;
809            let name = pkg.get("name")?.as_str()?.to_string();
810            let string_field = |key: &str| pkg.get(key).and_then(Value::as_str).map(str::to_string);
811            let keywords = pkg
812                .get("keywords")
813                .and_then(Value::as_array)
814                .map(|arr| {
815                    arr.iter()
816                        .filter_map(|k| k.as_str().map(str::to_string))
817                        .collect()
818                })
819                .unwrap_or_default();
820            let homepage = pkg
821                .get("links")
822                .and_then(|links| links.get("homepage"))
823                .and_then(Value::as_str)
824                .map(str::to_string);
825            Some(SearchResult {
826                name,
827                version: string_field("version").unwrap_or_default(),
828                description: string_field("description"),
829                keywords,
830                date: string_field("date"),
831                homepage,
832            })
833        })
834        .collect()
835}
836
837/// Percent-encode a search query for use as a URL query-string value: keep the RFC 3986 unreserved
838/// set, `%`-escape everything else (the query may contain spaces, scopes `@`, or slashes).
839fn encode_query(s: &str) -> String {
840    let mut out = String::with_capacity(s.len());
841    for b in s.bytes() {
842        match b {
843            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
844                out.push(b as char)
845            }
846            _ => out.push_str(&format!("%{b:02X}")),
847        }
848    }
849    out
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use serde_json::json;
856
857    #[test]
858    fn tarball_url_handles_scoped_and_unscoped() {
859        let reg = Registry::npm();
860        assert_eq!(
861            reg.tarball_url("lit", "3.3.3"),
862            "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz"
863        );
864        assert_eq!(
865            reg.tarball_url("@lit/context", "1.1.6"),
866            "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz"
867        );
868    }
869
870    #[test]
871    fn select_version_picks_newest_matching() {
872        let doc = json!({
873            "versions": {
874                "3.1.0": { "dist": { "tarball": "https://r/lit-3.1.0.tgz" } },
875                "3.3.3": {
876                    "license": "BSD-3-Clause",
877                    "dist": {
878                        "tarball": "https://r/lit-3.3.3.tgz",
879                        "integrity": "sha512-deadbeef"
880                    }
881                },
882                "4.0.0": { "dist": { "tarball": "https://r/lit-4.0.0.tgz" } },
883                "2.9.9": {}
884            }
885        });
886        let (ver, tarball, integrity, license) =
887            select_version(&doc, &"^3".parse().unwrap()).unwrap();
888        assert_eq!(ver, Version::parse("3.3.3").unwrap());
889        assert_eq!(tarball.as_deref(), Some("https://r/lit-3.3.3.tgz"));
890        // The registry's dist.integrity rides along so node_modules can verify the tarball.
891        assert_eq!(integrity.as_deref(), Some("sha512-deadbeef"));
892        // The declared license rides along too, so a generated lockfile can record it.
893        assert_eq!(license.as_deref(), Some("BSD-3-Clause"));
894    }
895
896    #[test]
897    fn select_version_integrity_is_none_when_absent() {
898        // A dist with a tarball but no integrity → integrity None. node_modules then refuses
899        // to install it unverified (from_lockfile is likewise strict on a missing sha512).
900        let doc = json!({ "versions": {
901            "1.0.0": { "dist": { "tarball": "https://r/x-1.0.0.tgz" } }
902        }});
903        let (_, tarball, integrity, _license) =
904            select_version(&doc, &"^1".parse().unwrap()).unwrap();
905        assert_eq!(tarball.as_deref(), Some("https://r/x-1.0.0.tgz"));
906        assert!(integrity.is_none());
907    }
908
909    #[test]
910    fn select_version_none_when_no_match() {
911        let doc = json!({ "versions": { "1.0.0": {}, "2.0.0": {} } });
912        assert!(select_version(&doc, &"^5".parse().unwrap()).is_none());
913    }
914
915    #[test]
916    fn license_of_normalizes_string_object_and_array_forms() {
917        // Modern SPDX string (what nearly every package publishes today).
918        assert_eq!(
919            license_of(&json!({ "license": "MIT" })).as_deref(),
920            Some("MIT")
921        );
922        // Legacy `{ type }` object.
923        assert_eq!(
924            license_of(&json!({ "license": { "type": "Apache-2.0", "url": "x" } })).as_deref(),
925            Some("Apache-2.0")
926        );
927        // Legacy `licenses: [{ type }]` array → joined with " OR ".
928        assert_eq!(
929            license_of(&json!({ "licenses": [{ "type": "MIT" }, { "type": "Apache-2.0" }] }))
930                .as_deref(),
931            Some("MIT OR Apache-2.0")
932        );
933        // None declared.
934        assert_eq!(license_of(&json!({ "dist": {} })), None);
935    }
936
937    /// A one-version packument carrying a `dependencies` map, mirroring the registry's
938    /// shape, so the graph walk can be exercised without the network.
939    fn packument_with(version: &str, deps: &[(&str, &str)]) -> Value {
940        let dep_map: serde_json::Map<String, Value> = deps
941            .iter()
942            .map(|(n, s)| (n.to_string(), json!(*s)))
943            .collect();
944        let mut versions = serde_json::Map::new();
945        versions.insert(
946            version.to_string(),
947            json!({
948                "dist": {
949                    "tarball": format!("https://r/{version}.tgz"),
950                    "integrity": format!("sha512-{version}"),
951                },
952                "dependencies": Value::Object(dep_map),
953            }),
954        );
955        json!({ "versions": Value::Object(versions) })
956    }
957
958    #[test]
959    fn resolve_tree_walks_transitively_dedups_and_handles_cycles() {
960        // a@1 → {b ^1, c ^1}; b@1 → {c ^1} (shared); c@1 → {a ^1} (cycle back to root).
961        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
962        pkgs.insert(
963            "a".into(),
964            packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
965        );
966        pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
967        pkgs.insert("c".into(), packument_with("1.5.0", &[("a", "^1")]));
968
969        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
970        let resolved = Registry::npm()
971            .resolve_tree_from(&roots, |name| {
972                pkgs.get(name)
973                    .cloned()
974                    .ok_or_else(|| format!("no packument for {name}").into())
975            })
976            .unwrap();
977
978        // Each of a, b, c resolved exactly once (cycle + shared dep deduped), sorted by name.
979        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
980        assert_eq!(names, ["a", "b", "c"]);
981        let ver = |n: &str| {
982            resolved
983                .iter()
984                .find(|r| r.name == n)
985                .unwrap()
986                .version
987                .to_string()
988        };
989        assert_eq!(ver("b"), "1.2.0");
990        assert_eq!(ver("c"), "1.5.0");
991
992        // dist.integrity threads through the transitive walk, ready for verification.
993        let integrity = |n: &str| {
994            resolved
995                .iter()
996                .find(|r| r.name == n)
997                .unwrap()
998                .integrity
999                .clone()
1000        };
1001        assert_eq!(integrity("b").as_deref(), Some("sha512-1.2.0"));
1002    }
1003
1004    #[test]
1005    fn resolve_tree_resolves_a_transitive_or_range() {
1006        // Regression: a transitive dep with an npm `||` range (e.g. @lit/context →
1007        // @lit/reactive-element `^1.6.2 || ^2.1.0`) must resolve, not fail to parse the `||`.
1008        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1009        pkgs.insert(
1010            "ctx".into(),
1011            packument_with("1.1.6", &[("re", "^1.6.2 || ^2.1.0")]),
1012        );
1013        pkgs.insert("re".into(), packument_with("2.1.0", &[]));
1014
1015        let roots = vec![("ctx".to_string(), "^1".parse().unwrap())];
1016        let resolved = Registry::npm()
1017            .resolve_tree_from(&roots, |name| {
1018                pkgs.get(name)
1019                    .cloned()
1020                    .ok_or_else(|| format!("no packument for {name}").into())
1021            })
1022            .unwrap();
1023
1024        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1025        assert_eq!(
1026            names,
1027            ["ctx", "re"],
1028            "the `||`-ranged transitive dep resolved"
1029        );
1030        assert_eq!(
1031            resolved
1032                .iter()
1033                .find(|r| r.name == "re")
1034                .unwrap()
1035                .version
1036                .to_string(),
1037            "2.1.0"
1038        );
1039    }
1040
1041    #[test]
1042    fn resolve_tree_errors_on_version_conflict() {
1043        // root requires x ^1; root also requires y, and y requires x ^2 → incompatible.
1044        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1045        pkgs.insert(
1046            "x".into(),
1047            json!({ "versions": {
1048                "1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
1049                "2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
1050            }}),
1051        );
1052        pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
1053
1054        let roots = vec![
1055            ("x".to_string(), "^1".parse().unwrap()),
1056            ("y".to_string(), "^1".parse().unwrap()),
1057        ];
1058        let err = Registry::npm()
1059            .resolve_tree_from(&roots, |name| {
1060                pkgs.get(name)
1061                    .cloned()
1062                    .ok_or_else(|| format!("no packument for {name}").into())
1063            })
1064            .unwrap_err();
1065        assert!(err.to_string().contains("version conflict"), "got: {err}");
1066    }
1067
1068    #[test]
1069    fn resolve_tree_nested_keeps_a_version_per_conflicting_range() {
1070        // The exact graph the flat resolver rejects above: root wants x ^1, y wants x ^2.
1071        // Nested resolution keeps both x versions — what an installed tree would contain.
1072        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1073        pkgs.insert(
1074            "x".into(),
1075            json!({ "versions": {
1076                "1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
1077                "2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
1078            }}),
1079        );
1080        pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
1081
1082        let roots = vec![
1083            ("x".to_string(), "^1".parse().unwrap()),
1084            ("y".to_string(), "^1".parse().unwrap()),
1085        ];
1086        let resolved = Registry::npm()
1087            .resolve_tree_nested_from(&roots, |name| {
1088                pkgs.get(name)
1089                    .cloned()
1090                    .ok_or_else(|| format!("no packument for {name}").into())
1091            })
1092            .unwrap();
1093
1094        let pairs: Vec<String> = resolved
1095            .iter()
1096            .map(|r| format!("{}@{}", r.name, r.version))
1097            .collect();
1098        assert_eq!(
1099            pairs,
1100            ["x@1.0.0", "x@2.0.0", "y@1.0.0"],
1101            "both conflicting x versions kept, sorted by name then version"
1102        );
1103    }
1104
1105    #[test]
1106    fn resolve_tree_nested_dedupes_and_terminates_on_a_conflicting_cycle() {
1107        // a@1 → b ^1; b@1 → a ^2 (conflicts with the root's a@1); a@2 → b ^1 (cycle, already
1108        // satisfied). The walk must dedupe satisfying ranges, nest the conflicting one exactly
1109        // once, and terminate.
1110        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1111        pkgs.insert(
1112            "a".into(),
1113            json!({ "versions": {
1114                "1.0.0": { "dist": { "tarball": "https://r/a1.tgz" }, "dependencies": { "b": "^1" } },
1115                "2.0.0": { "dist": { "tarball": "https://r/a2.tgz" }, "dependencies": { "b": "^1" } }
1116            }}),
1117        );
1118        pkgs.insert("b".into(), packument_with("1.0.0", &[("a", "^2")]));
1119
1120        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1121        let resolved = Registry::npm()
1122            .resolve_tree_nested_from(&roots, |name| {
1123                pkgs.get(name)
1124                    .cloned()
1125                    .ok_or_else(|| format!("no packument for {name}").into())
1126            })
1127            .unwrap();
1128
1129        let pairs: Vec<String> = resolved
1130            .iter()
1131            .map(|r| format!("{}@{}", r.name, r.version))
1132            .collect();
1133        assert_eq!(pairs, ["a@1.0.0", "a@2.0.0", "b@1.0.0"]);
1134    }
1135
1136    /// [`packument_with`], plus an `optionalDependencies` map.
1137    fn packument_with_optional(
1138        version: &str,
1139        deps: &[(&str, &str)],
1140        optional: &[(&str, &str)],
1141    ) -> Value {
1142        let mut doc = packument_with(version, deps);
1143        let opt_map: serde_json::Map<String, Value> = optional
1144            .iter()
1145            .map(|(n, s)| (n.to_string(), json!(*s)))
1146            .collect();
1147        doc["versions"][version]["optionalDependencies"] = Value::Object(opt_map);
1148        doc
1149    }
1150
1151    /// A `HashMap`-backed packument source for the walk tests.
1152    fn source_from(
1153        pkgs: &std::collections::HashMap<String, Value>,
1154    ) -> impl Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync + '_ {
1155        |name| {
1156            pkgs.get(name)
1157                .cloned()
1158                .ok_or_else(|| format!("no packument for {name}").into())
1159        }
1160    }
1161
1162    fn pairs(resolved: &[Resolved]) -> Vec<String> {
1163        resolved
1164            .iter()
1165            .map(|r| format!("{}@{}", r.name, r.version))
1166            .collect()
1167    }
1168
1169    #[test]
1170    fn resolve_tree_nested_terminates_on_a_mutual_cycle() {
1171        // a@1 ↔ b@1 require each other with satisfiable ranges: dedupe breaks the cycle.
1172        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1173        pkgs.insert("a".into(), packument_with("1.0.0", &[("b", "^1")]));
1174        pkgs.insert("b".into(), packument_with("1.0.0", &[("a", "^1")]));
1175
1176        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1177        let resolved = Registry::npm()
1178            .resolve_tree_nested_from(&roots, source_from(&pkgs))
1179            .unwrap();
1180        assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.0.0"]);
1181    }
1182
1183    #[test]
1184    fn resolve_tree_nested_reuses_a_satisfying_version_across_parents() {
1185        // The plain diamond under the nested policy: a → {b, c}, b → c — c resolves once.
1186        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1187        pkgs.insert(
1188            "a".into(),
1189            packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
1190        );
1191        pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
1192        pkgs.insert("c".into(), packument_with("1.5.0", &[]));
1193
1194        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1195        let resolved = Registry::npm()
1196            .resolve_tree_nested_from(&roots, source_from(&pkgs))
1197            .unwrap();
1198        assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.2.0", "c@1.5.0"]);
1199    }
1200
1201    #[test]
1202    fn optional_dependencies_traverse_nested_and_stay_ignored_flat() {
1203        // root has no regular deps and one optional dep; opt@1 pulls a regular child of its own,
1204        // which inherits the optional flag transitively.
1205        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1206        pkgs.insert(
1207            "root".into(),
1208            packument_with_optional("1.0.0", &[], &[("opt", "^1")]),
1209        );
1210        pkgs.insert("opt".into(), packument_with("1.1.0", &[("leaf", "^1")]));
1211        pkgs.insert("leaf".into(), packument_with("1.0.0", &[]));
1212
1213        let roots = vec![("root".to_string(), "^1".parse().unwrap())];
1214        let resolved = Registry::npm()
1215            .resolve_tree_nested_from(&roots, source_from(&pkgs))
1216            .unwrap();
1217        assert_eq!(pairs(&resolved), ["leaf@1.0.0", "opt@1.1.0", "root@1.0.0"]);
1218
1219        // The flat install walk ignores optionalDependencies entirely (unchanged behavior).
1220        let resolved = Registry::npm()
1221            .resolve_tree_from(&roots, source_from(&pkgs))
1222            .unwrap();
1223        assert_eq!(pairs(&resolved), ["root@1.0.0"]);
1224    }
1225
1226    #[test]
1227    fn dependencies_of_lets_an_optional_entry_override_in_place() {
1228        let doc = packument_with_optional("1.0.0", &[("x", "^1"), ("y", "^1")], &[("x", "^2")]);
1229        let version = Version::parse("1.0.0").unwrap();
1230        assert_eq!(
1231            dependencies_of(&doc, &version, true),
1232            [
1233                ("x".to_string(), "^2".to_string(), true),
1234                ("y".to_string(), "^1".to_string(), false),
1235            ],
1236            "the optional entry replaces the regular one in place, flag flipped"
1237        );
1238        assert_eq!(
1239            dependencies_of(&doc, &version, false),
1240            [
1241                ("x".to_string(), "^1".to_string(), false),
1242                ("y".to_string(), "^1".to_string(), false),
1243            ],
1244            "the install walk never sees optionalDependencies"
1245        );
1246    }
1247
1248    #[test]
1249    fn optional_fetch_failure_is_an_omission_and_siblings_still_resolve() {
1250        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1251        pkgs.insert(
1252            "root".into(),
1253            packument_with_optional("1.0.0", &[("b", "^1")], &[("gone", "^2")]),
1254        );
1255        pkgs.insert("b".into(), packument_with("1.0.0", &[]));
1256
1257        let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1258        let (resolved, omissions) = Registry::npm()
1259            .resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1260            .unwrap();
1261        assert_eq!(pairs(&resolved), ["b@1.0.0", "root@1.0.0"]);
1262        assert_eq!(omissions.len(), 1, "{omissions:?}");
1263        assert_eq!(omissions[0].name, "gone");
1264        assert!(
1265            omissions[0]
1266                .reason
1267                .contains("optional dependency failed to fetch"),
1268            "{}",
1269            omissions[0]
1270        );
1271    }
1272
1273    #[test]
1274    fn optional_version_mismatch_is_an_omission() {
1275        // `opt` exists but publishes no version matching the optional range.
1276        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1277        pkgs.insert(
1278            "root".into(),
1279            packument_with_optional("1.0.0", &[], &[("opt", "^9")]),
1280        );
1281        pkgs.insert("opt".into(), packument_with("1.0.0", &[]));
1282
1283        let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1284        let (resolved, omissions) = Registry::npm()
1285            .resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1286            .unwrap();
1287        assert_eq!(pairs(&resolved), ["root@1.0.0"]);
1288        assert_eq!(omissions.len(), 1);
1289        assert!(
1290            omissions[0].reason.contains("no published version matches"),
1291            "{}",
1292            omissions[0]
1293        );
1294    }
1295
1296    #[test]
1297    fn required_fetch_failure_still_aborts_the_audit_walk() {
1298        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1299        pkgs.insert("root".into(), packument_with("1.0.0", &[("gone", "^1")]));
1300
1301        let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1302        let err = Registry::npm()
1303            .resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1304            .unwrap_err();
1305        assert!(err.to_string().contains("no packument for gone"), "{err}");
1306    }
1307
1308    #[test]
1309    fn a_name_needed_by_a_required_edge_aborts_even_after_an_optional_omission() {
1310        // Round 2: a's optional edge on `x` fails → omission. Round 3: b's *required* edge on
1311        // `x` must abort from the stored error — and `x` is fetched exactly once overall.
1312        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1313        pkgs.insert("root".into(), packument_with("1.0.0", &[("a", "^1")]));
1314        pkgs.insert(
1315            "a".into(),
1316            packument_with_optional("1.0.0", &[("b", "^1")], &[("x", "^1")]),
1317        );
1318        pkgs.insert("b".into(), packument_with("1.0.0", &[("x", "^1")]));
1319
1320        let attempts: std::sync::Mutex<std::collections::HashMap<String, usize>> =
1321            std::sync::Mutex::new(std::collections::HashMap::new());
1322        let get = |name: &str| {
1323            *attempts
1324                .lock()
1325                .unwrap()
1326                .entry(name.to_string())
1327                .or_insert(0) += 1;
1328            pkgs.get(name)
1329                .cloned()
1330                .ok_or_else(|| format!("no packument for {name}").into())
1331        };
1332
1333        let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1334        let err = Registry::npm()
1335            .resolve_walk(&roots, get, |_| {}, NESTED_AUDIT)
1336            .unwrap_err();
1337        assert!(err.to_string().contains("no packument for x"), "{err}");
1338        assert_eq!(
1339            attempts.into_inner().unwrap().get("x"),
1340            Some(&1),
1341            "a failed name is never re-fetched"
1342        );
1343    }
1344
1345    #[test]
1346    fn transitive_git_spec_is_an_omission_nested_and_an_error_flat() {
1347        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1348        pkgs.insert(
1349            "a".into(),
1350            packument_with(
1351                "1.0.0",
1352                &[("g", "git+https://github.com/x/y.git"), ("b", "^1")],
1353            ),
1354        );
1355        pkgs.insert("b".into(), packument_with("1.0.0", &[]));
1356
1357        let roots3 = vec![("a".to_string(), "^1".parse().unwrap(), false)];
1358        let (resolved, omissions) = Registry::npm()
1359            .resolve_walk(&roots3, source_from(&pkgs), |_| {}, NESTED_AUDIT)
1360            .unwrap();
1361        assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.0.0"]);
1362        assert_eq!(omissions.len(), 1);
1363        assert_eq!(
1364            omissions[0].to_string(),
1365            "g (git+https://github.com/x/y.git: git dependency)"
1366        );
1367
1368        // The install walk keeps failing loudly on the same spec.
1369        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1370        let err = Registry::npm()
1371            .resolve_tree_from(&roots, source_from(&pkgs))
1372            .unwrap_err();
1373        assert!(err.to_string().contains("unsupported version"), "{err}");
1374    }
1375
1376    #[test]
1377    fn npm_alias_to_a_registry_range_resolves_the_target() {
1378        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1379        pkgs.insert(
1380            "a".into(),
1381            packument_with("1.0.0", &[("aliased", "npm:real@^1")]),
1382        );
1383        pkgs.insert("real".into(), packument_with("1.5.0", &[]));
1384
1385        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1386        let resolved = Registry::npm()
1387            .resolve_tree_nested_from(&roots, source_from(&pkgs))
1388            .unwrap();
1389        assert_eq!(
1390            pairs(&resolved),
1391            ["a@1.0.0", "real@1.5.0"],
1392            "the alias target resolves under its real name"
1393        );
1394    }
1395
1396    #[test]
1397    fn garbage_transitive_range_stays_a_hard_error_under_the_audit_policy() {
1398        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1399        pkgs.insert(
1400            "a".into(),
1401            packument_with("1.0.0", &[("bad", "%% nope %%")]),
1402        );
1403
1404        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
1405        let err = Registry::npm()
1406            .resolve_tree_nested_from(&roots, source_from(&pkgs))
1407            .unwrap_err();
1408        assert!(
1409            err.to_string().contains("unsupported version"),
1410            "malformed semver must fail clearly, not become an omission: {err}"
1411        );
1412    }
1413
1414    #[test]
1415    fn classify_dep_routes_protocols_aliases_and_ranges() {
1416        let omit_reason = |spec: &str| match classify_dep("p", spec).unwrap() {
1417            EdgeAction::Omit(o) => o.reason,
1418            EdgeAction::Resolve { .. } => panic!("{spec:?} should be an omission"),
1419        };
1420        assert_eq!(omit_reason("workspace:*"), "workspace: protocol");
1421        // `link:../x` would misread as a git shorthand through Spec::parse — the prefix check
1422        // runs first.
1423        assert_eq!(omit_reason("link:../x"), "link: protocol");
1424        assert_eq!(
1425            omit_reason("git+ssh://git@github.com/x/y.git"),
1426            "git dependency"
1427        );
1428        assert_eq!(omit_reason("user/repo"), "git dependency");
1429        assert_eq!(omit_reason("https://x.example/p.tgz"), "remote tarball");
1430        assert_eq!(omit_reason("file:../local"), "local path");
1431        assert_eq!(
1432            omit_reason("npm:x@file:../y"),
1433            "npm: alias to a non-registry target"
1434        );
1435
1436        match classify_dep("aliased", "npm:target@^2").unwrap() {
1437            EdgeAction::Resolve { name, .. } => assert_eq!(name, "target"),
1438            EdgeAction::Omit(o) => panic!("alias to registry must resolve, got {o}"),
1439        }
1440        match classify_dep("plain", "^1.2").unwrap() {
1441            EdgeAction::Resolve { name, .. } => assert_eq!(name, "plain"),
1442            EdgeAction::Omit(o) => panic!("registry range must resolve, got {o}"),
1443        }
1444        assert!(classify_dep("bad", "%% nope %%").is_err());
1445    }
1446
1447    #[test]
1448    fn omission_display_names_the_spec_when_present() {
1449        assert_eq!(
1450            Omission::new("g", "git+ssh://x/y", "git dependency").to_string(),
1451            "g (git+ssh://x/y: git dependency)"
1452        );
1453        assert_eq!(
1454            Omission::new("workspaces", "", "not traversed").to_string(),
1455            "workspaces (not traversed)"
1456        );
1457    }
1458
1459    #[test]
1460    fn prefetch_concurrency_stays_within_the_cap() {
1461        // A 12-name frontier (over the 8-thread cap); the fake fetch tracks its in-flight
1462        // high-water mark. The tiny sleep gives workers a chance to overlap — the assertion is
1463        // one-sided (never above the cap), so scheduling noise can't flake it.
1464        use std::sync::atomic::{AtomicUsize, Ordering};
1465        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1466        let leaves: Vec<String> = (1..=12).map(|i| format!("l{i:02}")).collect();
1467        pkgs.insert(
1468            "root".into(),
1469            packument_with(
1470                "1.0.0",
1471                &leaves
1472                    .iter()
1473                    .map(|l| (l.as_str(), "^1"))
1474                    .collect::<Vec<_>>(),
1475            ),
1476        );
1477        for leaf in &leaves {
1478            pkgs.insert(leaf.clone(), packument_with("1.0.0", &[]));
1479        }
1480
1481        let in_flight = AtomicUsize::new(0);
1482        let high_water = AtomicUsize::new(0);
1483        let get = |name: &str| {
1484            let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1485            high_water.fetch_max(now, Ordering::SeqCst);
1486            std::thread::sleep(std::time::Duration::from_millis(2));
1487            in_flight.fetch_sub(1, Ordering::SeqCst);
1488            pkgs.get(name)
1489                .cloned()
1490                .ok_or_else(|| format!("no packument for {name}").into())
1491        };
1492
1493        let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1494        let (resolved, _) = Registry::npm()
1495            .resolve_walk(&roots, get, |_| {}, NESTED_AUDIT)
1496            .unwrap();
1497        assert_eq!(resolved.len(), 13);
1498        assert!(
1499            high_water.load(Ordering::SeqCst) <= PACKUMENT_CONCURRENCY,
1500            "at most {PACKUMENT_CONCURRENCY} concurrent fetches, saw {}",
1501            high_water.load(Ordering::SeqCst)
1502        );
1503    }
1504
1505    #[test]
1506    fn parallel_prefetch_is_deterministic_and_fetches_each_name_once() {
1507        // Three levels, 12 distinct names — more than the 8-thread cap: root@1 → p01..p10 (^1
1508        // each), each pNN@1 → shared ^1, shared@1 → {}. The per-name fetch counter lives behind
1509        // a Mutex so the packument source stays `Fn + Sync` for the parallel prefetch.
1510        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1511        let parents: Vec<String> = (1..=10).map(|i| format!("p{i:02}")).collect();
1512        pkgs.insert(
1513            "root".into(),
1514            packument_with(
1515                "1.0.0",
1516                &parents
1517                    .iter()
1518                    .map(|p| (p.as_str(), "^1"))
1519                    .collect::<Vec<_>>(),
1520            ),
1521        );
1522        for p in &parents {
1523            pkgs.insert(p.clone(), packument_with("1.0.0", &[("shared", "^1")]));
1524        }
1525        pkgs.insert("shared".into(), packument_with("1.0.0", &[]));
1526
1527        let fetches: std::sync::Mutex<std::collections::HashMap<String, usize>> =
1528            std::sync::Mutex::new(std::collections::HashMap::new());
1529        let get = |name: &str| {
1530            *fetches.lock().unwrap().entry(name.to_string()).or_insert(0) += 1;
1531            pkgs.get(name)
1532                .cloned()
1533                .ok_or_else(|| format!("no packument for {name}").into())
1534        };
1535
1536        let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
1537        let seen: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
1538        let begins = std::sync::atomic::AtomicUsize::new(0);
1539        let dones = std::sync::atomic::AtomicUsize::new(0);
1540        let (resolved, _) = Registry::npm()
1541            .resolve_walk(
1542                &roots,
1543                get,
1544                |event| match event {
1545                    // Fetch events arrive from worker threads in nondeterministic cross-name
1546                    // order — count them, never sequence them.
1547                    ResolveEvent::FetchBegin { .. } => {
1548                        begins.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1549                    }
1550                    ResolveEvent::FetchDone { .. } => {
1551                        dones.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1552                    }
1553                    ResolveEvent::Resolved { count, .. } => seen.lock().unwrap().push(count),
1554                },
1555                NESTED_AUDIT,
1556            )
1557            .unwrap();
1558
1559        // Every name fetched exactly once, despite ten same-round requirements on `shared` —
1560        // and every fetch bracketed by one Begin and one Done.
1561        let fetches = fetches.into_inner().unwrap();
1562        assert_eq!(fetches.len(), 12);
1563        assert!(
1564            fetches.values().all(|&n| n == 1),
1565            "duplicate fetches: {fetches:?}"
1566        );
1567        assert_eq!(begins.into_inner(), 12);
1568        assert_eq!(dones.into_inner(), 12);
1569        // Deterministic ticks and output: 12 resolutions in FIFO order, sorted result.
1570        assert_eq!(seen.into_inner().unwrap(), (1..=12).collect::<Vec<_>>());
1571        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
1572        let expected: Vec<String> = parents
1573            .iter()
1574            .cloned()
1575            .chain(["root".to_string(), "shared".to_string()])
1576            .collect();
1577        assert_eq!(
1578            names,
1579            expected.iter().map(String::as_str).collect::<Vec<_>>()
1580        );
1581    }
1582
1583    #[test]
1584    fn resolve_walk_reports_each_resolution_to_the_observer() {
1585        // a@1 → {b ^1, c ^1}; b@1 → {c ^1} — c's second requirement dedupes and must NOT tick.
1586        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1587        pkgs.insert(
1588            "a".into(),
1589            packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
1590        );
1591        pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
1592        pkgs.insert("c".into(), packument_with("1.5.0", &[]));
1593
1594        let roots = vec![("a".to_string(), "^1".parse().unwrap(), false)];
1595        let seen: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
1596        let (resolved, _) = Registry::npm()
1597            .resolve_walk(
1598                &roots,
1599                |name| {
1600                    pkgs.get(name)
1601                        .cloned()
1602                        .ok_or_else(|| format!("no packument for {name}").into())
1603                },
1604                |event| {
1605                    if let ResolveEvent::Resolved { count, package } = event {
1606                        seen.lock()
1607                            .unwrap()
1608                            .push(format!("{count} {}@{}", package.name, package.version));
1609                    }
1610                },
1611                NESTED_AUDIT,
1612            )
1613            .unwrap();
1614
1615        // One event per resolution with the running total and the package, none for the deduped
1616        // requeue.
1617        let seen = seen.into_inner().unwrap();
1618        assert_eq!(seen, ["1 a@1.0.0", "2 b@1.2.0", "3 c@1.5.0"]);
1619        assert_eq!(seen.len(), resolved.len());
1620    }
1621
1622    #[test]
1623    fn fetch_events_bracket_each_fetch_once_including_failures() {
1624        // `ok` resolves; `bad` is an *optional* root whose fetch fails → an omission under the
1625        // audit policy. Every attempted name gets exactly one Begin and one Done — the failed
1626        // fetch included ("no longer in flight") — and Resolved counts stay the walk's 1..=n.
1627        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
1628        pkgs.insert("ok".into(), packument_with("1.0.0", &[]));
1629
1630        let events: std::sync::Mutex<std::collections::HashMap<String, (usize, usize)>> =
1631            std::sync::Mutex::new(std::collections::HashMap::new());
1632        let counts: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
1633        let roots = vec![
1634            ("ok".to_string(), "^1".parse().unwrap(), false),
1635            ("bad".to_string(), "^1".parse().unwrap(), true),
1636        ];
1637        let (resolved, omissions) = Registry::npm()
1638            .resolve_walk(
1639                &roots,
1640                |name| {
1641                    pkgs.get(name)
1642                        .cloned()
1643                        .ok_or_else(|| format!("no packument for {name}").into())
1644                },
1645                |event| match event {
1646                    ResolveEvent::FetchBegin { name } => {
1647                        events
1648                            .lock()
1649                            .unwrap()
1650                            .entry(name.to_string())
1651                            .or_default()
1652                            .0 += 1;
1653                    }
1654                    ResolveEvent::FetchDone { name } => {
1655                        events
1656                            .lock()
1657                            .unwrap()
1658                            .entry(name.to_string())
1659                            .or_default()
1660                            .1 += 1;
1661                    }
1662                    ResolveEvent::Resolved { count, .. } => counts.lock().unwrap().push(count),
1663                },
1664                NESTED_AUDIT,
1665            )
1666            .unwrap();
1667
1668        let events = events.into_inner().unwrap();
1669        assert_eq!(events.get("ok"), Some(&(1, 1)));
1670        assert_eq!(
1671            events.get("bad"),
1672            Some(&(1, 1)),
1673            "a failed fetch still closes"
1674        );
1675        assert_eq!(counts.into_inner().unwrap(), [1]);
1676        assert_eq!(resolved.len(), 1);
1677        assert_eq!(omissions.len(), 1);
1678        assert!(
1679            omissions[0]
1680                .reason
1681                .contains("optional dependency failed to fetch"),
1682            "{}",
1683            omissions[0]
1684        );
1685    }
1686
1687    #[test]
1688    fn parse_search_extracts_packages_and_skips_malformed() {
1689        let doc = json!({
1690            "objects": [
1691                { "package": {
1692                    "name": "lodash",
1693                    "version": "4.17.21",
1694                    "description": "Lodash modular utilities.",
1695                    "keywords": ["util", "functional"],
1696                    "date": "2021-02-20T15:42:16.891Z",
1697                    "links": { "npm": "https://www.npmjs.com/package/lodash", "homepage": "https://lodash.com/" }
1698                }},
1699                { "package": { "version": "1.0.0" } }, // no name → skipped
1700                { "score": {} }                         // no package → skipped
1701            ],
1702            "total": 1
1703        });
1704        let results = parse_search(&doc);
1705        assert_eq!(results.len(), 1);
1706        let r = &results[0];
1707        assert_eq!(r.name, "lodash");
1708        assert_eq!(r.version, "4.17.21");
1709        assert_eq!(r.description.as_deref(), Some("Lodash modular utilities."));
1710        assert_eq!(r.keywords, ["util", "functional"]);
1711        assert_eq!(r.homepage.as_deref(), Some("https://lodash.com/"));
1712        assert!(r.date.is_some());
1713    }
1714
1715    #[test]
1716    fn parse_search_empty_when_no_objects() {
1717        assert!(parse_search(&json!({})).is_empty());
1718        assert!(parse_search(&json!({ "objects": "nope" })).is_empty());
1719    }
1720
1721    #[test]
1722    fn encode_query_escapes_reserved_characters() {
1723        assert_eq!(encode_query("lodash"), "lodash");
1724        assert_eq!(encode_query("react dom"), "react%20dom");
1725        assert_eq!(encode_query("@lit/context"), "%40lit%2Fcontext");
1726    }
1727}