Skip to main content

aube_resolver/
lib.rs

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