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