1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
mod driver;
mod fetch;
mod seed;
mod vulnerable;
use crate::local_source::is_non_registry_specifier;
use crate::semver_util::version_satisfies;
use crate::{
Error, FxHashMap, PeerContextOptions, ReadPackageHook, Resolver, apply_peer_contexts, catalog,
hoist_auto_installed_peers,
};
use aube_lockfile::{DirectDep, LockedPackage, LockfileGraph};
use aube_manifest::PackageJson;
use aube_registry::VersionMetadata;
use std::collections::{BTreeMap, HashMap};
impl Resolver {
/// Resolve all dependencies from a package.json.
///
/// Uses batch-parallel BFS: each "wave" drains the queue, identifies
/// uncached package names, fetches their packuments concurrently, then
/// processes the entire batch before starting the next wave.
pub async fn resolve(
&mut self,
manifest: &PackageJson,
existing: Option<&LockfileGraph>,
) -> Result<LockfileGraph, Error> {
self.resolve_workspace(
&[(".".to_string(), manifest.clone())],
existing,
&HashMap::new(),
)
.await
}
/// Resolve all dependencies for a workspace (multiple importers).
///
/// `manifests` is a list of (importer_path, PackageJson) — e.g. (".", root), ("packages/app", app).
/// `workspace_packages` maps package name → version. Used both for
/// explicit `workspace:` protocol resolution and for yarn/npm/bun
/// style linkage where a bare semver range on a workspace-package
/// name resolves to the local copy when its version satisfies the
/// range.
pub async fn resolve_workspace(
&mut self,
manifests: &[(String, PackageJson)],
existing: Option<&LockfileGraph>,
workspace_packages: &HashMap<String, String>,
) -> Result<LockfileGraph, Error> {
// Run `readPackage` over each importer's own manifest before
// seeding, matching pnpm — which fires the hook on workspace
// project manifests, not just resolved registry packages. This
// lets a pnpmfile rewrite an importer's own `dependencies` /
// `devDependencies` / `optionalDependencies` / `peerDependencies`
// (e.g. local `link:` wiring of monorepo packages) before the
// resolver walks them. The registry-package hook still runs in
// the BFS loop, so a dep *added* by the importer hook is itself
// hooked when resolved, just like pnpm.
let hooked_manifests = if let Some(hook) = self.read_package_hook.as_deref_mut() {
let mut owned = manifests.to_vec();
apply_read_package_to_importers(hook, &mut owned).await?;
Some(owned)
} else {
None
};
let manifests = hooked_manifests.as_deref().unwrap_or(manifests);
driver::ResolveDriver::new(self, manifests, existing, workspace_packages)
.run()
.await
}
/// Is `(name, range)` safe to speculatively prefetch against the
/// registry?
///
/// Returns false for any spec that won't go through the registry
/// resolver at all — workspace/catalog/npm-alias/jsr ranges, local
/// (`file:`/`link:`/`git:`) specifiers, and bare ranges that match
/// a workspace package. Also false for any name listed in
/// `pnpm.overrides`, since the override may rewrite the spec into
/// one of the above and we can't cheaply tell ahead of time.
fn is_prefetchable(
&self,
name: &str,
range: &str,
workspace_packages: &HashMap<String, String>,
) -> bool {
let workspace_hit = workspace_packages
.get(name)
.is_some_and(|ws_v| version_satisfies(ws_v, range));
!aube_util::pkg::is_workspace_spec(range)
&& !aube_util::pkg::is_catalog_spec(range)
&& !aube_util::pkg::is_npm_spec(range)
&& !aube_util::pkg::is_jsr_spec(range)
&& !is_non_registry_specifier(range)
&& !self.overrides.contains_key(name)
&& !workspace_hit
}
/// Build the final `LockfileGraph` from accumulated resolver state.
///
/// Runs the catalog-pick materialization, hoists auto-installed
/// peers when `auto_install_peers` is on, and applies peer-context
/// suffixes. Returns the post-peer-context graph ready for lockfile
/// emission.
fn finalize_resolved_graph(
&self,
importers: BTreeMap<String, Vec<DirectDep>>,
resolved: BTreeMap<String, LockedPackage>,
resolved_versions: &FxHashMap<String, Vec<String>>,
resolved_times: BTreeMap<String, String>,
skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
catalog_picks: BTreeMap<String, BTreeMap<String, String>>,
) -> Result<LockfileGraph, Error> {
let resolved_catalogs =
catalog::materialize_catalog_picks(catalog_picks, resolved_versions);
let canonical = LockfileGraph {
importers,
packages: resolved,
settings: aube_lockfile::LockfileSettings {
auto_install_peers: self.auto_install_peers,
exclude_links_from_lockfile: self.exclude_links_from_lockfile,
// Tarball-URL recording is a lockfile-writer concern; the
// resolver never populates URLs itself. Install flips this
// on after the graph is built when the setting is active.
lockfile_include_tarball_url: false,
},
// Stamp the resolver's overrides into the output graph so the
// lockfile writer can round-trip them and the next install's
// drift check can compare them against the manifest.
overrides: self.overrides.clone(),
ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
times: resolved_times,
skipped_optional_dependencies,
catalogs: resolved_catalogs,
// Resolver output is format-agnostic; the bun writer layer
// defaults `configVersion` to 1 when emitting a fresh
// lockfile.
bun_config_version: None,
// Fresh resolves don't carry over unknown blocks; the
// install-side merge (`overlay_metadata_from`) copies
// them back from the prior lockfile when round-tripping.
patched_dependencies: BTreeMap::new(),
trusted_dependencies: Vec::new(),
runtimes: BTreeMap::new(),
extra_fields: BTreeMap::new(),
workspace_extra_fields: BTreeMap::new(),
};
// Second pass: hoist every auto-installed peer to its importer's
// direct deps so pnpm-style `node_modules/<peer>` top-level
// symlinks get created and the lockfile's `importers.` section
// lists them the way pnpm does with `auto-install-peers=true`.
// Skipped entirely when the setting is off — matches pnpm, which
// leaves the importer's `dependencies` untouched in that mode.
let hoisted = if self.auto_install_peers {
hoist_auto_installed_peers(canonical)
} else {
canonical
};
// Third pass: compute peer-context suffixes for every reachable
// package. See `apply_peer_contexts` for the details.
let peer_options = PeerContextOptions {
dedupe_peer_dependents: self.dedupe_peer_dependents,
dedupe_peers: self.dedupe_peers,
resolve_from_workspace_root: self.resolve_peers_from_workspace_root,
peers_suffix_max_length: self.peers_suffix_max_length,
};
let _diag_peer =
aube_util::diag::Span::new(aube_util::diag::Category::Resolver, "peer_context_apply");
let contextualized = apply_peer_contexts(hoisted, &peer_options)?;
drop(_diag_peer);
tracing::debug!(
"peer-context pass produced {} contextualized packages",
contextualized.packages.len()
);
Ok(contextualized)
}
}
/// Apply the project's `readPackage` hook to each importer manifest in
/// place. Mirrors pnpm, which fires the hook on workspace-project
/// manifests, not just resolved registry packages. Honored edits are the
/// dependency maps (`dependencies`, `devDependencies`,
/// `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta`);
/// identity (`name`/`version`) edits are ignored — and, like the
/// registry-package path in the BFS loop, an identity rewrite emits a
/// `WARN_AUBE_HOOK_IDENTITY_REWRITTEN` warning so the discarded edit isn't
/// silent.
async fn apply_read_package_to_importers(
hook: &mut dyn ReadPackageHook,
manifests: &mut [(String, PackageJson)],
) -> Result<(), Error> {
for (importer_path, manifest) in manifests.iter_mut() {
let input = importer_to_version_metadata(manifest, importer_path)?;
// Capture the (possibly synthesized) identity we hand the hook so an
// attempted rewrite can be reported rather than dropped silently.
let before_name = input.name.clone();
let before_version = input.version.clone();
let after = hook.read_package(input).await.map_err(|e| {
Error::Registry(
importer_label(importer_path, manifest),
format!("readPackage hook: {e}"),
)
})?;
if after.name != before_name || after.version != before_version {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_HOOK_IDENTITY_REWRITTEN,
"[pnpmfile] readPackage rewrote importer {}@{} identity to {}@{}; \
aube ignores identity edits",
before_name,
before_version,
after.name,
after.version,
);
}
apply_version_metadata_to_importer(manifest, after);
}
Ok(())
}
/// Build the `readPackage` hook input for an importer manifest. The hook
/// wire is [`VersionMetadata`] (the same shape the resolver hands the hook
/// for registry packages), so the manifest is round-tripped through JSON.
/// `name`/`version` are required by `VersionMetadata` yet optional on a
/// manifest (workspace roots routinely omit both) — inject inert defaults
/// so the conversion can't fail on a nameless root.
fn importer_to_version_metadata(
manifest: &PackageJson,
importer_path: &str,
) -> Result<VersionMetadata, Error> {
let mut value = serde_json::to_value(manifest).map_err(|e| {
Error::Registry(
importer_path.to_string(),
format!("readPackage hook: failed to serialize importer manifest: {e}"),
)
})?;
if !value.get("name").is_some_and(serde_json::Value::is_string) {
value["name"] = serde_json::Value::String(String::new());
}
if !value
.get("version")
.is_some_and(serde_json::Value::is_string)
{
value["version"] = serde_json::Value::String("0.0.0".to_string());
}
serde_json::from_value(value).map_err(|e| {
Error::Registry(
importer_path.to_string(),
format!("readPackage hook: failed to build hook input from importer manifest: {e}"),
)
})
}
/// Copy the honored dependency-map edits from the hook's returned manifest
/// back onto the importer. Identity and registry-only fields are ignored.
fn apply_version_metadata_to_importer(manifest: &mut PackageJson, after: VersionMetadata) {
manifest.dependencies = after.dependencies;
manifest.dev_dependencies = after.dev_dependencies;
manifest.optional_dependencies = after.optional_dependencies;
manifest.peer_dependencies = after.peer_dependencies;
// `peerDependenciesMeta` has no typed slot on `PackageJson`; it lives
// in the flattened `extra` map. Reflect hook edits there so downstream
// peer handling sees them, and drop the key when the hook cleared it so
// a removal round-trips.
if after.peer_dependencies_meta.is_empty() {
manifest.extra.remove("peerDependenciesMeta");
} else if let Ok(v) = serde_json::to_value(&after.peer_dependencies_meta) {
manifest.extra.insert("peerDependenciesMeta".to_string(), v);
}
}
/// Human-readable label for an importer in hook error messages: its
/// package name when present, else the importer path (`.` for the root).
fn importer_label(importer_path: &str, manifest: &PackageJson) -> String {
match manifest.name.as_deref() {
Some(name) if !name.is_empty() => name.to_string(),
_ => importer_path.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::future::Future;
use std::pin::Pin;
/// Minimal in-process `readPackage` hook driven by a closure, so the
/// importer-hook plumbing can be exercised without spawning a `node`
/// child (the real host).
struct MockHook<F>(F);
impl<F> ReadPackageHook for MockHook<F>
where
F: FnMut(VersionMetadata) -> Result<VersionMetadata, String> + Send,
{
fn read_package<'a>(
&'a mut self,
pkg: VersionMetadata,
) -> Pin<Box<dyn Future<Output = Result<VersionMetadata, String>> + Send + 'a>> {
let out = (self.0)(pkg);
Box::pin(async move { out })
}
}
fn manifest(name: Option<&str>) -> PackageJson {
PackageJson {
name: name.map(str::to_string),
..PackageJson::default()
}
}
#[tokio::test]
async fn applies_hook_edits_to_importer_self_manifest() {
let mut manifests = vec![(".".to_string(), manifest(Some("root-pkg")))];
let mut hook = MockHook(|mut pkg: VersionMetadata| {
if pkg.name == "root-pkg" {
pkg.dependencies
.insert("is-odd".to_string(), "3.0.1".to_string());
}
Ok(pkg)
});
apply_read_package_to_importers(&mut hook, &mut manifests)
.await
.unwrap();
assert_eq!(
manifests[0]
.1
.dependencies
.get("is-odd")
.map(String::as_str),
Some("3.0.1")
);
}
#[tokio::test]
async fn applies_hook_per_importer_in_a_workspace() {
// Each workspace member's own manifest is hooked independently —
// the rewrite is keyed on the package name the hook is called with.
let mut manifests = vec![
(".".to_string(), manifest(Some("root"))),
("packages/app".to_string(), manifest(Some("app"))),
("packages/lib".to_string(), manifest(Some("lib"))),
];
let mut hook = MockHook(|mut pkg: VersionMetadata| {
// Only `app` links a local dep; the others are untouched.
if pkg.name == "app" {
pkg.dependencies
.insert("@scope/lib".to_string(), "link:../lib".to_string());
}
Ok(pkg)
});
apply_read_package_to_importers(&mut hook, &mut manifests)
.await
.unwrap();
assert_eq!(
manifests[1]
.1
.dependencies
.get("@scope/lib")
.map(String::as_str),
Some("link:../lib")
);
assert!(manifests[0].1.dependencies.is_empty());
assert!(manifests[2].1.dependencies.is_empty());
}
#[tokio::test]
async fn nameless_root_is_still_passed_to_hook() {
// Workspace roots routinely omit `name`/`version`; the hook must
// still see (and be able to mutate) the manifest.
let mut manifests = vec![(".".to_string(), manifest(None))];
let mut hook = MockHook(|mut pkg: VersionMetadata| {
pkg.dependencies
.insert("marker".to_string(), "1.0.0".to_string());
Ok(pkg)
});
apply_read_package_to_importers(&mut hook, &mut manifests)
.await
.unwrap();
assert!(manifests[0].1.dependencies.contains_key("marker"));
}
#[tokio::test]
async fn hook_error_surfaces_as_registry_error() {
let mut manifests = vec![(".".to_string(), manifest(Some("x")))];
let mut hook = MockHook(|_pkg: VersionMetadata| Err("boom".to_string()));
let err = apply_read_package_to_importers(&mut hook, &mut manifests)
.await
.unwrap_err();
match err {
Error::Registry(name, msg) => {
assert_eq!(name, "x");
assert!(msg.contains("readPackage hook"), "got: {msg}");
assert!(msg.contains("boom"), "got: {msg}");
}
other => panic!("expected Registry error, got {other:?}"),
}
}
#[tokio::test]
async fn importer_identity_rewrite_is_ignored_but_deps_apply() {
// A hook that rewrites the importer's identity (name/version) while
// also editing deps: the identity edit is discarded (and warned
// about, mirroring the registry path), but the dep edit still lands.
let mut manifests = vec![(".".to_string(), manifest(Some("orig")))];
let mut hook = MockHook(|mut pkg: VersionMetadata| {
pkg.name = format!("{}-local", pkg.name);
pkg.version = "9.9.9".to_string();
pkg.dependencies
.insert("is-odd".to_string(), "3.0.1".to_string());
Ok(pkg)
});
apply_read_package_to_importers(&mut hook, &mut manifests)
.await
.unwrap();
// Identity rewrite is ignored — the importer keeps its own name.
assert_eq!(manifests[0].1.name.as_deref(), Some("orig"));
// The dependency edit is still honored.
assert_eq!(
manifests[0]
.1
.dependencies
.get("is-odd")
.map(String::as_str),
Some("3.0.1")
);
}
#[test]
fn importer_to_version_metadata_injects_defaults_for_nameless_root() {
let vm = importer_to_version_metadata(&manifest(None), ".").unwrap();
assert_eq!(vm.name, "");
assert_eq!(vm.version, "0.0.0");
}
#[test]
fn importer_to_version_metadata_carries_all_dep_maps() {
let mut m = manifest(Some("p"));
m.dependencies.insert("a".into(), "1.0.0".into());
m.dev_dependencies.insert("b".into(), "^2".into());
m.optional_dependencies.insert("c".into(), "*".into());
m.peer_dependencies.insert("d".into(), ">=3".into());
let vm = importer_to_version_metadata(&m, ".").unwrap();
assert_eq!(vm.dependencies.get("a").map(String::as_str), Some("1.0.0"));
assert_eq!(vm.dev_dependencies.get("b").map(String::as_str), Some("^2"));
assert_eq!(
vm.optional_dependencies.get("c").map(String::as_str),
Some("*")
);
assert_eq!(
vm.peer_dependencies.get("d").map(String::as_str),
Some(">=3")
);
}
#[test]
fn apply_version_metadata_keeps_dep_edits_and_ignores_identity() {
let mut m = manifest(Some("orig"));
let mut after = importer_to_version_metadata(&m, ".").unwrap();
after.name = "changed".into();
after.version = "9.9.9".into();
after.dependencies.insert("x".into(), "1".into());
apply_version_metadata_to_importer(&mut m, after);
// We never copy identity back, so the importer keeps its own name.
assert_eq!(m.name.as_deref(), Some("orig"));
assert_eq!(m.dependencies.get("x").map(String::as_str), Some("1"));
}
}