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