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