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    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        // pnpm stores catalog-expanded override values in its lockfile header,
117        // not the raw `catalog:` references from pnpm-workspace.yaml. Resolve
118        // every valid selector against the configured catalogs here so even an
119        // override that did not happen to match this graph is serialized in the
120        // same canonical form. Invalid/missing catalog references stay raw; a
121        // matching task reports the existing catalog diagnostic during resolve.
122        let mut lockfile_overrides = self.overrides.clone();
123        for rule in &self.override_rules {
124            let Some(catalog_name) = rule
125                .replacement
126                .strip_prefix("catalog:")
127                .map(|name| if name.is_empty() { "default" } else { name })
128            else {
129                continue;
130            };
131            let Some(real_range) = self
132                .catalogs
133                .get(catalog_name)
134                .and_then(|catalog| catalog.get(&rule.target.name))
135                .filter(|range| !aube_util::pkg::is_catalog_spec(range))
136            else {
137                continue;
138            };
139            lockfile_overrides.insert(rule.raw_key.clone(), real_range.clone());
140        }
141
142        let canonical = LockfileGraph {
143            importers,
144            packages: resolved,
145            settings: aube_lockfile::LockfileSettings {
146                auto_install_peers: self.auto_install_peers,
147                exclude_links_from_lockfile: self.exclude_links_from_lockfile,
148                // Tarball-URL recording is a lockfile-writer concern; the
149                // resolver never populates URLs itself. Install flips this
150                // on after the graph is built when the setting is active.
151                lockfile_include_tarball_url: false,
152            },
153            // Stamp the pnpm-canonical override values into the output graph so
154            // the writer and the next install's drift check see the same shape.
155            overrides: lockfile_overrides,
156            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
157            times: resolved_times,
158            skipped_optional_dependencies,
159            catalogs: resolved_catalogs,
160            // Resolver output is format-agnostic; the bun writer layer
161            // defaults `configVersion` to 1 when emitting a fresh
162            // lockfile.
163            bun_config_version: None,
164            // Fresh resolves don't carry over unknown blocks; the
165            // install-side merge (`overlay_metadata_from`) copies
166            // them back from the prior lockfile when round-tripping.
167            patched_dependencies: BTreeMap::new(),
168            trusted_dependencies: Vec::new(),
169            runtimes: BTreeMap::new(),
170            extra_fields: BTreeMap::new(),
171            workspace_extra_fields: BTreeMap::new(),
172            // pnpm config checksums are an install-flow concern, stamped
173            // onto the graph just before a pnpm-lock.yaml is written.
174            // A fresh resolve leaves them unset.
175            package_extensions_checksum: None,
176            pnpmfile_checksum: None,
177        };
178
179        // Second pass: hoist every auto-installed peer to a synthetic
180        // importer direct dep so the isolated linker creates the required
181        // `node_modules/<peer>` top-level symlink. The synthetic root keeps
182        // the requiring direct dependency's section classification.
183        // Skipped entirely when the setting is off — matches pnpm, which
184        // leaves the importer's `dependencies` untouched in that mode.
185        let hoisted = if self.auto_install_peers {
186            hoist_auto_installed_peers(canonical)
187        } else {
188            canonical
189        };
190
191        // Third pass: compute peer-context suffixes for every reachable
192        // package. See `apply_peer_contexts` for the details.
193        let peer_options = PeerContextOptions {
194            dedupe_peer_dependents: self.dedupe_peer_dependents,
195            dedupe_peers: self.dedupe_peers,
196            resolve_from_workspace_root: self.resolve_peers_from_workspace_root,
197            peers_suffix_max_length: self.peers_suffix_max_length,
198        };
199        let _diag_peer =
200            aube_util::diag::Span::new(aube_util::diag::Category::Resolver, "peer_context_apply");
201        let contextualized = apply_peer_contexts(hoisted, &peer_options)?;
202        drop(_diag_peer);
203        tracing::debug!(
204            "peer-context pass produced {} contextualized packages",
205            contextualized.packages.len()
206        );
207        Ok(contextualized)
208    }
209}
210
211/// Apply the project's `readPackage` hook to each importer manifest in
212/// place. Mirrors pnpm, which fires the hook on workspace-project
213/// manifests, not just resolved registry packages. Honored edits are the
214/// dependency maps (`dependencies`, `devDependencies`,
215/// `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta`);
216/// identity (`name`/`version`) edits are ignored — and, like the
217/// registry-package path in the BFS loop, an identity rewrite emits a
218/// `WARN_AUBE_HOOK_IDENTITY_REWRITTEN` warning so the discarded edit isn't
219/// silent.
220async fn apply_read_package_to_importers(
221    hook: &mut dyn ReadPackageHook,
222    manifests: &mut [(String, PackageJson)],
223) -> Result<(), Error> {
224    for (importer_path, manifest) in manifests.iter_mut() {
225        let input = importer_to_version_metadata(manifest, importer_path)?;
226        // Capture the (possibly synthesized) identity we hand the hook so an
227        // attempted rewrite can be reported rather than dropped silently.
228        let before_name = input.name.clone();
229        let before_version = input.version.clone();
230        let after = hook.read_package(input).await.map_err(|e| {
231            Error::Registry(
232                importer_label(importer_path, manifest),
233                format!("readPackage hook: {e}"),
234            )
235        })?;
236        if after.name != before_name || after.version != before_version {
237            tracing::warn!(
238                code = aube_codes::warnings::WARN_AUBE_HOOK_IDENTITY_REWRITTEN,
239                "[pnpmfile] readPackage rewrote importer {}@{} identity to {}@{}; \
240                         aube ignores identity edits",
241                before_name,
242                before_version,
243                after.name,
244                after.version,
245            );
246        }
247        apply_version_metadata_to_importer(manifest, after);
248    }
249    Ok(())
250}
251
252/// Build the `readPackage` hook input for an importer manifest. The hook
253/// wire is [`VersionMetadata`] (the same shape the resolver hands the hook
254/// for registry packages), so the manifest is round-tripped through JSON.
255/// `name`/`version` are required by `VersionMetadata` yet optional on a
256/// manifest (workspace roots routinely omit both) — inject inert defaults
257/// so the conversion can't fail on a nameless root.
258fn importer_to_version_metadata(
259    manifest: &PackageJson,
260    importer_path: &str,
261) -> Result<VersionMetadata, Error> {
262    let mut value = serde_json::to_value(manifest).map_err(|e| {
263        Error::Registry(
264            importer_path.to_string(),
265            format!("readPackage hook: failed to serialize importer manifest: {e}"),
266        )
267    })?;
268    if !value.get("name").is_some_and(serde_json::Value::is_string) {
269        value["name"] = serde_json::Value::String(String::new());
270    }
271    if !value
272        .get("version")
273        .is_some_and(serde_json::Value::is_string)
274    {
275        value["version"] = serde_json::Value::String("0.0.0".to_string());
276    }
277    serde_json::from_value(value).map_err(|e| {
278        Error::Registry(
279            importer_path.to_string(),
280            format!("readPackage hook: failed to build hook input from importer manifest: {e}"),
281        )
282    })
283}
284
285/// Copy the honored dependency-map edits from the hook's returned manifest
286/// back onto the importer. Identity and registry-only fields are ignored.
287fn apply_version_metadata_to_importer(manifest: &mut PackageJson, after: VersionMetadata) {
288    manifest.dependencies = after.dependencies;
289    manifest.dev_dependencies = after.dev_dependencies;
290    manifest.optional_dependencies = after.optional_dependencies;
291    manifest.peer_dependencies = after.peer_dependencies;
292    // `peerDependenciesMeta` has no typed slot on `PackageJson`; it lives
293    // in the flattened `extra` map. Reflect hook edits there so downstream
294    // peer handling sees them, and drop the key when the hook cleared it so
295    // a removal round-trips.
296    if after.peer_dependencies_meta.is_empty() {
297        manifest.extra.remove("peerDependenciesMeta");
298    } else if let Ok(v) = serde_json::to_value(&after.peer_dependencies_meta) {
299        manifest.extra.insert("peerDependenciesMeta".to_string(), v);
300    }
301}
302
303/// Human-readable label for an importer in hook error messages: its
304/// package name when present, else the importer path (`.` for the root).
305fn importer_label(importer_path: &str, manifest: &PackageJson) -> String {
306    match manifest.name.as_deref() {
307        Some(name) if !name.is_empty() => name.to_string(),
308        _ => importer_path.to_string(),
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::future::Future;
316    use std::pin::Pin;
317
318    /// Minimal in-process `readPackage` hook driven by a closure, so the
319    /// importer-hook plumbing can be exercised without spawning a `node`
320    /// child (the real host).
321    struct MockHook<F>(F);
322
323    impl<F> ReadPackageHook for MockHook<F>
324    where
325        F: FnMut(VersionMetadata) -> Result<VersionMetadata, String> + Send,
326    {
327        fn read_package<'a>(
328            &'a mut self,
329            pkg: VersionMetadata,
330        ) -> Pin<Box<dyn Future<Output = Result<VersionMetadata, String>> + Send + 'a>> {
331            let out = (self.0)(pkg);
332            Box::pin(async move { out })
333        }
334    }
335
336    fn manifest(name: Option<&str>) -> PackageJson {
337        PackageJson {
338            name: name.map(str::to_string),
339            ..PackageJson::default()
340        }
341    }
342
343    #[tokio::test]
344    async fn applies_hook_edits_to_importer_self_manifest() {
345        let mut manifests = vec![(".".to_string(), manifest(Some("root-pkg")))];
346        let mut hook = MockHook(|mut pkg: VersionMetadata| {
347            if pkg.name == "root-pkg" {
348                pkg.dependencies
349                    .insert("is-odd".to_string(), "3.0.1".to_string());
350            }
351            Ok(pkg)
352        });
353        apply_read_package_to_importers(&mut hook, &mut manifests)
354            .await
355            .unwrap();
356        assert_eq!(
357            manifests[0]
358                .1
359                .dependencies
360                .get("is-odd")
361                .map(String::as_str),
362            Some("3.0.1")
363        );
364    }
365
366    #[tokio::test]
367    async fn applies_hook_per_importer_in_a_workspace() {
368        // Each workspace member's own manifest is hooked independently —
369        // the rewrite is keyed on the package name the hook is called with.
370        let mut manifests = vec![
371            (".".to_string(), manifest(Some("root"))),
372            ("packages/app".to_string(), manifest(Some("app"))),
373            ("packages/lib".to_string(), manifest(Some("lib"))),
374        ];
375        let mut hook = MockHook(|mut pkg: VersionMetadata| {
376            // Only `app` links a local dep; the others are untouched.
377            if pkg.name == "app" {
378                pkg.dependencies
379                    .insert("@scope/lib".to_string(), "link:../lib".to_string());
380            }
381            Ok(pkg)
382        });
383        apply_read_package_to_importers(&mut hook, &mut manifests)
384            .await
385            .unwrap();
386        assert_eq!(
387            manifests[1]
388                .1
389                .dependencies
390                .get("@scope/lib")
391                .map(String::as_str),
392            Some("link:../lib")
393        );
394        assert!(manifests[0].1.dependencies.is_empty());
395        assert!(manifests[2].1.dependencies.is_empty());
396    }
397
398    #[tokio::test]
399    async fn nameless_root_is_still_passed_to_hook() {
400        // Workspace roots routinely omit `name`/`version`; the hook must
401        // still see (and be able to mutate) the manifest.
402        let mut manifests = vec![(".".to_string(), manifest(None))];
403        let mut hook = MockHook(|mut pkg: VersionMetadata| {
404            pkg.dependencies
405                .insert("marker".to_string(), "1.0.0".to_string());
406            Ok(pkg)
407        });
408        apply_read_package_to_importers(&mut hook, &mut manifests)
409            .await
410            .unwrap();
411        assert!(manifests[0].1.dependencies.contains_key("marker"));
412    }
413
414    #[tokio::test]
415    async fn hook_error_surfaces_as_registry_error() {
416        let mut manifests = vec![(".".to_string(), manifest(Some("x")))];
417        let mut hook = MockHook(|_pkg: VersionMetadata| Err("boom".to_string()));
418        let err = apply_read_package_to_importers(&mut hook, &mut manifests)
419            .await
420            .unwrap_err();
421        match err {
422            Error::Registry(name, msg) => {
423                assert_eq!(name, "x");
424                assert!(msg.contains("readPackage hook"), "got: {msg}");
425                assert!(msg.contains("boom"), "got: {msg}");
426            }
427            other => panic!("expected Registry error, got {other:?}"),
428        }
429    }
430
431    #[tokio::test]
432    async fn importer_identity_rewrite_is_ignored_but_deps_apply() {
433        // A hook that rewrites the importer's identity (name/version) while
434        // also editing deps: the identity edit is discarded (and warned
435        // about, mirroring the registry path), but the dep edit still lands.
436        let mut manifests = vec![(".".to_string(), manifest(Some("orig")))];
437        let mut hook = MockHook(|mut pkg: VersionMetadata| {
438            pkg.name = format!("{}-local", pkg.name);
439            pkg.version = "9.9.9".to_string();
440            pkg.dependencies
441                .insert("is-odd".to_string(), "3.0.1".to_string());
442            Ok(pkg)
443        });
444        apply_read_package_to_importers(&mut hook, &mut manifests)
445            .await
446            .unwrap();
447        // Identity rewrite is ignored — the importer keeps its own name.
448        assert_eq!(manifests[0].1.name.as_deref(), Some("orig"));
449        // The dependency edit is still honored.
450        assert_eq!(
451            manifests[0]
452                .1
453                .dependencies
454                .get("is-odd")
455                .map(String::as_str),
456            Some("3.0.1")
457        );
458    }
459
460    #[test]
461    fn importer_to_version_metadata_injects_defaults_for_nameless_root() {
462        let vm = importer_to_version_metadata(&manifest(None), ".").unwrap();
463        assert_eq!(vm.name, "");
464        assert_eq!(vm.version, "0.0.0");
465    }
466
467    #[test]
468    fn importer_to_version_metadata_carries_all_dep_maps() {
469        let mut m = manifest(Some("p"));
470        m.dependencies.insert("a".into(), "1.0.0".into());
471        m.dev_dependencies.insert("b".into(), "^2".into());
472        m.optional_dependencies.insert("c".into(), "*".into());
473        m.peer_dependencies.insert("d".into(), ">=3".into());
474        let vm = importer_to_version_metadata(&m, ".").unwrap();
475        assert_eq!(vm.dependencies.get("a").map(String::as_str), Some("1.0.0"));
476        assert_eq!(vm.dev_dependencies.get("b").map(String::as_str), Some("^2"));
477        assert_eq!(
478            vm.optional_dependencies.get("c").map(String::as_str),
479            Some("*")
480        );
481        assert_eq!(
482            vm.peer_dependencies.get("d").map(String::as_str),
483            Some(">=3")
484        );
485    }
486
487    #[test]
488    fn apply_version_metadata_keeps_dep_edits_and_ignores_identity() {
489        let mut m = manifest(Some("orig"));
490        let mut after = importer_to_version_metadata(&m, ".").unwrap();
491        after.name = "changed".into();
492        after.version = "9.9.9".into();
493        after.dependencies.insert("x".into(), "1".into());
494        apply_version_metadata_to_importer(&mut m, after);
495        // We never copy identity back, so the importer keeps its own name.
496        assert_eq!(m.name.as_deref(), Some("orig"));
497        assert_eq!(m.dependencies.get("x").map(String::as_str), Some("1"));
498    }
499}