Skip to main content

aube_resolver/
resolve.rs

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