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