Skip to main content

aube_resolver/
builder.rs

1use crate::FxHashMap;
2use crate::{
3    DependencyPolicy, MinimumReleaseAge, ReadPackageHook, ResolutionMode, ResolvedPackage,
4    Resolver, SupportedArchitectures, override_rule,
5};
6use aube_registry::client::RegistryClient;
7use std::collections::{BTreeMap, BTreeSet};
8use std::path::PathBuf;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11
12impl Resolver {
13    /// Stream-channel capacity between resolver and fetch coordinator.
14    ///
15    /// Was 64 (matched fetch concurrency). Bumped to 1024 because the
16    /// channel is just a backpressure-bounded mpsc — its job is to
17    /// absorb resolver bursts so the BFS loop never blocks on
18    /// `send().await` while the fetch coordinator is mid-tarball. Each
19    /// `ResolvedPackage` is ~200 bytes, so 1024 = ~200 KiB worst-case.
20    /// Real install graphs sustain 5–10 in-flight packages per fetch
21    /// permit, so 1024 covers ~20 000-pkg installs without backpressure
22    /// while still bounding heap on a runaway producer.
23    const DEFAULT_STREAM_CAPACITY: usize = 1024;
24
25    pub fn new(client: Arc<RegistryClient>) -> Self {
26        Self {
27            client,
28            // 1024 covers typical monorepo without rehash. 5000-pkg tail pays one grow.
29            cache: FxHashMap::with_capacity_and_hasher(1024, Default::default()),
30            resolved_tx: None,
31            packument_cache_dir: None,
32            packument_full_cache_dir: None,
33            auto_install_peers: true,
34            exclude_links_from_lockfile: false,
35            supported_architectures: SupportedArchitectures::default(),
36            overrides: BTreeMap::new(),
37            override_rules: Vec::new(),
38            ignored_optional_dependencies: BTreeSet::new(),
39            resolution_mode: ResolutionMode::Highest,
40            lowest_direct: false,
41            project_root: PathBuf::from("."),
42            ignore_scripts: false,
43            minimum_release_age: None,
44            catalogs: BTreeMap::new(),
45            read_package_hook: None,
46            dependency_policy: DependencyPolicy::default(),
47            vulnerable_ranges: BTreeMap::new(),
48            git_shallow_hosts: Vec::new(),
49            peers_suffix_max_length: 1000,
50            dedupe_peer_dependents: true,
51            dedupe_peers: false,
52            resolve_peers_from_workspace_root: true,
53            registry_supports_time_field: false,
54            force_metadata_primer: false,
55            packument_network_concurrency: None,
56        }
57    }
58
59    /// Create a resolver that streams resolved packages through a channel.
60    /// Returns `(resolver, receiver)`. The receiver yields packages as they're
61    /// discovered, allowing tarball fetches to start during resolution.
62    pub fn with_stream(client: Arc<RegistryClient>) -> (Self, mpsc::Receiver<ResolvedPackage>) {
63        Self::with_stream_capacity(client, Self::DEFAULT_STREAM_CAPACITY)
64    }
65
66    /// Create a streaming resolver with a bounded resolved-package buffer.
67    pub fn with_stream_capacity(
68        client: Arc<RegistryClient>,
69        capacity: usize,
70    ) -> (Self, mpsc::Receiver<ResolvedPackage>) {
71        let (tx, rx) = mpsc::channel(capacity.max(1));
72        (
73            Self {
74                client,
75                // 1024 covers typical monorepo without rehash. 5000-pkg tail pays one grow.
76                cache: FxHashMap::with_capacity_and_hasher(1024, Default::default()),
77                resolved_tx: Some(tx),
78                packument_cache_dir: None,
79                packument_full_cache_dir: None,
80                auto_install_peers: true,
81                exclude_links_from_lockfile: false,
82                supported_architectures: SupportedArchitectures::default(),
83                overrides: BTreeMap::new(),
84                override_rules: Vec::new(),
85                ignored_optional_dependencies: BTreeSet::new(),
86                resolution_mode: ResolutionMode::Highest,
87                lowest_direct: false,
88                project_root: PathBuf::from("."),
89                ignore_scripts: false,
90                minimum_release_age: None,
91                catalogs: BTreeMap::new(),
92                read_package_hook: None,
93                dependency_policy: DependencyPolicy::default(),
94                vulnerable_ranges: BTreeMap::new(),
95                git_shallow_hosts: Vec::new(),
96                peers_suffix_max_length: 1000,
97                dedupe_peer_dependents: true,
98                dedupe_peers: false,
99                resolve_peers_from_workspace_root: true,
100                registry_supports_time_field: false,
101                force_metadata_primer: false,
102                packument_network_concurrency: None,
103            },
104            rx,
105        )
106    }
107
108    pub fn with_packument_network_concurrency(mut self, n: Option<usize>) -> Self {
109        self.packument_network_concurrency = n.filter(|&n| n > 0);
110        self
111    }
112
113    /// Enable disk-backed packument caching with ETag/Last-Modified revalidation.
114    pub fn with_packument_cache(mut self, cache_dir: std::path::PathBuf) -> Self {
115        self.packument_cache_dir = Some(cache_dir);
116        self
117    }
118
119    /// Disk cache for full (non-corgi) packuments, used in
120    /// `ResolutionMode::TimeBased` so we can read the `time:` map.
121    pub fn with_packument_full_cache(mut self, cache_dir: std::path::PathBuf) -> Self {
122        self.packument_full_cache_dir = Some(cache_dir);
123        self
124    }
125
126    /// Set the resolution mode. Defaults to `Highest` (pnpm's classic
127    /// behavior). `TimeBased` switches direct deps to lowest-satisfying
128    /// and constrains transitives by a publish-date cutoff.
129    pub fn with_resolution_mode(mut self, mode: ResolutionMode) -> Self {
130        self.resolution_mode = mode;
131        self.lowest_direct = false;
132        self
133    }
134
135    /// Pick the lowest satisfying version for direct dependencies while
136    /// resolving transitives normally. Unlike `ResolutionMode::TimeBased`,
137    /// this neither computes a publish-time cutoff nor records publish times
138    /// in the lockfile.
139    pub fn with_lowest_direct(mut self, enabled: bool) -> Self {
140        self.lowest_direct = enabled;
141        if enabled {
142            self.resolution_mode = ResolutionMode::Highest;
143        }
144        self
145    }
146
147    /// Configure pnpm v11's `minimumReleaseAge` family of settings.
148    /// Pass `None` (or a config with `minutes == 0`) to disable.
149    pub fn with_minimum_release_age(mut self, mra: Option<MinimumReleaseAge>) -> Self {
150        self.minimum_release_age = mra.filter(|m| m.minutes > 0);
151        self
152    }
153
154    /// Whether the resolver should round-trip registry `time:` entries
155    /// into the output graph (and from there into the lockfile's
156    /// top-level `time:` block).
157    ///
158    /// pnpm writes `time:` to the lockfile *only* under
159    /// `resolution-mode=time-based`. In `resolveDependencies.ts` the
160    /// `time` map is populated solely inside the `if (ctx.resolutionMode
161    /// === 'time-based')` branch, and `updateLockfile` then guards
162    /// `newLockfile.time = …` behind that map being truthy. The
163    /// `minimumReleaseAge` and `trustPolicy=no-downgrade` policies do
164    /// *not* persist `time:` — pnpm enforces them from a separate
165    /// on-disk metadata cache (re-fetching full metadata as needed), so
166    /// its lockfiles stay `time:`-free even with both policies active.
167    ///
168    /// aube mirrors that here: the two policies still drive `needs_time`
169    /// (we fetch the publish dates to enforce them in-memory during the
170    /// resolve), but they no longer leak a `time:` block that pnpm would
171    /// never write. Including them previously produced a spurious `time:`
172    /// block on every default install (aube defaults `trustPolicy` to
173    /// `no-downgrade` and `minimumReleaseAge` to 1440), which showed up
174    /// as churn in a pnpm ↔ aube lockfile diff.
175    pub(crate) fn should_record_times(&self) -> bool {
176        self.resolution_mode == ResolutionMode::TimeBased
177    }
178
179    /// Override the default `auto-install-peers=true` behavior. pnpm reads
180    /// this from `.npmrc` or `pnpm-workspace.yaml`; aube's install command
181    /// plumbs the resolved value through here before running resolution.
182    pub fn with_auto_install_peers(mut self, auto_install_peers: bool) -> Self {
183        self.auto_install_peers = auto_install_peers;
184        self
185    }
186
187    /// Configure pnpm's `peersSuffixMaxLength`. When the peer suffix body
188    /// on a `dep_path` would exceed this many bytes, the post-pass
189    /// replaces the whole suffix with a parenthesized short hash
190    /// `(<short-hash>)` (pnpm's `createPeerDepGraphHash`). Default 1000
191    /// (pnpm's default).
192    pub fn with_peers_suffix_max_length(mut self, max_length: usize) -> Self {
193        self.peers_suffix_max_length = max_length;
194        self
195    }
196
197    /// Override the default `dedupe-peer-dependents=true` behavior. When
198    /// false, the peer-context pass keeps every distinct ancestor-scope
199    /// variant of a package instead of collapsing peer-equivalent ones
200    /// into a single dep_path. Plumbed from `.npmrc` /
201    /// `pnpm-workspace.yaml` via the install command.
202    pub fn with_dedupe_peer_dependents(mut self, value: bool) -> Self {
203        self.dedupe_peer_dependents = value;
204        self
205    }
206
207    /// Override the default `dedupe-peers=false` behavior. When true,
208    /// peer suffixes in the lockfile drop the peer name and emit only
209    /// the resolved version — `(18.2.0)` instead of `(react@18.2.0)`.
210    /// Plumbed from `.npmrc` / `pnpm-workspace.yaml` via the install
211    /// command.
212    pub fn with_dedupe_peers(mut self, value: bool) -> Self {
213        self.dedupe_peers = value;
214        self
215    }
216
217    /// Override the default `resolve-peers-from-workspace-root=true`
218    /// behavior. When false, peer resolution stops at the importer's
219    /// own scope + BFS-auto-installed transitives instead of consulting
220    /// the workspace root's direct deps as a fallback tier. Plumbed
221    /// from `.npmrc` / `pnpm-workspace.yaml` via the install command.
222    pub fn with_resolve_peers_from_workspace_root(mut self, value: bool) -> Self {
223        self.resolve_peers_from_workspace_root = value;
224        self
225    }
226
227    /// Configure pnpm's `registry-supports-time-field`. When true,
228    /// the resolver keeps using the abbreviated (corgi) packument
229    /// path even when `time:` is needed, saving one full-packument
230    /// fetch per distinct package. Safe for registries that embed
231    /// `time` in their abbreviated responses (Verdaccio 5.15.1+, JSR,
232    /// most in-house mirrors); leave at the default `false` for
233    /// npmjs.org.
234    pub fn with_registry_supports_time_field(mut self, value: bool) -> Self {
235        self.registry_supports_time_field = value;
236        self
237    }
238
239    /// Force the bundled metadata primer on for npm-compatible
240    /// mirrors. Normally the primer only seeds npmjs.org cache entries
241    /// because it was generated from npmjs metadata.
242    pub fn with_force_metadata_primer(mut self, value: bool) -> Self {
243        self.force_metadata_primer = value;
244        self
245    }
246
247    /// Configure pnpm's `exclude-links-from-lockfile` setting. Only
248    /// affects lockfile serialization — the resolver still builds the
249    /// same graph either way, but the value is stamped into
250    /// `LockfileGraph::settings` so the pnpm writer can filter `link:`
251    /// importer entries on write.
252    pub fn with_exclude_links_from_lockfile(mut self, value: bool) -> Self {
253        self.exclude_links_from_lockfile = value;
254        self
255    }
256
257    /// Override the host platform triple used when filtering optional
258    /// dependencies. See [`platform::SupportedArchitectures`].
259    pub fn with_supported_architectures(mut self, value: SupportedArchitectures) -> Self {
260        self.supported_architectures = value;
261        self
262    }
263
264    /// Provide dependency overrides. The map's keys are selector
265    /// strings — bare name, `parent>child`, `foo@<2`, `**/foo`, or any
266    /// combination thereof — and values are version specifiers (or
267    /// `npm:` aliases). Keys are compiled into `override_rule`
268    /// structures; unparseable keys are dropped. Whenever the resolver
269    /// encounters a task matching a rule (by name + ancestor chain +
270    /// optional version constraints), the requested range is replaced
271    /// with the rule's replacement before any packument fetch or
272    /// version pick. Workspace + manifest sources are merged by the
273    /// caller.
274    pub fn with_overrides(mut self, overrides: BTreeMap<String, String>) -> Self {
275        self.override_rules = override_rule::compile(&overrides);
276        self.overrides = overrides;
277        self
278    }
279
280    /// Provide workspace catalog ranges. Outer key is the catalog name
281    /// (`default` for the unnamed `catalog:` field in
282    /// `pnpm-workspace.yaml`); inner key is the package name. The
283    /// resolver rewrites `catalog:` and `catalog:<name>` task ranges
284    /// against this map before the override / npm-alias passes, and
285    /// records the picks in the output graph's `catalogs` field.
286    pub fn with_catalogs(mut self, catalogs: BTreeMap<String, BTreeMap<String, String>>) -> Self {
287        self.catalogs = catalogs;
288        self
289    }
290
291    /// Set the project root used to resolve `file:` / `link:` paths.
292    /// `file:./vendor/foo` resolves against this directory, and a
293    /// matching directory / tarball is read to drive resolution of the
294    /// local package's transitive deps.
295    pub fn with_project_root(mut self, project_root: PathBuf) -> Self {
296        self.project_root = project_root;
297        self
298    }
299
300    pub fn with_ignore_scripts(mut self, ignore_scripts: bool) -> Self {
301        self.ignore_scripts = ignore_scripts;
302        self
303    }
304
305    /// Names to strip from every `optionalDependencies` map before
306    /// enqueueing (pnpm's `pnpm.ignoredOptionalDependencies`). Applied
307    /// to both root and transitive optional deps. Empty by default.
308    pub fn with_ignored_optional_dependencies(mut self, ignored: BTreeSet<String>) -> Self {
309        self.ignored_optional_dependencies = ignored;
310        self
311    }
312
313    /// Install a `readPackage` hook. The resolver calls it once per
314    /// version-picked packument before enqueueing transitives; see
315    /// [`ReadPackageHook`] for what mutations are honored.
316    pub fn with_read_package_hook(mut self, hook: Box<dyn ReadPackageHook>) -> Self {
317        self.read_package_hook = Some(hook);
318        self
319    }
320
321    /// Configure dependency resolution policy settings such as
322    /// `packageExtensions`, `allowedDeprecatedVersions`, `trustPolicy*`,
323    /// and `blockExoticSubdeps`.
324    pub fn with_dependency_policy(mut self, policy: DependencyPolicy) -> Self {
325        self.dependency_policy = policy;
326        self
327    }
328
329    /// Prefer non-vulnerable versions for the supplied audit ranges.
330    /// Used by `audit --fix=update` to reuse the normal resolver while
331    /// steering only vulnerable packages away from affected versions.
332    pub fn with_vulnerable_ranges(mut self, ranges: BTreeMap<String, Vec<String>>) -> Self {
333        self.vulnerable_ranges = ranges;
334        self
335    }
336
337    /// Set the `git-shallow-hosts` list used when cloning git deps.
338    /// When a git URL's host matches an entry here (exact match,
339    /// same as pnpm), aube attempts a shallow fetch by SHA; other
340    /// hosts get a plain `git fetch origin`. An empty list forces
341    /// every git dep through the full-fetch path.
342    pub fn with_git_shallow_hosts(mut self, hosts: Vec<String>) -> Self {
343        self.git_shallow_hosts = hosts;
344        self
345    }
346}