Skip to main content

aube_resolver/
resolve.rs

1mod driver;
2mod fetch;
3mod seed;
4mod vulnerable;
5
6use crate::local_source::is_non_registry_specifier;
7use crate::semver_util::version_satisfies;
8use crate::{
9    Error, FxHashMap, PeerContextOptions, ReadPackageHook, Resolver, apply_peer_contexts, catalog,
10    hoist_auto_installed_peers,
11};
12use aube_lockfile::{DirectDep, LockedPackage, LockfileGraph};
13use aube_manifest::PackageJson;
14use aube_registry::VersionMetadata;
15use std::collections::{BTreeMap, HashMap};
16
17impl Resolver {
18    /// Resolve all dependencies from a package.json.
19    ///
20    /// Uses batch-parallel BFS: each "wave" drains the queue, identifies
21    /// uncached package names, fetches their packuments concurrently, then
22    /// processes the entire batch before starting the next wave.
23    pub async fn resolve(
24        &mut self,
25        manifest: &PackageJson,
26        existing: Option<&LockfileGraph>,
27    ) -> Result<LockfileGraph, Error> {
28        self.resolve_workspace(
29            &[(".".to_string(), manifest.clone())],
30            existing,
31            &HashMap::new(),
32        )
33        .await
34    }
35
36    /// Resolve all dependencies for a workspace (multiple importers).
37    ///
38    /// `manifests` is a list of (importer_path, PackageJson) — e.g. (".", root), ("packages/app", app).
39    /// `workspace_packages` maps package name → version. Used both for
40    /// explicit `workspace:` protocol resolution and for yarn/npm/bun
41    /// style linkage where a bare semver range on a workspace-package
42    /// name resolves to the local copy when its version satisfies the
43    /// range.
44    pub async fn resolve_workspace(
45        &mut self,
46        manifests: &[(String, PackageJson)],
47        existing: Option<&LockfileGraph>,
48        workspace_packages: &HashMap<String, String>,
49    ) -> Result<LockfileGraph, Error> {
50        // Run `readPackage` over each importer's own manifest before
51        // seeding, matching pnpm — which fires the hook on workspace
52        // project manifests, not just resolved registry packages. This
53        // lets a pnpmfile rewrite an importer's own `dependencies` /
54        // `devDependencies` / `optionalDependencies` / `peerDependencies`
55        // (e.g. local `link:` wiring of monorepo packages) before the
56        // resolver walks them. The registry-package hook still runs in
57        // the BFS loop, so a dep *added* by the importer hook is itself
58        // hooked when resolved, just like pnpm.
59        let hooked_manifests = if let Some(hook) = self.read_package_hook.as_deref_mut() {
60            let mut owned = manifests.to_vec();
61            apply_read_package_to_importers(hook, &mut owned).await?;
62            Some(owned)
63        } else {
64            None
65        };
66        let manifests = hooked_manifests.as_deref().unwrap_or(manifests);
67        driver::ResolveDriver::new(self, manifests, existing, workspace_packages)
68            .run()
69            .await
70    }
71
72    /// Is `(name, range)` safe to speculatively prefetch against the
73    /// registry?
74    ///
75    /// Returns false for any spec that won't go through the registry
76    /// resolver at all — workspace/catalog/npm-alias/jsr ranges, local
77    /// (`file:`/`link:`/`git:`) specifiers, and bare ranges that match
78    /// a workspace package. Also false for any name listed in
79    /// `pnpm.overrides`, since the override may rewrite the spec into
80    /// one of the above and we can't cheaply tell ahead of time.
81    fn is_prefetchable(
82        &self,
83        name: &str,
84        range: &str,
85        workspace_packages: &HashMap<String, String>,
86    ) -> bool {
87        let workspace_hit = workspace_packages
88            .get(name)
89            .is_some_and(|ws_v| version_satisfies(ws_v, range));
90        !aube_util::pkg::is_workspace_spec(range)
91            && !aube_util::pkg::is_catalog_spec(range)
92            && !aube_util::pkg::is_npm_spec(range)
93            && !aube_util::pkg::is_jsr_spec(range)
94            && !is_non_registry_specifier(range)
95            && !self.overrides.contains_key(name)
96            && !workspace_hit
97    }
98
99    /// Build the final `LockfileGraph` from accumulated resolver state.
100    ///
101    /// Runs the catalog-pick materialization, hoists auto-installed
102    /// peers when `auto_install_peers` is on, and applies peer-context
103    /// suffixes. Returns the post-peer-context graph ready for lockfile
104    /// emission.
105    fn finalize_resolved_graph(
106        &self,
107        importers: BTreeMap<String, Vec<DirectDep>>,
108        resolved: BTreeMap<String, LockedPackage>,
109        resolved_versions: &FxHashMap<String, Vec<String>>,
110        resolved_times: BTreeMap<String, String>,
111        skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
112        catalog_picks: BTreeMap<String, BTreeMap<String, String>>,
113    ) -> Result<LockfileGraph, Error> {
114        let resolved_catalogs =
115            catalog::materialize_catalog_picks(catalog_picks, resolved_versions);
116
117        let canonical = LockfileGraph {
118            importers,
119            packages: resolved,
120            settings: aube_lockfile::LockfileSettings {
121                auto_install_peers: self.auto_install_peers,
122                exclude_links_from_lockfile: self.exclude_links_from_lockfile,
123                // Tarball-URL recording is a lockfile-writer concern; the
124                // resolver never populates URLs itself. Install flips this
125                // on after the graph is built when the setting is active.
126                lockfile_include_tarball_url: false,
127            },
128            // Stamp the resolver's overrides into the output graph so the
129            // lockfile writer can round-trip them and the next install's
130            // drift check can compare them against the manifest.
131            overrides: self.overrides.clone(),
132            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
133            times: resolved_times,
134            skipped_optional_dependencies,
135            catalogs: resolved_catalogs,
136            // Resolver output is format-agnostic; the bun writer layer
137            // defaults `configVersion` to 1 when emitting a fresh
138            // lockfile.
139            bun_config_version: None,
140            // Fresh resolves don't carry over unknown blocks; the
141            // install-side merge (`overlay_metadata_from`) copies
142            // them back from the prior lockfile when round-tripping.
143            patched_dependencies: BTreeMap::new(),
144            trusted_dependencies: Vec::new(),
145            runtimes: BTreeMap::new(),
146            extra_fields: BTreeMap::new(),
147            workspace_extra_fields: BTreeMap::new(),
148            // pnpm config checksums are an install-flow concern, stamped
149            // onto the graph just before a pnpm-lock.yaml is written.
150            // A fresh resolve leaves them unset.
151            package_extensions_checksum: None,
152            pnpmfile_checksum: None,
153        };
154
155        // Second pass: hoist every auto-installed peer to its importer's
156        // direct deps so pnpm-style `node_modules/<peer>` top-level
157        // symlinks get created and the lockfile's `importers.` section
158        // lists them the way pnpm does with `auto-install-peers=true`.
159        // Skipped entirely when the setting is off — matches pnpm, which
160        // leaves the importer's `dependencies` untouched in that mode.
161        let hoisted = if self.auto_install_peers {
162            hoist_auto_installed_peers(canonical)
163        } else {
164            canonical
165        };
166
167        // Third pass: compute peer-context suffixes for every reachable
168        // package. See `apply_peer_contexts` for the details.
169        let peer_options = PeerContextOptions {
170            dedupe_peer_dependents: self.dedupe_peer_dependents,
171            dedupe_peers: self.dedupe_peers,
172            resolve_from_workspace_root: self.resolve_peers_from_workspace_root,
173            peers_suffix_max_length: self.peers_suffix_max_length,
174        };
175        let _diag_peer =
176            aube_util::diag::Span::new(aube_util::diag::Category::Resolver, "peer_context_apply");
177        let contextualized = apply_peer_contexts(hoisted, &peer_options)?;
178        drop(_diag_peer);
179        tracing::debug!(
180            "peer-context pass produced {} contextualized packages",
181            contextualized.packages.len()
182        );
183        Ok(contextualized)
184    }
185}
186
187/// Apply the project's `readPackage` hook to each importer manifest in
188/// place. Mirrors pnpm, which fires the hook on workspace-project
189/// manifests, not just resolved registry packages. Honored edits are the
190/// dependency maps (`dependencies`, `devDependencies`,
191/// `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta`);
192/// identity (`name`/`version`) edits are ignored — and, like the
193/// registry-package path in the BFS loop, an identity rewrite emits a
194/// `WARN_AUBE_HOOK_IDENTITY_REWRITTEN` warning so the discarded edit isn't
195/// silent.
196async fn apply_read_package_to_importers(
197    hook: &mut dyn ReadPackageHook,
198    manifests: &mut [(String, PackageJson)],
199) -> Result<(), Error> {
200    for (importer_path, manifest) in manifests.iter_mut() {
201        let input = importer_to_version_metadata(manifest, importer_path)?;
202        // Capture the (possibly synthesized) identity we hand the hook so an
203        // attempted rewrite can be reported rather than dropped silently.
204        let before_name = input.name.clone();
205        let before_version = input.version.clone();
206        let after = hook.read_package(input).await.map_err(|e| {
207            Error::Registry(
208                importer_label(importer_path, manifest),
209                format!("readPackage hook: {e}"),
210            )
211        })?;
212        if after.name != before_name || after.version != before_version {
213            tracing::warn!(
214                code = aube_codes::warnings::WARN_AUBE_HOOK_IDENTITY_REWRITTEN,
215                "[pnpmfile] readPackage rewrote importer {}@{} identity to {}@{}; \
216                         aube ignores identity edits",
217                before_name,
218                before_version,
219                after.name,
220                after.version,
221            );
222        }
223        apply_version_metadata_to_importer(manifest, after);
224    }
225    Ok(())
226}
227
228/// Build the `readPackage` hook input for an importer manifest. The hook
229/// wire is [`VersionMetadata`] (the same shape the resolver hands the hook
230/// for registry packages), so the manifest is round-tripped through JSON.
231/// `name`/`version` are required by `VersionMetadata` yet optional on a
232/// manifest (workspace roots routinely omit both) — inject inert defaults
233/// so the conversion can't fail on a nameless root.
234fn importer_to_version_metadata(
235    manifest: &PackageJson,
236    importer_path: &str,
237) -> Result<VersionMetadata, Error> {
238    let mut value = serde_json::to_value(manifest).map_err(|e| {
239        Error::Registry(
240            importer_path.to_string(),
241            format!("readPackage hook: failed to serialize importer manifest: {e}"),
242        )
243    })?;
244    if !value.get("name").is_some_and(serde_json::Value::is_string) {
245        value["name"] = serde_json::Value::String(String::new());
246    }
247    if !value
248        .get("version")
249        .is_some_and(serde_json::Value::is_string)
250    {
251        value["version"] = serde_json::Value::String("0.0.0".to_string());
252    }
253    serde_json::from_value(value).map_err(|e| {
254        Error::Registry(
255            importer_path.to_string(),
256            format!("readPackage hook: failed to build hook input from importer manifest: {e}"),
257        )
258    })
259}
260
261/// Copy the honored dependency-map edits from the hook's returned manifest
262/// back onto the importer. Identity and registry-only fields are ignored.
263fn apply_version_metadata_to_importer(manifest: &mut PackageJson, after: VersionMetadata) {
264    manifest.dependencies = after.dependencies;
265    manifest.dev_dependencies = after.dev_dependencies;
266    manifest.optional_dependencies = after.optional_dependencies;
267    manifest.peer_dependencies = after.peer_dependencies;
268    // `peerDependenciesMeta` has no typed slot on `PackageJson`; it lives
269    // in the flattened `extra` map. Reflect hook edits there so downstream
270    // peer handling sees them, and drop the key when the hook cleared it so
271    // a removal round-trips.
272    if after.peer_dependencies_meta.is_empty() {
273        manifest.extra.remove("peerDependenciesMeta");
274    } else if let Ok(v) = serde_json::to_value(&after.peer_dependencies_meta) {
275        manifest.extra.insert("peerDependenciesMeta".to_string(), v);
276    }
277}
278
279/// Human-readable label for an importer in hook error messages: its
280/// package name when present, else the importer path (`.` for the root).
281fn importer_label(importer_path: &str, manifest: &PackageJson) -> String {
282    match manifest.name.as_deref() {
283        Some(name) if !name.is_empty() => name.to_string(),
284        _ => importer_path.to_string(),
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::future::Future;
292    use std::pin::Pin;
293
294    /// Minimal in-process `readPackage` hook driven by a closure, so the
295    /// importer-hook plumbing can be exercised without spawning a `node`
296    /// child (the real host).
297    struct MockHook<F>(F);
298
299    impl<F> ReadPackageHook for MockHook<F>
300    where
301        F: FnMut(VersionMetadata) -> Result<VersionMetadata, String> + Send,
302    {
303        fn read_package<'a>(
304            &'a mut self,
305            pkg: VersionMetadata,
306        ) -> Pin<Box<dyn Future<Output = Result<VersionMetadata, String>> + Send + 'a>> {
307            let out = (self.0)(pkg);
308            Box::pin(async move { out })
309        }
310    }
311
312    fn manifest(name: Option<&str>) -> PackageJson {
313        PackageJson {
314            name: name.map(str::to_string),
315            ..PackageJson::default()
316        }
317    }
318
319    #[tokio::test]
320    async fn applies_hook_edits_to_importer_self_manifest() {
321        let mut manifests = vec![(".".to_string(), manifest(Some("root-pkg")))];
322        let mut hook = MockHook(|mut pkg: VersionMetadata| {
323            if pkg.name == "root-pkg" {
324                pkg.dependencies
325                    .insert("is-odd".to_string(), "3.0.1".to_string());
326            }
327            Ok(pkg)
328        });
329        apply_read_package_to_importers(&mut hook, &mut manifests)
330            .await
331            .unwrap();
332        assert_eq!(
333            manifests[0]
334                .1
335                .dependencies
336                .get("is-odd")
337                .map(String::as_str),
338            Some("3.0.1")
339        );
340    }
341
342    #[tokio::test]
343    async fn applies_hook_per_importer_in_a_workspace() {
344        // Each workspace member's own manifest is hooked independently —
345        // the rewrite is keyed on the package name the hook is called with.
346        let mut manifests = vec![
347            (".".to_string(), manifest(Some("root"))),
348            ("packages/app".to_string(), manifest(Some("app"))),
349            ("packages/lib".to_string(), manifest(Some("lib"))),
350        ];
351        let mut hook = MockHook(|mut pkg: VersionMetadata| {
352            // Only `app` links a local dep; the others are untouched.
353            if pkg.name == "app" {
354                pkg.dependencies
355                    .insert("@scope/lib".to_string(), "link:../lib".to_string());
356            }
357            Ok(pkg)
358        });
359        apply_read_package_to_importers(&mut hook, &mut manifests)
360            .await
361            .unwrap();
362        assert_eq!(
363            manifests[1]
364                .1
365                .dependencies
366                .get("@scope/lib")
367                .map(String::as_str),
368            Some("link:../lib")
369        );
370        assert!(manifests[0].1.dependencies.is_empty());
371        assert!(manifests[2].1.dependencies.is_empty());
372    }
373
374    #[tokio::test]
375    async fn nameless_root_is_still_passed_to_hook() {
376        // Workspace roots routinely omit `name`/`version`; the hook must
377        // still see (and be able to mutate) the manifest.
378        let mut manifests = vec![(".".to_string(), manifest(None))];
379        let mut hook = MockHook(|mut pkg: VersionMetadata| {
380            pkg.dependencies
381                .insert("marker".to_string(), "1.0.0".to_string());
382            Ok(pkg)
383        });
384        apply_read_package_to_importers(&mut hook, &mut manifests)
385            .await
386            .unwrap();
387        assert!(manifests[0].1.dependencies.contains_key("marker"));
388    }
389
390    #[tokio::test]
391    async fn hook_error_surfaces_as_registry_error() {
392        let mut manifests = vec![(".".to_string(), manifest(Some("x")))];
393        let mut hook = MockHook(|_pkg: VersionMetadata| Err("boom".to_string()));
394        let err = apply_read_package_to_importers(&mut hook, &mut manifests)
395            .await
396            .unwrap_err();
397        match err {
398            Error::Registry(name, msg) => {
399                assert_eq!(name, "x");
400                assert!(msg.contains("readPackage hook"), "got: {msg}");
401                assert!(msg.contains("boom"), "got: {msg}");
402            }
403            other => panic!("expected Registry error, got {other:?}"),
404        }
405    }
406
407    #[tokio::test]
408    async fn importer_identity_rewrite_is_ignored_but_deps_apply() {
409        // A hook that rewrites the importer's identity (name/version) while
410        // also editing deps: the identity edit is discarded (and warned
411        // about, mirroring the registry path), but the dep edit still lands.
412        let mut manifests = vec![(".".to_string(), manifest(Some("orig")))];
413        let mut hook = MockHook(|mut pkg: VersionMetadata| {
414            pkg.name = format!("{}-local", pkg.name);
415            pkg.version = "9.9.9".to_string();
416            pkg.dependencies
417                .insert("is-odd".to_string(), "3.0.1".to_string());
418            Ok(pkg)
419        });
420        apply_read_package_to_importers(&mut hook, &mut manifests)
421            .await
422            .unwrap();
423        // Identity rewrite is ignored — the importer keeps its own name.
424        assert_eq!(manifests[0].1.name.as_deref(), Some("orig"));
425        // The dependency edit is still honored.
426        assert_eq!(
427            manifests[0]
428                .1
429                .dependencies
430                .get("is-odd")
431                .map(String::as_str),
432            Some("3.0.1")
433        );
434    }
435
436    #[test]
437    fn importer_to_version_metadata_injects_defaults_for_nameless_root() {
438        let vm = importer_to_version_metadata(&manifest(None), ".").unwrap();
439        assert_eq!(vm.name, "");
440        assert_eq!(vm.version, "0.0.0");
441    }
442
443    #[test]
444    fn importer_to_version_metadata_carries_all_dep_maps() {
445        let mut m = manifest(Some("p"));
446        m.dependencies.insert("a".into(), "1.0.0".into());
447        m.dev_dependencies.insert("b".into(), "^2".into());
448        m.optional_dependencies.insert("c".into(), "*".into());
449        m.peer_dependencies.insert("d".into(), ">=3".into());
450        let vm = importer_to_version_metadata(&m, ".").unwrap();
451        assert_eq!(vm.dependencies.get("a").map(String::as_str), Some("1.0.0"));
452        assert_eq!(vm.dev_dependencies.get("b").map(String::as_str), Some("^2"));
453        assert_eq!(
454            vm.optional_dependencies.get("c").map(String::as_str),
455            Some("*")
456        );
457        assert_eq!(
458            vm.peer_dependencies.get("d").map(String::as_str),
459            Some(">=3")
460        );
461    }
462
463    #[test]
464    fn apply_version_metadata_keeps_dep_edits_and_ignores_identity() {
465        let mut m = manifest(Some("orig"));
466        let mut after = importer_to_version_metadata(&m, ".").unwrap();
467        after.name = "changed".into();
468        after.version = "9.9.9".into();
469        after.dependencies.insert("x".into(), "1".into());
470        apply_version_metadata_to_importer(&mut m, after);
471        // We never copy identity back, so the importer keeps its own name.
472        assert_eq!(m.name.as_deref(), Some("orig"));
473        assert_eq!(m.dependencies.get("x").map(String::as_str), Some("1"));
474    }
475}