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