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