use crate::version_satisfies;
use crate::{FxHashMap, FxHashSet};
use aube_lockfile::{DepType, DirectDep, LockedPackage, LockfileGraph};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnmetPeer {
pub from_dep_path: String,
pub from_name: String,
pub peer_name: String,
pub declared: String,
pub found: Option<String>,
}
pub fn detect_unmet_peers(graph: &LockfileGraph) -> Vec<UnmetPeer> {
let mut unmet = Vec::new();
for pkg in graph.packages.values() {
for (peer_name, declared_range) in &pkg.peer_dependencies {
let optional = pkg
.peer_dependencies_meta
.get(peer_name)
.map(|m| m.optional)
.unwrap_or(false);
if optional {
continue;
}
let found_tail = pkg.dependencies.get(peer_name);
let found_version = found_tail.map(|t| canonical_tail(t).to_string());
let satisfied = match &found_version {
Some(v) => version_satisfies(v, declared_range),
None => false,
};
if satisfied {
continue;
}
unmet.push(UnmetPeer {
from_dep_path: pkg.dep_path.clone(),
from_name: pkg.name.clone(),
peer_name: peer_name.clone(),
declared: declared_range.clone(),
found: found_version,
});
}
}
unmet.sort_by(|a, b| {
(a.from_dep_path.as_str(), a.peer_name.as_str())
.cmp(&(b.from_dep_path.as_str(), b.peer_name.as_str()))
});
unmet
}
pub fn hoist_auto_installed_peers(mut graph: LockfileGraph) -> LockfileGraph {
let importer_paths: Vec<String> = graph.importers.keys().cloned().collect();
for importer_path in importer_paths {
let Some(direct_deps) = graph.importers.get(&importer_path) else {
continue;
};
let mut satisfied: FxHashSet<String> = direct_deps.iter().map(|d| d.name.clone()).collect();
let mut additions: Vec<DirectDep> = Vec::new();
for dep_path in direct_deps.iter().map(|d| &d.dep_path) {
let Some(pkg) = graph.packages.get(dep_path) else {
continue;
};
for (peer_name, peer_range) in &pkg.peer_dependencies {
if satisfied.contains(peer_name) {
continue;
}
let resolved_version = pkg.dependencies.get(peer_name).cloned().or_else(|| {
graph
.packages
.values()
.filter(|p| p.name == *peer_name)
.filter_map(|p| {
node_semver::Version::parse(&p.version)
.ok()
.map(|v| (v, p.version.clone()))
})
.max_by(|a, b| a.0.cmp(&b.0))
.map(|(_, s)| s)
});
let Some(version) = resolved_version else {
continue;
};
let canonical_version = canonical_tail(&version).to_string();
let synth_dep_path = format!("{peer_name}@{canonical_version}");
if !graph.packages.contains_key(&synth_dep_path) {
continue;
}
satisfied.insert(peer_name.clone());
additions.push(DirectDep {
name: peer_name.clone(),
dep_path: synth_dep_path,
dep_type: DepType::Production,
specifier: Some(peer_range.clone()),
});
}
}
if !additions.is_empty() {
tracing::debug!(
"hoisted {} auto-installed peer(s) into importer {}",
additions.len(),
importer_path
);
if let Some(deps) = graph.importers.get_mut(&importer_path) {
deps.extend(additions);
deps.sort_by(|a, b| a.name.cmp(&b.name));
}
}
}
graph
}
#[derive(Debug, Clone, Copy)]
pub struct PeerContextOptions {
pub dedupe_peer_dependents: bool,
pub dedupe_peers: bool,
pub resolve_from_workspace_root: bool,
pub peers_suffix_max_length: usize,
}
impl Default for PeerContextOptions {
fn default() -> Self {
Self {
dedupe_peer_dependents: true,
dedupe_peers: false,
resolve_from_workspace_root: true,
peers_suffix_max_length: 1000,
}
}
}
pub fn apply_peer_contexts(
canonical: LockfileGraph,
options: &PeerContextOptions,
) -> Result<LockfileGraph, crate::Error> {
const MAX_ITERATIONS: usize = 16;
let mut current = canonical;
let mut converged = false;
let graph_hash = |g: &LockfileGraph| -> u64 {
let total_deps: usize = g.packages.values().map(|p| p.dependencies.len()).sum();
let mut tokens: Vec<&str> = Vec::with_capacity(g.packages.len() * 3 + total_deps * 2);
for (k, pkg) in &g.packages {
tokens.push(k.as_str());
tokens.push("\x1f");
for (name, tail) in &pkg.dependencies {
tokens.push(name.as_str());
tokens.push(tail.as_str());
}
tokens.push("\x1e");
}
aube_util::hash::ordered_seq_hash(tokens.iter().copied())
};
let mut before = graph_hash(¤t);
for i in 0..MAX_ITERATIONS {
let after_once = apply_peer_contexts_once(current, options);
let next = if options.dedupe_peer_dependents {
dedupe_peer_variants(after_once)
} else {
after_once
};
let after = graph_hash(&next);
if before == after {
tracing::debug!("peer-context pass converged after {i} iteration(s)");
current = next;
converged = true;
break;
}
current = next;
before = after;
}
if !converged {
tracing::error!(
code = aube_codes::errors::ERR_AUBE_PEER_CONTEXT_NOT_CONVERGED,
max_iterations = MAX_ITERATIONS,
"peer-context hit MAX_ITERATIONS={MAX_ITERATIONS} without convergence"
);
return Err(crate::Error::PeerContextDivergence(MAX_ITERATIONS));
}
let current = propagate_peer_suffixes_to_ancestors(current, options);
let result = if options.dedupe_peers {
dedupe_peer_suffixes(current)
} else {
current
};
Ok(result)
}
pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph {
let canonical_base = |key: &str| -> String { canonical_tail(key).to_string() };
let peer_base = |tail: &str| -> String { canonical_tail(tail).to_string() };
let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
for key in graph.packages.keys() {
groups
.entry(canonical_base(key))
.or_default()
.push(key.clone());
}
let mut rewrite: BTreeMap<String, String> = BTreeMap::new();
for (_base, mut keys) in groups {
if keys.len() < 2 {
continue;
}
keys.sort();
let mut parent: Vec<usize> = (0..keys.len()).collect();
fn find(parent: &mut [usize], i: usize) -> usize {
if parent[i] == i {
i
} else {
let r = find(parent, parent[i]);
parent[i] = r;
r
}
}
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
let pa = &graph.packages[&keys[i]];
let pb = &graph.packages[&keys[j]];
if pa.version != pb.version {
continue;
}
let peer_names: BTreeSet<&String> = pa
.peer_dependencies
.keys()
.chain(pb.peer_dependencies.keys())
.collect();
let equivalent = peer_names.iter().all(|name| {
match (
pa.dependencies.get(name.as_str()),
pb.dependencies.get(name.as_str()),
) {
(Some(va), Some(vb)) => peer_base(va) == peer_base(vb),
(None, None) => true,
_ => false,
}
});
if equivalent {
let ri = find(&mut parent, i);
let rj = find(&mut parent, j);
if ri != rj {
parent[ri] = rj;
}
}
}
}
#[allow(clippy::needless_range_loop)]
{
let mut class_rep: BTreeMap<usize, String> = BTreeMap::new();
for i in 0..keys.len() {
let root = find(&mut parent, i);
class_rep
.entry(root)
.and_modify(|cur| {
if keys[i] < *cur {
*cur = keys[i].clone();
}
})
.or_insert_with(|| keys[i].clone());
}
for i in 0..keys.len() {
let root = find(&mut parent, i);
let canonical = class_rep[&root].clone();
if keys[i] != canonical {
rewrite.insert(keys[i].clone(), canonical);
}
}
}
}
if rewrite.is_empty() {
return graph;
}
let LockfileGraph {
importers,
packages,
settings,
overrides,
package_extensions_checksum,
pnpmfile_checksum,
ignored_optional_dependencies,
times,
skipped_optional_dependencies,
catalogs,
bun_config_version,
patched_dependencies,
trusted_dependencies,
runtimes,
extra_fields,
workspace_extra_fields,
} = graph;
let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
for (key, mut pkg) in packages {
if rewrite.contains_key(&key) {
continue;
}
for (dep_name, dep_tail) in pkg.dependencies.iter_mut() {
let dep_key = format!("{dep_name}@{dep_tail}");
if let Some(canonical) = rewrite.get(&dep_key) {
let new_tail = canonical
.strip_prefix(&format!("{dep_name}@"))
.map(|s| s.to_string())
.unwrap_or_else(|| canonical.clone());
*dep_tail = new_tail;
}
}
new_packages.insert(key, pkg);
}
let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
for (importer_path, deps) in importers {
let mut new_deps = Vec::with_capacity(deps.len());
for mut dep in deps {
if let Some(canonical) = rewrite.get(&dep.dep_path) {
dep.dep_path = canonical.clone();
}
new_deps.push(dep);
}
new_importers.insert(importer_path, new_deps);
}
LockfileGraph {
importers: new_importers,
packages: new_packages,
settings,
overrides,
package_extensions_checksum,
pnpmfile_checksum,
ignored_optional_dependencies,
times,
skipped_optional_dependencies,
catalogs,
bun_config_version,
patched_dependencies,
trusted_dependencies,
runtimes,
extra_fields,
workspace_extra_fields,
}
}
fn apply_peer_contexts_once(
canonical: LockfileGraph,
options: &PeerContextOptions,
) -> LockfileGraph {
let mut out_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
let mut name_index: FxHashMap<&str, Vec<&LockedPackage>> =
FxHashMap::with_capacity_and_hasher(canonical.packages.len(), Default::default());
for pkg in canonical.packages.values() {
name_index.entry(pkg.name.as_str()).or_default().push(pkg);
}
let root_scope: FxHashMap<String, String> = canonical
.importers
.get(".")
.map(|deps| scope_map_from_deps(deps))
.unwrap_or_default();
for (importer_path, direct_deps) in &canonical.importers {
let importer_scope = scope_map_from_deps(direct_deps);
let mut new_deps = Vec::with_capacity(direct_deps.len());
for dep in direct_deps {
let mut visiting: FxHashSet<String> = FxHashSet::default();
let new_dep_path = visit_peer_context(
&dep.dep_path,
&canonical,
&name_index,
&importer_scope,
&root_scope,
&mut out_packages,
&mut visiting,
options,
)
.unwrap_or_else(|| dep.dep_path.clone());
new_deps.push(DirectDep {
name: dep.name.clone(),
dep_path: new_dep_path,
dep_type: dep.dep_type,
specifier: dep.specifier.clone(),
});
}
new_importers.insert(importer_path.clone(), new_deps);
}
LockfileGraph {
importers: new_importers,
packages: out_packages,
settings: canonical.settings,
overrides: canonical.overrides,
package_extensions_checksum: canonical.package_extensions_checksum,
pnpmfile_checksum: canonical.pnpmfile_checksum,
ignored_optional_dependencies: canonical.ignored_optional_dependencies,
runtimes: canonical.runtimes,
times: canonical.times,
skipped_optional_dependencies: canonical.skipped_optional_dependencies,
catalogs: canonical.catalogs,
bun_config_version: canonical.bun_config_version,
patched_dependencies: canonical.patched_dependencies,
trusted_dependencies: canonical.trusted_dependencies,
extra_fields: canonical.extra_fields,
workspace_extra_fields: canonical.workspace_extra_fields,
}
}
fn canonical_tail(s: &str) -> &str {
s.split('(').next().unwrap_or(s)
}
fn scope_map_from_deps(deps: &[DirectDep]) -> FxHashMap<String, String> {
let mut out = FxHashMap::with_capacity_and_hasher(deps.len(), Default::default());
for d in deps {
let prefix_len = d.name.len() + 1;
let tail = if d.dep_path.len() > prefix_len
&& d.dep_path.as_bytes().get(d.name.len()) == Some(&b'@')
&& d.dep_path.as_bytes().starts_with(d.name.as_bytes())
{
d.dep_path[prefix_len..].to_string()
} else {
d.dep_path.clone()
};
out.insert(d.name.clone(), tail);
}
out
}
pub(crate) fn is_hashed_peer_suffix(s: &str) -> bool {
let Some(inner) = s.strip_prefix('(').and_then(|x| x.strip_suffix(')')) else {
return false;
};
inner.len() == 32
&& inner
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn short_peer_hash(input: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(input.as_bytes());
let mut out = String::with_capacity(32);
for byte in digest.iter().take(16) {
use std::fmt::Write;
let _ = write!(out, "{byte:02x}");
}
out
}
pub(crate) fn effective_peer_suffix(suffix: &str, max_length: usize) -> String {
let dir_name = suffix
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(suffix);
if dir_name.len() > max_length {
format!("({})", short_peer_hash(dir_name))
} else {
suffix.to_string()
}
}
pub(crate) fn contains_canonical_back_ref(value: &str, canonical: &str) -> bool {
let bytes = value.as_bytes();
let target = canonical.as_bytes();
if target.is_empty() || target.len() > bytes.len() {
return false;
}
let mut i = 0;
while i + target.len() <= bytes.len() {
if &bytes[i..i + target.len()] == target {
let before = if i == 0 { b'\0' } else { bytes[i - 1] };
let after = bytes.get(i + target.len()).copied().unwrap_or(b'\0');
let before_ok = before == b'(';
let after_ok = after == b'(' || after == b')' || after == b'\0';
if before_ok && after_ok {
return true;
}
}
i += 1;
}
false
}
fn outer_paren_segments(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut segments = Vec::new();
let mut i = 0;
while i < bytes.len() && bytes[i] != b'(' {
i += 1;
}
while i < bytes.len() {
if bytes[i] != b'(' {
i += 1;
continue;
}
let start = i;
let mut depth: i32 = 0;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
i += 1;
segments.push(&s[start..i]);
break;
}
}
_ => {}
}
i += 1;
}
if depth != 0 {
break;
}
}
segments
}
fn peer_name_from_segment(seg: &str) -> Option<&str> {
let inner = seg.strip_prefix('(')?;
let scan_end = inner.find('(').unwrap_or(inner.len());
let head = &inner[..scan_end];
head.rfind('@').map(|idx| &head[..idx])
}
fn peer_names_in_segments_recursive(segments: &[&str]) -> BTreeSet<String> {
let mut names = BTreeSet::new();
for seg in segments {
if let Some(name) = peer_name_from_segment(seg) {
names.insert(name.to_string());
}
let Some(inner) = seg.strip_prefix('(').and_then(|s| s.strip_suffix(')')) else {
continue;
};
if let Some(open) = inner.find('(') {
let nested = &inner[open..];
let nested_segments = outer_paren_segments(nested);
for nested_name in peer_names_in_segments_recursive(&nested_segments) {
names.insert(nested_name);
}
}
}
names
}
fn propagate_peer_suffixes_to_ancestors(
graph: LockfileGraph,
options: &PeerContextOptions,
) -> LockfileGraph {
let mut forward: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut has_own_peers: BTreeMap<String, bool> = BTreeMap::new();
let mut provides: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (key, pkg) in &graph.packages {
let children: Vec<String> = pkg
.dependencies
.iter()
.map(|(n, t)| format!("{n}@{t}"))
.filter(|k| graph.packages.contains_key(k))
.collect();
forward.insert(key.clone(), children);
has_own_peers.insert(key.clone(), !pkg.peer_dependencies.is_empty());
let supplied: BTreeSet<String> = pkg
.dependencies
.keys()
.chain(pkg.optional_dependencies.keys())
.cloned()
.collect();
provides.insert(key.clone(), supplied);
}
let mut cumulative: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
let mut visiting: BTreeSet<String> = BTreeSet::new();
fn collect(
key: &str,
forward: &BTreeMap<String, Vec<String>>,
has_own_peers: &BTreeMap<String, bool>,
provides: &BTreeMap<String, BTreeSet<String>>,
cumulative: &mut BTreeMap<String, BTreeMap<String, String>>,
visiting: &mut BTreeSet<String>,
) -> BTreeMap<String, String> {
if let Some(c) = cumulative.get(key) {
return c.clone();
}
if !visiting.insert(key.to_string()) {
return BTreeMap::new();
}
let self_segments = outer_paren_segments(key);
let mut acc: BTreeMap<String, String> = BTreeMap::new();
for seg in &self_segments {
if let Some(name) = peer_name_from_segment(seg) {
acc.entry(name.to_string())
.or_insert_with(|| seg.to_string());
}
}
if has_own_peers.get(key).copied().unwrap_or(false) {
visiting.remove(key);
cumulative.insert(key.to_string(), acc.clone());
return acc;
}
let canonical_name = canonical_tail(key)
.rsplit_once('@')
.map(|(name, _ver)| name.to_string())
.unwrap_or_default();
let mut suppressed: BTreeSet<String> = peer_names_in_segments_recursive(&self_segments);
if !canonical_name.is_empty() {
suppressed.insert(canonical_name);
}
if let Some(supplied) = provides.get(key) {
suppressed.extend(supplied.iter().cloned());
}
if let Some(children) = forward.get(key) {
for child in children {
let child_peers = collect(
child,
forward,
has_own_peers,
provides,
cumulative,
visiting,
);
for (name, seg) in child_peers {
if suppressed.contains(&name) {
continue;
}
acc.entry(name).or_insert(seg);
}
}
}
visiting.remove(key);
cumulative.insert(key.to_string(), acc.clone());
acc
}
let pkg_keys: Vec<String> = graph.packages.keys().cloned().collect();
for key in &pkg_keys {
collect(
key,
&forward,
&has_own_peers,
&provides,
&mut cumulative,
&mut visiting,
);
}
for deps in graph.importers.values() {
for dep in deps {
collect(
&dep.dep_path,
&forward,
&has_own_peers,
&provides,
&mut cumulative,
&mut visiting,
);
}
}
let mut rewrite: BTreeMap<String, String> = BTreeMap::new();
for key in &pkg_keys {
let Some(segments) = cumulative.get(key) else {
continue;
};
if graph
.packages
.get(key)
.and_then(|p| p.local_source.as_ref())
.is_some_and(|s| s.is_globally_shareable())
{
continue;
}
let canonical = canonical_tail(key);
if is_hashed_peer_suffix(&key[canonical.len()..]) {
continue;
}
let suffix: String = segments.values().cloned().collect();
let effective_suffix = effective_peer_suffix(&suffix, options.peers_suffix_max_length);
let new_key = format!("{canonical}{effective_suffix}");
if new_key != *key {
rewrite.insert(key.clone(), new_key);
}
}
if rewrite.is_empty() {
return graph;
}
let rewrite_tail = |child_name: &str, tail: &str| -> String {
let old_key = format!("{child_name}@{tail}");
match rewrite.get(&old_key) {
Some(new_key) => new_key
.strip_prefix(&format!("{child_name}@"))
.map(|s| s.to_string())
.unwrap_or_else(|| tail.to_string()),
None => tail.to_string(),
}
};
let LockfileGraph {
importers,
packages,
settings,
overrides,
package_extensions_checksum,
pnpmfile_checksum,
ignored_optional_dependencies,
times,
skipped_optional_dependencies,
catalogs,
bun_config_version,
patched_dependencies,
trusted_dependencies,
runtimes,
extra_fields,
workspace_extra_fields,
} = graph;
let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
for (old_key, mut pkg) in packages {
let new_key = rewrite.get(&old_key).cloned().unwrap_or(old_key);
for (name, tail) in pkg.dependencies.iter_mut() {
*tail = rewrite_tail(name, tail);
}
for (name, tail) in pkg.optional_dependencies.iter_mut() {
*tail = rewrite_tail(name, tail);
}
pkg.dep_path = new_key.clone();
new_packages.entry(new_key).or_insert(pkg);
}
let new_importers: BTreeMap<String, Vec<DirectDep>> = importers
.into_iter()
.map(|(path, deps)| {
let rewritten = deps
.into_iter()
.map(|d| {
let new_dep_path = rewrite.get(&d.dep_path).cloned().unwrap_or(d.dep_path);
DirectDep {
name: d.name,
dep_path: new_dep_path,
dep_type: d.dep_type,
specifier: d.specifier,
}
})
.collect();
(path, rewritten)
})
.collect();
LockfileGraph {
importers: new_importers,
packages: new_packages,
settings,
overrides,
package_extensions_checksum,
pnpmfile_checksum,
ignored_optional_dependencies,
times,
skipped_optional_dependencies,
catalogs,
bun_config_version,
patched_dependencies,
trusted_dependencies,
runtimes,
extra_fields,
workspace_extra_fields,
}
}
pub(crate) fn dedupe_peer_suffixes(graph: LockfileGraph) -> LockfileGraph {
let mut target_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut intended: BTreeMap<String, String> = BTreeMap::new();
for key in graph.packages.keys() {
let new_key = apply_dedupe_peers_to_key(key);
*target_counts.entry(new_key.clone()).or_insert(0) += 1;
intended.insert(key.clone(), new_key);
}
let rewrite: BTreeMap<String, String> = intended
.into_iter()
.map(|(old, new)| {
if target_counts.get(&new).copied().unwrap_or(0) > 1 {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_PEER_DEDUPE_COLLISION,
"dedupe-peers: collision on {new} — keeping {old} in full form to avoid \
dropping a distinct peer-variant"
);
(old.clone(), old)
} else {
(old, new)
}
})
.collect();
let rewrite_tail = |child_name: &str, tail: &str| -> String {
let old_key = format!("{child_name}@{tail}");
match rewrite.get(&old_key) {
Some(new_key) => new_key
.strip_prefix(&format!("{child_name}@"))
.map(|s| s.to_string())
.unwrap_or_else(|| tail.to_string()),
None => apply_dedupe_peers_to_tail(tail),
}
};
let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
for (old_key, pkg) in graph.packages {
let new_key = rewrite
.get(&old_key)
.cloned()
.unwrap_or_else(|| old_key.clone());
let new_dependencies: BTreeMap<String, String> = pkg
.dependencies
.into_iter()
.map(|(n, v)| {
let new_v = rewrite_tail(&n, &v);
(n, new_v)
})
.collect();
let new_optional_dependencies: BTreeMap<String, String> = pkg
.optional_dependencies
.into_iter()
.map(|(n, v)| {
let new_v = rewrite_tail(&n, &v);
(n, new_v)
})
.collect();
new_packages.insert(
new_key.clone(),
LockedPackage {
name: pkg.name,
version: pkg.version,
integrity: pkg.integrity,
dependencies: new_dependencies,
optional_dependencies: new_optional_dependencies,
peer_dependencies: pkg.peer_dependencies,
peer_dependencies_meta: pkg.peer_dependencies_meta,
dep_path: new_key,
local_source: pkg.local_source,
os: pkg.os,
cpu: pkg.cpu,
libc: pkg.libc,
bundled_dependencies: pkg.bundled_dependencies,
optional: pkg.optional,
transitive_peer_dependencies: pkg.transitive_peer_dependencies,
tarball_url: pkg.tarball_url,
registry_git_hosted: pkg.registry_git_hosted,
alias_of: pkg.alias_of,
yarn_checksum: pkg.yarn_checksum,
engines: pkg.engines,
bin: pkg.bin,
declared_dependencies: pkg.declared_dependencies,
license: pkg.license,
funding_url: pkg.funding_url,
extra_meta: pkg.extra_meta,
},
);
}
let new_importers: BTreeMap<String, Vec<DirectDep>> = graph
.importers
.into_iter()
.map(|(path, deps)| {
let rewritten = deps
.into_iter()
.map(|d| {
let new_dep_path = rewrite
.get(&d.dep_path)
.cloned()
.unwrap_or_else(|| apply_dedupe_peers_to_key(&d.dep_path));
DirectDep {
name: d.name,
dep_path: new_dep_path,
dep_type: d.dep_type,
specifier: d.specifier,
}
})
.collect();
(path, rewritten)
})
.collect();
LockfileGraph {
importers: new_importers,
packages: new_packages,
settings: graph.settings,
overrides: graph.overrides,
package_extensions_checksum: graph.package_extensions_checksum,
pnpmfile_checksum: graph.pnpmfile_checksum,
ignored_optional_dependencies: graph.ignored_optional_dependencies,
runtimes: graph.runtimes,
times: graph.times,
skipped_optional_dependencies: graph.skipped_optional_dependencies,
catalogs: graph.catalogs,
bun_config_version: graph.bun_config_version,
patched_dependencies: graph.patched_dependencies,
trusted_dependencies: graph.trusted_dependencies,
extra_fields: graph.extra_fields,
workspace_extra_fields: graph.workspace_extra_fields,
}
}
pub(crate) fn apply_dedupe_peers_to_key(key: &str) -> String {
let mut parts = key.split('(');
let Some(first) = parts.next() else {
return key.to_string();
};
let mut out = String::with_capacity(key.len());
out.push_str(first);
for part in parts {
out.push('(');
if let Some(at_idx) = part.rfind('@') {
let close_idx = part.find([')', '(']).unwrap_or(usize::MAX);
if at_idx < close_idx {
out.push_str(&part[at_idx + 1..]);
continue;
}
}
out.push_str(part);
}
out
}
fn apply_dedupe_peers_to_tail(tail: &str) -> String {
apply_dedupe_peers_to_key(tail)
}
#[allow(clippy::too_many_arguments)]
fn visit_peer_context<'g>(
input_dep_path: &str,
graph: &'g LockfileGraph,
name_index: &FxHashMap<&'g str, Vec<&'g LockedPackage>>,
ancestor_scope: &FxHashMap<String, String>,
root_scope: &FxHashMap<String, String>,
out_packages: &mut BTreeMap<String, LockedPackage>,
visiting: &mut FxHashSet<String>,
options: &PeerContextOptions,
) -> Option<String> {
let pkg = graph.packages.get(input_dep_path)?;
let canonical_base = canonical_tail(input_dep_path).to_string();
let mut peer_context: Vec<(String, String)> = Vec::new();
for (peer_name, declared_range) in &pkg.peer_dependencies {
let satisfies_declared = |v: &str| -> bool {
let canonical = canonical_tail(v);
version_satisfies(canonical, declared_range)
};
let from_ancestor = ancestor_scope
.get(peer_name)
.filter(|v| satisfies_declared(v))
.cloned();
let from_ancestor_incompatible = ancestor_scope.get(peer_name).cloned();
let from_pkg_deps = pkg
.dependencies
.get(peer_name)
.filter(|v| satisfies_declared(v))
.cloned();
let from_pkg_deps_incompatible = pkg.dependencies.get(peer_name).cloned();
let from_root = if options.resolve_from_workspace_root {
root_scope
.get(peer_name)
.filter(|v| satisfies_declared(v))
.cloned()
} else {
None
};
let from_root_incompatible = if options.resolve_from_workspace_root {
root_scope.get(peer_name).cloned()
} else {
None
};
let from_graph_scan = || {
name_index
.get(peer_name.as_str())
.into_iter()
.flat_map(|bucket| bucket.iter().copied())
.filter(|p| version_satisfies(&p.version, declared_range))
.filter_map(|p| {
let tail = p
.dep_path
.strip_prefix(&format!("{}@", p.name))
.map(|s| s.to_string())
.unwrap_or_else(|| p.version.clone());
node_semver::Version::parse(&p.version)
.ok()
.map(|ver| (ver, tail))
})
.max_by(|a, b| a.0.cmp(&b.0))
.map(|(_, tail)| tail)
};
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)
} else {
from_ancestor
.or(from_pkg_deps)
.or(from_ancestor_incompatible)
.or(from_pkg_deps_incompatible)
.or(from_root)
.or_else(from_graph_scan)
.or(from_root_incompatible)
};
if let Some(version) = resolved {
peer_context.push((peer_name.clone(), version));
}
}
peer_context.sort_by(|a, b| a.0.cmp(&b.0));
let suffix: String = peer_context
.iter()
.map(|(n, v)| {
let cycles_back = contains_canonical_back_ref(v, &canonical_base);
let display_v = if cycles_back {
canonical_tail(v).to_string()
} else {
v.clone()
};
format!("({n}@{display_v})")
})
.collect();
let effective_suffix = effective_peer_suffix(&suffix, options.peers_suffix_max_length);
let contextualized = format!("{canonical_base}{effective_suffix}");
if out_packages.contains_key(&contextualized) || visiting.contains(&contextualized) {
return Some(contextualized);
}
visiting.insert(contextualized.clone());
let mut child_scope = ancestor_scope.clone();
for (name, version) in &pkg.dependencies {
child_scope.insert(name.clone(), version.clone());
}
for (name, version) in &peer_context {
child_scope.insert(name.clone(), version.clone());
}
let peer_context_versions: FxHashMap<String, String> = peer_context.iter().cloned().collect();
let mut new_dependencies: BTreeMap<String, String> = BTreeMap::new();
let mut visited_dep_names: FxHashSet<String> = FxHashSet::default();
for (child_name, child_version_tail) in &pkg.dependencies {
let lookup_tail = match peer_context_versions.get(child_name) {
Some(v) => v.clone(),
None => child_version_tail.clone(),
};
let child_canonical_dep_path = format!("{child_name}@{lookup_tail}");
let child_new = visit_peer_context(
&child_canonical_dep_path,
graph,
name_index,
&child_scope,
root_scope,
out_packages,
visiting,
options,
);
let new_tail = match child_new {
Some(new_dep_path) => new_dep_path
.strip_prefix(&format!("{child_name}@"))
.map(|s| s.to_string())
.unwrap_or_else(|| lookup_tail.clone()),
None => lookup_tail.clone(),
};
new_dependencies.insert(child_name.clone(), new_tail);
visited_dep_names.insert(child_name.clone());
}
for (peer_name, peer_version) in &peer_context {
if visited_dep_names.contains(peer_name) {
continue;
}
let child_canonical_dep_path = format!("{peer_name}@{peer_version}");
let child_new = visit_peer_context(
&child_canonical_dep_path,
graph,
name_index,
&child_scope,
root_scope,
out_packages,
visiting,
options,
);
if let Some(new_dep_path) = child_new {
let new_tail = new_dep_path
.strip_prefix(&format!("{peer_name}@"))
.map(|s| s.to_string())
.unwrap_or_else(|| peer_version.clone());
new_dependencies.insert(peer_name.clone(), new_tail);
}
}
visiting.remove(&contextualized);
let new_optional_dependencies: BTreeMap<String, String> = pkg
.optional_dependencies
.keys()
.filter_map(|name| {
new_dependencies
.get(name)
.map(|tail| (name.clone(), tail.clone()))
})
.collect();
out_packages.insert(
contextualized.clone(),
LockedPackage {
name: pkg.name.clone(),
version: pkg.version.clone(),
integrity: pkg.integrity.clone(),
dependencies: new_dependencies,
optional_dependencies: new_optional_dependencies,
peer_dependencies: pkg.peer_dependencies.clone(),
peer_dependencies_meta: pkg.peer_dependencies_meta.clone(),
dep_path: contextualized.clone(),
local_source: pkg.local_source.clone(),
os: pkg.os.clone(),
cpu: pkg.cpu.clone(),
libc: pkg.libc.clone(),
bundled_dependencies: pkg.bundled_dependencies.clone(),
optional: pkg.optional,
transitive_peer_dependencies: pkg.transitive_peer_dependencies.clone(),
tarball_url: pkg.tarball_url.clone(),
registry_git_hosted: pkg.registry_git_hosted,
alias_of: pkg.alias_of.clone(),
yarn_checksum: pkg.yarn_checksum.clone(),
engines: pkg.engines.clone(),
bin: pkg.bin.clone(),
declared_dependencies: pkg.declared_dependencies.clone(),
license: pkg.license.clone(),
funding_url: pkg.funding_url.clone(),
extra_meta: pkg.extra_meta.clone(),
},
);
Some(contextualized)
}
#[cfg(test)]
mod tests {
use super::*;
use aube_lockfile::{DepType, DirectDep, PeerDepMeta};
fn locked(name: &str, deps: &[(&str, &str)]) -> LockedPackage {
LockedPackage {
name: name.to_string(),
version: "1.0.0".to_string(),
dep_path: format!("{name}@1.0.0"),
dependencies: deps
.iter()
.map(|(n, v)| ((*n).to_string(), (*v).to_string()))
.collect(),
..Default::default()
}
}
fn graph_with_cousin_peer() -> LockfileGraph {
let mut g = LockfileGraph::default();
g.importers.insert(
".".to_string(),
vec![DirectDep {
name: "app".to_string(),
dep_path: "app@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("1.0.0".to_string()),
}],
);
for p in [
locked("app", &[("plugin", "1.0.0"), ("sibling", "1.0.0")]),
locked("plugin", &[]),
locked("sibling", &[("theme", "1.0.0")]),
locked("theme", &[]),
] {
g.packages.insert(p.dep_path.clone(), p);
}
g
}
#[test]
fn optional_peer_is_not_bound_via_graph_scan() {
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 out = apply_peer_contexts(g, &PeerContextOptions::default()).expect("peer pass");
assert!(
out.packages.contains_key("plugin@1.0.0"),
"plugin keeps bare key"
);
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"
);
}
#[test]
fn required_peer_still_binds_via_graph_scan() {
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());
let out = apply_peer_contexts(g, &PeerContextOptions::default()).expect("peer pass");
assert!(
out.packages.contains_key("plugin@1.0.0(theme@1.0.0)"),
"a required peer should still resolve through the graph-wide scan"
);
}
}