Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 228 additions & 19 deletions crates/aube-resolver/src/peer_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,73 @@ pub fn apply_peer_contexts(
Ok(result)
}

/// pnpm's `dedupe-peer-dependents` also folds a variant's *optional*
/// peers together with those of every other variant of the same package
/// that does not disagree with it: a variant resolved with `jiti` and one
/// resolved with `yaml` both end up as `(jiti@…)(yaml@…)`. Required peers
/// already converge through the graph-wide scan, so only optional peers
/// need this. Each variant, in key order, takes the optional peers it
/// declares but lacks from every other variant whose already-resolved
/// peers all match its own (variants with more resolved dependencies
/// first). The next fixed-point pass reads the added
/// entries back through `pkg.dependencies` and renames the key.
fn absorb_compatible_optional_peers(graph: &mut LockfileGraph, keys: &[String]) {
let original: BTreeMap<&String, BTreeMap<String, String>> = keys
.iter()
.map(|k| (k, graph.packages[k].dependencies.clone()))
.collect();
for key in keys {
let mut absorbed: Vec<(String, String)> = Vec::new();
let mut current = graph.packages[key].dependencies.clone();
let optional_peers: Vec<String> = graph.packages[key]
.peer_dependencies_meta
.iter()
.filter(|(name, meta)| {
meta.optional && graph.packages[key].peer_dependencies.contains_key(*name)
})
.map(|(name, _)| name.clone())
.collect();
if optional_peers.is_empty() {
continue;
}
let mut others: Vec<&String> = keys.iter().filter(|k| *k != key).collect();
// Richer variants first: `(jiti@2)(yaml@2)` outranks `(jiti@1)`.
others.sort_by_key(|k| std::cmp::Reverse(original[*k].len()));
for other_key in others {
let other = &graph.packages[other_key];
let other_deps = &original[other_key];
let peer_names = || {
graph.packages[key]
.peer_dependencies
.keys()
.chain(other.peer_dependencies.keys())
};
let conflicts = peer_names().any(|name| {
matches!(
(current.get(name), other_deps.get(name)),
(Some(a), Some(b)) if canonical_tail(a) != canonical_tail(b)
)
});
if conflicts {
continue;
}
for name in &optional_peers {
if current.contains_key(name) {
continue;
}
if let Some(tail) = other_deps.get(name) {
current.insert(name.clone(), tail.clone());
absorbed.push((name.clone(), tail.clone()));
}
}
}
if let Some(pkg) = graph.packages.get_mut(key) {
pkg.optional_dependencies.extend(absorbed.iter().cloned());
pkg.dependencies.extend(absorbed);
}
}
}

