pub mod bun;
pub mod dep_path_filename;
pub mod graph_hash;
pub mod merge;
pub mod npm;
pub mod pnpm;
pub mod yarn;
pub use merge::{MergeReport, merge_branch_lockfiles};
use smallvec::SmallVec;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub type PlatformList = SmallVec<[String; 2]>;
#[derive(Debug, Clone, Default)]
pub struct LockfileGraph {
pub importers: BTreeMap<String, Vec<DirectDep>>,
pub packages: BTreeMap<String, LockedPackage>,
pub settings: LockfileSettings,
pub overrides: BTreeMap<String, String>,
pub ignored_optional_dependencies: BTreeSet<String>,
pub times: BTreeMap<String, String>,
pub skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
pub catalogs: BTreeMap<String, BTreeMap<String, CatalogEntry>>,
pub bun_config_version: Option<u32>,
pub patched_dependencies: BTreeMap<String, String>,
pub trusted_dependencies: Vec<String>,
pub extra_fields: BTreeMap<String, serde_json::Value>,
pub workspace_extra_fields: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogEntry {
pub specifier: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockfileSettings {
pub auto_install_peers: bool,
pub exclude_links_from_lockfile: bool,
pub lockfile_include_tarball_url: bool,
}
impl Default for LockfileSettings {
fn default() -> Self {
Self {
auto_install_peers: true,
exclude_links_from_lockfile: false,
lockfile_include_tarball_url: false,
}
}
}
#[derive(Debug, Clone)]
pub struct DirectDep {
pub name: String,
pub dep_path: String,
pub dep_type: DepType,
pub specifier: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepType {
Production,
Dev,
Optional,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalSource {
Directory(PathBuf),
Tarball(PathBuf),
Link(PathBuf),
Git(GitSource),
RemoteTarball(RemoteTarballSource),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteTarballSource {
pub url: String,
pub integrity: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitSource {
pub url: String,
pub committish: Option<String>,
pub resolved: String,
pub subpath: Option<String>,
}
impl LocalSource {
pub fn path(&self) -> Option<&Path> {
match self {
LocalSource::Directory(p) | LocalSource::Tarball(p) | LocalSource::Link(p) => Some(p),
LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
}
}
pub fn kind_str(&self) -> &'static str {
match self {
LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
LocalSource::Link(_) => "link",
LocalSource::Git(_) => "git",
LocalSource::RemoteTarball(_) => "url",
}
}
pub fn path_posix(&self) -> String {
self.path()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default()
}
pub fn specifier(&self) -> String {
match self {
LocalSource::Git(g) => match &g.subpath {
Some(sub) => format!("{}#{}&path:/{}", g.url, g.resolved, sub),
None => format!("{}#{}", g.url, g.resolved),
},
LocalSource::RemoteTarball(t) => t.url.clone(),
_ => format!("{}:{}", self.kind_str(), self.path_posix()),
}
}
pub fn dep_path(&self, name: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
match self {
LocalSource::Git(g) => {
hasher.update(g.url.as_bytes());
hasher.update(b"#");
hasher.update(g.resolved.as_bytes());
if let Some(sub) = &g.subpath {
hasher.update(b"&path:/");
hasher.update(sub.as_bytes());
}
}
LocalSource::RemoteTarball(t) => {
hasher.update(t.url.as_bytes());
}
_ => hasher.update(self.path_posix().as_bytes()),
}
let digest = hasher.finalize();
let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("{name}@{}+{short}", self.kind_str())
}
pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
if let Some((url, committish, subpath)) = parse_git_spec(spec) {
return Some(LocalSource::Git(GitSource {
url,
committish,
resolved: String::new(),
subpath,
}));
}
if Self::looks_like_remote_tarball_url(spec) {
return Some(LocalSource::RemoteTarball(RemoteTarballSource {
url: spec.to_string(),
integrity: String::new(),
}));
}
let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
("file", r)
} else if let Some(r) = spec.strip_prefix("link:") {
("link", r)
} else {
return None;
};
let rel = PathBuf::from(rest);
let abs = project_root.join(&rel);
if kind == "link" {
return Some(LocalSource::Link(rel));
}
if abs.is_file() && Self::path_looks_like_tarball(&rel) {
return Some(LocalSource::Tarball(rel));
}
Some(LocalSource::Directory(rel))
}
pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
spec.starts_with("https://") || spec.starts_with("http://")
}
pub fn path_looks_like_tarball(path: &Path) -> bool {
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return false,
};
let lower = name.to_ascii_lowercase();
lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
}
}
pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>, Option<String>)> {
let (body, committish, subpath) = match spec.find('#') {
Some(idx) => {
let (c, s) = parse_git_fragment(&spec[idx + 1..]);
(&spec[..idx], c, s)
}
None => (spec, None, None),
};
let is_bare_transport = body.starts_with("https://")
|| body.starts_with("http://")
|| body.starts_with("ssh://")
|| body.starts_with("file://");
let url = if let Some(rest) = body.strip_prefix("git+") {
rest.to_string()
} else if body.starts_with("git://") {
body.to_string()
} else if let Some(scp) = parse_scp_url(body) {
scp
} else if let Some(path) = body.strip_prefix("github:") {
format!("https://github.com/{path}.git")
} else if let Some(path) = body.strip_prefix("gitlab:") {
format!("https://gitlab.com/{path}.git")
} else if let Some(path) = body.strip_prefix("bitbucket:") {
format!("https://bitbucket.org/{path}.git")
} else if is_bare_transport && body.ends_with(".git") {
body.to_string()
} else if is_bare_transport
&& committish
.as_deref()
.is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
{
body.to_string()
} else {
return None;
};
Some((url, committish, subpath))
}
fn parse_scp_url(body: &str) -> Option<String> {
if body.contains("://") {
return None;
}
let colon = body.find(':')?;
let before = &body[..colon];
let path = &body[colon + 1..];
if before.is_empty() || path.is_empty() {
return None;
}
if path.starts_with('/') {
return None;
}
let at = before.find('@')?;
let user = &before[..at];
let host = &before[at + 1..];
if user.is_empty() || host.is_empty() || host.contains('/') || host.contains('@') {
return None;
}
if !matches!(host, "github.com" | "gitlab.com" | "bitbucket.org") {
return None;
}
Some(format!("ssh://{user}@{host}/{path}"))
}
pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
parse_git_fragment(fragment).0
}
pub(crate) fn parse_git_fragment(fragment: &str) -> (Option<String>, Option<String>) {
if fragment.is_empty() {
return (None, None);
}
let mut fallback: Option<&str> = None;
let mut preferred: Option<&str> = None;
let mut subpath: Option<String> = None;
for part in fragment.split('&') {
if part.is_empty() {
continue;
}
let split = part.split_once('=').or_else(|| {
part.split_once(':')
.filter(|(k, _)| matches!(*k, "commit" | "tag" | "head" | "branch" | "path"))
});
let (key, value) = split.unwrap_or(("", part));
if value.is_empty() {
continue;
}
match key {
"commit" => {
preferred.get_or_insert(value);
}
"tag" | "head" | "branch" => {
fallback.get_or_insert(value);
}
"path" => {
if subpath.is_some() {
continue;
}
let trimmed = value.trim_start_matches('/');
if trimmed.is_empty() {
continue;
}
if trimmed
.split('/')
.any(|c| c.is_empty() || c == "." || c == "..")
{
continue;
}
subpath = Some(trimmed.to_string());
}
"" => {
fallback.get_or_insert(value);
}
_ => {}
}
}
(preferred.or(fallback).map(ToString::to_string), subpath)
}
#[derive(Debug, Clone, Default)]
pub struct LockedPackage {
pub name: String,
pub version: String,
pub integrity: Option<String>,
pub dependencies: BTreeMap<String, String>,
pub optional_dependencies: BTreeMap<String, String>,
pub peer_dependencies: BTreeMap<String, String>,
pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
pub dep_path: String,
pub local_source: Option<LocalSource>,
pub os: PlatformList,
pub cpu: PlatformList,
pub libc: PlatformList,
pub bundled_dependencies: Vec<String>,
pub tarball_url: Option<String>,
pub alias_of: Option<String>,
pub yarn_checksum: Option<String>,
pub engines: BTreeMap<String, String>,
pub bin: BTreeMap<String, String>,
pub declared_dependencies: BTreeMap<String, String>,
pub license: Option<String>,
pub funding_url: Option<String>,
pub optional: bool,
pub transitive_peer_dependencies: Vec<String>,
pub extra_meta: BTreeMap<String, serde_json::Value>,
}
impl LockedPackage {
pub fn registry_name(&self) -> &str {
self.alias_of.as_deref().unwrap_or(&self.name)
}
pub fn spec_key(&self) -> String {
format!("{}@{}", self.name, self.version)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PeerDepMeta {
pub optional: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockfileKind {
Aube,
Pnpm,
Npm,
Yarn,
YarnBerry,
NpmShrinkwrap,
Bun,
}
impl LockfileKind {
pub fn filename(self) -> &'static str {
match self {
LockfileKind::Aube => "aube-lock.yaml",
LockfileKind::Pnpm => "pnpm-lock.yaml",
LockfileKind::Npm => "package-lock.json",
LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
LockfileKind::Bun => "bun.lock",
}
}
}
impl LockfileGraph {
pub fn root_deps(&self) -> &[DirectDep] {
self.importers.get(".").map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn get_package(&self, dep_path: &str) -> Option<&LockedPackage> {
self.packages.get(dep_path)
}
fn transitive_closure<'a>(
&self,
roots: impl IntoIterator<Item = &'a str>,
) -> std::collections::HashSet<String> {
let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
for root in roots {
if reachable.insert(root.to_string()) {
queue.push_back(root.to_string());
}
}
while let Some(dep_path) = queue.pop_front() {
let Some(pkg) = self.packages.get(&dep_path) else {
continue;
};
for (child_name, child_version) in &pkg.dependencies {
let child_key = format!("{child_name}@{child_version}");
if reachable.insert(child_key.clone()) {
queue.push_back(child_key);
}
}
}
reachable
}
fn packages_restricted_to(
&self,
reachable: &std::collections::HashSet<String>,
) -> BTreeMap<String, LockedPackage> {
self.packages
.iter()
.filter(|(dep_path, _)| reachable.contains(*dep_path))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
pub fn filter_deps<F>(&self, keep: F) -> LockfileGraph
where
F: Fn(&DirectDep) -> bool,
{
let importers: BTreeMap<String, Vec<DirectDep>> = self
.importers
.iter()
.map(|(path, deps)| {
let filtered: Vec<DirectDep> = deps.iter().filter(|d| keep(d)).cloned().collect();
(path.clone(), filtered)
})
.collect();
let reachable = self.transitive_closure(
importers
.values()
.flat_map(|deps| deps.iter().map(|d| d.dep_path.as_str())),
);
let packages = self.packages_restricted_to(&reachable);
LockfileGraph {
importers,
packages,
settings: self.settings.clone(),
overrides: self.overrides.clone(),
ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
times: self.times.clone(),
skipped_optional_dependencies: self.skipped_optional_dependencies.clone(),
catalogs: self.catalogs.clone(),
bun_config_version: self.bun_config_version,
patched_dependencies: self.patched_dependencies.clone(),
trusted_dependencies: self.trusted_dependencies.clone(),
extra_fields: self.extra_fields.clone(),
workspace_extra_fields: self.workspace_extra_fields.clone(),
}
}
pub fn subset_to_importer<F>(&self, importer_path: &str, keep: F) -> Option<LockfileGraph>
where
F: Fn(&DirectDep) -> bool,
{
let src_deps = self.importers.get(importer_path)?;
let kept: Vec<DirectDep> = src_deps.iter().filter(|d| keep(d)).cloned().collect();
let reachable = self.transitive_closure(kept.iter().map(|d| d.dep_path.as_str()));
let packages = self.packages_restricted_to(&reachable);
let mut skipped_optional_dependencies = BTreeMap::new();
if let Some(skipped) = self.skipped_optional_dependencies.get(importer_path) {
skipped_optional_dependencies.insert(".".to_string(), skipped.clone());
}
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), kept);
Some(LockfileGraph {
importers,
packages,
settings: self.settings.clone(),
overrides: self.overrides.clone(),
ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
times: self.times.clone(),
skipped_optional_dependencies,
catalogs: self.catalogs.clone(),
bun_config_version: self.bun_config_version,
patched_dependencies: self.patched_dependencies.clone(),
trusted_dependencies: self.trusted_dependencies.clone(),
extra_fields: self.extra_fields.clone(),
workspace_extra_fields: self.workspace_extra_fields.clone(),
})
}
pub fn overlay_metadata_from(&mut self, prior: &LockfileGraph) {
let prior_index = build_canonical_map(prior);
for pkg in self.packages.values_mut() {
let key = pkg.spec_key();
let Some(prior_pkg) = prior_index.get(&key) else {
continue;
};
if pkg.license.is_none() && prior_pkg.license.is_some() {
pkg.license = prior_pkg.license.clone();
}
if pkg.funding_url.is_none() && prior_pkg.funding_url.is_some() {
pkg.funding_url = prior_pkg.funding_url.clone();
}
for (k, v) in &prior_pkg.extra_meta {
pkg.extra_meta.entry(k.clone()).or_insert_with(|| v.clone());
}
}
if self.bun_config_version.is_none() {
self.bun_config_version = prior.bun_config_version;
}
if self.patched_dependencies.is_empty() {
self.patched_dependencies = prior.patched_dependencies.clone();
}
if self.trusted_dependencies.is_empty() {
self.trusted_dependencies = prior.trusted_dependencies.clone();
}
if self.extra_fields.is_empty() {
self.extra_fields = prior.extra_fields.clone();
}
if self.workspace_extra_fields.is_empty() {
self.workspace_extra_fields = prior.workspace_extra_fields.clone();
}
}
pub fn check_drift(
&self,
manifest: &aube_manifest::PackageJson,
workspace_overrides: &BTreeMap<String, String>,
workspace_ignored_optional: &[String],
workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
) -> DriftStatus {
let effective = resolve_catalog_refs_in_overrides(
&merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
workspace_catalogs,
);
let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
if let Some(reason) = overrides_drift_reason(&locked, &effective) {
return DriftStatus::Stale { reason };
}
let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
effective_ignored.extend(workspace_ignored_optional.iter().cloned());
if let Some(reason) =
ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored)
{
return DriftStatus::Stale { reason };
}
self.check_drift_for_importer(".", manifest, &effective)
}
pub fn check_drift_workspace(
&self,
manifests: &[(String, aube_manifest::PackageJson)],
workspace_overrides: &BTreeMap<String, String>,
workspace_ignored_optional: &[String],
workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
) -> DriftStatus {
let effective_overrides = match manifests.iter().find(|(p, _)| p == ".") {
Some((_, root_manifest)) => {
let effective = resolve_catalog_refs_in_overrides(
&merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides),
workspace_catalogs,
);
let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
if let Some(reason) = overrides_drift_reason(&locked, &effective) {
return DriftStatus::Stale { reason };
}
let mut effective_ignored = root_manifest.pnpm_ignored_optional_dependencies();
effective_ignored.extend(workspace_ignored_optional.iter().cloned());
if let Some(reason) = ignored_optional_drift_reason(
&self.ignored_optional_dependencies,
&effective_ignored,
) {
return DriftStatus::Stale { reason };
}
effective
}
None => BTreeMap::new(),
};
for (importer_path, manifest) in manifests {
match self.check_drift_for_importer(importer_path, manifest, &effective_overrides) {
DriftStatus::Fresh => continue,
stale => return stale,
}
}
DriftStatus::Fresh
}
pub fn check_catalogs_drift(
&self,
workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
) -> DriftStatus {
for (cat_name, cat) in workspace_catalogs {
let Some(locked) = self.catalogs.get(cat_name) else {
continue;
};
for (pkg, spec) in cat {
if let Some(entry) = locked.get(pkg)
&& entry.specifier != *spec
{
return DriftStatus::Stale {
reason: format!(
"catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
entry.specifier
),
};
}
}
}
for (cat_name, cat) in &self.catalogs {
let workspace_cat = workspace_catalogs.get(cat_name);
for pkg in cat.keys() {
if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
return DriftStatus::Stale {
reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
};
}
}
}
DriftStatus::Fresh
}
fn check_drift_for_importer(
&self,
importer_path: &str,
manifest: &aube_manifest::PackageJson,
effective_overrides: &BTreeMap<String, String>,
) -> DriftStatus {
let label = if importer_path == "." {
String::new()
} else {
format!("{importer_path}: ")
};
let importer_deps: &[DirectDep] = self
.importers
.get(importer_path)
.map(|v| v.as_slice())
.unwrap_or(&[]);
if importer_deps.iter().all(|d| d.specifier.is_none()) {
return DriftStatus::Fresh;
}
let lockfile_specs: BTreeMap<&str, &str> = importer_deps
.iter()
.filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
.collect();
let skipped_optionals: BTreeMap<&str, &str> = self
.skipped_optional_dependencies
.get(importer_path)
.map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
.unwrap_or_default();
let ignored = &self.ignored_optional_dependencies;
let manifest_deps = manifest
.dependencies
.iter()
.map(|(k, v)| (k, v, false))
.chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
.chain(
manifest
.optional_dependencies
.iter()
.filter(|(name, _)| !ignored.contains(name.as_str()))
.map(|(k, v)| (k, v, true)),
);
for (name, spec, is_optional) in manifest_deps {
match lockfile_specs.get(name.as_str()) {
None => {
if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
if *locked_spec == spec {
continue;
}
return DriftStatus::Stale {
reason: format!(
"{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
),
};
}
return DriftStatus::Stale {
reason: format!("{label}manifest adds {name}@{spec}"),
};
}
Some(locked_spec) if *locked_spec != spec => {
if let Some(override_spec) = effective_overrides.get(name.as_str())
&& override_spec == locked_spec
{
continue;
}
return DriftStatus::Stale {
reason: format!(
"{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
),
};
}
Some(_) => {}
}
}
let manifest_names: std::collections::HashSet<&str> = manifest
.dependencies
.keys()
.chain(manifest.dev_dependencies.keys())
.chain(
manifest
.optional_dependencies
.keys()
.filter(|name| !ignored.contains(name.as_str())),
)
.map(|s| s.as_str())
.collect();
let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
.packages
.values()
.flat_map(|p| {
p.peer_dependencies
.iter()
.map(|(name, range)| (name.as_str(), range.as_str()))
})
.collect();
for (locked_name, locked_spec) in &lockfile_specs {
if manifest_names.contains(locked_name) {
continue;
}
if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
continue;
}
return DriftStatus::Stale {
reason: format!("{label}manifest removed {locked_name}"),
};
}
DriftStatus::Fresh
}
}
fn merge_manifest_and_workspace_overrides(
manifest: &aube_manifest::PackageJson,
workspace_overrides: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut out = manifest.overrides_map();
for (k, v) in workspace_overrides {
out.insert(k.clone(), v.clone());
}
out
}
fn resolve_catalog_refs_in_overrides(
overrides: &BTreeMap<String, String>,
workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
) -> BTreeMap<String, String> {
overrides
.iter()
.map(|(k, v)| {
let resolved = v
.strip_prefix("catalog:")
.map(|tail| if tail.is_empty() { "default" } else { tail })
.and_then(|cat_name| workspace_catalogs.get(cat_name))
.and_then(|cat| cat.get(override_key_package_name(k)))
.cloned()
.unwrap_or_else(|| v.clone());
(k.clone(), resolved)
})
.collect()
}
fn override_key_package_name(key: &str) -> &str {
let last = key.rsplit('>').next().unwrap_or(key);
if let Some(after_scope) = last.strip_prefix('@') {
match after_scope.find('@') {
Some(idx) => &last[..idx + 1],
None => last,
}
} else {
match last.find('@') {
Some(idx) => &last[..idx],
None => last,
}
}
}
fn overrides_drift_reason(
lockfile: &BTreeMap<String, String>,
manifest: &BTreeMap<String, String>,
) -> Option<String> {
for (k, v) in manifest {
match lockfile.get(k) {
None => return Some(format!("overrides: manifest adds {k}@{v}")),
Some(locked) if locked != v => {
return Some(format!("overrides: {k} changed ({locked} → {v})"));
}
Some(_) => {}
}
}
for k in lockfile.keys() {
if !manifest.contains_key(k) {
return Some(format!("overrides: manifest removes {k}"));
}
}
None
}
fn ignored_optional_drift_reason(
lockfile: &BTreeSet<String>,
manifest: &BTreeSet<String>,
) -> Option<String> {
for name in manifest {
if !lockfile.contains(name) {
return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
}
}
for name in lockfile {
if !manifest.contains(name) {
return Some(format!(
"ignoredOptionalDependencies: manifest removes {name}"
));
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriftStatus {
Fresh,
Stale { reason: String },
}
pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
}
pub fn write_lockfile(
project_dir: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<(), Error> {
write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
Ok(())
}
pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
for pkg in graph.packages.values() {
canonical.entry(pkg.spec_key()).or_insert(pkg);
}
canonical
}
pub fn write_lockfile_preserving_existing(
project_dir: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<PathBuf, Error> {
let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
write_lockfile_as(project_dir, graph, manifest, kind)
}
pub fn write_lockfile_as(
project_dir: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
kind: LockfileKind,
) -> Result<PathBuf, Error> {
let filename = match kind {
LockfileKind::Aube => aube_lock_filename(project_dir),
LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
other => other.filename().to_string(),
};
let path = project_dir.join(&filename);
match kind {
LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
LockfileKind::Bun => bun::write(&path, graph, manifest)?,
}
Ok(path)
}
pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
for (path, kind) in lockfile_candidates(project_dir, true) {
if path.exists() {
return Some(refine_yarn_kind(&path, kind));
}
}
None
}
pub fn aube_lock_filename(project_dir: &Path) -> String {
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
if let Ok(map) = cache.lock()
&& let Some(hit) = map.get(project_dir)
{
return hit.clone();
}
let resolved = if !git_branch_lockfile_enabled(project_dir) {
"aube-lock.yaml".to_string()
} else {
match current_git_branch(project_dir) {
Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
None => "aube-lock.yaml".to_string(),
}
};
if let Ok(mut map) = cache.lock() {
map.insert(project_dir.to_path_buf(), resolved.clone());
}
resolved
}
pub fn pnpm_lock_filename(project_dir: &Path) -> String {
let aube_name = aube_lock_filename(project_dir);
aube_name
.strip_prefix("aube-lock.")
.map(|rest| format!("pnpm-lock.{rest}"))
.unwrap_or_else(|| "pnpm-lock.yaml".to_string())
}
fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
return false;
};
let npmrc: Vec<(String, String)> = Vec::new();
let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
aube_settings::resolved::git_branch_lockfile(&ctx)
}
pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.args(["-C"])
.arg(project_dir)
.args(["branch", "--show-current"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
pub fn parse_lockfile(
project_dir: &Path,
manifest: &aube_manifest::PackageJson,
) -> Result<LockfileGraph, Error> {
let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
Ok(graph)
}
pub fn parse_lockfile_with_kind(
project_dir: &Path,
manifest: &aube_manifest::PackageJson,
) -> Result<(LockfileGraph, LockfileKind), Error> {
reject_bun_binary(project_dir)?;
for (path, kind) in lockfile_candidates(project_dir, true) {
if !path.exists() {
continue;
}
let kind = refine_yarn_kind(&path, kind);
let graph = parse_one(&path, kind, manifest)?;
return Ok((graph, kind));
}
Err(Error::NotFound(project_dir.to_path_buf()))
}
pub fn parse_for_import(
project_dir: &Path,
manifest: &aube_manifest::PackageJson,
) -> Result<(LockfileGraph, LockfileKind), Error> {
reject_bun_binary(project_dir)?;
for (path, kind) in lockfile_candidates(project_dir, false) {
if !path.exists() {
continue;
}
let kind = refine_yarn_kind(&path, kind);
let graph = parse_one(&path, kind, manifest)?;
return Ok((graph, kind));
}
Err(Error::NotFound(project_dir.to_path_buf()))
}
fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
let lockb = project_dir.join("bun.lockb");
let text = project_dir.join("bun.lock");
if lockb.exists() && !text.exists() {
return Err(Error::parse(
&lockb,
"bun.lockb (binary format) is not supported — run `bun install --save-text-lockfile` to generate a bun.lock text file first, or upgrade to bun 1.2+ where text is the default",
));
}
Ok(())
}
fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
let mut out = Vec::new();
if include_aube {
let branch_name = aube_lock_filename(project_dir);
if branch_name != "aube-lock.yaml" {
out.push((project_dir.join(&branch_name), LockfileKind::Aube));
}
out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
}
let pnpm_branch = {
let mut s = aube_lock_filename(project_dir);
if let Some(rest) = s.strip_prefix("aube-lock.") {
s = format!("pnpm-lock.{rest}");
}
s
};
if pnpm_branch != "pnpm-lock.yaml" {
out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
}
out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
out.push((
project_dir.join("npm-shrinkwrap.json"),
LockfileKind::NpmShrinkwrap,
));
out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
out
}
fn parse_one(
path: &Path,
kind: LockfileKind,
manifest: &aube_manifest::PackageJson,
) -> Result<LockfileGraph, Error> {
match kind {
LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
LockfileKind::Bun => bun::parse(path),
}
}
fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
LockfileKind::YarnBerry
} else {
kind
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("no lockfile found in {0}")]
NotFound(std::path::PathBuf),
#[error("unsupported lockfile format: {0}")]
UnsupportedFormat(String),
#[error("failed to read lockfile {0}: {1}")]
Io(std::path::PathBuf, std::io::Error),
#[error("failed to parse lockfile {0}: {1}")]
Parse(std::path::PathBuf, String),
#[error(transparent)]
#[diagnostic(transparent)]
ParseDiag(Box<aube_manifest::ParseError>),
}
pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
}
pub fn parse_json<T: serde::de::DeserializeOwned>(
path: &std::path::Path,
content: String,
) -> Result<T, Error> {
let mut buf = content.clone().into_bytes();
match simd_json::serde::from_slice(&mut buf) {
Ok(v) => Ok(v),
Err(_) => match serde_json::from_str(&content) {
Ok(v) => Ok(v),
Err(e) => Err(Error::parse_json_err(path, content, &e)),
},
}
}
impl Error {
pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
Error::Parse(path.to_path_buf(), msg.into())
}
pub fn parse_json_err(
path: &std::path::Path,
content: String,
err: &serde_json::Error,
) -> Self {
Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
path, content, err,
)))
}
pub fn parse_yaml_err(
path: &std::path::Path,
content: String,
err: &serde_yaml::Error,
) -> Self {
Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
path, content, err,
)))
}
}
#[cfg(test)]
mod parse_diag_tests {
use super::*;
use std::path::Path;
#[test]
fn parse_json_attaches_span_for_bad_input() {
let path = Path::new("package-lock.json");
let content = r#"{"name":"x","#.to_string();
let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
else {
panic!("parse_json must produce ParseDiag on malformed input");
};
let offset: usize = pe.span.offset();
let len: usize = pe.span.len();
assert!(offset + len <= content.len());
assert_eq!(pe.path, path);
}
#[test]
fn parse_yaml_err_attaches_span_for_bad_input() {
let path = Path::new("yarn.lock");
let content = "packages:\n\t- pkg\n".to_string();
let yaml_err: serde_yaml::Error = serde_yaml::from_str::<serde_yaml::Value>(&content)
.expect_err("tab-indented YAML must fail");
let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
panic!("parse_yaml_err must produce ParseDiag");
};
let offset: usize = pe.span.offset();
let len: usize = pe.span.len();
assert!(offset + len <= content.len());
assert_eq!(pe.path, path);
}
}
#[cfg(test)]
mod looks_like_remote_tarball_url_tests {
use super::*;
#[test]
fn matches_https_tgz() {
assert!(LocalSource::looks_like_remote_tarball_url(
"https://example.com/pkg-1.0.0.tgz"
));
}
#[test]
fn matches_http_tar_gz() {
assert!(LocalSource::looks_like_remote_tarball_url(
"http://example.com/pkg-1.0.0.tar.gz"
));
}
#[test]
fn strips_fragment_before_suffix_check() {
assert!(LocalSource::looks_like_remote_tarball_url(
"https://example.com/pkg-1.0.0.tgz#sha512-abc"
));
}
#[test]
fn strips_query_string_before_suffix_check() {
assert!(LocalSource::looks_like_remote_tarball_url(
"https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
));
assert!(LocalSource::looks_like_remote_tarball_url(
"https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
));
}
#[test]
fn matches_bare_http_url_without_tarball_suffix() {
assert!(LocalSource::looks_like_remote_tarball_url(
"https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
));
assert!(LocalSource::looks_like_remote_tarball_url(
"https://codeload.github.com/user/repo/tar.gz/main"
));
}
#[test]
fn rejects_non_http_schemes() {
assert!(!LocalSource::looks_like_remote_tarball_url(
"ftp://example.com/pkg.tgz"
));
assert!(!LocalSource::looks_like_remote_tarball_url(
"git://example.com/repo.git"
));
}
#[test]
fn parse_classifies_bare_http_url_as_remote_tarball() {
use std::path::Path;
let parsed = LocalSource::parse(
"https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
Path::new(""),
);
assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
}
#[test]
fn parse_prefers_git_over_tarball_for_dot_git_url() {
use std::path::Path;
let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
assert!(matches!(parsed, Some(LocalSource::Git(_))));
}
}
#[cfg(test)]
mod filename_tests {
use super::*;
#[test]
fn defaults_to_plain_lockfile_when_setting_absent() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
}
#[test]
fn defaults_to_plain_lockfile_when_setting_explicit_false() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"gitBranchLockfile: false\n",
)
.unwrap();
assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
}
#[test]
fn uses_branch_filename_when_enabled_inside_git_repo() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"gitBranchLockfile: true\n",
)
.unwrap();
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(["-C"])
.arg(dir.path())
.args(args)
.output()
.unwrap()
};
if run(&["init", "-q"]).status.success() {
run(&["checkout", "-q", "-b", "feature/x"]);
assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
}
}
}
#[cfg(test)]
mod git_spec_tests {
use super::*;
#[test]
fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
assert_eq!(url, "https://host/user/repo");
assert_eq!(committish, None);
assert_eq!(subpath, None);
let sha = "abcdef0123456789abcdef0123456789abcdef01";
let source = LocalSource::Git(GitSource {
url: url.clone(),
committish: None,
resolved: sha.to_string(),
subpath: None,
});
let lockfile_version = source.specifier();
assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
let (round_url, round_committish, round_subpath) =
parse_git_spec(&lockfile_version).unwrap();
assert_eq!(round_url, "https://host/user/repo");
assert_eq!(round_committish.as_deref(), Some(sha));
assert_eq!(round_subpath, None);
}
#[test]
fn bare_https_without_dot_git_and_no_committish_is_not_git() {
assert!(parse_git_spec("https://example.com/pkg").is_none());
}
#[test]
fn github_shorthand_expands_and_roundtrips() {
let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
assert_eq!(url, "https://github.com/user/repo.git");
}
#[test]
fn scp_form_recognized() {
let (url, committish, _) =
parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
assert!(committish.is_none());
}
#[test]
fn scp_form_with_ref_recognized() {
let (url, committish, _) =
parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
assert_eq!(committish.as_deref(), Some("0.1.5"));
}
#[test]
fn scp_form_bitbucket_recognized() {
let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
}
#[test]
fn scp_form_unknown_host_rejected() {
assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
}
#[test]
fn scp_form_without_user_rejected() {
assert!(parse_git_spec("github.com:user/repo.git").is_none());
}
#[test]
fn commit_selector_fragment_normalizes_to_sha() {
let sha = "abcdef0123456789abcdef0123456789abcdef01";
let (url, committish, _) =
parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
assert_eq!(url, "https://host/user/repo.git");
assert_eq!(committish.as_deref(), Some(sha));
}
#[test]
fn named_selector_fragment_normalizes_to_ref() {
let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
assert_eq!(url, "https://host/user/repo");
assert_eq!(committish.as_deref(), Some("v1.2.3"));
}
#[test]
fn pnpm_path_subpath_extracted_from_fragment() {
let (url, committish, subpath) =
parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
assert_eq!(url, "https://github.com/org/dep.git");
assert_eq!(committish.as_deref(), Some("v0.1.4"));
assert_eq!(subpath.as_deref(), Some("packages/special"));
}
#[test]
fn path_subpath_roundtrips_via_specifier() {
let sha = "abcdef0123456789abcdef0123456789abcdef01";
let source = LocalSource::Git(GitSource {
url: "https://github.com/org/dep.git".to_string(),
committish: None,
resolved: sha.to_string(),
subpath: Some("packages/special".to_string()),
});
let spec = source.specifier();
assert_eq!(
spec,
format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
);
let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
assert_eq!(url, "https://github.com/org/dep.git");
assert_eq!(committish.as_deref(), Some(sha));
assert_eq!(subpath.as_deref(), Some("packages/special"));
}
#[test]
fn path_traversal_components_in_subpath_are_rejected() {
let cases = [
"github:org/dep#main&path:/../../etc",
"github:org/dep#main&path:/packages/../../../etc",
"github:org/dep#main&path:/./packages/foo",
"github:org/dep#main&path:/packages//foo",
];
for spec in cases {
let (_, _, subpath) = parse_git_spec(spec).unwrap();
assert_eq!(subpath, None, "spec should drop subpath: {spec}");
}
}
#[test]
fn dep_path_distinguishes_subpaths_under_same_commit() {
let sha = "abcdef0123456789abcdef0123456789abcdef01";
let a = LocalSource::Git(GitSource {
url: "https://example.com/r.git".to_string(),
committish: None,
resolved: sha.to_string(),
subpath: Some("packages/a".to_string()),
});
let b = LocalSource::Git(GitSource {
url: "https://example.com/r.git".to_string(),
committish: None,
resolved: sha.to_string(),
subpath: Some("packages/b".to_string()),
});
assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
}
}
#[cfg(test)]
mod drift_tests {
use super::*;
use aube_manifest::PackageJson;
use std::collections::BTreeMap;
fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
let mut m = PackageJson {
name: Some("test".into()),
version: Some("1.0.0".into()),
dependencies: BTreeMap::new(),
dev_dependencies: BTreeMap::new(),
peer_dependencies: BTreeMap::new(),
optional_dependencies: BTreeMap::new(),
update_config: None,
scripts: BTreeMap::new(),
engines: BTreeMap::new(),
workspaces: None,
bundled_dependencies: None,
extra: BTreeMap::new(),
};
for (name, spec) in deps {
m.dependencies.insert((*name).into(), (*spec).into());
}
m
}
fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
let direct: Vec<DirectDep> = deps
.iter()
.map(|(name, spec, dep_path)| DirectDep {
name: (*name).into(),
dep_path: (*dep_path).into(),
dep_type: DepType::Production,
specifier: Some((*spec).into()),
})
.collect();
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), direct);
LockfileGraph {
importers,
packages: BTreeMap::new(),
..Default::default()
}
}
#[test]
fn fresh_when_specifiers_match() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
assert_eq!(
graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
DriftStatus::Fresh
);
}
#[test]
fn stale_when_specifier_changes() {
let manifest = make_manifest(&[("lodash", "^4.18.0")]);
let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
DriftStatus::Fresh => panic!("expected Stale"),
}
}
#[test]
fn stale_when_manifest_adds_dep() {
let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("express")),
DriftStatus::Fresh => panic!("expected Stale"),
}
}
#[test]
fn stale_when_manifest_removes_dep() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let graph = make_graph(&[
("lodash", "^4.17.0", "lodash@4.17.21"),
("express", "^4.18.0", "express@4.18.0"),
]);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("express")),
DriftStatus::Fresh => panic!("expected Stale"),
}
}
#[test]
fn fresh_when_lockfile_has_auto_hoisted_peer() {
let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
let mut graph = make_graph(&[
(
"use-sync-external-store",
"1.2.0",
"use-sync-external-store@1.2.0",
),
("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
]);
let mut declaring_pkg = LockedPackage {
name: "use-sync-external-store".into(),
version: "1.2.0".into(),
dep_path: "use-sync-external-store@1.2.0".into(),
..Default::default()
};
declaring_pkg
.peer_dependencies
.insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
graph
.packages
.insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
assert_eq!(
graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
DriftStatus::Fresh
);
}
#[test]
fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
let mut graph = make_graph(&[
(
"use-sync-external-store",
"1.2.0",
"use-sync-external-store@1.2.0",
),
("react", "17.0.2", "react@17.0.2"),
]);
let mut consumer = LockedPackage {
name: "use-sync-external-store".into(),
version: "1.2.0".into(),
dep_path: "use-sync-external-store@1.2.0".into(),
..Default::default()
};
consumer
.peer_dependencies
.insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
graph
.packages
.insert("use-sync-external-store@1.2.0".into(), consumer);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("react")),
DriftStatus::Fresh => panic!(
"drift check should flag a removed user-pinned dep as stale, \
even when its name matches a peer declaration"
),
}
}
#[test]
fn stale_when_lockfile_has_removed_non_peer_dep() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let graph = make_graph(&[
("lodash", "^4.17.0", "lodash@4.17.21"),
("chalk", "^5.0.0", "chalk@5.0.0"),
]);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
DriftStatus::Fresh => panic!("expected Stale"),
}
}
#[test]
fn fresh_when_no_specifiers_recorded() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let graph = LockfileGraph {
importers: {
let mut m = BTreeMap::new();
m.insert(
".".to_string(),
vec![DirectDep {
name: "lodash".into(),
dep_path: "lodash@4.17.21".into(),
dep_type: DepType::Production,
specifier: None,
}],
);
m
},
packages: BTreeMap::new(),
..Default::default()
};
assert_eq!(
graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
DriftStatus::Fresh
);
}
#[test]
fn stale_when_manifest_adds_override() {
let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
manifest
.extra
.insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
DriftStatus::Fresh => panic!("expected Stale"),
}
}
#[test]
fn stale_drift_message_names_changed_override_key() {
let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
manifest
.extra
.insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
graph.overrides.insert("lodash".into(), "4.17.21".into());
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => {
assert!(reason.contains("lodash"), "expected key in: {reason}");
assert!(
reason.contains("4.17.21"),
"expected old value in: {reason}"
);
assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
}
DriftStatus::Fresh => panic!("expected Stale"),
}
}
#[test]
fn stale_when_manifest_removes_override() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
graph.overrides.insert("lodash".into(), "4.17.21".into());
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => {
assert!(reason.contains("removes"));
assert!(reason.contains("lodash"));
}
DriftStatus::Fresh => panic!("expected Stale"),
}
}
#[test]
fn fresh_when_overrides_match() {
let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
manifest
.extra
.insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
graph.overrides.insert("lodash".into(), "4.17.21".into());
assert_eq!(
graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
DriftStatus::Fresh
);
}
#[test]
fn fresh_when_workspace_yaml_overrides_match_lockfile() {
let manifest = make_manifest(&[("semver", "^7.5.0")]);
let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
graph.overrides.insert("semver".into(), "7.7.1".into());
let mut ws_overrides = BTreeMap::new();
ws_overrides.insert("semver".into(), "7.7.1".into());
assert_eq!(
graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
DriftStatus::Fresh,
);
}
#[test]
fn workspace_yaml_overrides_win_over_package_json() {
let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
manifest
.extra
.insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
graph.overrides.insert("semver".into(), "7.7.1".into());
let mut ws_overrides = BTreeMap::new();
ws_overrides.insert("semver".into(), "7.7.1".into());
assert_eq!(
graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
DriftStatus::Fresh,
);
}
#[test]
fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
graph.overrides.insert("lodash".into(), "4.17.21".into());
let mut ws_overrides = BTreeMap::new();
ws_overrides.insert("lodash".into(), "catalog:".into());
let mut catalogs = BTreeMap::new();
let mut default_cat = BTreeMap::new();
default_cat.insert("lodash".into(), "4.17.21".into());
catalogs.insert("default".into(), default_cat);
assert_eq!(
graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
DriftStatus::Fresh,
);
}
#[test]
fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
graph.overrides.insert("lodash".into(), "4.17.21".into());
let mut ws_overrides = BTreeMap::new();
ws_overrides.insert("lodash".into(), "catalog:evens".into());
let mut catalogs = BTreeMap::new();
let mut evens = BTreeMap::new();
evens.insert("lodash".into(), "4.17.21".into());
catalogs.insert("evens".into(), evens);
assert_eq!(
graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
DriftStatus::Fresh,
);
}
#[test]
fn stale_when_override_catalog_ref_diverges_from_lockfile() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
graph.overrides.insert("lodash".into(), "4.17.21".into());
let mut ws_overrides = BTreeMap::new();
ws_overrides.insert("lodash".into(), "catalog:".into());
let mut catalogs = BTreeMap::new();
let mut default_cat = BTreeMap::new();
default_cat.insert("lodash".into(), "4.17.22".into());
catalogs.insert("default".into(), default_cat);
match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
other => panic!("expected stale, got {other:?}"),
}
}
#[test]
fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "lodash".into(),
dep_path: "lodash@4.17.21".into(),
dep_type: DepType::Production,
specifier: Some("4.17.21".into()),
}],
);
let mut graph = LockfileGraph {
importers,
..Default::default()
};
graph.overrides.insert("lodash".into(), "4.17.21".into());
let mut ws_overrides = BTreeMap::new();
ws_overrides.insert("lodash".into(), "4.17.21".into());
assert_eq!(
graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
DriftStatus::Fresh,
);
}
#[test]
fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
let manifest = make_manifest(&[("lodash", "^4.17.0")]);
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
graph
.ignored_optional_dependencies
.insert("fsevents".to_string());
let ws_ignored = vec!["fsevents".to_string()];
assert_eq!(
graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
DriftStatus::Fresh,
);
}
#[test]
fn fresh_when_optional_dep_was_recorded_as_skipped() {
let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
manifest
.optional_dependencies
.insert("fsevents".into(), "^2.3.0".into());
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
let mut inner = BTreeMap::new();
inner.insert("fsevents".to_string(), "^2.3.0".to_string());
graph
.skipped_optional_dependencies
.insert(".".to_string(), inner);
assert_eq!(
graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
DriftStatus::Fresh
);
}
#[test]
fn stale_when_new_optional_dep_was_never_seen() {
let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
manifest
.optional_dependencies
.insert("fsevents".into(), "^2.3.0".into());
let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
}
}
#[test]
fn stale_when_skipped_optional_dep_specifier_changes() {
let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
manifest
.optional_dependencies
.insert("fsevents".into(), "^2.4.0".into());
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
let mut inner = BTreeMap::new();
inner.insert("fsevents".to_string(), "^2.3.0".to_string());
graph
.skipped_optional_dependencies
.insert(".".to_string(), inner);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
}
}
#[test]
fn stale_when_skipped_optional_is_promoted_to_required() {
let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
manifest.optional_dependencies.clear();
let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
let mut inner = BTreeMap::new();
inner.insert("fsevents".to_string(), "^2.3.0".to_string());
graph
.skipped_optional_dependencies
.insert(".".to_string(), inner);
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
DriftStatus::Fresh => {
panic!("expected Stale: skipped-optional exemption must not apply to required deps")
}
}
}
#[test]
fn stale_when_optional_dep_specifier_changes_in_lockfile() {
let mut manifest = make_manifest(&[]);
manifest
.optional_dependencies
.insert("fsevents".into(), "^2.4.0".into());
let mut graph = make_graph(&[]);
graph.importers.get_mut(".").unwrap().push(DirectDep {
name: "fsevents".into(),
dep_path: "fsevents@2.3.0".into(),
dep_type: DepType::Optional,
specifier: Some("^2.3.0".into()),
});
match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
}
}
#[test]
fn fresh_for_empty_manifest_and_lockfile() {
let manifest = make_manifest(&[]);
let graph = make_graph(&[]);
assert_eq!(
graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
DriftStatus::Fresh
);
}
#[test]
fn workspace_drift_detects_change_in_non_root_importer() {
let root_dep = DirectDep {
name: "lodash".into(),
dep_path: "lodash@4.17.21".into(),
dep_type: DepType::Production,
specifier: Some("^4.17.0".into()),
};
let app_dep = DirectDep {
name: "express".into(),
dep_path: "express@4.18.0".into(),
dep_type: DepType::Production,
specifier: Some("^4.18.0".into()),
};
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), vec![root_dep]);
importers.insert("packages/app".to_string(), vec![app_dep]);
let graph = LockfileGraph {
importers,
packages: BTreeMap::new(),
..Default::default()
};
let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
let app_manifest = make_manifest(&[("express", "^5.0.0")]);
let workspace_manifests = vec![
(".".to_string(), root_manifest.clone()),
("packages/app".to_string(), app_manifest),
];
match graph.check_drift_workspace(
&workspace_manifests,
&BTreeMap::new(),
&[],
&BTreeMap::new(),
) {
DriftStatus::Stale { reason } => {
assert!(reason.contains("packages/app"));
assert!(reason.contains("express"));
}
DriftStatus::Fresh => panic!("expected Stale"),
}
assert_eq!(
graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
DriftStatus::Fresh
);
}
#[test]
fn filter_deps_prunes_dev_only_subtree() {
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".into(),
dep_path: "foo@1.0.0".into(),
dep_type: DepType::Production,
specifier: Some("^1.0.0".into()),
},
DirectDep {
name: "jest".into(),
dep_path: "jest@29.0.0".into(),
dep_type: DepType::Dev,
specifier: Some("^29.0.0".into()),
},
],
);
let mut packages = BTreeMap::new();
let mut foo_deps = BTreeMap::new();
foo_deps.insert("bar".to_string(), "2.0.0".to_string());
packages.insert(
"foo@1.0.0".to_string(),
LockedPackage {
name: "foo".into(),
version: "1.0.0".into(),
integrity: None,
dependencies: foo_deps,
dep_path: "foo@1.0.0".into(),
..Default::default()
},
);
packages.insert(
"bar@2.0.0".to_string(),
LockedPackage {
name: "bar".into(),
version: "2.0.0".into(),
integrity: None,
dependencies: BTreeMap::new(),
dep_path: "bar@2.0.0".into(),
..Default::default()
},
);
let mut jest_deps = BTreeMap::new();
jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
packages.insert(
"jest@29.0.0".to_string(),
LockedPackage {
name: "jest".into(),
version: "29.0.0".into(),
integrity: None,
dependencies: jest_deps,
dep_path: "jest@29.0.0".into(),
..Default::default()
},
);
packages.insert(
"jest-core@29.0.0".to_string(),
LockedPackage {
name: "jest-core".into(),
version: "29.0.0".into(),
integrity: None,
dependencies: BTreeMap::new(),
dep_path: "jest-core@29.0.0".into(),
..Default::default()
},
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
let roots = prod.root_deps();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].name, "foo");
assert!(prod.packages.contains_key("foo@1.0.0"));
assert!(prod.packages.contains_key("bar@2.0.0"));
assert!(!prod.packages.contains_key("jest@29.0.0"));
assert!(!prod.packages.contains_key("jest-core@29.0.0"));
}
#[test]
fn filter_deps_preserves_lockfile_settings() {
let graph = LockfileGraph {
importers: BTreeMap::new(),
packages: BTreeMap::new(),
settings: LockfileSettings {
auto_install_peers: false,
exclude_links_from_lockfile: true,
lockfile_include_tarball_url: false,
},
..Default::default()
};
let filtered = graph.filter_deps(|_| true);
assert!(!filtered.settings.auto_install_peers);
assert!(filtered.settings.exclude_links_from_lockfile);
}
#[test]
fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".into(),
dep_path: "foo@1.0.0".into(),
dep_type: DepType::Production,
specifier: Some("^1.0.0".into()),
},
DirectDep {
name: "jest".into(),
dep_path: "jest@29.0.0".into(),
dep_type: DepType::Dev,
specifier: Some("^29.0.0".into()),
},
],
);
let mut packages = BTreeMap::new();
for (name, ver, deps) in [
("foo", "1.0.0", vec![("shared", "1.0.0")]),
("jest", "29.0.0", vec![("shared", "1.0.0")]),
("shared", "1.0.0", vec![]),
] {
let mut dep_map = BTreeMap::new();
for (n, v) in deps {
dep_map.insert(n.to_string(), v.to_string());
}
packages.insert(
format!("{name}@{ver}"),
LockedPackage {
name: name.into(),
version: ver.into(),
integrity: None,
dependencies: dep_map,
dep_path: format!("{name}@{ver}"),
..Default::default()
},
);
}
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
assert!(prod.packages.contains_key("foo@1.0.0"));
assert!(prod.packages.contains_key("shared@1.0.0"));
assert!(!prod.packages.contains_key("jest@29.0.0"));
}
#[test]
fn subset_to_importer_returns_none_for_missing_importer() {
let graph = LockfileGraph {
importers: BTreeMap::new(),
packages: BTreeMap::new(),
..Default::default()
};
assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
}
#[test]
fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), vec![]);
importers.insert(
"packages/lib".to_string(),
vec![DirectDep {
name: "is-odd".into(),
dep_path: "is-odd@3.0.1".into(),
dep_type: DepType::Production,
specifier: Some("^3.0.1".into()),
}],
);
importers.insert(
"packages/app".to_string(),
vec![DirectDep {
name: "express".into(),
dep_path: "express@4.18.0".into(),
dep_type: DepType::Production,
specifier: Some("^4.18.0".into()),
}],
);
let mut packages = BTreeMap::new();
let mut is_odd_deps = BTreeMap::new();
is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
packages.insert(
"is-odd@3.0.1".to_string(),
LockedPackage {
name: "is-odd".into(),
version: "3.0.1".into(),
dependencies: is_odd_deps,
dep_path: "is-odd@3.0.1".into(),
..Default::default()
},
);
packages.insert(
"is-number@6.0.0".to_string(),
LockedPackage {
name: "is-number".into(),
version: "6.0.0".into(),
dep_path: "is-number@6.0.0".into(),
..Default::default()
},
);
packages.insert(
"express@4.18.0".to_string(),
LockedPackage {
name: "express".into(),
version: "4.18.0".into(),
dep_path: "express@4.18.0".into(),
..Default::default()
},
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let subset = graph
.subset_to_importer("packages/lib", |_| true)
.expect("packages/lib importer present");
assert_eq!(subset.importers.len(), 1);
let roots = subset.root_deps();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].name, "is-odd");
assert!(subset.packages.contains_key("is-odd@3.0.1"));
assert!(subset.packages.contains_key("is-number@6.0.0"));
assert!(!subset.packages.contains_key("express@4.18.0"));
}
#[test]
fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
let mut importers = BTreeMap::new();
importers.insert(
"packages/lib".to_string(),
vec![
DirectDep {
name: "is-odd".into(),
dep_path: "is-odd@3.0.1".into(),
dep_type: DepType::Production,
specifier: Some("^3.0.1".into()),
},
DirectDep {
name: "jest".into(),
dep_path: "jest@29.0.0".into(),
dep_type: DepType::Dev,
specifier: Some("^29.0.0".into()),
},
],
);
let mut packages = BTreeMap::new();
packages.insert(
"is-odd@3.0.1".to_string(),
LockedPackage {
name: "is-odd".into(),
version: "3.0.1".into(),
dep_path: "is-odd@3.0.1".into(),
..Default::default()
},
);
packages.insert(
"jest@29.0.0".to_string(),
LockedPackage {
name: "jest".into(),
version: "29.0.0".into(),
dep_path: "jest@29.0.0".into(),
..Default::default()
},
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let prod = graph
.subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
.expect("importer present");
let roots = prod.root_deps();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].name, "is-odd");
assert!(prod.packages.contains_key("is-odd@3.0.1"));
assert!(!prod.packages.contains_key("jest@29.0.0"));
}
#[test]
fn subset_to_importer_preserves_graph_settings() {
let mut importers = BTreeMap::new();
importers.insert("packages/lib".to_string(), vec![]);
let graph = LockfileGraph {
importers,
packages: BTreeMap::new(),
settings: LockfileSettings {
auto_install_peers: false,
exclude_links_from_lockfile: true,
lockfile_include_tarball_url: true,
},
..Default::default()
};
let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
assert!(!subset.settings.auto_install_peers);
assert!(subset.settings.exclude_links_from_lockfile);
assert!(subset.settings.lockfile_include_tarball_url);
}
#[test]
fn subset_to_importer_rekeys_skipped_optionals_to_root() {
let mut importers = BTreeMap::new();
importers.insert("packages/lib".to_string(), vec![]);
importers.insert("packages/app".to_string(), vec![]);
let mut skipped = BTreeMap::new();
let mut lib_skip = BTreeMap::new();
lib_skip.insert("fsevents".to_string(), "^2".to_string());
skipped.insert("packages/lib".to_string(), lib_skip);
let mut app_skip = BTreeMap::new();
app_skip.insert("ghost".to_string(), "*".to_string());
skipped.insert("packages/app".to_string(), app_skip);
let graph = LockfileGraph {
importers,
packages: BTreeMap::new(),
skipped_optional_dependencies: skipped,
..Default::default()
};
let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
assert_eq!(subset.skipped_optional_dependencies.len(), 1);
let root = subset.skipped_optional_dependencies.get(".").unwrap();
assert!(root.contains_key("fsevents"));
assert!(!root.contains_key("ghost"));
}
#[test]
fn workspace_drift_fresh_when_all_importers_match() {
let root_dep = DirectDep {
name: "lodash".into(),
dep_path: "lodash@4.17.21".into(),
dep_type: DepType::Production,
specifier: Some("^4.17.0".into()),
};
let app_dep = DirectDep {
name: "express".into(),
dep_path: "express@4.18.0".into(),
dep_type: DepType::Production,
specifier: Some("^4.18.0".into()),
};
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), vec![root_dep]);
importers.insert("packages/app".to_string(), vec![app_dep]);
let graph = LockfileGraph {
importers,
packages: BTreeMap::new(),
..Default::default()
};
let workspace_manifests = vec![
(".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
(
"packages/app".to_string(),
make_manifest(&[("express", "^4.18.0")]),
),
];
assert_eq!(
graph.check_drift_workspace(
&workspace_manifests,
&BTreeMap::new(),
&[],
&BTreeMap::new()
),
DriftStatus::Fresh
);
}
#[allow(clippy::type_complexity)]
fn mk_catalogs(
entries: &[(&str, &[(&str, &str, &str)])],
) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
for (cat, pkgs) in entries {
let mut inner = BTreeMap::new();
for (pkg, spec, ver) in *pkgs {
inner.insert(
(*pkg).to_string(),
CatalogEntry {
specifier: (*spec).to_string(),
version: (*ver).to_string(),
},
);
}
out.insert((*cat).to_string(), inner);
}
out
}
fn mk_workspace_catalogs(
entries: &[(&str, &[(&str, &str)])],
) -> BTreeMap<String, BTreeMap<String, String>> {
entries
.iter()
.map(|(cat, pkgs)| {
(
(*cat).to_string(),
pkgs.iter()
.map(|(p, s)| ((*p).to_string(), (*s).to_string()))
.collect(),
)
})
.collect()
}
#[test]
fn catalog_drift_fresh_when_specifiers_match() {
let graph = LockfileGraph {
catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
..Default::default()
};
let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
}
#[test]
fn catalog_drift_stale_on_changed_specifier() {
let graph = LockfileGraph {
catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
..Default::default()
};
let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
match graph.check_catalogs_drift(&ws) {
DriftStatus::Stale { reason } => assert!(reason.contains("react")),
other => panic!("expected stale, got {other:?}"),
}
}
#[test]
fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
let graph = LockfileGraph::default();
let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
}
#[test]
fn catalog_drift_stale_on_removed_workspace_entry() {
let graph = LockfileGraph {
catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
..Default::default()
};
let ws = mk_workspace_catalogs(&[]);
assert!(matches!(
graph.check_catalogs_drift(&ws),
DriftStatus::Stale { .. }
));
}
}