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