/// Cross-subtree peer-variant dedupe. When `dedupe-peer-dependents` is
/// on, packages that landed at different contextualized dep_paths but
/// resolved every declared peer to the *same* version (ignoring the
Expand All @@ -490,7 +557,7 @@ pub fn apply_peer_contexts(
///
/// Packages whose `peer_dependencies` map is empty — i.e. the canonical
/// base already has only one variant — are skipped.
pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph {
pub(crate) fn dedupe_peer_variants(mut graph: LockfileGraph) -> LockfileGraph {
let canonical_base = |key: &str| -> String { canonical_tail(key).to_string() };
// Only the peer-bearing part of the resolved peer tail is
// comparable across subtrees — the nested suffix could differ even
Expand All @@ -514,6 +581,7 @@ pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph {
}
// Deterministic order for canonical selection + stable hashing.
keys.sort();
absorb_compatible_optional_peers(&mut graph, &keys);
// Union-find over equivalence classes. Two variants are
// equivalent when each declared peer name resolves to the same
// peer base in both (or is missing from both).
Expand Down Expand Up @@ -1834,22 +1902,29 @@ fn visit_peer_context<'g>(
.map(|(_, provider)| provider)
};

// pnpm resolves an *optional* peer (one flagged
// `peerDependenciesMeta.optional`) only from the resolution path it
// is actually on — the nearest ancestor, the package's own
// auto-installed deps, or the workspace root — and otherwise leaves
// it unresolved so it surfaces under `transitivePeerDependencies`.
// It never reaches for a range-incompatible version or scans the
// whole graph for an unrelated copy. Mirroring that is what lets
// `typescript` (an optional peer the root provides) take a dep-path
// suffix while debug's optional `supports-color` (which nothing on
// the path provides) bubbles up instead of binding to a cousin.
// pnpm 11 resolves an *optional* peer (one flagged
// `peerDependenciesMeta.optional`) from the nearest ancestor, the
// package's own auto-installed deps, or the workspace root, and
// otherwise from any version already resolved elsewhere in the
// graph that satisfies the range (even in an unrelated importer's
// subtree). Unlike a required peer it never binds to a
// range-incompatible version. That graph-wide lookup is part of
// pnpm's `dedupe-peer-dependents` pass, so it is off with the
// setting off. A package nothing in the graph
// satisfies (debug's `supports-color` when no `chalk@4` is
// installed) stays unresolved and surfaces under
// `transitivePeerDependencies`.
let is_optional = pkg
.peer_dependencies_meta
.get(peer_name)
.is_some_and(|m| m.optional);
let resolved = if is_optional {
from_ancestor.or(from_pkg_deps).or(from_root)
from_ancestor.or(from_pkg_deps).or(from_root).or_else(|| {
options
.dedupe_peer_dependents
.then(from_graph_scan)
.flatten()
})
} else {
from_ancestor
.or(from_pkg_deps)
Expand Down Expand Up @@ -2035,9 +2110,18 @@ fn visit_peer_context<'g>(
}

visiting.remove(&contextualized);
// pnpm lists a resolved *optional* peer under the snapshot's
// `optionalDependencies`, next to the package's real optional deps.
// A name the package already lists as a plain dependency stays required.
let new_optional_dependencies: BTreeMap<String, String> = pkg
.optional_dependencies
.keys()
.chain(peer_context.iter().map(|(name, _)| name).filter(|name| {
pkg.peer_dependencies_meta
.get(*name)
.is_some_and(|m| m.optional)
&& !pkg.dependencies.contains_key(*name)
}))
Comment thread
jdx marked this conversation as resolved.
Resolved
.filter_map(|name| {
new_dependencies
.get(name)
Expand Down Expand Up @@ -2179,7 +2263,10 @@ mod tests {
}

#[test]
fn optional_peer_is_not_bound_via_graph_scan() {
fn optional_peer_binds_via_graph_scan_and_lists_under_optional_dependencies() {
// pnpm 11 resolves an optional peer from any satisfying version
// already in the graph (here `theme`, a cousin of `plugin`) and
// records it under the snapshot's `optionalDependencies`.
let mut g = graph_with_cousin_peer();
let plugin = g.packages.get_mut("plugin@1.0.0").expect("plugin present");
plugin
Expand All @@ -2191,17 +2278,139 @@ mod tests {

let out = apply_peer_contexts(g, &PeerContextOptions::default()).expect("peer pass");

assert!(
out.packages.contains_key("plugin@1.0.0"),
"plugin keeps bare key"
let plugin = out
.packages
.get("plugin@1.0.0(theme@1.0.0)")
.expect("optional peer present in the graph takes a suffix");
assert!(plugin.optional_dependencies.contains_key("theme"));
}

#[test]
fn absorb_copies_compatible_optional_peers_and_keeps_conflicts_apart() {
let peer_meta = PeerDepMeta { optional: true };
let variant = |key: &str, deps: &[(&str, &str)]| {
let mut p = LockedPackage {
version: "1.0.0".to_string(),
dep_path: key.to_string(),
..locked("vite", deps)
};
for peer in ["jiti", "yaml"] {
p.peer_dependencies
.insert(peer.to_string(), "*".to_string());
p.peer_dependencies_meta
.insert(peer.to_string(), peer_meta.clone());
}
p
};
let mut g = LockfileGraph::default();
let keys = [
"vite@1.0.0(jiti@1.0.0)",
"vite@1.0.0(jiti@2.0.0)",
"vite@1.0.0(yaml@1.0.0)",
];
for (key, deps) in keys.iter().zip([
vec![("jiti", "1.0.0")],
vec![("jiti", "2.0.0")],
vec![("yaml", "1.0.0")],
]) {
g.packages.insert((*key).to_string(), variant(key, &deps));
}
let keys: Vec<String> = keys.iter().map(|k| (*k).to_string()).collect();

absorb_compatible_optional_peers(&mut g, &keys);

let deps = |key: &str| g.packages[key].dependencies.clone();
let expect = |pairs: &[(&str, &str)]| -> BTreeMap<String, String> {
pairs
.iter()
.map(|(n, v)| ((*n).to_string(), (*v).to_string()))
.collect()
};
assert_eq!(
deps("vite@1.0.0(jiti@1.0.0)"),
expect(&[("jiti", "1.0.0"), ("yaml", "1.0.0")])
);
assert_eq!(
deps("vite@1.0.0(jiti@2.0.0)"),
expect(&[("jiti", "2.0.0"), ("yaml", "1.0.0")])
);
// `yaml`-only absorbs the first compatible variant, never both.
assert_eq!(
deps("vite@1.0.0(yaml@1.0.0)"),
expect(&[("jiti", "1.0.0"), ("yaml", "1.0.0")])
);
assert!(
!out.packages.contains_key("plugin@1.0.0(theme@1.0.0)"),
"an optional peer reachable only via the graph scan must stay \
unresolved so it surfaces under transitivePeerDependencies"
g.packages["vite@1.0.0(jiti@1.0.0)"]
.optional_dependencies
.contains_key("yaml")
);
}

#[test]
fn optional_peer_variants_absorb_each_others_compatible_peers() {
// importer a resolves vite with `jiti`, importer b with `yaml`;
// pnpm's dedupe-peer-dependents folds both into `(jiti)(yaml)`.
let versioned = |name: &str| LockedPackage {
version: "1.0.0".to_string(),
..locked(name, &[])
};
let mut vite = versioned("vite");
for peer in ["jiti", "yaml"] {
vite.peer_dependencies
.insert(peer.to_string(), "*".to_string());
vite.peer_dependencies_meta
.insert(peer.to_string(), PeerDepMeta { optional: true });
}
let mut g = LockfileGraph::default();
for (importer, peer) in [("a", "jiti"), ("b", "yaml")] {
g.importers.insert(
importer.to_string(),
["vite", peer]
.iter()
.map(|n| DirectDep {
name: (*n).to_string(),
dep_path: format!("{n}@1.0.0"),
dep_type: DepType::Production,
specifier: Some("*".to_string()),
})
.collect(),
);
}
for p in [vite, versioned("jiti"), versioned("yaml")] {
g.packages.insert(p.dep_path.clone(), p);
}

let out = apply_peer_contexts(g, &PeerContextOptions::default()).expect("peer pass");

let vites: Vec<&String> = out
.packages
.keys()
.filter(|k| k.starts_with("vite@"))
.collect();
assert_eq!(vites, ["vite@1.0.0(jiti@1.0.0)(yaml@1.0.0)"]);
Comment thread
jdx marked this conversation as resolved.
Resolved
}

#[test]
fn optional_peer_graph_scan_is_off_without_dedupe_peer_dependents() {
let mut g = graph_with_cousin_peer();
let plugin = g.packages.get_mut("plugin@1.0.0").expect("plugin present");
plugin
.peer_dependencies
.insert("theme".to_string(), "*".to_string());
plugin
.peer_dependencies_meta
.insert("theme".to_string(), PeerDepMeta { optional: true });
let options = PeerContextOptions {
dedupe_peer_dependents: false,
..PeerContextOptions::default()
};

let out = apply_peer_contexts(g, &options).expect("peer pass");

assert!(out.packages.contains_key("plugin@1.0.0"));
assert!(!out.packages.contains_key("plugin@1.0.0(theme@1.0.0)"));
}

#[test]
fn required_peer_still_binds_via_graph_scan() {
// Same shape, but `theme` is a *required* peer (no meta entry):
Expand Down
11 changes: 5 additions & 6 deletions crates/aube-resolver/src/resolve/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1178,12 +1178,11 @@ impl<'a> ResolveDriver<'a> {
// range (debug's optional `supports-color`, typescript-eslint's
// optional `typescript`, …). Synthesize the same `*` so peer
// context resolves these exactly like pnpm: an optional peer that
// a real ancestor / the workspace root provides (typescript) gets
// a dep-path suffix, while one nothing on the path provides
// (supports-color) is left unresolved and surfaces under
// `transitivePeerDependencies`. The optional-peer branch in
// `visit_peer_context` is what keeps the graph-wide scan from
// binding the latter to an unrelated copy in the tree.
// an ancestor, the workspace root, or any satisfying version
// elsewhere in the graph provides gets a dep-path suffix, while
// one nothing in the graph satisfies is left unresolved and
// surfaces under `transitivePeerDependencies`. See the
// optional-peer branch in `visit_peer_context`.
let mut peer_deps = version_meta.peer_dependencies.clone();
for name in version_meta.peer_dependencies_meta.keys() {
peer_deps
Expand Down
Loading