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