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