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