Skip to main content

aube_resolver/
lib.rs

1mod builder;
2mod catalog;
3mod direct_dep_info;
4mod error;
5mod local_source;
6// `locked_index` is an internal lockfile-reuse index. It is only `pub` under
7// the `bench` feature so the `locked_lookup` bench can reach `LockedIndex`;
8// the default build keeps it `pub(crate)`, off the public surface.
9#[cfg(feature = "bench")]
10pub mod locked_index;
11#[cfg(not(feature = "bench"))]
12pub(crate) mod locked_index;
13pub mod override_rule;
14mod package_ext;
15mod peer_context;
16pub mod platform;
17mod primer;
18mod resolve;
19mod semver_util;
20mod trust;
21mod types;
22
23pub use direct_dep_info::{AgeGatedUpdate, DirectDepInfo};
24pub use error::{AgeGateDetails, CatalogDetails, Error, ExoticSubdepDetails, NoMatchDetails};
25pub use local_source::resolve_exec_script_path;
26pub use package_ext::is_deprecation_allowed;
27pub use peer_context::{
28    PeerContextOptions, UnmetPeer, apply_peer_contexts, detect_unmet_peers,
29    hoist_auto_installed_peers,
30};
31pub use platform::{SupportedArchitectures, is_supported};
32pub use primer::{
33    PruneStats as PrimerPruneStats, popular_package_names, prune_cache as prune_primer_cache,
34};
35pub use semver_util::{PickResult, pick_version_for_add};
36pub use trust::{
37    MissingTimeDetails as MissingTrustTimeDetails, PriorTrustEvidence, TrustCheckError,
38    TrustDowngradeDetails, check_no_downgrade, check_no_downgrade_history, evidence_for,
39    strongest_prior_evidence,
40};
41pub use trust::{PackageVersionPolicy, TrustEvidence, TrustExcludeParseError, TrustExcludeRules};
42pub use types::{
43    DependencyPolicy, MinimumReleaseAge, PackageExtension, ReadPackageHook, ResolutionMode,
44    ResolvedPackage, TrustPolicy,
45};
46
47pub const YARN_EXEC_WRAPPER: &str = r#"
48const env = JSON.parse(process.env.AUBE_YARN_EXEC_ENV);
49globalThis.execEnv = env;
50for (const name of ['fs', 'path', 'child_process', 'os', 'crypto', 'url', 'util', 'stream', 'buffer']) {
51  globalThis[name] = require(name);
52}
53(async () => {
54  await import(url.pathToFileURL(process.argv[1]).href);
55})().catch((err) => {
56  console.error(err);
57  process.exit(1);
58});
59"#;
60
61use semver_util::version_satisfies;
62
63#[cfg(test)]
64use aube_lockfile::{DirectDep, LocalSource, LockedPackage, LockfileGraph};
65#[cfg(test)]
66use aube_manifest::PackageJson;
67#[cfg(test)]
68use error::{
69    RegistryErrorKind, build_age_gate, build_no_match, classify_registry_error,
70    format_registry_help,
71};
72#[cfg(test)]
73use local_source::{dep_path_for, should_block_exotic_subdep};
74#[cfg(test)]
75use package_ext::{
76    apply_package_extensions, apply_package_extensions_to_deps, package_selector_matches,
77    pick_override_spec,
78};
79#[cfg(test)]
80use peer_context::{
81    apply_dedupe_peers_to_key, contains_canonical_back_ref, dedupe_peer_suffixes,
82    dedupe_peer_variants, effective_peer_suffix, is_hashed_peer_suffix,
83};
84#[cfg(test)]
85use semver_util::{pick_version, strip_alias_prefix};
86#[cfg(test)]
87use types::format_iso8601_utc;
88
89use aube_lockfile::DepType;
90use aube_registry::Packument;
91use aube_registry::client::RegistryClient;
92use std::collections::{BTreeMap, BTreeSet};
93use std::path::PathBuf;
94use std::sync::Arc;
95use tokio::sync::mpsc;
96
97// Re-export shared aube-util collection aliases under the original
98// FxHashMap name to avoid touching every call site.
99pub(crate) use aube_util::collections::FxMap as FxHashMap;
100pub(crate) use aube_util::collections::FxSet as FxHashSet;
101
102/// BFS dependency resolver.
103pub struct Resolver {
104    client: Arc<RegistryClient>,
105    cache: FxHashMap<String, Packument>,
106    /// Optional channel to stream resolved packages as they're discovered.
107    resolved_tx: Option<mpsc::Sender<ResolvedPackage>>,
108    /// Optional disk cache directory for packuments (with ETag revalidation).
109    packument_cache_dir: Option<std::path::PathBuf>,
110    /// Separate disk cache for full (non-corgi) packuments; only used
111    /// when `resolution_mode` is `TimeBased` (which needs the `time:`
112    /// map). Defaults to the sibling `packuments-full-v1/` directory
113    /// next to `packument_cache_dir`.
114    packument_full_cache_dir: Option<std::path::PathBuf>,
115    /// When true (pnpm's default), required `peerDependencies` are enqueued
116    /// during resolution. An importer's own peers become direct dependencies;
117    /// dependency peers remain contextual to the packages that require them.
118    /// When false, peers are not auto-installed and unmet dependency peers
119    /// still surface through `detect_unmet_peers`.
120    auto_install_peers: bool,
121    /// pnpm's `exclude-links-from-lockfile`. Round-tripped through the
122    /// lockfile's `settings:` header; when true, the pnpm writer omits
123    /// `link:` deps from the importer `dependencies:` maps so a
124    /// sibling symlink change doesn't churn the lockfile. Defaults to
125    /// false (pnpm's default). Does not affect resolution itself, only
126    /// the `canonical.settings.exclude_links_from_lockfile` flag the
127    /// writer reads.
128    exclude_links_from_lockfile: bool,
129    /// User-declared override for the host platform triple, used when
130    /// deciding whether an optional dep's `os`/`cpu`/`libc` constraints
131    /// are satisfied. Empty fields fall back to the host.
132    supported_architectures: SupportedArchitectures,
133    /// Raw dependency override map from the manifest (selector key →
134    /// replacement spec). Round-tripped verbatim through the lockfile
135    /// for drift detection; the compiled form in `override_rules` is
136    /// what the resolver hot loop actually consults.
137    overrides: BTreeMap<String, String>,
138    /// Compiled view of `overrides`. Built by `with_overrides`.
139    /// Unparseable selector keys are dropped at compile time so the
140    /// matcher never has to think about them.
141    override_rules: Vec<override_rule::OverrideRule>,
142    /// Names listed in the root manifest's `pnpm.ignoredOptionalDependencies`.
143    /// Any optional dep (root or transitive) whose name is in this set is
144    /// dropped before enqueueing — the resolver never fetches or locks it.
145    /// Mirrors pnpm's `createOptionalDependenciesRemover` read-package hook.
146    ignored_optional_dependencies: BTreeSet<String>,
147    /// pnpm's `resolution-mode`.
148    resolution_mode: ResolutionMode,
149    /// Project root used to resolve `file:` / `link:` paths to the
150    /// target directory. Defaults to the current working directory;
151    /// callers set it via `with_project_root`.
152    project_root: PathBuf,
153    /// When true, resolver-time `exec:` generators are blocked the
154    /// same way fetch-time execution is blocked.
155    ignore_scripts: bool,
156    /// pnpm v11's `minimumReleaseAge` triplet. `None` disables the
157    /// supply-chain age gate entirely (matching `minimumReleaseAge: 0`).
158    minimum_release_age: Option<MinimumReleaseAge>,
159    /// Workspace catalog ranges. Outer key is the catalog name
160    /// (`default` for the unnamed `catalog:` field in
161    /// `pnpm-workspace.yaml`); inner key is the package name; value is
162    /// the version range. When the resolver encounters a `catalog:` or
163    /// `catalog:<name>` task range, it rewrites the task in place to
164    /// the matching range *before* the override / npm-alias passes,
165    /// while preserving the original `catalog:...` text in
166    /// `original_specifier` so the lockfile importer keeps the
167    /// reference verbatim.
168    catalogs: BTreeMap<String, BTreeMap<String, String>>,
169    /// Optional `readPackage` hook, invoked once per resolved package
170    /// before its transitive deps are enqueued. See [`ReadPackageHook`].
171    /// Wired up by `aube` when a `.pnpmfile.cjs` is detected and
172    /// `--ignore-pnpmfile` was not set.
173    read_package_hook: Option<Box<dyn ReadPackageHook>>,
174    dependency_policy: DependencyPolicy,
175    /// Advisory ranges to avoid when resolving audit fixes. The map is
176    /// keyed by registry package name and values are npm semver ranges
177    /// from `vulnerable_versions`. When a clean satisfying version
178    /// exists, it wins over locked/sibling reuse and the normal highest
179    /// pick; if not, resolution falls back to the ordinary pick so the
180    /// caller can report the advisory as remaining.
181    vulnerable_ranges: BTreeMap<String, Vec<String>>,
182    /// Hosts for which aube performs shallow git clones, mirroring
183    /// pnpm's `git-shallow-hosts`. When a git dep's URL host is in
184    /// this list, the store attempts `git fetch --depth 1 origin
185    /// <sha>` (falling back to a full fetch if the server refuses);
186    /// otherwise it goes straight to a full fetch. Defaults to an
187    /// empty list — `aube` populates it from the generated
188    /// `aube_settings::resolved::git_shallow_hosts` accessor (which
189    /// carries the pnpm-compat default list baked in from
190    /// `settings.toml`) via [`Self::with_git_shallow_hosts`]. Library
191    /// callers who construct a `Resolver` directly must set it
192    /// explicitly if they want the pnpm list; keeping the list in
193    /// one place (`settings.toml`) avoids drift.
194    git_shallow_hosts: Vec<String>,
195    /// pnpm's `peersSuffixMaxLength`. When the peer-ID suffix body on a
196    /// `dep_path` (the `(name@version)(…)` portion without its outer
197    /// parens) would exceed this many bytes, the post-pass replaces the
198    /// whole suffix with a parenthesized short hash `(<short-hash>)` —
199    /// the first 32 chars of SHA-256 of the body, matching pnpm's
200    /// `createPeerDepGraphHash` lockfile format. Default 1000.
201    peers_suffix_max_length: usize,
202    /// pnpm's `dedupe-peer-dependents`. When true (pnpm's default),
203    /// the peer-context post-pass collapses multiple dep_path variants
204    /// of the same canonical package into a single entry when their
205    /// peer resolutions are pairwise-equivalent. When false, every
206    /// distinct ancestor scope gets its own variant — useful for
207    /// debugging peer-context divergence or mimicking pnpm v6/v7
208    /// behavior.
209    dedupe_peer_dependents: bool,
210    /// pnpm's `dedupe-peers`. When true, peer suffixes in the lockfile
211    /// emit just the resolved version — `(18.2.0)` — instead of the
212    /// full `(react@18.2.0)` form. Shorter dep_paths at the cost of
213    /// peer-name fidelity in the snapshot. Defaults to false.
214    dedupe_peers: bool,
215    /// pnpm's `resolve-peers-from-workspace-root`. When true (pnpm's
216    /// default), an importer's unresolved peer can be satisfied by a
217    /// dependency declared in the root importer's `package.json`, even
218    /// when no ancestor scope carries that dep. Common monorepo knob:
219    /// the workspace root pins shared peers like `react`, and every
220    /// subpackage can peer on it without hoisting the version into
221    /// every sibling.
222    resolve_peers_from_workspace_root: bool,
223    /// pnpm's `registry-supports-time-field`. When true, the resolver
224    /// trusts the abbreviated (corgi) packument to carry the `time:`
225    /// map and keeps using the cheap `fetch_packument_cached` path
226    /// even under time-aware resolution (`TimeBased` or
227    /// `minimumReleaseAge`). Defaults to false — the same assumption
228    /// pnpm and npmjs.org ship with — so the resolver falls back to
229    /// the full-packument fetch to get `time:` reliably. No effect
230    /// when neither time-based resolution nor `minimumReleaseAge` is
231    /// active, since the abbreviated path is already the only one
232    /// running.
233    registry_supports_time_field: bool,
234    /// Use the bundled metadata primer even when the configured
235    /// registry is not npmjs.org. Intended for npm-compatible mirrors
236    /// and controlled benchmarks; tarball URLs are rewritten to the
237    /// active registry before cache seeding so installs still fetch
238    /// package bytes from the configured source.
239    force_metadata_primer: bool,
240    pub(crate) packument_network_concurrency: Option<usize>,
241}
242
243pub(crate) struct ResolveTask {
244    pub(crate) name: String,
245    pub(crate) range: String,
246    dep_type: DepType,
247    is_root: bool,
248    /// The parent dep_path, for wiring up transitive dep references
249    parent: Option<String>,
250    /// Which importer this task belongs to (e.g., "." or "packages/app")
251    pub(crate) importer: String,
252    /// The original specifier from package.json before any rewrites
253    /// (e.g. `"npm:real-pkg@^2.0.0"` for an alias, or `"^4.17.0"` for a normal range).
254    /// Only set for root deps; retained for diagnostics and skipped-optional
255    /// drift metadata even when an override changes the lockfile specifier.
256    pub(crate) original_specifier: Option<String>,
257    /// Override-applied specifier pnpm records on a direct importer dependency.
258    /// `None` when no override fired, so ordinary catalog dependencies continue
259    /// to emit their raw `catalog:` manifest specifier.
260    lockfile_override_specifier: Option<String>,
261    /// Real registry package name for npm-alias tasks.
262    ///
263    /// When a task arrives with `range` like `"npm:h3@2.0.1-rc.20"`,
264    /// the preprocessing loop strips the prefix and sets this field to
265    /// the real package name (`"h3"`) while *keeping* `name` as the
266    /// user-facing alias (`"h3-v2"`, the key the package.json used).
267    /// Every identity-facing site — dep_path formation, direct-dep
268    /// records, parent `dependencies` wiring, the resolved-versions
269    /// dedupe map — uses `name`, so the alias survives all the way
270    /// to the linker and ends up as `node_modules/<alias>/` with
271    /// `LockedPackage.alias_of = Some(real_name)`. Only registry
272    /// I/O (packument fetch, tarball URL derivation) consults this
273    /// field.
274    ///
275    /// `None` for ordinary (non-aliased) tasks — `name` is already
276    /// the registry name and nothing downstream needs to distinguish.
277    real_name: Option<String>,
278    /// Outermost-first chain of `(name, version)` ancestors above this
279    /// task in the dependency graph, used by `parent>child` override
280    /// selectors. Empty for root/importer deps. Each child-enqueue
281    /// site is responsible for extending its parent's chain with the
282    /// parent's own `(name, version)` frame.
283    ///
284    /// `Arc<[_]>` rather than `Vec` because the chain is immutable once
285    /// built and is shared, unmodified, by every dependency a package
286    /// enqueues: the child chain is materialized into a `Vec` once per
287    /// package, frozen here, and each per-dep enqueue is then a refcount
288    /// bump instead of a full deep clone (clone cost scaled with graph
289    /// edges × depth before this).
290    pub(crate) ancestors: Arc<[(String, String)]>,
291    /// `true` when an override rewrote `range` to a `link:`/`file:`
292    /// path. Override paths are anchored at the project root (where the
293    /// override is declared), not at the consuming workspace package or
294    /// transitive parent — same convention pnpm follows. Without this
295    /// signal the local-source resolver would re-anchor `link:./libs/x`
296    /// against the importer or parent dir and walk to a phantom path.
297    pub(crate) range_from_override: bool,
298}
299
300impl ResolveTask {
301    /// Name to use for registry operations (packument fetch, tarball
302    /// URL). Returns `real_name` for aliased tasks and `name`
303    /// otherwise. Every call site that talks to the registry goes
304    /// through this accessor so alias handling stays localized.
305    fn registry_name(&self) -> &str {
306        self.real_name.as_deref().unwrap_or(&self.name)
307    }
308
309    fn lockfile_specifier(&self) -> Option<String> {
310        self.lockfile_override_specifier
311            .clone()
312            .or_else(|| self.original_specifier.clone())
313    }
314
315    /// Construct a root-importer task for `(name, range)` under
316    /// `importer`, with the appropriate `dep_type` and no parent/ancestry.
317    /// Every root-dep enqueue site uses this shape; the factory keeps
318    /// the literal in one place so a new field added to `ResolveTask`
319    /// lands consistently across prod/dev/optional loops.
320    fn root(name: String, range: String, dep_type: DepType, importer: String) -> Self {
321        let original = range.clone();
322        Self {
323            name,
324            range,
325            dep_type,
326            is_root: true,
327            parent: None,
328            importer,
329            original_specifier: Some(original),
330            lockfile_override_specifier: None,
331            real_name: None,
332            ancestors: Arc::from([]),
333            range_from_override: false,
334        }
335    }
336
337    /// Construct a transitive (non-root) task discovered by walking a
338    /// parent package's dependency map. Carries the parent dep_path
339    /// and inherited ancestor chain for overrides.
340    fn transitive(
341        name: String,
342        range: String,
343        dep_type: DepType,
344        parent: String,
345        importer: String,
346        ancestors: Arc<[(String, String)]>,
347    ) -> Self {
348        Self {
349            name,
350            range,
351            dep_type,
352            is_root: false,
353            parent: Some(parent),
354            importer,
355            original_specifier: None,
356            lockfile_override_specifier: None,
357            real_name: None,
358            ancestors,
359            range_from_override: false,
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests;