Skip to main content

aube_resolver/
resolve.rs

1use crate::local_source::{
2    dep_path_for, is_non_registry_specifier, read_local_manifest, rebase_local, resolve_git_source,
3    resolve_remote_tarball, should_block_exotic_subdep,
4};
5use crate::package_ext::{apply_package_extensions, pick_override_spec};
6use crate::semver_util::{PickResult, pick_version, version_satisfies};
7use crate::{
8    Error, ExoticSubdepDetails, PeerContextOptions, ResolutionMode, ResolveTask, ResolvedPackage,
9    Resolver, apply_peer_contexts, catalog, error, hoist_auto_installed_peers,
10    is_deprecation_allowed, is_supported,
11};
12use crate::{FxHashMap, FxHashSet};
13use aube_lockfile::{DepType, DirectDep, LocalSource, LockedPackage, LockfileGraph};
14use aube_manifest::PackageJson;
15use aube_registry::Packument;
16use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
17use std::sync::Arc;
18
19impl Resolver {
20    /// Resolve all dependencies from a package.json.
21    ///
22    /// Uses batch-parallel BFS: each "wave" drains the queue, identifies
23    /// uncached package names, fetches their packuments concurrently, then
24    /// processes the entire batch before starting the next wave.
25    pub async fn resolve(
26        &mut self,
27        manifest: &PackageJson,
28        existing: Option<&LockfileGraph>,
29    ) -> Result<LockfileGraph, Error> {
30        self.resolve_workspace(
31            &[(".".to_string(), manifest.clone())],
32            existing,
33            &HashMap::new(),
34        )
35        .await
36    }
37
38    /// Resolve all dependencies for a workspace (multiple importers).
39    ///
40    /// `manifests` is a list of (importer_path, PackageJson) — e.g. (".", root), ("packages/app", app).
41    /// `workspace_packages` maps package name → version. Used both for
42    /// explicit `workspace:` protocol resolution and for yarn/npm/bun
43    /// style linkage where a bare semver range on a workspace-package
44    /// name resolves to the local copy when its version satisfies the
45    /// range.
46    pub async fn resolve_workspace(
47        &mut self,
48        manifests: &[(String, PackageJson)],
49        existing: Option<&LockfileGraph>,
50        workspace_packages: &HashMap<String, String>,
51    ) -> Result<LockfileGraph, Error> {
52        let resolve_start = std::time::Instant::now();
53        let mut packument_fetch_count = 0u32;
54        let mut packument_fetch_time = std::time::Duration::ZERO;
55        let mut lockfile_reuse_count = 0u32;
56        let mut resolved: BTreeMap<String, LockedPackage> = BTreeMap::new();
57        // 1024 covers typical monorepo. 5000-dep graphs take one grow.
58        let mut resolved_versions: FxHashMap<String, Vec<String>> =
59            FxHashMap::with_capacity_and_hasher(1024, Default::default());
60        let mut importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
61        let mut queue: VecDeque<ResolveTask> = VecDeque::with_capacity(512);
62        let mut visited: FxHashSet<std::sync::Arc<str>> =
63            FxHashSet::with_capacity_and_hasher(2048, Default::default());
64        // Round-tripped to the lockfile's top-level `time:` block so
65        // subsequent installs can reuse them for the cutoff computation.
66        // Populated opportunistically from whatever packuments we fetch:
67        // empty when the metadata omits `time` (corgi from npmjs.org in
68        // default mode), filled when it doesn't (Verdaccio, or the
69        // full-packument path taken for time-based resolution and
70        // `minimumReleaseAge`). This matches pnpm's `publishedAt` wiring.
71        let mut resolved_times: BTreeMap<String, String> = BTreeMap::new();
72        // Per-importer record of optionals the resolver intentionally
73        // dropped on this run — either filtered by os/cpu/libc or
74        // named in `pnpm.ignoredOptionalDependencies`. Round-tripped
75        // through the lockfile so drift detection on subsequent
76        // installs can distinguish "previously skipped" from "newly
77        // added by the user".
78        let mut skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>> =
79            BTreeMap::new();
80        // Catalog picks gathered as the BFS rewrites `catalog:` task
81        // ranges. Outer key: catalog name. Inner: package name → spec.
82        // Resolved versions are filled in post-resolution by walking
83        // `resolved_versions` for the spec, since the picked version is
84        // an output the BFS doesn't know until version_satisfies fires.
85        let mut catalog_picks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
86        let importer_declared_dep_names: BTreeMap<String, BTreeSet<String>> = manifests
87            .iter()
88            .map(|(importer_path, manifest)| {
89                let names = manifest
90                    .dependencies
91                    .keys()
92                    .chain(manifest.dev_dependencies.keys())
93                    .chain(manifest.optional_dependencies.keys())
94                    .cloned()
95                    .collect();
96                (importer_path.clone(), names)
97            })
98            .collect();
99        // ISO-8601 UTC cutoff string. npm's registry `time` map uses
100        // `Z`-suffixed UTC timestamps throughout, which sort
101        // lexicographically — so a raw `String` doubles as a
102        // comparable instant without pulling in a date library.
103        //
104        // Two independent features feed this cutoff:
105        //   - `minimum_release_age` (pnpm v11 default, supply-chain
106        //     mitigation): seeded *before* wave 0 so even direct deps
107        //     are filtered. The exclude list and strict-mode behavior
108        //     are scoped per-package by `pick_version` below.
109        //   - `resolution-mode=time-based`: derived from the max
110        //     publish time across direct deps once wave 0 finishes,
111        //     then constrains transitives only.
112        // When both are configured, the resolver carries both cutoffs
113        // and the picker takes the more restrictive (earlier) one.
114        let mut published_by: Option<String> =
115            self.minimum_release_age.as_ref().and_then(|m| m.cutoff());
116        if let Some(c) = published_by.as_deref() {
117            tracing::debug!("minimumReleaseAge cutoff: {}", c);
118        }
119
120        seed_direct_deps(
121            manifests,
122            &self.ignored_optional_dependencies,
123            &mut queue,
124            &mut importers,
125        );
126
127        // Pipelined resolver state. The resolver is strictly serial in
128        // its *processing* order (tasks are popped and version-picked
129        // in seed/BFS order, which is what keeps the output lockfile
130        // byte-deterministic across runs) but fetches run freely in
131        // the background via `in_flight`. When a popped task's
132        // packument isn't in the cache, the main loop waits inline on
133        // `in_flight.join_next()` — harvesting whatever other fetches
134        // happen to land in the meantime — until this task's
135        // packument is available. Because `ensure_fetch!` is called
136        // speculatively at every enqueue site, by the time a task is
137        // popped its packument is usually already cached, so the
138        // wait is short.
139        let shared_semaphore = Arc::new(tokio::sync::Semaphore::new(
140            self.packument_network_concurrency.unwrap_or(64),
141        ));
142        // Time-based mode and `minimumReleaseAge` both need the
143        // packument's `time:` map. The abbreviated (corgi) response
144        // omits `time` by default, so we normally fall back to the
145        // full packument. `registry-supports-time-field=true` flips
146        // that: the user is asserting the configured registry ships
147        // `time` in corgi too (Verdaccio 5.15.1+, JSR, etc.), so the
148        // cheaper abbreviated path stays on the hot path and we save
149        // one full-packument fetch per distinct package.
150        let needs_time = (self.resolution_mode == ResolutionMode::TimeBased
151            || self.minimum_release_age.is_some()
152            || self.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade)
153            && !self.registry_supports_time_field;
154        // When time data is required, fetch the full packument directly.
155        // The previous corgi-first shortcut saved bytes for old packages
156        // but cost an extra round trip for active packages whose top-level
157        // `modified` timestamp was newer than the cutoff. Clean installs of
158        // modern dependency graphs are dominated by those active packages.
159
160        // In-flight packument fetches. The spawned task returns the
161        // `(name, packument, from_primer)` tuple so `join_next` gives
162        // us back the identity of whichever fetch landed next without
163        // a side table lookup. `from_primer` matters because the
164        // bundled primer intentionally keeps only a capped slice of
165        // high-traffic package histories; a range miss against that
166        // slice must fall through to the live registry before we
167        // report `ERR_AUBE_NO_MATCHING_VERSION`.
168        #[allow(clippy::type_complexity)]
169        let mut in_flight: tokio::task::JoinSet<Result<(String, Packument, bool), Error>> =
170            tokio::task::JoinSet::new();
171        // Names whose fetch has been spawned but not yet harvested.
172        // Dedupes spawn calls when multiple tasks discover the same
173        // transitive before any of them has been processed.
174        let mut in_flight_names: FxHashSet<String> = FxHashSet::default();
175        let mut primer_seeded_names: FxHashSet<String> = FxHashSet::default();
176        // TimeBased wave-0 gate: the publish-time cutoff is derived
177        // from the direct deps' resolved versions, so transitives
178        // that reach the version-pick step before all directs have
179        // completed must wait. Populated only when
180        // `cutoff_pending == true` (TimeBased mode); `Highest` mode
181        // leaves these at their defaults and the gate is a no-op.
182        let mut direct_deps_pending: usize = queue.len();
183        let mut cutoff_pending = self.resolution_mode == ResolutionMode::TimeBased;
184        let mut deferred_transitives: Vec<ResolveTask> = Vec::new();
185
186        // Set of names present in the existing lockfile. Used as a
187        // prefetch gate: names the lockfile already covers will hit
188        // the lockfile-reuse path and don't need their packuments
189        // fetched, so prefetching them is wasted tokio-spawn
190        // overhead. Load-bearing for `aube add` and
191        // frozen-lockfile-install scenarios where most tasks go
192        // through lockfile-reuse.
193        //
194        // This is strictly a *prefetch* gate, not a correctness
195        // gate: a task that fails sibling dedupe AND lockfile reuse
196        // (because its range doesn't match any of the lockfile's
197        // versions for that name) still needs a fresh fetch, and
198        // the wait-for-fetch loop below calls `ensure_fetch!`
199        // without consulting `existing_names`.
200        // Borrow names from `existing` instead of cloning. The set
201        // lives only inside `Resolver::resolve` and the prior
202        // lockfile graph outlives it. Skips 5000 String allocations
203        // on a 5000-pkg lockfile at resolve-entry.
204        let existing_names: FxHashSet<&str> = existing
205            .map(|g| g.packages.values().map(|p| p.name.as_str()).collect())
206            .unwrap_or_default();
207
208        // Spawn a packument fetch into `in_flight` if one isn't
209        // already running for `name` and the packument isn't
210        // already cached. Gated *only* on in-flight + cache —
211        // callers that want to skip prefetching names already
212        // covered by the lockfile check `existing_names` explicitly
213        // before invoking the macro.
214        macro_rules! ensure_fetch {
215            ($name:expr) => {{
216                let name: &str = $name;
217                if !in_flight_names.contains(name) && !self.cache.contains_key(name) {
218                    let name_owned = name.to_string();
219                    in_flight_names.insert(name_owned.clone());
220                    let client = self.client.clone();
221                    let cache_dir = self.packument_cache_dir.clone();
222                    let full_cache_dir = self.packument_full_cache_dir.clone();
223                    let minimum_release_age_excludes_name = self
224                        .minimum_release_age
225                        .as_ref()
226                        .is_some_and(|mra| mra.exclude.contains(name));
227                    let primer_covers_cutoff = minimum_release_age_excludes_name
228                        || published_by
229                            .as_deref()
230                            .is_none_or(crate::primer::covers_cutoff);
231                    let use_metadata_primer = (self.force_metadata_primer
232                        || client.uses_default_npm_registry_for(&name_owned))
233                        && primer_covers_cutoff;
234                    let force_metadata_primer = self.force_metadata_primer;
235                    let sem = shared_semaphore.clone();
236                    in_flight.spawn(async move {
237                        let _permit = sem
238                            .acquire_owned()
239                            .await
240                            .map_err(|e| Error::Registry(name_owned.clone(), e.to_string()))?;
241                        let mut cached = if needs_time {
242                            match full_cache_dir.as_ref() {
243                                Some(dir) => client.cached_full_packument_lookup(&name_owned, dir),
244                                None => Default::default(),
245                            }
246                        } else if let Some(ref dir) = cache_dir {
247                            client.cached_packument_lookup(&name_owned, dir)
248                        } else {
249                            Default::default()
250                        };
251                        if let Some(packument) = cached.packument.take() {
252                            return Ok::<_, Error>((name_owned, packument, false));
253                        }
254                        if use_metadata_primer
255                            && !cached.stale
256                            && let Some(seed) = crate::primer::get(&name_owned)
257                        {
258                            let mut packument = seed.packument();
259                            if force_metadata_primer {
260                                for version in packument.versions.values_mut() {
261                                    let tarball =
262                                        client.tarball_url(&version.name, &version.version);
263                                    version.dist = version.dist.take().map(|mut dist| {
264                                        dist.tarball = tarball;
265                                        dist
266                                    });
267                                }
268                            }
269                            if needs_time {
270                                if let Some(dir) = full_cache_dir.as_ref() {
271                                    client.seed_full_packument_cache(
272                                        &name_owned,
273                                        dir,
274                                        &packument,
275                                        seed.etag.as_deref(),
276                                        seed.last_modified.as_deref(),
277                                        false,
278                                    );
279                                }
280                            } else if let Some(dir) = cache_dir.as_ref() {
281                                client.seed_packument_cache(
282                                    &name_owned,
283                                    dir,
284                                    &packument,
285                                    seed.etag.as_deref(),
286                                    seed.last_modified.as_deref(),
287                                    false,
288                                );
289                            }
290                            return Ok::<_, Error>((name_owned, packument, true));
291                        }
292                        let packument = if needs_time {
293                            match full_cache_dir.as_ref() {
294                                Some(dir) => {
295                                    client
296                                        .fetch_packument_with_time_cached_after_lookup(
297                                            &name_owned,
298                                            dir,
299                                            cached,
300                                        )
301                                        .await
302                                }
303                                None => client.fetch_packument(&name_owned).await,
304                            }
305                        } else if let Some(ref dir) = cache_dir {
306                            client
307                                .fetch_packument_cached_after_lookup(&name_owned, dir, cached)
308                                .await
309                        } else {
310                            client.fetch_packument(&name_owned).await
311                        }
312                        .map_err(|e| Error::Registry(name_owned.clone(), e.to_string()))?;
313                        Ok::<_, Error>((name_owned, packument, false))
314                    });
315                }
316            }};
317        }
318
319        // Decrement the pending-directs counter when a root task
320        // reaches a terminal state. Used by the TimeBased cutoff
321        // trigger at the top of the outer loop.
322        macro_rules! note_root_done {
323            () => {
324                if direct_deps_pending > 0 {
325                    direct_deps_pending -= 1;
326                }
327            };
328        }
329
330        // `(name, range)` is safe to speculatively prefetch against
331        // the registry when:
332        //
333        //   - The range isn't a protocol we rewrite in preprocessing
334        //     (`workspace:` / `catalog:` / `npm:` alias) — for those
335        //     we don't know the real package name yet, so fetching
336        //     the raw task name is either useless (preprocessing
337        //     won't go through the registry at all) or wrong (we'd
338        //     fetch the alias key instead of the real package).
339        //   - The range isn't a `file:` / `link:` / `git:` /
340        //     remote-tarball spec (covered by
341        //     `is_non_registry_specifier`).
342        //   - The name isn't in the overrides map — an override can
343        //     rewrite the range into any of the above, and we can't
344        //     cheaply tell whether it will, so be conservative.
345        //
346        // Called both from the upfront prefetch loop over seeded
347        // root deps *and* from the three transitive-enqueue sites
348        // inside the version-pick body, where the same class of
349        // unsafe specs can arrive via a published package's
350        // `dependencies` / `optionalDependencies` / `peerDependencies`
351        // maps (real-world case: a package whose dependency entry
352        // is an npm alias).
353        macro_rules! prefetchable {
354            ($name:expr, $range:expr) => {{
355                let r: &str = $range;
356                let n: &str = $name;
357                // A bare semver range that matches a workspace package
358                // will resolve to the workspace without ever reading
359                // the packument, so prefetching would just be a
360                // speculative 404 on e.g. an unpublished monorepo
361                // package.
362                let workspace_hit = workspace_packages
363                    .get(n)
364                    .is_some_and(|ws_v| version_satisfies(ws_v, r));
365                !aube_util::pkg::is_workspace_spec(r)
366                    && !aube_util::pkg::is_catalog_spec(r)
367                    && !aube_util::pkg::is_npm_spec(r)
368                    && !aube_util::pkg::is_jsr_spec(r)
369                    && !is_non_registry_specifier(r)
370                    && !self.overrides.contains_key(n)
371                    && !workspace_hit
372            }};
373        }
374
375        // Fire prefetches for every seeded root dep up front, so
376        // their packuments are already in flight by the time the
377        // first task is popped. Skip lockfile-covered names —
378        // they'll hit the lockfile-reuse path and never need their
379        // packuments — and anything `prefetchable!` rejects.
380        for task in queue.iter() {
381            if !prefetchable!(task.name.as_str(), task.range.as_str()) {
382                continue;
383            }
384            if existing_names.contains(task.name.as_str()) {
385                continue;
386            }
387            ensure_fetch!(&task.name);
388        }
389
390        'outer: loop {
391            // TimeBased cutoff trigger. Fires the first time
392            // `direct_deps_pending` hits zero with the cutoff still
393            // pending — at which point every direct dep has been
394            // version-picked (or terminated in preprocessing),
395            // `resolved_times` holds their publish times, and we can
396            // derive the max to seed `published_by` for the
397            // transitives we deferred.
398            if cutoff_pending && direct_deps_pending == 0 {
399                let direct_dep_paths: FxHashSet<&String> = importers
400                    .values()
401                    .flat_map(|deps| deps.iter().map(|d| &d.dep_path))
402                    .collect();
403                let mut max_time: Option<&String> = None;
404                for (dep_path, t) in resolved_times.iter() {
405                    if !direct_dep_paths.contains(dep_path) {
406                        continue;
407                    }
408                    if max_time.map(|m| t > m).unwrap_or(true) {
409                        max_time = Some(t);
410                    }
411                }
412                if let Some(existing_graph) = existing {
413                    for (dep_path, t) in &existing_graph.times {
414                        if !direct_dep_paths.contains(dep_path) {
415                            continue;
416                        }
417                        if max_time.map(|m| t > m).unwrap_or(true) {
418                            max_time = Some(t);
419                        }
420                    }
421                }
422                if let Some(m) = max_time {
423                    tracing::debug!("time-based resolution cutoff: {}", m);
424                    published_by = Some(match published_by.take() {
425                        Some(existing) if existing.as_str() < m.as_str() => existing,
426                        _ => m.clone(),
427                    });
428                }
429                cutoff_pending = false;
430                queue.extend(deferred_transitives.drain(..));
431            }
432
433            let Some(mut task) = queue.pop_front() else {
434                if !deferred_transitives.is_empty() {
435                    return Err(Error::Registry(
436                        "(resolver)".to_string(),
437                        format!(
438                            "{} transitives still deferred when resolve completed",
439                            deferred_transitives.len()
440                        ),
441                    ));
442                }
443                break 'outer;
444            };
445
446            // Body of the former per-task preprocessing loop.
447            // The old wave-based code split this into a
448            // preprocessing pass and a post-fetch version-pick
449            // pass with a fetch barrier between them. Here both
450            // passes run inline for a single task: preprocess →
451            // sibling dedupe → lockfile reuse → wait on this
452            // task's packument → version-pick → enqueue
453            // transitives. The bare block keeps the original
454            // indentation so the diff stays readable against the
455            // prior shape; `continue` inside it still continues
456            // the 'outer loop because a bare block is not itself
457            // a loop.
458            {
459                // Apply bare-name overrides + npm-alias rewrites in a
460                // small fixed-point loop. Two interleavings need to
461                // work simultaneously:
462                //   1. The override *value* is itself a `npm:` alias
463                //      (e.g. `"foo": "npm:bar@^2"`). The first override
464                //      pass rewrites `task.range`; the alias pass then
465                //      rewrites `task.name` to `bar`.
466                //   2. The user's *declared dep* is an `npm:` alias
467                //      (e.g. `"foo": "npm:bar@^1"`) and the override
468                //      targets the real package (`"overrides":
469                //      {"bar": "2.0.0"}`). The first override pass
470                //      misses (`task.name` is still `foo`), the alias
471                //      pass rewrites `task.name = "bar"`, and the
472                //      second override pass catches it.
473                // A two-iteration cap is enough — after one alias
474                // rewrite the name is canonical, and an override that
475                // points at a third package is itself constrained by
476                // the same rule, so there's no infinite chain.
477                //
478                // We deliberately don't touch `original_specifier`,
479                // since the lockfile/importer record should still
480                // reflect what the user wrote in package.json —
481                // overrides are a graph-shaping rule, not a rewrite of
482                // the user's declared deps.
483                // Catalog protocol: rewrite `catalog:` and
484                // `catalog:<name>` to the workspace catalog's actual
485                // range *before* the override loop, so overrides can
486                // still target a catalog dep by bare name. The original
487                // `catalog:...` text stays in `original_specifier` so
488                // the lockfile importer keeps the catalog reference and
489                // drift detection works.
490                if let Some((catalog_name, real_range)) =
491                    self.resolve_catalog_spec(&task.name, &task.range)?
492                {
493                    tracing::trace!("catalog: {} {} -> {}", task.name, task.range, real_range);
494                    catalog_picks
495                        .entry(catalog_name)
496                        .or_default()
497                        .insert(task.name.clone(), real_range.clone());
498                    task.range = real_range;
499                }
500
501                for _ in 0..2 {
502                    let mut changed = false;
503                    if let Some(override_spec) = pick_override_spec(
504                        &self.override_rules,
505                        &task.name,
506                        &task.range,
507                        &task.ancestors,
508                    ) {
509                        // pnpm's removal marker: an override value of
510                        // `"-"` drops the dep edge entirely. Skip before
511                        // catalog/alias rewrites so `-` never reaches
512                        // the registry resolver. The dropped edge never
513                        // gets written to the parent's `.dependencies`
514                        // map (that write happens downstream) and, for
515                        // direct deps, never gets pushed into the
516                        // importer's direct-dep list.
517                        if override_spec == "-" {
518                            tracing::trace!("override: {}@{} -> dropped", task.name, task.range,);
519                            if task.is_root {
520                                note_root_done!();
521                            }
522                            continue 'outer;
523                        }
524                        // An override may itself point at a catalog
525                        // entry (e.g. `"overrides": {"foo": "catalog:"}`).
526                        // The catalog pre-pass above already ran against
527                        // the original range, so resolve the indirection
528                        // here before assigning — otherwise `catalog:`
529                        // leaks through to the registry resolver.
530                        // Stash the catalog pick in a local so we only
531                        // record it if the override actually moves
532                        // `task.range`.
533                        let (effective_spec, pending_pick) =
534                            match self.resolve_catalog_spec(&task.name, &override_spec)? {
535                                Some((catalog_name, real_range)) => {
536                                    (real_range.clone(), Some((catalog_name, real_range)))
537                                }
538                                None => (override_spec, None),
539                            };
540                        if task.range != effective_spec {
541                            if let Some((catalog_name, real_range)) = pending_pick {
542                                catalog_picks
543                                    .entry(catalog_name)
544                                    .or_default()
545                                    .insert(task.name.clone(), real_range);
546                            }
547                            tracing::trace!(
548                                "override: {}@{} -> {}",
549                                task.name,
550                                task.range,
551                                effective_spec
552                            );
553                            // Overrides are declared at the project root,
554                            // so a substituted `link:./libs/x` /
555                            // `file:./vendor/y` path is project-root-
556                            // relative — never importer- or parent-
557                            // relative. Mark the task so the local-source
558                            // branch anchors the path correctly even when
559                            // the consumer is a workspace pkg or a nested
560                            // local parent.
561                            if is_non_registry_specifier(&effective_spec) {
562                                task.range_from_override = true;
563                            }
564                            task.range = effective_spec;
565                            // If the override replaced the spec with a
566                            // bare range (not itself an `npm:` / `jsr:`
567                            // alias), it's targeting `task.name` —
568                            // implicitly undoing any prior alias
569                            // rewrite. Without this, an override that
570                            // fires after a catalog-aliased entry
571                            // (e.g. catalog `js-yaml:
572                            // npm:@zkochan/js-yaml@0.0.11`, override
573                            // `js-yaml@<3.14.2: ^3.14.2`) would keep
574                            // `task.real_name = @zkochan/js-yaml` and
575                            // try to fetch `^3.14.2` from a packument
576                            // that only carries `0.0.x`. If the
577                            // override's value is itself an alias, the
578                            // alias pass below picks up the new target
579                            // on the next loop iteration.
580                            if task.real_name.is_some()
581                                && !task.range.starts_with("npm:")
582                                && !task.range.starts_with("jsr:")
583                            {
584                                task.real_name = None;
585                            }
586                            changed = true;
587                        }
588                    }
589                    if let Some(rest) = task.range.strip_prefix("npm:")
590                        && let Some(at_idx) = rest.rfind('@')
591                    {
592                        let real_name = rest[..at_idx].to_string();
593                        let real_range = rest[at_idx + 1..].to_string();
594                        // Keep `task.name` as the user-facing alias
595                        // (the key the package.json used) and stash
596                        // the registry name on `real_name` so every
597                        // identity-facing site — dep_path formation,
598                        // direct-dep records, parent wiring — sees
599                        // the alias, while only packument/tarball
600                        // fetch sites (via `task.registry_name()`)
601                        // hit the real package. Overwriting
602                        // `task.name` here would collapse
603                        // `node_modules/h3-v2/` to `node_modules/h3/`
604                        // and any `require("h3-v2")` would break.
605                        if task.real_name.as_deref() != Some(real_name.as_str())
606                            || real_range != task.range
607                        {
608                            tracing::trace!(
609                                "npm alias: {} -> {}@{}",
610                                task.name,
611                                real_name,
612                                real_range
613                            );
614                            task.real_name = Some(real_name);
615                            task.range = real_range;
616                            changed = true;
617                        }
618                    }
619                    // `jsr:<range>` and `jsr:<@scope/name>[@<range>]` both
620                    // land here. JSR's npm-compat endpoint serves every
621                    // package under `@jsr/<scope>__<name>`, but the
622                    // user-facing dependency name stays the JSR name (or
623                    // explicit alias) from package.json. Keep `task.name`
624                    // unchanged for dep_path/importer/link identity and
625                    // stash the npm-compat name in `real_name`, matching
626                    // the npm-alias path above. Only registry IO should
627                    // see `@jsr/...`.
628                    if let Some(rest) = task.range.strip_prefix("jsr:") {
629                        let (jsr_name_raw, jsr_range) = if let Some(body) = rest.strip_prefix('@') {
630                            match body.rfind('@') {
631                                Some(rel_at) => {
632                                    // Indices are relative to `body`; add 1 for
633                                    // the `@` we just stripped so we can slice
634                                    // against the original `rest`.
635                                    let at_idx = rel_at + 1;
636                                    (rest[..at_idx].to_string(), rest[at_idx + 1..].to_string())
637                                }
638                                None => (rest.to_string(), "latest".to_string()),
639                            }
640                        } else {
641                            // Bare range form — the manifest key carries the
642                            // JSR name (e.g. `"@std/collections": "jsr:^1"`).
643                            (task.name.clone(), rest.to_string())
644                        };
645                        match aube_registry::jsr::jsr_to_npm_name(&jsr_name_raw) {
646                            Some(npm_name) => {
647                                if task.real_name.as_deref() != Some(npm_name.as_str())
648                                    || jsr_range != task.range
649                                {
650                                    tracing::trace!(
651                                        "jsr: {} -> {}@{}",
652                                        task.name,
653                                        npm_name,
654                                        jsr_range,
655                                    );
656                                    task.real_name = Some(npm_name);
657                                    task.range = jsr_range;
658                                    changed = true;
659                                }
660                            }
661                            None => {
662                                return Err(Error::Registry(
663                                    task.name.clone(),
664                                    format!(
665                                        "invalid jsr: spec `{}` — expected `jsr:@scope/name[@range]`",
666                                        task.range,
667                                    ),
668                                ));
669                            }
670                        }
671                    }
672                    if !changed {
673                        break;
674                    }
675                }
676
677                // Handle file: / link: / git: protocols — the dep points
678                // at a path on disk or a remote git repo rather than a
679                // registry package. Root deps anchor on the importer's
680                // directory; transitive `link:`/`file:` deps anchor on
681                // the parent package's source root, but only when the
682                // parent itself was a `file:`/`link:` source (a workspace
683                // sibling or a directly-linked local dir). Registry-
684                // hosted parents have no on-disk source to resolve a
685                // relative path against, so transitive `link:`/`file:`
686                // from them stays an error.
687                if is_non_registry_specifier(&task.range) {
688                    // Root-declared `pnpm.overrides` opts the user into
689                    // the rewritten `link:`/`file:` target by name, so
690                    // they bypass the exotic-subdep block — otherwise
691                    // an override aimed at a transitive of a registry
692                    // package would always lose to the default-on
693                    // guard.
694                    if !task.range_from_override
695                        && should_block_exotic_subdep(
696                            &task,
697                            &resolved,
698                            self.dependency_policy.block_exotic_subdeps,
699                        )
700                    {
701                        return Err(Error::BlockedExoticSubdep(Box::new(ExoticSubdepDetails {
702                            name: task.name.clone(),
703                            spec: task.range.clone(),
704                            parent: task
705                                .parent
706                                .clone()
707                                .unwrap_or_else(|| "<unknown>".to_string()),
708                            ancestors: task.ancestors.clone(),
709                            importer: task.importer.clone(),
710                        })));
711                    }
712                    // Pull the parent's on-disk source root, when the
713                    // parent is a Directory/Link source. The BFS always
714                    // inserts a parent into `resolved` before enqueuing
715                    // its children, so for transitive tasks the parent
716                    // record is reliably present here.
717                    let parent_source_root: Option<std::path::PathBuf> = (!task.is_root)
718                        .then(|| {
719                            task.parent
720                                .as_ref()
721                                .and_then(|dp| resolved.get(dp))
722                                .and_then(|pkg| pkg.local_source.as_ref())
723                                .and_then(|src| match src {
724                                    LocalSource::Directory(p) | LocalSource::Link(p) => {
725                                        Some(self.project_root.join(p))
726                                    }
727                                    _ => None,
728                                })
729                        })
730                        .flatten();
731                    // Override-substituted link:/file: paths are
732                    // project-root-relative regardless of where the
733                    // consumer lives — pin them at the root before any
734                    // importer/parent fallback wins.
735                    let importer_root = if task.range_from_override {
736                        self.project_root.clone()
737                    } else {
738                        parent_source_root.clone().unwrap_or_else(|| {
739                            if task.importer == "." {
740                                self.project_root.clone()
741                            } else {
742                                self.project_root.join(&task.importer)
743                            }
744                        })
745                    };
746                    let Some(raw_local) = LocalSource::parse(&task.range, &importer_root) else {
747                        return Err(Error::Registry(
748                            task.name.clone(),
749                            format!("unparseable local specifier: {}", task.range),
750                        ));
751                    };
752                    // Git and remote-tarball specifiers don't reference
753                    // a path, so they pass through regardless of parent
754                    // shape. `link:`/`file:` transitives only resolve
755                    // when we either (a) located a parent source root
756                    // or (b) inherited the path from a project-root-
757                    // anchored override.
758                    if !task.is_root
759                        && parent_source_root.is_none()
760                        && !task.range_from_override
761                        && matches!(
762                            raw_local,
763                            LocalSource::Directory(_)
764                                | LocalSource::Tarball(_)
765                                | LocalSource::Link(_)
766                        )
767                    {
768                        return Err(Error::Registry(
769                            task.name.clone(),
770                            format!(
771                                "transitive local specifier {} cannot be resolved without the parent package source root",
772                                task.range
773                            ),
774                        ));
775                    }
776                    let (local, real_version, target_deps) = if let LocalSource::Git(ref g) =
777                        raw_local
778                    {
779                        let shallow = aube_store::git_host_in_list(&g.url, &self.git_shallow_hosts);
780                        let (resolved_local, version, deps) =
781                            resolve_git_source(&task.name, g, shallow, Some(self.client.as_ref()))
782                                .await
783                                .map_err(|e| {
784                                    Error::Registry(
785                                        task.name.clone(),
786                                        format!("git resolve {}: {e}", task.range),
787                                    )
788                                })?;
789                        (resolved_local, version, deps)
790                    } else if let LocalSource::RemoteTarball(ref t) = raw_local {
791                        let (resolved_local, version, deps) =
792                            resolve_remote_tarball(&task.name, t, self.client.as_ref())
793                                .await
794                                .map_err(|e| {
795                                    Error::Registry(
796                                        task.name.clone(),
797                                        format!("remote tarball {}: {e}", task.range),
798                                    )
799                                })?;
800                        (resolved_local, version, deps)
801                    } else {
802                        // Rewrite the path to be relative to the
803                        // project root so every downstream consumer
804                        // can resolve it with a single
805                        // `project_root.join(rel)`.
806                        let local = rebase_local(&raw_local, &importer_root, &self.project_root);
807                        let (_target_name, version, deps) =
808                            read_local_manifest(&raw_local, &importer_root).unwrap_or_else(|_| {
809                                (task.name.clone(), "0.0.0".to_string(), BTreeMap::new())
810                            });
811                        (local, version, deps)
812                    };
813                    let dep_path = local.dep_path(&task.name);
814                    let linked_name = task.name.clone();
815
816                    if task.is_root
817                        && let Some(deps) = importers.get_mut(&task.importer)
818                    {
819                        deps.push(DirectDep {
820                            name: task.name.clone(),
821                            dep_path: dep_path.clone(),
822                            dep_type: task.dep_type,
823                            specifier: task.original_specifier.clone(),
824                        });
825                    }
826
827                    // Wire parent -> this exotic transitive. Without
828                    // this, the parent snapshot's `dependencies` map
829                    // omits the git/url/file subdep entirely, so the
830                    // linker never creates the sibling symlink inside
831                    // the parent's node_modules and the package fails
832                    // to resolve at runtime. The value is the dep_path
833                    // tail (e.g. `git+<hash>`) so the linker can
834                    // reconstruct the full dep_path by concatenating
835                    // `{name}@{value}` — matching the key format used
836                    // when inserting the resolved package below.
837                    if let Some(ref parent_dp) = task.parent
838                        && let Some(parent_pkg) = resolved.get_mut(parent_dp)
839                    {
840                        // `local.dep_path(name)` always returns
841                        // `{name}@{tail}`; if that invariant ever
842                        // breaks we'd silently store a malformed dep
843                        // value that the pnpm writer would emit as-is.
844                        let name_prefix = format!("{}@", task.name);
845                        debug_assert!(
846                            dep_path.starts_with(&name_prefix),
847                            "local.dep_path returned {dep_path:?} without expected prefix {name_prefix:?}"
848                        );
849                        let dep_tail = dep_path
850                            .strip_prefix(&name_prefix)
851                            .unwrap_or(&dep_path)
852                            .to_string();
853                        parent_pkg
854                            .dependencies
855                            .insert(task.name.clone(), dep_tail.clone());
856                        if task.dep_type == DepType::Optional {
857                            parent_pkg
858                                .optional_dependencies
859                                .insert(task.name.clone(), dep_tail);
860                        }
861                    }
862
863                    if visited.insert(std::sync::Arc::from(dep_path.as_str())) {
864                        resolved.insert(
865                            dep_path.clone(),
866                            LockedPackage {
867                                name: linked_name.clone(),
868                                version: real_version.clone(),
869                                dep_path: dep_path.clone(),
870                                local_source: Some(local.clone()),
871                                ..Default::default()
872                            },
873                        );
874                        if let Some(ref tx) = self.resolved_tx {
875                            let _ = tx
876                                .send(ResolvedPackage {
877                                    dep_path: dep_path.clone(),
878                                    name: linked_name.clone(),
879                                    version: real_version.clone(),
880                                    integrity: None,
881                                    tarball_url: None,
882                                    // local_source deps aren't aliased —
883                                    // `file:`/`link:` specifiers go
884                                    // through the local-source branch,
885                                    // not the `npm:` rewrite.
886                                    alias_of: None,
887                                    local_source: Some(local.clone()),
888                                    // Local `file:`/`link:` packages never
889                                    // carry npm-style platform constraints
890                                    // — they're whatever the user points
891                                    // at, so the fetch coordinator treats
892                                    // them as unconstrained (always fetch).
893                                    os: aube_lockfile::PlatformList::new(),
894                                    cpu: aube_lockfile::PlatformList::new(),
895                                    libc: aube_lockfile::PlatformList::new(),
896                                    deprecated: None,
897                                    unpacked_size: None,
898                                })
899                                .await;
900                        }
901                        // Enqueue transitive deps of the local package
902                        // (directories + tarballs only — `link:` deps
903                        // are fully the target's responsibility).
904                        if !matches!(local, LocalSource::Link(_)) {
905                            let mut child_ancestors = task.ancestors.clone();
906                            child_ancestors.push((linked_name.clone(), real_version.clone()));
907                            for (child_name, child_range) in target_deps {
908                                queue.push_back(ResolveTask::transitive(
909                                    child_name,
910                                    child_range,
911                                    DepType::Production,
912                                    dep_path.clone(),
913                                    task.importer.clone(),
914                                    child_ancestors.clone(),
915                                ));
916                            }
917                        }
918                    }
919                    if task.is_root {
920                        note_root_done!();
921                    }
922                    continue;
923                }
924
925                // Handle workspace linkage. Two cases resolve to the
926                // workspace package rather than the registry:
927                //   1. Explicit `workspace:` protocol (pnpm/yarn-berry
928                //      style). The range after the prefix is accepted
929                //      unconditionally — the user asserted this should
930                //      link.
931                //   2. Bare semver range whose name matches a workspace
932                //      package whose version satisfies the range. This
933                //      is the yarn-v1 / npm / bun default: siblings pin
934                //      each other with normal version strings and
935                //      expect the workspace to win over the registry.
936                //      A workspace is typically either unpublished or
937                //      is itself the source of truth for its name, so
938                //      preferring the local copy matches every other
939                //      mainstream pm.
940                if let Some(ws_version) = workspace_packages.get(&task.name)
941                    && (match task.range.strip_prefix("workspace:") {
942                        // workspace:*, workspace:^, workspace:~
943                        // bind to whatever local workspace version is.
944                        // These are pnpm's "don't pin me, just track
945                        // local" sigils. Match them before range check.
946                        Some("" | "*" | "^" | "~") => true,
947                        // workspace:<range> like workspace:^2.0.0 or
948                        // workspace:1.x. Must still satisfy local
949                        // version. Before this fix, any workspace:
950                        // prefix short-circuited. Consumer could pin
951                        // workspace:^2 against local 1.0.0 and aube
952                        // would silently link the wrong version.
953                        // pnpm errors here with no-matching-version.
954                        Some(rest) => version_satisfies(ws_version, rest),
955                        // Bare semver (no workspace: prefix) path.
956                        // Linker walks up to workspace yarn-v1 style.
957                        // Special case `*` and `""` (bare catch-all)
958                        // to always match the workspace copy, even
959                        // when the ws version is a prerelease like
960                        // `0.0.0-0` which semver strict rules would
961                        // otherwise exclude. Placeholder versions
962                        // are common in fresh changesets-managed
963                        // workspaces and would silently fall through
964                        // to registry resolution otherwise, picking
965                        // up a stale published build instead of the
966                        // local source.
967                        None if task.range.is_empty() || task.range == "*" => true,
968                        None => version_satisfies(ws_version, &task.range),
969                    })
970                {
971                    let dep_path = dep_path_for(&task.name, ws_version);
972                    if task.is_root
973                        && let Some(deps) = importers.get_mut(&task.importer)
974                    {
975                        deps.push(DirectDep {
976                            name: task.name.clone(),
977                            dep_path: dep_path.clone(),
978                            dep_type: task.dep_type,
979                            specifier: task.original_specifier.clone(),
980                        });
981                    }
982                    if let Some(ref parent_dp) = task.parent
983                        && let Some(parent_pkg) = resolved.get_mut(parent_dp)
984                    {
985                        parent_pkg
986                            .dependencies
987                            .insert(task.name.clone(), ws_version.clone());
988                        if task.dep_type == DepType::Optional {
989                            parent_pkg
990                                .optional_dependencies
991                                .insert(task.name.clone(), ws_version.clone());
992                        }
993                    }
994                    if task.is_root {
995                        note_root_done!();
996                    }
997                    continue;
998                }
999
1000                // Sibling dedupe. If another task for this same name
1001                // has already settled on a version that satisfies
1002                // this task's range, wire up to that resolution and
1003                // short-circuit. In the old wave code this check
1004                // lived in the post-fetch loop as `existing_match`;
1005                // in the pipelined loop we run it up front so
1006                // dedupable tasks never block on a fetch or a
1007                // lockfile scan.
1008                if let Some(matched_ver) = resolved_versions.get(&task.name).and_then(|versions| {
1009                    versions
1010                        .iter()
1011                        .find(|v| {
1012                            version_satisfies(v, &task.range)
1013                                && !is_vulnerable(task.registry_name(), v, &self.vulnerable_ranges)
1014                        })
1015                        .cloned()
1016                }) {
1017                    let dep_path = dep_path_for(&task.name, &matched_ver);
1018                    if task.is_root
1019                        && let Some(deps) = importers.get_mut(&task.importer)
1020                    {
1021                        deps.push(DirectDep {
1022                            name: task.name.clone(),
1023                            dep_path: dep_path.clone(),
1024                            dep_type: task.dep_type,
1025                            specifier: task.original_specifier.clone(),
1026                        });
1027                    }
1028                    if let Some(ref parent_dp) = task.parent
1029                        && let Some(parent_pkg) = resolved.get_mut(parent_dp)
1030                    {
1031                        parent_pkg
1032                            .dependencies
1033                            .insert(task.name.clone(), matched_ver.clone());
1034                        if task.dep_type == DepType::Optional {
1035                            parent_pkg
1036                                .optional_dependencies
1037                                .insert(task.name.clone(), matched_ver);
1038                        }
1039                    }
1040                    if task.is_root {
1041                        note_root_done!();
1042                    }
1043                    continue;
1044                }
1045
1046                // Lockfile reuse. Runs unconditionally after sibling
1047                // dedupe fails — the old code gated this behind a
1048                // `cache.contains_key` check, but in the pipelined
1049                // loop the cache is populated incrementally and the
1050                // gate was a false optimization.
1051                {
1052                    if let Some(locked_pkg) = existing.and_then(|g| {
1053                        g.packages.values().find(|p| {
1054                            p.name == task.name
1055                                && version_satisfies(&p.version, &task.range)
1056                                && !is_vulnerable(
1057                                    task.registry_name(),
1058                                    &p.version,
1059                                    &self.vulnerable_ranges,
1060                                )
1061                        })
1062                    }) {
1063                        // Drop optional deps whose platform constraints
1064                        // don't match the active host / supported set.
1065                        // This is the path that handles frozen/lockfile
1066                        // installs on a different machine than the one
1067                        // that wrote the lockfile.
1068                        if task.dep_type == DepType::Optional
1069                            && !is_supported(
1070                                &locked_pkg.os,
1071                                &locked_pkg.cpu,
1072                                &locked_pkg.libc,
1073                                &self.supported_architectures,
1074                            )
1075                        {
1076                            tracing::debug!(
1077                                "skipping optional dep {}@{}: platform mismatch",
1078                                task.name,
1079                                locked_pkg.version
1080                            );
1081                            if task.is_root
1082                                && let Some(spec) = task.original_specifier.as_ref()
1083                            {
1084                                skipped_optional_dependencies
1085                                    .entry(task.importer.clone())
1086                                    .or_default()
1087                                    .insert(task.name.clone(), spec.clone());
1088                            }
1089                            if task.is_root {
1090                                note_root_done!();
1091                            }
1092                            continue;
1093                        }
1094                        let version = locked_pkg.version.clone();
1095                        let dep_path = dep_path_for(&task.name, &version);
1096
1097                        if task.is_root
1098                            && let Some(deps) = importers.get_mut(&task.importer)
1099                        {
1100                            deps.push(DirectDep {
1101                                name: task.name.clone(),
1102                                dep_path: dep_path.clone(),
1103                                dep_type: task.dep_type,
1104                                specifier: task.original_specifier.clone(),
1105                            });
1106                        }
1107                        if let Some(ref parent_dp) = task.parent
1108                            && let Some(parent_pkg) = resolved.get_mut(parent_dp)
1109                        {
1110                            parent_pkg
1111                                .dependencies
1112                                .insert(task.name.clone(), version.clone());
1113                            if task.dep_type == DepType::Optional {
1114                                parent_pkg
1115                                    .optional_dependencies
1116                                    .insert(task.name.clone(), version.clone());
1117                            }
1118                        }
1119                        if visited.insert(std::sync::Arc::from(dep_path.as_str())) {
1120                            resolved_versions
1121                                .entry(task.name.clone())
1122                                .or_default()
1123                                .push(version.clone());
1124
1125                            // Carry any round-tripped publish time
1126                            // forward so (a) the cutoff computation at
1127                            // the end of wave 0 can see reused directs
1128                            // alongside freshly-resolved ones and
1129                            // (b) the next lockfile write preserves the
1130                            // existing `time:` entry even when this
1131                            // install reuses the locked version without
1132                            // re-fetching a packument.
1133                            if self.should_record_times()
1134                                && let Some(g) = existing
1135                                && let Some(t) = g.times.get(&dep_path)
1136                            {
1137                                resolved_times.insert(dep_path.clone(), t.clone());
1138                            }
1139
1140                            if let Some(ref tx) = self.resolved_tx {
1141                                let _ = tx
1142                                    .send(ResolvedPackage {
1143                                        dep_path: dep_path.clone(),
1144                                        name: task.name.clone(),
1145                                        version: version.clone(),
1146                                        integrity: locked_pkg.integrity.clone(),
1147                                        tarball_url: locked_pkg.tarball_url.clone(),
1148                                        // Carry the alias identity
1149                                        // through the reuse path — the
1150                                        // existing `locked_pkg` already
1151                                        // records it if the lockfile held
1152                                        // an aliased entry, so the
1153                                        // streaming fetch still hits the
1154                                        // real registry name.
1155                                        alias_of: locked_pkg.alias_of.clone(),
1156                                        local_source: locked_pkg.local_source.clone(),
1157                                        os: locked_pkg.os.clone(),
1158                                        cpu: locked_pkg.cpu.clone(),
1159                                        libc: locked_pkg.libc.clone(),
1160                                        // Lockfile reuse skips the packument
1161                                        // fetch, so we have no deprecation
1162                                        // message to forward here. The
1163                                        // `aube deprecations` command re-queries
1164                                        // packuments live for the
1165                                        // after-the-fact view.
1166                                        deprecated: None,
1167                                        // Same reasoning: lockfile reuse
1168                                        // doesn't refetch the packument and
1169                                        // LockedPackage doesn't carry size
1170                                        // metadata, so the size-estimate
1171                                        // segment stays absent for these
1172                                        // packages. The progress UI displays
1173                                        // a running download total instead
1174                                        // when the estimate is unavailable.
1175                                        unpacked_size: None,
1176                                    })
1177                                    .await;
1178                            }
1179
1180                            // Carry declared peer deps forward from the
1181                            // existing lockfile so subsequent peer-context
1182                            // computation sees them without a re-fetch.
1183                            resolved.insert(
1184                                dep_path.clone(),
1185                                LockedPackage {
1186                                    name: task.name.clone(),
1187                                    version: version.clone(),
1188                                    integrity: locked_pkg.integrity.clone(),
1189                                    dependencies: BTreeMap::new(),
1190                                    optional_dependencies: BTreeMap::new(),
1191                                    peer_dependencies: locked_pkg.peer_dependencies.clone(),
1192                                    peer_dependencies_meta: locked_pkg
1193                                        .peer_dependencies_meta
1194                                        .clone(),
1195                                    dep_path: dep_path.clone(),
1196                                    local_source: locked_pkg.local_source.clone(),
1197                                    os: locked_pkg.os.clone(),
1198                                    cpu: locked_pkg.cpu.clone(),
1199                                    libc: locked_pkg.libc.clone(),
1200                                    bundled_dependencies: locked_pkg.bundled_dependencies.clone(),
1201                                    optional: locked_pkg.optional,
1202                                    transitive_peer_dependencies: locked_pkg
1203                                        .transitive_peer_dependencies
1204                                        .clone(),
1205                                    tarball_url: locked_pkg.tarball_url.clone(),
1206                                    alias_of: locked_pkg.alias_of.clone(),
1207                                    yarn_checksum: locked_pkg.yarn_checksum.clone(),
1208                                    engines: locked_pkg.engines.clone(),
1209                                    bin: locked_pkg.bin.clone(),
1210                                    declared_dependencies: locked_pkg.declared_dependencies.clone(),
1211                                    license: locked_pkg.license.clone(),
1212                                    funding_url: locked_pkg.funding_url.clone(),
1213                                    extra_meta: locked_pkg.extra_meta.clone(),
1214                                },
1215                            );
1216
1217                            // Enqueue transitive deps from the locked package.
1218                            // Strip any peer-context suffix off the version
1219                            // before treating it as a semver range — a
1220                            // locked `"18.2.0(react@18.2.0)"` tail should
1221                            // match against packuments as just `18.2.0`.
1222                            // Also strip a leading `name@` if present:
1223                            // bun/yarn parsers store transitive deps in
1224                            // `name@version` (full dep_path) form, while
1225                            // pnpm stores bare versions. Without the
1226                            // strip, a yarn/bun-locked `is-odd` would
1227                            // emit a transitive task for is-number with
1228                            // range `"is-number@6.0.0"`, which doesn't
1229                            // parse as semver and fails resolution.
1230                            // The lockfile already omitted bundled dep
1231                            // edges on write, so iterating
1232                            // `locked_pkg.dependencies` naturally skips them.
1233                            let mut child_ancestors = task.ancestors.clone();
1234                            child_ancestors.push((task.name.clone(), version.clone()));
1235                            for (dep_name, dep_version) in &locked_pkg.dependencies {
1236                                let prefix = format!("{dep_name}@");
1237                                let stripped =
1238                                    dep_version.strip_prefix(&prefix).unwrap_or(dep_version);
1239                                let canonical_version =
1240                                    stripped.split('(').next().unwrap_or(stripped).to_string();
1241                                let dep_type =
1242                                    if locked_pkg.optional_dependencies.contains_key(dep_name) {
1243                                        DepType::Optional
1244                                    } else {
1245                                        DepType::Production
1246                                    };
1247                                queue.push_back(ResolveTask::transitive(
1248                                    dep_name.clone(),
1249                                    canonical_version,
1250                                    dep_type,
1251                                    dep_path.clone(),
1252                                    task.importer.clone(),
1253                                    child_ancestors.clone(),
1254                                ));
1255                            }
1256                        }
1257                        lockfile_reuse_count += 1;
1258                        if task.is_root {
1259                            note_root_done!();
1260                        }
1261                        continue;
1262                    }
1263                }
1264
1265                // Packument not in cache. Spawn its fetch if one
1266                // isn't already running, then wait for packument
1267                // fetches to land until this task's packument is
1268                // available. Other fetches that happen to complete
1269                // while we're waiting get cached opportunistically,
1270                // which is exactly what lets the pipeline overlap
1271                // network and CPU: by the time a later task is
1272                // popped its packument is usually already sitting
1273                // in the cache because it landed while an earlier
1274                // task was being waited on.
1275                let wait_start = std::time::Instant::now();
1276                // Cache is keyed by the *registry* name — for aliased
1277                // tasks `task.name` is the user-facing alias (e.g.
1278                // `h3-v2`), which would never hit. `registry_name()`
1279                // returns the alias-resolved target (`h3`) on
1280                // aliased tasks and `task.name` otherwise.
1281                let fetch_name = task.registry_name().to_string();
1282                while !self.cache.contains_key(&fetch_name) {
1283                    ensure_fetch!(&fetch_name);
1284                    match in_flight.join_next().await {
1285                        Some(Ok(Ok((name, packument, from_primer)))) => {
1286                            in_flight_names.remove(&name);
1287                            if from_primer {
1288                                primer_seeded_names.insert(name.clone());
1289                            }
1290                            self.cache.insert(name, packument);
1291                            packument_fetch_count += 1;
1292                        }
1293                        Some(Ok(Err(e))) => return Err(e),
1294                        Some(Err(join_err)) => {
1295                            return Err(Error::Registry(
1296                                "(join)".to_string(),
1297                                join_err.to_string(),
1298                            ));
1299                        }
1300                        None => {
1301                            // ensure_fetch! guarantees something is
1302                            // in flight if the cache still doesn't
1303                            // hold this name, so a None here means
1304                            // the spawn failed silently. Surface it.
1305                            return Err(Error::Registry(
1306                                fetch_name.clone(),
1307                                "packument fetch disappeared before completing".to_string(),
1308                            ));
1309                        }
1310                    }
1311                }
1312                packument_fetch_time += wait_start.elapsed();
1313
1314                // TimeBased wave-0 gate. Transitives that reach
1315                // the version-pick step while the cutoff is still
1316                // unknown must wait until the direct deps have
1317                // been picked and the cutoff has been derived;
1318                // otherwise they'd pick against a `None` cutoff
1319                // and miss the filter. In `Highest` mode (the
1320                // default), `cutoff_pending` starts false and this
1321                // is a no-op.
1322                if cutoff_pending && !task.is_root {
1323                    deferred_transitives.push(task);
1324                    continue;
1325                }
1326
1327                // Version-pick + transitive enqueue. Was a separate
1328                // sub-loop over `processed_batch` in the old wave
1329                // code; here it's inline as the tail of the per-task
1330                // pipeline now that we know the packument is in
1331                // cache. `registry_name()` is the cache key for
1332                // aliased tasks (cache is populated under the real
1333                // registry name), so use the same accessor here.
1334                // Find locked version
1335                let locked_version = existing.and_then(|g| {
1336                    g.packages
1337                        .values()
1338                        .find(|p| p.name == task.name && version_satisfies(&p.version, &task.range))
1339                        .map(|p| p.version.as_str())
1340                        .filter(|v| {
1341                            !is_vulnerable(task.registry_name(), v, &self.vulnerable_ranges)
1342                        })
1343                });
1344
1345                // Direct deps in time-based mode pick the lowest
1346                // satisfying version; everything else (transitives,
1347                // and all picks in Highest mode) picks highest.
1348                let pick_lowest = self.resolution_mode == ResolutionMode::TimeBased && task.is_root;
1349                // Apply the cutoff unless this package is on the
1350                // minimumReleaseAge exclude list. The exclude list only
1351                // suppresses the *minimumReleaseAge* leg, not the
1352                // time-based-mode leg — but since we collapse both
1353                // into the same `published_by` string at this point,
1354                // we have to skip the cutoff entirely for excluded
1355                // names. Acceptable: time-based mode and exclude
1356                // lists aren't expected to coexist in the wild.
1357                let cutoff_for_pkg = match self.minimum_release_age.as_ref() {
1358                    Some(mra) if mra.exclude.contains(&task.name) => None,
1359                    _ => published_by.as_deref(),
1360                };
1361                // Strict semantics in two cases:
1362                //   - `minimumReleaseAgeStrict=true` (the user opted in
1363                //     to hard failures), or
1364                //   - the cutoff comes from `--resolution-mode=time-based`
1365                //     alone, with no `minimumReleaseAge` configured. The
1366                //     time-based cutoff is intended as a hard wall — if
1367                //     no version fits, the *correct* fix is for the user
1368                //     to update the lockfile, not for the resolver to
1369                //     silently pick a different version.
1370                let strict = match self.minimum_release_age.as_ref() {
1371                    Some(m) => m.strict,
1372                    None => true,
1373                };
1374                let registry_name = task.registry_name().to_string();
1375                let selected_pick = loop {
1376                    let packument = self.cache.get(&registry_name).ok_or_else(|| {
1377                        Error::Registry(registry_name.clone(), "packument not in cache".to_string())
1378                    })?;
1379                    let pick = pick_version(
1380                        packument,
1381                        &task.range,
1382                        locked_version,
1383                        pick_lowest,
1384                        cutoff_for_pkg,
1385                        strict,
1386                    );
1387                    match pick {
1388                        PickResult::Found(meta) => break meta.clone(),
1389                        PickResult::AgeGated | PickResult::NoMatch
1390                            if primer_seeded_names.remove(&registry_name) =>
1391                        {
1392                            let fetch_start = std::time::Instant::now();
1393                            let live = if needs_time {
1394                                match self.packument_full_cache_dir.as_ref() {
1395                                    Some(dir) => {
1396                                        self.client
1397                                            .fetch_packument_with_time_cached(&registry_name, dir)
1398                                            .await
1399                                    }
1400                                    None => self.client.fetch_packument(&registry_name).await,
1401                                }
1402                            } else {
1403                                match self.client.fetch_packument(&registry_name).await {
1404                                    Ok(live) => {
1405                                        if let Some(dir) = self.packument_cache_dir.as_ref() {
1406                                            self.client.replace_packument_cache(
1407                                                &registry_name,
1408                                                dir,
1409                                                &live,
1410                                            );
1411                                        }
1412                                        Ok(live)
1413                                    }
1414                                    Err(err) => Err(err),
1415                                }
1416                            }
1417                            .map_err(|e| Error::Registry(registry_name.clone(), e.to_string()))?;
1418                            packument_fetch_time += fetch_start.elapsed();
1419                            packument_fetch_count += 1;
1420                            self.cache.insert(registry_name.clone(), live);
1421                        }
1422                        // Only surface `AgeGate` when the cutoff actually
1423                        // came from `minimumReleaseAge`. When it came from
1424                        // `--resolution-mode=time-based` alone, the user
1425                        // never opted into the supply-chain age gate, so
1426                        // the failure should report as a plain no-match
1427                        // instead of a misleading "older than 0 minutes".
1428                        PickResult::AgeGated => match self.minimum_release_age.as_ref() {
1429                            Some(mra) => {
1430                                return Err(Error::AgeGate(Box::new(error::build_age_gate(
1431                                    &task,
1432                                    packument,
1433                                    mra.minutes,
1434                                ))));
1435                            }
1436                            None => {
1437                                return Err(Error::NoMatch(Box::new(error::build_no_match(
1438                                    &task, packument,
1439                                ))));
1440                            }
1441                        },
1442                        PickResult::NoMatch => {
1443                            return Err(Error::NoMatch(Box::new(error::build_no_match(
1444                                &task, packument,
1445                            ))));
1446                        }
1447                    }
1448                };
1449                let packument = self.cache.get(&registry_name).ok_or_else(|| {
1450                    Error::Registry(registry_name.clone(), "packument not in cache".to_string())
1451                })?;
1452                let picked_ref = prefer_non_vulnerable_pick(
1453                    task.registry_name(),
1454                    packument,
1455                    &task.range,
1456                    &selected_pick,
1457                    pick_lowest,
1458                    cutoff_for_pkg,
1459                    &self.vulnerable_ranges,
1460                );
1461                // Trust-policy enforcement runs *before* any other
1462                // post-pick processing (mirrors pnpm's placement
1463                // immediately after `pickPackage`). Skip when policy is
1464                // off so the off-by-default case is a single enum
1465                // compare. The check needs the live packument's `time`
1466                // map and all version metadata, both of which are still
1467                // in scope here from L1191.
1468                if self.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade {
1469                    crate::trust::check_no_downgrade(
1470                        packument,
1471                        &picked_ref.version,
1472                        picked_ref,
1473                        &self.dependency_policy.trust_policy_exclude,
1474                        self.dependency_policy.trust_policy_ignore_after,
1475                    )
1476                    .map_err(|e| match e {
1477                        crate::trust::TrustCheckError::Downgrade(d) => {
1478                            Error::TrustDowngrade(Box::new(d))
1479                        }
1480                        crate::trust::TrustCheckError::MissingTime(d) => {
1481                            Error::TrustCheckMissingTime(Box::new(d))
1482                        }
1483                    })?;
1484                }
1485
1486                // Clone the picked metadata into an owned value so we can
1487                // both run the `readPackage` hook (which needs a
1488                // disjoint `&mut self` borrow) and, later, mutate the
1489                // resolver's own caches without holding a borrow into
1490                // `self.cache`. Also grab the publish-time entry now,
1491                // for the same reason.
1492                let mut picked_owned = picked_ref.clone();
1493                let picked_publish_time = packument.time.get(&picked_ref.version).cloned();
1494                // Skip the readPackage hook entirely for a `(name, version)`
1495                // pair we've already fully processed via a prior task. The
1496                // mutated dep maps only drive the transitive enqueue below,
1497                // and that block is short-circuited by the `visited` guard
1498                // later in this iteration — so running the hook here would
1499                // just burn an IPC round-trip whose result is discarded.
1500                let prehook_dep_path = dep_path_for(&task.name, &picked_ref.version);
1501                let already_visited = visited.contains(prehook_dep_path.as_str());
1502
1503                if !already_visited {
1504                    apply_package_extensions(
1505                        &mut picked_owned,
1506                        &self.dependency_policy.package_extensions,
1507                    );
1508                }
1509
1510                // readPackage hook. Runs at most once per version-picked
1511                // package, before transitive enqueue. We honor edits to
1512                // the four dep maps and warn on (then discard) edits to
1513                // name/version/dist/platform/`hasInstallScript` — pnpm
1514                // tolerates readPackage returning a hollowed-out
1515                // object, so we restore those fields from the original
1516                // packument entry after the call.
1517                if !already_visited && let Some(hook) = self.read_package_hook.as_mut() {
1518                    let before_name = picked_owned.name.clone();
1519                    let before_version = picked_owned.version.clone();
1520                    let before_dist = picked_owned.dist.clone();
1521                    let before_os = picked_owned.os.clone();
1522                    let before_cpu = picked_owned.cpu.clone();
1523                    let before_libc = picked_owned.libc.clone();
1524                    let before_bundled = picked_owned.bundled_dependencies.clone();
1525                    let before_has_install_script = picked_owned.has_install_script;
1526                    let before_deprecated = picked_owned.deprecated.clone();
1527                    let input = picked_owned.clone();
1528                    let mut after = hook.read_package(input).await.map_err(|e| {
1529                        Error::Registry(before_name.clone(), format!("readPackage hook: {e}"))
1530                    })?;
1531                    if after.name != before_name || after.version != before_version {
1532                        tracing::warn!(
1533                            code = aube_codes::warnings::WARN_AUBE_HOOK_IDENTITY_REWRITTEN,
1534                            "[pnpmfile] readPackage rewrote {}@{} identity to {}@{}; \
1535                             aube ignores identity edits",
1536                            before_name,
1537                            before_version,
1538                            after.name,
1539                            after.version,
1540                        );
1541                    }
1542                    after.name = before_name;
1543                    after.version = before_version;
1544                    after.dist = before_dist;
1545                    after.os = before_os;
1546                    after.cpu = before_cpu;
1547                    after.libc = before_libc;
1548                    after.bundled_dependencies = before_bundled;
1549                    after.has_install_script = before_has_install_script;
1550                    after.deprecated = before_deprecated;
1551                    picked_owned = after;
1552                }
1553                let version_meta = &picked_owned;
1554
1555                // Optional deps that don't match the host platform get
1556                // silently dropped — pnpm parity. Required deps with a
1557                // bad platform still get installed; the warning matches
1558                // pnpm's `packageIsInstallable` behavior.
1559                let platform_ok = is_supported(
1560                    &version_meta.os,
1561                    &version_meta.cpu,
1562                    &version_meta.libc,
1563                    &self.supported_architectures,
1564                );
1565                if !platform_ok {
1566                    if task.dep_type == DepType::Optional {
1567                        tracing::debug!(
1568                            "skipping optional dep {}@{}: unsupported platform (os={:?} cpu={:?} libc={:?})",
1569                            task.name,
1570                            version_meta.version,
1571                            version_meta.os,
1572                            version_meta.cpu,
1573                            version_meta.libc
1574                        );
1575                        if task.is_root
1576                            && let Some(spec) = task.original_specifier.as_ref()
1577                        {
1578                            skipped_optional_dependencies
1579                                .entry(task.importer.clone())
1580                                .or_default()
1581                                .insert(task.name.clone(), spec.clone());
1582                        }
1583                        if task.is_root {
1584                            note_root_done!();
1585                        }
1586                        continue;
1587                    }
1588                    tracing::warn!(
1589                        code = aube_codes::warnings::WARN_AUBE_UNSUPPORTED_PLATFORM_INSTALL,
1590                        "required dep {}@{} declares unsupported platform (os={:?} cpu={:?} libc={:?}); installing anyway",
1591                        task.name,
1592                        version_meta.version,
1593                        version_meta.os,
1594                        version_meta.cpu,
1595                        version_meta.libc
1596                    );
1597                }
1598
1599                let version = version_meta.version.clone();
1600                let dep_path = dep_path_for(&task.name, &version);
1601
1602                // Record publish time for the cutoff / `time:` block
1603                // whenever the packument carries one — matches pnpm,
1604                // which populates `publishedAt` opportunistically via
1605                // `meta.time?.[version]` regardless of resolution mode.
1606                // Corgi packuments from npmjs.org omit `time`, so in
1607                // Highest mode this is usually a no-op; Verdaccio
1608                // (v5.15.1+) and full-packument fetches do include it,
1609                // and then we round-trip it into the lockfile just like
1610                // pnpm does.
1611                if self.should_record_times()
1612                    && let Some(t) = picked_publish_time.as_ref()
1613                {
1614                    resolved_times.insert(dep_path.clone(), t.clone());
1615                }
1616
1617                // Record root dep
1618                if task.is_root
1619                    && let Some(deps) = importers.get_mut(&task.importer)
1620                {
1621                    deps.push(DirectDep {
1622                        name: task.name.clone(),
1623                        dep_path: dep_path.clone(),
1624                        dep_type: task.dep_type,
1625                        specifier: task.original_specifier.clone(),
1626                    });
1627                }
1628
1629                // Wire parent
1630                if let Some(ref parent_dp) = task.parent
1631                    && let Some(parent_pkg) = resolved.get_mut(parent_dp)
1632                {
1633                    parent_pkg
1634                        .dependencies
1635                        .insert(task.name.clone(), version.clone());
1636                    if task.dep_type == DepType::Optional {
1637                        parent_pkg
1638                            .optional_dependencies
1639                            .insert(task.name.clone(), version.clone());
1640                    }
1641                }
1642
1643                // Skip if already fully processed this exact version
1644                if visited.contains(dep_path.as_str()) {
1645                    if task.is_root {
1646                        note_root_done!();
1647                    }
1648                    continue;
1649                }
1650                visited.insert(std::sync::Arc::from(dep_path.as_str()));
1651
1652                tracing::trace!("resolved {}@{}", task.name, version);
1653
1654                // Forward a deprecation message to the install command,
1655                // subject to `allowedDeprecatedVersions` suppression.
1656                // User-facing rendering is the CLI's job — doing it here
1657                // would fire per resolved version with no way for the
1658                // caller to batch or filter direct-vs-transitive.
1659                let deprecated_msg: Option<Arc<str>> =
1660                    version_meta.deprecated.as_deref().and_then(|msg| {
1661                        let suppressed = is_deprecation_allowed(
1662                            &task.name,
1663                            &version,
1664                            &self.dependency_policy.allowed_deprecated_versions,
1665                        );
1666                        (!suppressed).then(|| Arc::<str>::from(msg))
1667                    });
1668
1669                // Track this version
1670                resolved_versions
1671                    .entry(task.name.clone())
1672                    .or_default()
1673                    .push(version.clone());
1674
1675                let integrity = version_meta.dist.as_ref().and_then(|d| d.integrity.clone());
1676                // Always stash the registry tarball URL on the locked
1677                // package. pnpm / yarn writers gate emission on
1678                // `lockfile_include_tarball_url` (so the pnpm
1679                // round-trip stays byte-identical for projects that
1680                // opted out); the npm writer emits `resolved:` on
1681                // every package entry unconditionally, which is what
1682                // npm itself writes. Carrying the URL on every
1683                // LockedPackage lets both policies work without a
1684                // second packument fetch at write time.
1685                let tarball_url = version_meta.dist.as_ref().map(|d| d.tarball.clone());
1686
1687                // Stream this resolved package for early tarball fetching.
1688                // `alias_of` mirrors what the LockedPackage below
1689                // will carry — the streaming fetch consumer in
1690                // install.rs uses it to derive the real tarball URL
1691                // for aliased packages where `name` alone (`h3-v2`)
1692                // would 404.
1693                if let Some(ref tx) = self.resolved_tx {
1694                    let _ = tx
1695                        .send(ResolvedPackage {
1696                            dep_path: dep_path.clone(),
1697                            name: task.name.clone(),
1698                            version: version.clone(),
1699                            integrity: integrity.clone(),
1700                            tarball_url: tarball_url.clone(),
1701                            alias_of: task.real_name.clone(),
1702                            local_source: None,
1703                            os: version_meta.os.iter().cloned().collect(),
1704                            cpu: version_meta.cpu.iter().cloned().collect(),
1705                            libc: version_meta.libc.iter().cloned().collect(),
1706                            deprecated: deprecated_msg.clone(),
1707                            unpacked_size: version_meta.dist.as_ref().and_then(|d| d.unpacked_size),
1708                        })
1709                        .await;
1710                }
1711
1712                // Capture the declared peer deps now so the post-pass can
1713                // compute each consumer's peer context without re-reading
1714                // the packument.
1715                let peer_deps = version_meta.peer_dependencies.clone();
1716                let peer_meta: BTreeMap<String, aube_lockfile::PeerDepMeta> = version_meta
1717                    .peer_dependencies_meta
1718                    .iter()
1719                    .map(|(k, v)| {
1720                        (
1721                            k.clone(),
1722                            aube_lockfile::PeerDepMeta {
1723                                optional: v.optional,
1724                            },
1725                        )
1726                    })
1727                    .collect();
1728                // `bundledDependencies` names are shipped inside the
1729                // tarball itself and must not be resolved from the
1730                // registry. If we did enqueue them, we'd fetch a
1731                // (possibly different) version and plant a sibling
1732                // symlink inside `.aube/<parent>@ver/node_modules/`
1733                // that would shadow the bundled copy during Node's
1734                // directory walk. Compute the skip set once here and
1735                // store the names on the LockedPackage so restore
1736                // (from lockfile, skipping this code path) also
1737                // knows to avoid the sibling symlinks — see the
1738                // `.dependencies` write-through downstream.
1739                let bundled_names: FxHashSet<String> = version_meta
1740                    .bundled_dependencies
1741                    .as_ref()
1742                    .map(|b| {
1743                        b.names(&version_meta.dependencies)
1744                            .into_iter()
1745                            .map(String::from)
1746                            .collect()
1747                    })
1748                    .unwrap_or_default();
1749
1750                resolved.insert(
1751                    dep_path.clone(),
1752                    LockedPackage {
1753                        name: task.name.clone(),
1754                        version: version.clone(),
1755                        integrity,
1756                        dependencies: BTreeMap::new(),
1757                        optional_dependencies: BTreeMap::new(),
1758                        peer_dependencies: peer_deps,
1759                        peer_dependencies_meta: peer_meta,
1760                        dep_path: dep_path.clone(),
1761                        local_source: None,
1762                        os: version_meta.os.iter().cloned().collect(),
1763                        cpu: version_meta.cpu.iter().cloned().collect(),
1764                        libc: version_meta.libc.iter().cloned().collect(),
1765                        bundled_dependencies: {
1766                            let mut v: Vec<String> = bundled_names.iter().cloned().collect();
1767                            v.sort();
1768                            v
1769                        },
1770                        tarball_url,
1771                        // `name` is the alias for npm-aliased tasks
1772                        // (`"h3-v2": "npm:h3@..."` → name = "h3-v2"),
1773                        // so stash the real registry name here. The
1774                        // lockfile writer + installer consult
1775                        // `alias_of` whenever they need to hit the
1776                        // registry, matching how the npm-lockfile
1777                        // reader populates this field.
1778                        alias_of: task.real_name.clone(),
1779                        yarn_checksum: None,
1780                        engines: version_meta.engines.clone(),
1781                        // Rehydrate a string-form bin (`"bin": "cli.js"`)
1782                        // into `{<package_name>: "cli.js"}` — registry
1783                        // packuments leave the name off, expecting
1784                        // consumers to default it to the package name.
1785                        // Doing it here keeps bun's per-entry meta
1786                        // byte-identical to bun's own output without
1787                        // pushing the fixup into every writer.
1788                        bin: {
1789                            let mut m = version_meta.bin.clone();
1790                            if let Some(path) = m.remove("") {
1791                                // String-form `bin` in a packument
1792                                // (`"bin": "cli.js"`) is implicitly
1793                                // named after the real registry
1794                                // package — not the alias. For an
1795                                // aliased dep (`"h3-v2": "npm:h3@…"`)
1796                                // the bun writer must emit the bin
1797                                // under `h3`, not `h3-v2`, or the
1798                                // map drifts against bun's own
1799                                // output (and the shim install path
1800                                // creates the wrong binary name).
1801                                let bin_name =
1802                                    task.real_name.as_deref().unwrap_or(&task.name).to_string();
1803                                m.insert(bin_name, path);
1804                            }
1805                            m
1806                        },
1807                        // Declared ranges straight from the packument's
1808                        // `dependencies` / `optionalDependencies`. Fed
1809                        // back out by npm / yarn / bun writers so
1810                        // nested package entries keep the original
1811                        // specifiers instead of collapsing to pins.
1812                        declared_dependencies: {
1813                            let mut m = version_meta.dependencies.clone();
1814                            for (k, v) in &version_meta.optional_dependencies {
1815                                m.insert(k.clone(), v.clone());
1816                            }
1817                            m
1818                        },
1819                        license: version_meta.license.clone(),
1820                        funding_url: version_meta.funding_url.clone(),
1821                        optional: false,
1822                        transitive_peer_dependencies: Vec::new(),
1823                        extra_meta: BTreeMap::new(),
1824                    },
1825                );
1826
1827                // Enqueue transitive deps. Kick off a background
1828                // packument fetch the instant we discover the dep
1829                // name — so by the time the task is popped off the
1830                // queue below, its packument is usually already in
1831                // flight (and often already in cache). This is where
1832                // the pipeline overlaps fetches with CPU work without
1833                // any explicit wave barrier.
1834                //
1835                // Compute the child ancestor chain once — the same
1836                // frame (this package's name + resolved version)
1837                // applies to every dep / optionalDep / peer we enqueue
1838                // below.
1839                let mut child_ancestors = task.ancestors.clone();
1840                child_ancestors.push((task.name.clone(), version.clone()));
1841
1842                for (dep_name, dep_range) in &version_meta.dependencies {
1843                    if bundled_names.contains(dep_name) {
1844                        continue;
1845                    }
1846                    if self.dependency_policy.block_exotic_subdeps
1847                        && is_non_registry_specifier(dep_range)
1848                    {
1849                        return Err(Error::Registry(
1850                            dep_name.clone(),
1851                            format!(
1852                                "uses exotic specifier \"{dep_range}\" which is blocked \
1853                                 by blockExoticSubdeps (declared by {})",
1854                                task.name
1855                            ),
1856                        ));
1857                    }
1858                    if !existing_names.contains(dep_name.as_str())
1859                        && prefetchable!(dep_name.as_str(), dep_range.as_str())
1860                    {
1861                        ensure_fetch!(dep_name);
1862                    }
1863                    queue.push_back(ResolveTask::transitive(
1864                        dep_name.clone(),
1865                        dep_range.clone(),
1866                        DepType::Production,
1867                        dep_path.clone(),
1868                        task.importer.clone(),
1869                        child_ancestors.clone(),
1870                    ));
1871                }
1872
1873                for (dep_name, dep_range) in &version_meta.optional_dependencies {
1874                    if bundled_names.contains(dep_name) {
1875                        continue;
1876                    }
1877                    if self.ignored_optional_dependencies.contains(dep_name) {
1878                        continue;
1879                    }
1880                    if self.dependency_policy.block_exotic_subdeps
1881                        && is_non_registry_specifier(dep_range)
1882                    {
1883                        tracing::warn!(
1884                            code = aube_codes::warnings::WARN_AUBE_EXOTIC_SUBDEP_SKIPPED,
1885                            "skipping optional dependency {dep_name} of {} — \
1886                             exotic specifier \"{dep_range}\" blocked by blockExoticSubdeps",
1887                            task.name
1888                        );
1889                        continue;
1890                    }
1891                    if !existing_names.contains(dep_name.as_str())
1892                        && prefetchable!(dep_name.as_str(), dep_range.as_str())
1893                    {
1894                        ensure_fetch!(dep_name);
1895                    }
1896                    queue.push_back(ResolveTask::transitive(
1897                        dep_name.clone(),
1898                        dep_range.clone(),
1899                        DepType::Optional,
1900                        dep_path.clone(),
1901                        task.importer.clone(),
1902                        child_ancestors.clone(),
1903                    ));
1904                }
1905
1906                // Peer dependencies: enqueue only required peers that
1907                // are truly missing from the importer/root scope. The
1908                // post-pass below (`apply_peer_contexts`) computes
1909                // which version each consumer sees, via ancestor
1910                // scope, and assigns peer-suffixed dep_paths.
1911                //
1912                // pnpm's `auto-install-peers=true` fills in missing
1913                // required peers, but it does not install optional peer
1914                // alternatives that the user did not ask for, and it
1915                // does not install a second compatible peer when the
1916                // importer already declares that peer name at an
1917                // incompatible version. In the latter case pnpm keeps
1918                // the user's direct dependency and reports an unmet
1919                // peer warning.
1920                //
1921                // When `auto-install-peers=false`, we skip enqueueing
1922                // peers entirely. Users are on the hook for adding
1923                // them to `package.json` themselves. Unmet peers still
1924                // surface as warnings via `detect_unmet_peers` after
1925                // resolve — in fact more so, since nothing gets
1926                // auto-installed.
1927                //
1928                // Skip peers that are already declared as regular or
1929                // optional deps of the same package — those already have a
1930                // task queued via the loops above, and duplicating would
1931                // just burn a queue slot.
1932                if self.auto_install_peers {
1933                    for (dep_name, dep_range) in &version_meta.peer_dependencies {
1934                        let peer_optional = version_meta
1935                            .peer_dependencies_meta
1936                            .get(dep_name)
1937                            .map(|m| m.optional)
1938                            .unwrap_or(false);
1939                        // Optional peers are opt-in integrations, not
1940                        // auto-install candidates. Users who need one must
1941                        // declare it in their own manifest so the normal dep
1942                        // loops above resolve it explicitly.
1943                        if peer_optional {
1944                            continue;
1945                        }
1946                        let importer_declares_peer = importer_declared_dep_names
1947                            .get(&task.importer)
1948                            .is_some_and(|names| names.contains(dep_name));
1949                        let root_declares_peer = self.resolve_peers_from_workspace_root
1950                            && task.importer != "."
1951                            && importer_declared_dep_names
1952                                .get(".")
1953                                .is_some_and(|names| names.contains(dep_name));
1954                        let peer_dep_is_ancestor =
1955                            task.ancestors.iter().any(|(name, _)| name == dep_name);
1956                        if importer_declares_peer || root_declares_peer || peer_dep_is_ancestor {
1957                            continue;
1958                        }
1959                        if version_meta.dependencies.contains_key(dep_name)
1960                            || version_meta.optional_dependencies.contains_key(dep_name)
1961                            || bundled_names.contains(dep_name)
1962                        {
1963                            continue;
1964                        }
1965                        if self.dependency_policy.block_exotic_subdeps
1966                            && is_non_registry_specifier(dep_range)
1967                        {
1968                            tracing::warn!(
1969                                code = aube_codes::warnings::WARN_AUBE_EXOTIC_SUBDEP_SKIPPED,
1970                                "skipping peer dependency {dep_name} of {} — \
1971                                 exotic specifier \"{dep_range}\" blocked \
1972                                 by blockExoticSubdeps",
1973                                task.name
1974                            );
1975                            continue;
1976                        }
1977                        if !existing_names.contains(dep_name.as_str())
1978                            && prefetchable!(dep_name.as_str(), dep_range.as_str())
1979                        {
1980                            ensure_fetch!(dep_name);
1981                        }
1982                        queue.push_back(ResolveTask::transitive(
1983                            dep_name.clone(),
1984                            dep_range.clone(),
1985                            DepType::Production,
1986                            dep_path.clone(),
1987                            task.importer.clone(),
1988                            child_ancestors.clone(),
1989                        ));
1990                    }
1991                }
1992
1993                // Root task just completed its full version-pick
1994                // path. Decrement the pending-directs counter so
1995                // the TimeBased cutoff trigger at the top of the
1996                // outer loop can fire once wave 0 is resolved.
1997                if task.is_root {
1998                    note_root_done!();
1999                }
2000            }
2001        }
2002
2003        // Drain any remaining in-flight fetches so their tasks get
2004        // cleanly joined. Normally the main loop has harvested every
2005        // spawned fetch by the time the queue drains, but a few may
2006        // still be pending if the resolver short-circuited via
2007        // sibling dedupe or lockfile reuse after ensure_fetch! had
2008        // already spawned them.
2009        while in_flight.join_next().await.is_some() {}
2010
2011        let resolve_elapsed = resolve_start.elapsed();
2012        tracing::debug!(
2013            "resolver: {:.1?} total, {} packuments fetched ({:.1?} wall), {} reused from lockfile, {} packages resolved",
2014            resolve_elapsed,
2015            packument_fetch_count,
2016            packument_fetch_time,
2017            lockfile_reuse_count,
2018            resolved.len()
2019        );
2020
2021        let resolved_catalogs =
2022            catalog::materialize_catalog_picks(catalog_picks, &resolved_versions);
2023
2024        let canonical = LockfileGraph {
2025            importers,
2026            packages: resolved,
2027            settings: aube_lockfile::LockfileSettings {
2028                auto_install_peers: self.auto_install_peers,
2029                exclude_links_from_lockfile: self.exclude_links_from_lockfile,
2030                // Tarball-URL recording is a lockfile-writer concern; the
2031                // resolver never populates URLs itself. Install flips this
2032                // on after the graph is built when the setting is active.
2033                lockfile_include_tarball_url: false,
2034            },
2035            // Stamp the resolver's overrides into the output graph so the
2036            // lockfile writer can round-trip them and the next install's
2037            // drift check can compare them against the manifest.
2038            overrides: self.overrides.clone(),
2039            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
2040            times: resolved_times,
2041            skipped_optional_dependencies,
2042            catalogs: resolved_catalogs,
2043            // Resolver output is format-agnostic; the bun writer layer
2044            // defaults `configVersion` to 1 when emitting a fresh
2045            // lockfile.
2046            bun_config_version: None,
2047            // Fresh resolves don't carry over unknown blocks; the
2048            // install-side merge (`overlay_metadata_from`) copies
2049            // them back from the prior lockfile when round-tripping.
2050            patched_dependencies: BTreeMap::new(),
2051            trusted_dependencies: Vec::new(),
2052            extra_fields: BTreeMap::new(),
2053            workspace_extra_fields: BTreeMap::new(),
2054        };
2055
2056        // Second pass: hoist every auto-installed peer to its importer's
2057        // direct deps so pnpm-style `node_modules/<peer>` top-level
2058        // symlinks get created and the lockfile's `importers.` section
2059        // lists them the way pnpm does with `auto-install-peers=true`.
2060        // Skipped entirely when the setting is off — matches pnpm, which
2061        // leaves the importer's `dependencies` untouched in that mode.
2062        let hoisted = if self.auto_install_peers {
2063            hoist_auto_installed_peers(canonical)
2064        } else {
2065            canonical
2066        };
2067
2068        // Third pass: compute peer-context suffixes for every reachable
2069        // package. See `apply_peer_contexts` for the details.
2070        let peer_options = PeerContextOptions {
2071            dedupe_peer_dependents: self.dedupe_peer_dependents,
2072            dedupe_peers: self.dedupe_peers,
2073            resolve_from_workspace_root: self.resolve_peers_from_workspace_root,
2074            peers_suffix_max_length: self.peers_suffix_max_length,
2075        };
2076        let contextualized = apply_peer_contexts(hoisted, &peer_options)?;
2077        tracing::debug!(
2078            "peer-context pass produced {} contextualized packages",
2079            contextualized.packages.len()
2080        );
2081        Ok(contextualized)
2082    }
2083}
2084
2085fn is_vulnerable(
2086    package_name: &str,
2087    version: &str,
2088    vulnerable_ranges: &BTreeMap<String, Vec<String>>,
2089) -> bool {
2090    let Some(ranges) = vulnerable_ranges.get(package_name) else {
2091        return false;
2092    };
2093    let Ok(version) = node_semver::Version::parse(version) else {
2094        return false;
2095    };
2096    ranges
2097        .iter()
2098        .filter_map(|range| node_semver::Range::parse(range).ok())
2099        .any(|range| version.satisfies(&range))
2100}
2101
2102fn prefer_non_vulnerable_pick<'a>(
2103    package_name: &str,
2104    packument: &'a Packument,
2105    range_str: &str,
2106    fallback: &'a aube_registry::VersionMetadata,
2107    pick_lowest: bool,
2108    cutoff: Option<&str>,
2109    vulnerable_ranges: &BTreeMap<String, Vec<String>>,
2110) -> &'a aube_registry::VersionMetadata {
2111    if !is_vulnerable(package_name, &fallback.version, vulnerable_ranges) {
2112        return fallback;
2113    }
2114    let Ok(range) = node_semver::Range::parse(crate::semver_util::normalize_range(range_str))
2115    else {
2116        return fallback;
2117    };
2118    let passes_cutoff = |ver: &str| -> bool {
2119        let Some(c) = cutoff else { return true };
2120        match packument.time.get(ver) {
2121            Some(t) => t.as_str() <= c,
2122            None => true,
2123        }
2124    };
2125    let mut best: Option<(node_semver::Version, &'a aube_registry::VersionMetadata)> = None;
2126    for (ver_str, meta) in &packument.versions {
2127        let Ok(version) = node_semver::Version::parse(ver_str) else {
2128            continue;
2129        };
2130        if !version.satisfies(&range)
2131            || !passes_cutoff(ver_str)
2132            || is_vulnerable(package_name, ver_str, vulnerable_ranges)
2133        {
2134            continue;
2135        }
2136        let replace = best.as_ref().is_none_or(|(cur, _)| {
2137            if pick_lowest {
2138                version < *cur
2139            } else {
2140                version > *cur
2141            }
2142        });
2143        if replace {
2144            best = Some((version, meta));
2145        }
2146    }
2147    best.map(|(_, meta)| meta).unwrap_or(fallback)
2148}
2149
2150/// Seed the BFS queue with direct deps from every importer manifest.
2151///
2152/// When a package is declared in more than one section
2153/// (`dependencies` + `devDependencies`, etc.) we keep only the
2154/// highest-priority entry — `dependencies` > `devDependencies` >
2155/// `optionalDependencies` — matching pnpm, which silently drops
2156/// the lower-priority duplicates on resolve. Without this the
2157/// same name gets pushed into the importer's `DirectDep` list
2158/// twice (once per section), and the linker's parallel step 2
2159/// races to create the same `node_modules/<name>` symlink from
2160/// two tasks, producing an `EEXIST` on the loser.
2161fn seed_direct_deps(
2162    manifests: &[(String, PackageJson)],
2163    ignored_optional_dependencies: &BTreeSet<String>,
2164    queue: &mut VecDeque<ResolveTask>,
2165    importers: &mut BTreeMap<String, Vec<DirectDep>>,
2166) {
2167    for (importer_path, manifest) in manifests {
2168        importers.insert(importer_path.clone(), Vec::new());
2169
2170        for (name, range) in &manifest.dependencies {
2171            queue.push_back(ResolveTask::root(
2172                name.clone(),
2173                range.clone(),
2174                DepType::Production,
2175                importer_path.clone(),
2176            ));
2177        }
2178        for (name, range) in &manifest.dev_dependencies {
2179            if manifest.dependencies.contains_key(name) {
2180                continue;
2181            }
2182            queue.push_back(ResolveTask::root(
2183                name.clone(),
2184                range.clone(),
2185                DepType::Dev,
2186                importer_path.clone(),
2187            ));
2188        }
2189        for (name, range) in &manifest.optional_dependencies {
2190            if ignored_optional_dependencies.contains(name) {
2191                tracing::debug!(
2192                    "ignoring optional dependency {name} (pnpm.ignoredOptionalDependencies)"
2193                );
2194                continue;
2195            }
2196            if manifest.dependencies.contains_key(name)
2197                || manifest.dev_dependencies.contains_key(name)
2198            {
2199                continue;
2200            }
2201            queue.push_back(ResolveTask::root(
2202                name.clone(),
2203                range.clone(),
2204                DepType::Optional,
2205                importer_path.clone(),
2206            ));
2207        }
2208    }
2209}