Skip to main content

aube_resolver/
resolve.rs

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