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