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