Skip to main content

aube_resolver/
lib.rs

1mod builder;
2mod catalog;
3mod error;
4mod local_source;
5pub mod override_rule;
6mod package_ext;
7mod peer_context;
8pub mod platform;
9mod resolve;
10mod semver_util;
11mod types;
12
13pub use error::{AgeGateDetails, CatalogDetails, Error, ExoticSubdepDetails, NoMatchDetails};
14pub use package_ext::is_deprecation_allowed;
15pub use peer_context::{
16    PeerContextOptions, UnmetPeer, apply_peer_contexts, detect_unmet_peers,
17    hoist_auto_installed_peers,
18};
19pub use platform::{SupportedArchitectures, is_supported};
20pub use types::{
21    DependencyPolicy, MinimumReleaseAge, PackageExtension, ReadPackageHook, ResolutionMode,
22    ResolvedPackage, TrustPolicy,
23};
24
25use semver_util::version_satisfies;
26
27#[cfg(test)]
28use aube_lockfile::{DirectDep, LocalSource, LockedPackage, LockfileGraph};
29#[cfg(test)]
30use aube_manifest::PackageJson;
31#[cfg(test)]
32use error::{
33    RegistryErrorKind, build_age_gate, build_no_match, classify_registry_error,
34    format_registry_help,
35};
36#[cfg(test)]
37use local_source::{dep_path_for, should_block_exotic_subdep};
38#[cfg(test)]
39use package_ext::{apply_package_extensions, package_selector_matches, pick_override_spec};
40#[cfg(test)]
41use peer_context::{
42    apply_dedupe_peers_to_key, contains_canonical_back_ref, dedupe_peer_suffixes,
43    dedupe_peer_variants, hash_peer_suffix,
44};
45#[cfg(test)]
46use semver_util::{PickResult, pick_version, strip_alias_prefix};
47#[cfg(test)]
48use types::format_iso8601_utc;
49
50use aube_lockfile::DepType;
51use aube_registry::Packument;
52use aube_registry::client::RegistryClient;
53use rustc_hash::FxHashMap;
54use std::collections::{BTreeMap, BTreeSet};
55use std::path::PathBuf;
56use std::sync::Arc;
57use tokio::sync::mpsc;
58
59/// BFS dependency resolver.
60pub struct Resolver {
61    client: Arc<RegistryClient>,
62    cache: FxHashMap<String, Packument>,
63    /// Optional channel to stream resolved packages as they're discovered.
64    resolved_tx: Option<mpsc::UnboundedSender<ResolvedPackage>>,
65    /// Optional disk cache directory for packuments (with ETag revalidation).
66    packument_cache_dir: Option<std::path::PathBuf>,
67    /// Separate disk cache for full (non-corgi) packuments; only used
68    /// when `resolution_mode` is `TimeBased` (which needs the `time:`
69    /// map). Defaults to the sibling `packuments-full-v1/` directory
70    /// next to `packument_cache_dir`.
71    packument_full_cache_dir: Option<std::path::PathBuf>,
72    /// When true (pnpm's default), a package's declared `peerDependencies`
73    /// are enqueued like regular transitives and — if not already
74    /// satisfied by the importer — hoisted to the importer's direct deps.
75    /// When false, peers neither get auto-installed as transitives nor
76    /// hoisted; unmet peers still surface as warnings via
77    /// `detect_unmet_peers`, but the user is on the hook for adding them
78    /// explicitly to `package.json`.
79    auto_install_peers: bool,
80    /// pnpm's `exclude-links-from-lockfile`. Round-tripped through the
81    /// lockfile's `settings:` header; when true, the pnpm writer omits
82    /// `link:` deps from the importer `dependencies:` maps so a
83    /// sibling symlink change doesn't churn the lockfile. Defaults to
84    /// false (pnpm's default). Does not affect resolution itself, only
85    /// the `canonical.settings.exclude_links_from_lockfile` flag the
86    /// writer reads.
87    exclude_links_from_lockfile: bool,
88    /// User-declared override for the host platform triple, used when
89    /// deciding whether an optional dep's `os`/`cpu`/`libc` constraints
90    /// are satisfied. Empty fields fall back to the host.
91    supported_architectures: SupportedArchitectures,
92    /// Raw dependency override map from the manifest (selector key →
93    /// replacement spec). Round-tripped verbatim through the lockfile
94    /// for drift detection; the compiled form in `override_rules` is
95    /// what the resolver hot loop actually consults.
96    overrides: BTreeMap<String, String>,
97    /// Compiled view of `overrides`. Built by `with_overrides`.
98    /// Unparseable selector keys are dropped at compile time so the
99    /// matcher never has to think about them.
100    override_rules: Vec<override_rule::OverrideRule>,
101    /// Names listed in the root manifest's `pnpm.ignoredOptionalDependencies`.
102    /// Any optional dep (root or transitive) whose name is in this set is
103    /// dropped before enqueueing — the resolver never fetches or locks it.
104    /// Mirrors pnpm's `createOptionalDependenciesRemover` read-package hook.
105    ignored_optional_dependencies: BTreeSet<String>,
106    /// pnpm's `resolution-mode` — `Highest` (default) or `TimeBased`.
107    resolution_mode: ResolutionMode,
108    /// Project root used to resolve `file:` / `link:` paths to the
109    /// target directory. Defaults to the current working directory;
110    /// callers set it via `with_project_root`.
111    project_root: PathBuf,
112    /// pnpm v11's `minimumReleaseAge` triplet. `None` disables the
113    /// supply-chain age gate entirely (matching `minimumReleaseAge: 0`).
114    minimum_release_age: Option<MinimumReleaseAge>,
115    /// Workspace catalog ranges. Outer key is the catalog name
116    /// (`default` for the unnamed `catalog:` field in
117    /// `pnpm-workspace.yaml`); inner key is the package name; value is
118    /// the version range. When the resolver encounters a `catalog:` or
119    /// `catalog:<name>` task range, it rewrites the task in place to
120    /// the matching range *before* the override / npm-alias passes,
121    /// while preserving the original `catalog:...` text in
122    /// `original_specifier` so the lockfile importer keeps the
123    /// reference verbatim.
124    catalogs: BTreeMap<String, BTreeMap<String, String>>,
125    /// Optional `readPackage` hook, invoked once per resolved package
126    /// before its transitive deps are enqueued. See [`ReadPackageHook`].
127    /// Wired up by `aube` when a `.pnpmfile.cjs` is detected and
128    /// `--ignore-pnpmfile` was not set.
129    read_package_hook: Option<Box<dyn ReadPackageHook>>,
130    dependency_policy: DependencyPolicy,
131    /// Hosts for which aube performs shallow git clones, mirroring
132    /// pnpm's `git-shallow-hosts`. When a git dep's URL host is in
133    /// this list, the store attempts `git fetch --depth 1 origin
134    /// <sha>` (falling back to a full fetch if the server refuses);
135    /// otherwise it goes straight to a full fetch. Defaults to an
136    /// empty list — `aube` populates it from the generated
137    /// `aube_settings::resolved::git_shallow_hosts` accessor (which
138    /// carries the pnpm-compat default list baked in from
139    /// `settings.toml`) via [`Self::with_git_shallow_hosts`]. Library
140    /// callers who construct a `Resolver` directly must set it
141    /// explicitly if they want the pnpm list; keeping the list in
142    /// one place (`settings.toml`) avoids drift.
143    git_shallow_hosts: Vec<String>,
144    /// pnpm's `peersSuffixMaxLength`. When the peer-ID suffix on a
145    /// `dep_path` (the `(name@version)(…)` portion) would exceed this
146    /// many bytes, the post-pass replaces the whole suffix with
147    /// `_<hex>` where `<hex>` is the first 10 chars of SHA-256 of the
148    /// full suffix. Matches pnpm's lockfile format. Default 1000.
149    peers_suffix_max_length: usize,
150    /// pnpm's `dedupe-peer-dependents`. When true (pnpm's default),
151    /// the peer-context post-pass collapses multiple dep_path variants
152    /// of the same canonical package into a single entry when their
153    /// peer resolutions are pairwise-equivalent. When false, every
154    /// distinct ancestor scope gets its own variant — useful for
155    /// debugging peer-context divergence or mimicking pnpm v6/v7
156    /// behavior.
157    dedupe_peer_dependents: bool,
158    /// pnpm's `dedupe-peers`. When true, peer suffixes in the lockfile
159    /// emit just the resolved version — `(18.2.0)` — instead of the
160    /// full `(react@18.2.0)` form. Shorter dep_paths at the cost of
161    /// peer-name fidelity in the snapshot. Defaults to false.
162    dedupe_peers: bool,
163    /// pnpm's `resolve-peers-from-workspace-root`. When true (pnpm's
164    /// default), an importer's unresolved peer can be satisfied by a
165    /// dependency declared in the root importer's `package.json`, even
166    /// when no ancestor scope carries that dep. Common monorepo knob:
167    /// the workspace root pins shared peers like `react`, and every
168    /// subpackage can peer on it without hoisting the version into
169    /// every sibling.
170    resolve_peers_from_workspace_root: bool,
171    /// pnpm's `registry-supports-time-field`. When true, the resolver
172    /// trusts the abbreviated (corgi) packument to carry the `time:`
173    /// map and keeps using the cheap `fetch_packument_cached` path
174    /// even under time-aware resolution (`TimeBased` or
175    /// `minimumReleaseAge`). Defaults to false — the same assumption
176    /// pnpm and npmjs.org ship with — so the resolver falls back to
177    /// the full-packument fetch to get `time:` reliably. No effect
178    /// when neither time-based resolution nor `minimumReleaseAge` is
179    /// active, since the abbreviated path is already the only one
180    /// running.
181    registry_supports_time_field: bool,
182    pub(crate) packument_network_concurrency: Option<usize>,
183}
184
185pub(crate) struct ResolveTask {
186    pub(crate) name: String,
187    pub(crate) range: String,
188    dep_type: DepType,
189    is_root: bool,
190    /// The parent dep_path, for wiring up transitive dep references
191    parent: Option<String>,
192    /// Which importer this task belongs to (e.g., "." or "packages/app")
193    pub(crate) importer: String,
194    /// The original specifier from package.json before any rewrites
195    /// (e.g. `"npm:real-pkg@^2.0.0"` for an alias, or `"^4.17.0"` for a normal range).
196    /// Only set for root deps; recorded into the lockfile for drift detection.
197    pub(crate) original_specifier: Option<String>,
198    /// Real registry package name for npm-alias tasks.
199    ///
200    /// When a task arrives with `range` like `"npm:h3@2.0.1-rc.20"`,
201    /// the preprocessing loop strips the prefix and sets this field to
202    /// the real package name (`"h3"`) while *keeping* `name` as the
203    /// user-facing alias (`"h3-v2"`, the key the package.json used).
204    /// Every identity-facing site — dep_path formation, direct-dep
205    /// records, parent `dependencies` wiring, the resolved-versions
206    /// dedupe map — uses `name`, so the alias survives all the way
207    /// to the linker and ends up as `node_modules/<alias>/` with
208    /// `LockedPackage.alias_of = Some(real_name)`. Only registry
209    /// I/O (packument fetch, tarball URL derivation) consults this
210    /// field.
211    ///
212    /// `None` for ordinary (non-aliased) tasks — `name` is already
213    /// the registry name and nothing downstream needs to distinguish.
214    real_name: Option<String>,
215    /// Outermost-first chain of `(name, version)` ancestors above this
216    /// task in the dependency graph, used by `parent>child` override
217    /// selectors. Empty for root/importer deps. Each child-enqueue
218    /// site is responsible for extending its parent's chain with the
219    /// parent's own `(name, version)` frame.
220    pub(crate) ancestors: Vec<(String, String)>,
221}
222
223impl ResolveTask {
224    /// Name to use for registry operations (packument fetch, tarball
225    /// URL). Returns `real_name` for aliased tasks and `name`
226    /// otherwise. Every call site that talks to the registry goes
227    /// through this accessor so alias handling stays localized.
228    fn registry_name(&self) -> &str {
229        self.real_name.as_deref().unwrap_or(&self.name)
230    }
231
232    /// Construct a root-importer task for `(name, range)` under
233    /// `importer`, with the appropriate `dep_type` and no parent/ancestry.
234    /// Every root-dep enqueue site uses this shape; the factory keeps
235    /// the literal in one place so a new field added to `ResolveTask`
236    /// lands consistently across prod/dev/optional loops.
237    fn root(name: String, range: String, dep_type: DepType, importer: String) -> Self {
238        let original = range.clone();
239        Self {
240            name,
241            range,
242            dep_type,
243            is_root: true,
244            parent: None,
245            importer,
246            original_specifier: Some(original),
247            real_name: None,
248            ancestors: Vec::new(),
249        }
250    }
251
252    /// Construct a transitive (non-root) task discovered by walking a
253    /// parent package's dependency map. Carries the parent dep_path
254    /// and inherited ancestor chain for overrides.
255    fn transitive(
256        name: String,
257        range: String,
258        dep_type: DepType,
259        parent: String,
260        importer: String,
261        ancestors: Vec<(String, String)>,
262    ) -> Self {
263        Self {
264            name,
265            range,
266            dep_type,
267            is_root: false,
268            parent: Some(parent),
269            importer,
270            original_specifier: None,
271            real_name: None,
272            ancestors,
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests;