1pub mod bun;
2pub mod dep_path_filename;
3pub mod graph_hash;
4pub mod merge;
5pub mod npm;
6mod override_match;
7pub mod pnpm;
8pub mod yarn;
9
10pub use merge::{MergeReport, merge_branch_lockfiles};
11
12use smallvec::SmallVec;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15
16pub type PlatformList = SmallVec<[String; 2]>;
21
22#[derive(Debug, Clone, Default)]
24pub struct LockfileGraph {
25 pub importers: BTreeMap<String, Vec<DirectDep>>,
28 pub packages: BTreeMap<String, LockedPackage>,
30 pub settings: LockfileSettings,
35 pub overrides: BTreeMap<String, String>,
45 pub ignored_optional_dependencies: BTreeSet<String>,
51 pub times: BTreeMap<String, String>,
57 pub skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
70 pub catalogs: BTreeMap<String, BTreeMap<String, CatalogEntry>>,
78 pub bun_config_version: Option<u32>,
86 pub patched_dependencies: BTreeMap<String, String>,
92 pub trusted_dependencies: Vec<String>,
103 pub extra_fields: BTreeMap<String, serde_json::Value>,
111 pub workspace_extra_fields: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct CatalogEntry {
122 pub specifier: String,
123 pub version: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct LockfileSettings {
130 pub auto_install_peers: bool,
133 pub exclude_links_from_lockfile: bool,
136 pub lockfile_include_tarball_url: bool,
144}
145
146impl Default for LockfileSettings {
147 fn default() -> Self {
148 Self {
149 auto_install_peers: true,
150 exclude_links_from_lockfile: false,
151 lockfile_include_tarball_url: false,
152 }
153 }
154}
155
156#[derive(Debug, Clone)]
158pub struct DirectDep {
159 pub name: String,
160 pub dep_path: String,
162 pub dep_type: DepType,
163 pub specifier: Option<String>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum DepType {
172 Production,
173 Dev,
174 Optional,
175}
176
177pub fn dep_type_label(dt: DepType) -> &'static str {
183 match dt {
184 DepType::Production => "dependencies",
185 DepType::Dev => "devDependencies",
186 DepType::Optional => "optionalDependencies",
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum LocalSource {
198 Directory(PathBuf),
202 Tarball(PathBuf),
205 Link(PathBuf),
209 Git(GitSource),
218 RemoteTarball(RemoteTarballSource),
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct RemoteTarballSource {
229 pub url: String,
230 pub integrity: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct GitSource {
236 pub url: String,
237 pub committish: Option<String>,
238 pub resolved: String,
239 pub subpath: Option<String>,
245}
246
247impl LocalSource {
248 pub fn path(&self) -> Option<&Path> {
251 match self {
252 LocalSource::Directory(p) | LocalSource::Tarball(p) | LocalSource::Link(p) => Some(p),
253 LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
254 }
255 }
256
257 pub fn kind_str(&self) -> &'static str {
259 match self {
260 LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
261 LocalSource::Link(_) => "link",
262 LocalSource::Git(_) => "git",
263 LocalSource::RemoteTarball(_) => "url",
264 }
265 }
266
267 pub fn path_posix(&self) -> String {
276 self.path()
277 .map(|p| p.to_string_lossy().replace('\\', "/"))
278 .unwrap_or_default()
279 }
280
281 pub fn specifier(&self) -> String {
289 match self {
290 LocalSource::Git(g) => match &g.subpath {
291 Some(sub) => format!("{}#{}&path:/{}", g.url, g.resolved, sub),
292 None => format!("{}#{}", g.url, g.resolved),
293 },
294 LocalSource::RemoteTarball(t) => t.url.clone(),
295 _ => format!("{}:{}", self.kind_str(), self.path_posix()),
296 }
297 }
298
299 pub fn dep_path(&self, name: &str) -> String {
315 use sha2::{Digest, Sha256};
316 let mut hasher = Sha256::new();
317 match self {
318 LocalSource::Git(g) => {
319 hasher.update(g.url.as_bytes());
320 hasher.update(b"#");
321 hasher.update(g.resolved.as_bytes());
322 if let Some(sub) = &g.subpath {
323 hasher.update(b"&path:/");
324 hasher.update(sub.as_bytes());
325 }
326 }
327 LocalSource::RemoteTarball(t) => {
328 hasher.update(t.url.as_bytes());
329 }
330 _ => hasher.update(self.path_posix().as_bytes()),
331 }
332 let digest = hasher.finalize();
333 let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
334 format!("{name}@{}+{short}", self.kind_str())
335 }
336
337 pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
343 if let Some((url, committish, subpath)) = parse_git_spec(spec) {
347 return Some(LocalSource::Git(GitSource {
352 url,
353 committish,
354 resolved: String::new(),
355 subpath,
356 }));
357 }
358 if Self::looks_like_remote_tarball_url(spec) {
364 return Some(LocalSource::RemoteTarball(RemoteTarballSource {
365 url: spec.to_string(),
366 integrity: String::new(),
367 }));
368 }
369 let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
370 ("file", r)
371 } else if let Some(r) = spec.strip_prefix("link:") {
372 ("link", r)
373 } else {
374 return None;
375 };
376 let rel = PathBuf::from(rest);
377 let abs = project_root.join(&rel);
378 if kind == "link" {
379 return Some(LocalSource::Link(rel));
380 }
381 if abs.is_file() && Self::path_looks_like_tarball(&rel) {
382 return Some(LocalSource::Tarball(rel));
383 }
384 Some(LocalSource::Directory(rel))
385 }
386
387 pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
396 spec.starts_with("https://") || spec.starts_with("http://")
397 }
398
399 pub fn path_looks_like_tarball(path: &Path) -> bool {
400 let name = match path.file_name().and_then(|n| n.to_str()) {
401 Some(n) => n,
402 None => return false,
403 };
404 let lower = name.to_ascii_lowercase();
405 lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
406 }
407}
408
409pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>, Option<String>)> {
428 let (body, committish, subpath) = match spec.find('#') {
429 Some(idx) => {
430 let (c, s) = parse_git_fragment(&spec[idx + 1..]);
431 (&spec[..idx], c, s)
432 }
433 None => (spec, None, None),
434 };
435 let is_bare_transport = body.starts_with("https://")
436 || body.starts_with("http://")
437 || body.starts_with("ssh://")
438 || body.starts_with("file://");
439 let url = if let Some(rest) = body.strip_prefix("git+") {
440 rest.to_string()
443 } else if body.starts_with("git://") {
444 body.to_string()
445 } else if let Some(scp) = parse_scp_url(body) {
446 scp
447 } else if let Some(path) = body.strip_prefix("github:") {
448 format!("https://github.com/{path}.git")
449 } else if let Some(path) = body.strip_prefix("gitlab:") {
450 format!("https://gitlab.com/{path}.git")
451 } else if let Some(path) = body.strip_prefix("bitbucket:") {
452 format!("https://bitbucket.org/{path}.git")
453 } else if is_bare_transport && body.ends_with(".git") {
454 body.to_string()
455 } else if is_bare_transport
456 && committish
457 .as_deref()
458 .is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
459 {
460 body.to_string()
465 } else if is_bare_github_shorthand(body) {
466 format!("https://github.com/{body}.git")
470 } else {
471 return None;
472 };
473 Some((url, committish, subpath))
474}
475
476fn is_bare_github_shorthand(body: &str) -> bool {
482 let Some((owner, repo)) = body.split_once('/') else {
483 return false;
484 };
485 !owner.is_empty()
486 && !owner.starts_with('.')
487 && !repo.is_empty()
488 && !repo.contains('/')
489 && owner
490 .bytes()
491 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
492 && repo
493 .bytes()
494 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
495}
496
497#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct HostedGit {
507 pub host: HostedGitHost,
508 pub owner: String,
509 pub repo: String,
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
513pub enum HostedGitHost {
514 GitHub,
515 GitLab,
516 Bitbucket,
517}
518
519impl HostedGit {
520 pub fn https_url(&self) -> String {
525 let host = self.host.host_domain();
526 format!("https://{host}/{}/{}.git", self.owner, self.repo)
527 }
528
529 pub fn tarball_url(&self, committish: &str) -> Option<String> {
536 if committish.len() != 40 || !committish.chars().all(|c| c.is_ascii_hexdigit()) {
537 return None;
538 }
539 let sha = committish.to_ascii_lowercase();
540 Some(match self.host {
541 HostedGitHost::GitHub => format!(
542 "https://codeload.github.com/{}/{}/tar.gz/{sha}",
543 self.owner, self.repo
544 ),
545 HostedGitHost::GitLab => format!(
546 "https://gitlab.com/{}/{}/-/archive/{sha}/{}-{sha}.tar.gz",
547 self.owner, self.repo, self.repo
548 ),
549 HostedGitHost::Bitbucket => format!(
550 "https://bitbucket.org/{}/{}/get/{sha}.tar.gz",
551 self.owner, self.repo
552 ),
553 })
554 }
555}
556
557impl HostedGitHost {
558 fn from_domain(domain: &str) -> Option<Self> {
559 match domain {
560 "github.com" => Some(HostedGitHost::GitHub),
561 "gitlab.com" => Some(HostedGitHost::GitLab),
562 "bitbucket.org" => Some(HostedGitHost::Bitbucket),
563 _ => None,
564 }
565 }
566
567 pub fn host_domain(self) -> &'static str {
568 match self {
569 HostedGitHost::GitHub => "github.com",
570 HostedGitHost::GitLab => "gitlab.com",
571 HostedGitHost::Bitbucket => "bitbucket.org",
572 }
573 }
574}
575
576pub fn parse_hosted_git(url: &str) -> Option<HostedGit> {
593 let body = url.strip_prefix("git+").unwrap_or(url);
594 let after_scheme = if let Some(rest) = body.strip_prefix("https://") {
595 rest
596 } else if let Some(rest) = body.strip_prefix("http://") {
597 rest
598 } else if let Some(rest) = body.strip_prefix("ssh://") {
599 rest
600 } else if let Some(rest) = body.strip_prefix("git://") {
601 rest
602 } else {
603 let scp_path = parse_scp_url(body)?;
607 return parse_hosted_git(&scp_path);
608 };
609 let host_and_path = match after_scheme.split_once('@') {
611 Some((_, rest)) => rest,
612 None => after_scheme,
613 };
614 let (host, path) = host_and_path.split_once('/')?;
615 let host = HostedGitHost::from_domain(host)?;
616 let mut segs = path.splitn(3, '/');
621 let owner = segs.next()?;
622 let repo = segs.next()?;
623 if owner.is_empty() || repo.is_empty() || segs.next().is_some() {
624 return None;
625 }
626 let repo = repo
627 .strip_suffix(".git")
628 .unwrap_or(repo)
629 .trim_end_matches('/');
630 if repo.is_empty() {
631 return None;
632 }
633 Some(HostedGit {
634 host,
635 owner: owner.to_string(),
636 repo: repo.to_string(),
637 })
638}
639
640fn parse_scp_url(body: &str) -> Option<String> {
641 if body.contains("://") {
642 return None;
643 }
644 let colon = body.find(':')?;
645 let before = &body[..colon];
646 let path = &body[colon + 1..];
647 if before.is_empty() || path.is_empty() {
648 return None;
649 }
650 if path.starts_with('/') {
651 return None;
652 }
653 let at = before.find('@')?;
654 let user = &before[..at];
655 let host = &before[at + 1..];
656 if user.is_empty() || host.is_empty() || host.contains('/') || host.contains('@') {
657 return None;
658 }
659 if !matches!(host, "github.com" | "gitlab.com" | "bitbucket.org") {
663 return None;
664 }
665 Some(format!("ssh://{user}@{host}/{path}"))
666}
667
668pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
676 parse_git_fragment(fragment).0
677}
678
679pub(crate) fn parse_git_fragment(fragment: &str) -> (Option<String>, Option<String>) {
687 if fragment.is_empty() {
688 return (None, None);
689 }
690
691 let mut fallback: Option<&str> = None;
692 let mut preferred: Option<&str> = None;
693 let mut subpath: Option<String> = None;
694 for part in fragment.split('&') {
695 if part.is_empty() {
696 continue;
697 }
698 let split = part.split_once('=').or_else(|| {
704 part.split_once(':')
705 .filter(|(k, _)| matches!(*k, "commit" | "tag" | "head" | "branch" | "path"))
706 });
707 let (key, value) = split.unwrap_or(("", part));
708 if value.is_empty() {
709 continue;
710 }
711 match key {
712 "commit" => {
713 preferred.get_or_insert(value);
714 }
715 "tag" | "head" | "branch" => {
716 fallback.get_or_insert(value);
717 }
718 "path" => {
719 if subpath.is_some() {
725 continue;
727 }
728 let trimmed = value.trim_start_matches('/');
729 if trimmed.is_empty() {
730 continue;
731 }
732 if trimmed
733 .split('/')
734 .any(|c| c.is_empty() || c == "." || c == "..")
735 {
736 continue;
737 }
738 subpath = Some(trimmed.to_string());
739 }
740 "" => {
741 fallback.get_or_insert(value);
742 }
743 _ => {}
744 }
745 }
746
747 (preferred.or(fallback).map(ToString::to_string), subpath)
748}
749
750#[derive(Debug, Clone, Default)]
759pub struct LockedPackage {
760 pub name: String,
762 pub version: String,
764 pub integrity: Option<String>,
766 pub dependencies: BTreeMap<String, String>,
768 pub optional_dependencies: BTreeMap<String, String>,
773 pub peer_dependencies: BTreeMap<String, String>,
777 pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
779 pub dep_path: String,
783 pub local_source: Option<LocalSource>,
788 pub os: PlatformList,
793 pub cpu: PlatformList,
794 pub libc: PlatformList,
795 pub bundled_dependencies: Vec<String>,
804 pub tarball_url: Option<String>,
812 pub alias_of: Option<String>,
827 pub yarn_checksum: Option<String>,
838 pub engines: BTreeMap<String, String>,
843 pub bin: BTreeMap<String, String>,
852 pub declared_dependencies: BTreeMap<String, String>,
870 pub license: Option<String>,
875 pub funding_url: Option<String>,
880 pub optional: bool,
888 pub transitive_peer_dependencies: Vec<String>,
897 pub extra_meta: BTreeMap<String, serde_json::Value>,
905}
906
907impl LockedPackage {
908 pub fn registry_name(&self) -> &str {
914 self.alias_of.as_deref().unwrap_or(&self.name)
915 }
916
917 pub fn spec_key(&self) -> String {
921 format!("{}@{}", self.name, self.version)
922 }
923}
924
925#[derive(Debug, Clone, Default, PartialEq, Eq)]
928pub struct PeerDepMeta {
929 pub optional: bool,
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935pub enum LockfileKind {
936 Aube,
940 Pnpm,
943 Npm,
944 Yarn,
947 YarnBerry,
953 NpmShrinkwrap,
954 Bun,
955}
956
957impl LockfileKind {
958 pub fn filename(self) -> &'static str {
959 match self {
960 LockfileKind::Aube => "aube-lock.yaml",
961 LockfileKind::Pnpm => "pnpm-lock.yaml",
962 LockfileKind::Npm => "package-lock.json",
963 LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
964 LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
965 LockfileKind::Bun => "bun.lock",
966 }
967 }
968}
969
970impl LockfileGraph {
971 pub fn root_deps(&self) -> &[DirectDep] {
973 self.importers.get(".").map(|v| v.as_slice()).unwrap_or(&[])
974 }
975
976 pub fn get_package(&self, dep_path: &str) -> Option<&LockedPackage> {
978 self.packages.get(dep_path)
979 }
980
981 fn transitive_closure<'a>(
992 &self,
993 roots: impl IntoIterator<Item = &'a str>,
994 ) -> std::collections::HashSet<String> {
995 let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
996 let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
997 for root in roots {
998 if reachable.insert(root.to_string()) {
999 queue.push_back(root.to_string());
1000 }
1001 }
1002 while let Some(dep_path) = queue.pop_front() {
1003 let Some(pkg) = self.packages.get(&dep_path) else {
1004 continue;
1005 };
1006 for (child_name, child_version) in &pkg.dependencies {
1007 let child_key = format!("{child_name}@{child_version}");
1008 if reachable.insert(child_key.clone()) {
1009 queue.push_back(child_key);
1010 }
1011 }
1012 }
1013 reachable
1014 }
1015
1016 fn packages_restricted_to(
1020 &self,
1021 reachable: &std::collections::HashSet<String>,
1022 ) -> BTreeMap<String, LockedPackage> {
1023 self.packages
1024 .iter()
1025 .filter(|(dep_path, _)| reachable.contains(*dep_path))
1026 .map(|(k, v)| (k.clone(), v.clone()))
1027 .collect()
1028 }
1029
1030 pub fn filter_deps<F>(&self, keep: F) -> LockfileGraph
1043 where
1044 F: Fn(&DirectDep) -> bool,
1045 {
1046 let importers: BTreeMap<String, Vec<DirectDep>> = self
1048 .importers
1049 .iter()
1050 .map(|(path, deps)| {
1051 let filtered: Vec<DirectDep> = deps.iter().filter(|d| keep(d)).cloned().collect();
1052 (path.clone(), filtered)
1053 })
1054 .collect();
1055
1056 let reachable = self.transitive_closure(
1058 importers
1059 .values()
1060 .flat_map(|deps| deps.iter().map(|d| d.dep_path.as_str())),
1061 );
1062 let packages = self.packages_restricted_to(&reachable);
1063
1064 LockfileGraph {
1065 importers,
1066 packages,
1067 settings: self.settings.clone(),
1072 overrides: self.overrides.clone(),
1075 ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
1076 times: self.times.clone(),
1080 skipped_optional_dependencies: self.skipped_optional_dependencies.clone(),
1081 catalogs: self.catalogs.clone(),
1082 bun_config_version: self.bun_config_version,
1083 patched_dependencies: self.patched_dependencies.clone(),
1084 trusted_dependencies: self.trusted_dependencies.clone(),
1085 extra_fields: self.extra_fields.clone(),
1086 workspace_extra_fields: self.workspace_extra_fields.clone(),
1087 }
1088 }
1089
1090 pub fn subset_to_importer<F>(&self, importer_path: &str, keep: F) -> Option<LockfileGraph>
1111 where
1112 F: Fn(&DirectDep) -> bool,
1113 {
1114 let src_deps = self.importers.get(importer_path)?;
1115 let kept: Vec<DirectDep> = src_deps.iter().filter(|d| keep(d)).cloned().collect();
1116
1117 let reachable = self.transitive_closure(kept.iter().map(|d| d.dep_path.as_str()));
1120 let packages = self.packages_restricted_to(&reachable);
1121
1122 let mut skipped_optional_dependencies = BTreeMap::new();
1126 if let Some(skipped) = self.skipped_optional_dependencies.get(importer_path) {
1127 skipped_optional_dependencies.insert(".".to_string(), skipped.clone());
1128 }
1129
1130 let mut importers = BTreeMap::new();
1131 importers.insert(".".to_string(), kept);
1132
1133 Some(LockfileGraph {
1134 importers,
1135 packages,
1136 settings: self.settings.clone(),
1137 overrides: self.overrides.clone(),
1138 ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
1139 times: self.times.clone(),
1140 skipped_optional_dependencies,
1141 catalogs: self.catalogs.clone(),
1142 bun_config_version: self.bun_config_version,
1143 patched_dependencies: self.patched_dependencies.clone(),
1144 trusted_dependencies: self.trusted_dependencies.clone(),
1145 extra_fields: self.extra_fields.clone(),
1146 workspace_extra_fields: self.workspace_extra_fields.clone(),
1147 })
1148 }
1149
1150 pub fn overlay_metadata_from(&mut self, prior: &LockfileGraph) {
1164 let prior_index = build_canonical_map(prior);
1168 for pkg in self.packages.values_mut() {
1169 let key = pkg.spec_key();
1170 let Some(prior_pkg) = prior_index.get(&key) else {
1171 continue;
1172 };
1173 if pkg.license.is_none() && prior_pkg.license.is_some() {
1174 pkg.license = prior_pkg.license.clone();
1175 }
1176 if pkg.funding_url.is_none() && prior_pkg.funding_url.is_some() {
1177 pkg.funding_url = prior_pkg.funding_url.clone();
1178 }
1179 for (k, v) in &prior_pkg.extra_meta {
1185 pkg.extra_meta.entry(k.clone()).or_insert_with(|| v.clone());
1186 }
1187 }
1188 if self.bun_config_version.is_none() {
1189 self.bun_config_version = prior.bun_config_version;
1190 }
1191 if self.patched_dependencies.is_empty() {
1192 self.patched_dependencies = prior.patched_dependencies.clone();
1193 }
1194 if self.trusted_dependencies.is_empty() {
1195 self.trusted_dependencies = prior.trusted_dependencies.clone();
1196 }
1197 if self.extra_fields.is_empty() {
1198 self.extra_fields = prior.extra_fields.clone();
1199 }
1200 if self.workspace_extra_fields.is_empty() {
1201 self.workspace_extra_fields = prior.workspace_extra_fields.clone();
1202 }
1203 }
1204
1205 pub fn check_drift(
1243 &self,
1244 manifest: &aube_manifest::PackageJson,
1245 workspace_overrides: &BTreeMap<String, String>,
1246 workspace_ignored_optional: &[String],
1247 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1248 ) -> DriftStatus {
1249 let effective = resolve_catalog_refs_in_overrides(
1250 &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
1251 workspace_catalogs,
1252 );
1253 let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
1254 if let Some(reason) = overrides_drift_reason(&locked, &effective) {
1255 return DriftStatus::Stale { reason };
1256 }
1257 let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
1258 effective_ignored.extend(workspace_ignored_optional.iter().cloned());
1259 if let Some(reason) =
1260 ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored)
1261 {
1262 return DriftStatus::Stale { reason };
1263 }
1264 self.check_drift_for_importer(".", manifest, &effective)
1265 }
1266
1267 pub fn check_drift_workspace(
1278 &self,
1279 manifests: &[(String, aube_manifest::PackageJson)],
1280 workspace_overrides: &BTreeMap<String, String>,
1281 workspace_ignored_optional: &[String],
1282 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1283 is_workspace_install: bool,
1284 ) -> DriftStatus {
1285 let effective_overrides = match manifests.iter().find(|(p, _)| p == ".") {
1290 Some((_, root_manifest)) => {
1291 let effective = resolve_catalog_refs_in_overrides(
1292 &merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides),
1293 workspace_catalogs,
1294 );
1295 let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
1296 if let Some(reason) = overrides_drift_reason(&locked, &effective) {
1297 return DriftStatus::Stale { reason };
1298 }
1299 let mut effective_ignored = root_manifest.pnpm_ignored_optional_dependencies();
1300 effective_ignored.extend(workspace_ignored_optional.iter().cloned());
1301 if let Some(reason) = ignored_optional_drift_reason(
1302 &self.ignored_optional_dependencies,
1303 &effective_ignored,
1304 ) {
1305 return DriftStatus::Stale { reason };
1306 }
1307 effective
1308 }
1309 None => BTreeMap::new(),
1310 };
1311 let workspace_link_names: std::collections::HashSet<&str> = manifests
1312 .iter()
1313 .filter(|(path, _)| path != ".")
1314 .filter_map(|(_, manifest)| manifest.name.as_deref())
1315 .collect();
1316 for (importer_path, manifest) in manifests {
1317 match self.check_drift_for_importer_with_workspace_links(
1318 importer_path,
1319 manifest,
1320 &effective_overrides,
1321 &workspace_link_names,
1322 ) {
1323 DriftStatus::Fresh => continue,
1324 stale => return stale,
1325 }
1326 }
1327 if is_workspace_install {
1346 let current_importers: std::collections::HashSet<&str> =
1347 manifests.iter().map(|(p, _)| p.as_str()).collect();
1348 for importer_path in self.importers.keys() {
1349 if !current_importers.contains(importer_path.as_str()) {
1350 return DriftStatus::Stale {
1351 reason: format!(
1352 "workspace importer {importer_path} is in the lockfile but not in the workspace"
1353 ),
1354 };
1355 }
1356 }
1357 }
1358 DriftStatus::Fresh
1359 }
1360
1361 pub fn check_catalogs_drift(
1383 &self,
1384 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1385 ) -> DriftStatus {
1386 for (cat_name, cat) in workspace_catalogs {
1387 let Some(locked) = self.catalogs.get(cat_name) else {
1388 continue;
1389 };
1390 for (pkg, spec) in cat {
1391 if let Some(entry) = locked.get(pkg)
1392 && entry.specifier != *spec
1393 {
1394 return DriftStatus::Stale {
1395 reason: format!(
1396 "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
1397 entry.specifier
1398 ),
1399 };
1400 }
1401 }
1402 }
1403 for (cat_name, cat) in &self.catalogs {
1404 let workspace_cat = workspace_catalogs.get(cat_name);
1405 for pkg in cat.keys() {
1406 if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
1407 return DriftStatus::Stale {
1408 reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
1409 };
1410 }
1411 }
1412 }
1413 DriftStatus::Fresh
1414 }
1415
1416 fn check_drift_for_importer(
1422 &self,
1423 importer_path: &str,
1424 manifest: &aube_manifest::PackageJson,
1425 effective_overrides: &BTreeMap<String, String>,
1426 ) -> DriftStatus {
1427 self.check_drift_for_importer_with_workspace_links(
1428 importer_path,
1429 manifest,
1430 effective_overrides,
1431 &std::collections::HashSet::new(),
1432 )
1433 }
1434
1435 fn check_drift_for_importer_with_workspace_links(
1436 &self,
1437 importer_path: &str,
1438 manifest: &aube_manifest::PackageJson,
1439 effective_overrides: &BTreeMap<String, String>,
1440 workspace_link_names: &std::collections::HashSet<&str>,
1441 ) -> DriftStatus {
1442 let label = if importer_path == "." {
1443 String::new()
1444 } else {
1445 format!("{importer_path}: ")
1446 };
1447
1448 let importer_deps: &[DirectDep] = self
1449 .importers
1450 .get(importer_path)
1451 .map(|v| v.as_slice())
1452 .unwrap_or(&[]);
1453
1454 if importer_deps.iter().all(|d| d.specifier.is_none()) {
1456 return DriftStatus::Fresh;
1457 }
1458 let lockfile_specs: BTreeMap<&str, &str> = importer_deps
1459 .iter()
1460 .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
1461 .collect();
1462
1463 let override_rules = override_match::compile(effective_overrides);
1464
1465 let skipped_optionals: BTreeMap<&str, &str> = self
1471 .skipped_optional_dependencies
1472 .get(importer_path)
1473 .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
1474 .unwrap_or_default();
1475
1476 let ignored = &self.ignored_optional_dependencies;
1490 let manifest_deps = manifest
1491 .dependencies
1492 .iter()
1493 .map(|(k, v)| (k, v, false))
1494 .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
1495 .chain(
1496 manifest
1497 .optional_dependencies
1498 .iter()
1499 .filter(|(name, _)| !ignored.contains(name.as_str()))
1500 .map(|(k, v)| (k, v, true)),
1501 );
1502
1503 for (name, spec, is_optional) in manifest_deps {
1504 match lockfile_specs.get(name.as_str()) {
1505 None => {
1506 if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
1516 if *locked_spec == spec {
1517 continue;
1518 }
1519 return DriftStatus::Stale {
1520 reason: format!(
1521 "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
1522 ),
1523 };
1524 }
1525 return DriftStatus::Stale {
1526 reason: format!("{label}manifest adds {name}@{spec}"),
1527 };
1528 }
1529 Some(locked_spec) if *locked_spec != spec => {
1530 if let Some(override_spec) =
1540 override_match::apply(&override_rules, name.as_str(), spec)
1541 && override_spec == *locked_spec
1542 {
1543 continue;
1544 }
1545 return DriftStatus::Stale {
1546 reason: format!(
1547 "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
1548 ),
1549 };
1550 }
1551 Some(_) => {}
1552 }
1553 }
1554
1555 let mut manifest_dep_types: BTreeMap<&str, DepType> = BTreeMap::new();
1563 for name in manifest.dependencies.keys() {
1564 manifest_dep_types.insert(name.as_str(), DepType::Production);
1565 }
1566 for name in manifest.dev_dependencies.keys() {
1567 manifest_dep_types
1568 .entry(name.as_str())
1569 .or_insert(DepType::Dev);
1570 }
1571 for name in manifest.optional_dependencies.keys() {
1572 if ignored.contains(name.as_str()) {
1573 continue;
1574 }
1575 manifest_dep_types
1576 .entry(name.as_str())
1577 .or_insert(DepType::Optional);
1578 }
1579 for dep in importer_deps {
1580 let Some(expected) = manifest_dep_types.get(dep.name.as_str()) else {
1581 continue;
1582 };
1583 if *expected != dep.dep_type {
1584 return DriftStatus::Stale {
1585 reason: format!(
1586 "{label}{}: manifest section is {}, lockfile section is {}",
1587 dep.name,
1588 dep_type_label(*expected),
1589 dep_type_label(dep.dep_type),
1590 ),
1591 };
1592 }
1593 }
1594
1595 let manifest_names: std::collections::HashSet<&str> = manifest
1616 .dependencies
1617 .keys()
1618 .chain(manifest.dev_dependencies.keys())
1619 .chain(
1620 manifest
1621 .optional_dependencies
1622 .keys()
1623 .filter(|name| !ignored.contains(name.as_str())),
1624 )
1625 .map(|s| s.as_str())
1626 .collect();
1627 let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
1628 .packages
1629 .values()
1630 .flat_map(|p| {
1631 p.peer_dependencies
1632 .iter()
1633 .map(|(name, range)| (name.as_str(), range.as_str()))
1634 })
1635 .collect();
1636 for (locked_name, locked_spec) in &lockfile_specs {
1637 if manifest_names.contains(locked_name) {
1638 continue;
1639 }
1640 if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
1641 continue;
1642 }
1643 let workspace_link = importer_path == "."
1644 && workspace_link_names.contains(locked_name)
1645 && importer_deps
1646 .iter()
1647 .find(|dep| dep.name == *locked_name)
1648 .and_then(|dep| self.packages.get(&dep.dep_path))
1649 .is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))));
1650 if workspace_link {
1651 continue;
1652 }
1653 return DriftStatus::Stale {
1654 reason: format!("{label}manifest removed {locked_name}"),
1655 };
1656 }
1657
1658 DriftStatus::Fresh
1659 }
1660}
1661
1662fn merge_manifest_and_workspace_overrides(
1668 manifest: &aube_manifest::PackageJson,
1669 workspace_overrides: &BTreeMap<String, String>,
1670) -> BTreeMap<String, String> {
1671 let mut out = manifest.overrides_map();
1672 for (k, v) in workspace_overrides {
1673 out.insert(k.clone(), v.clone());
1674 }
1675 out
1676}
1677
1678fn resolve_catalog_refs_in_overrides(
1688 overrides: &BTreeMap<String, String>,
1689 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1690) -> BTreeMap<String, String> {
1691 overrides
1692 .iter()
1693 .map(|(k, v)| {
1694 let resolved = v
1695 .strip_prefix("catalog:")
1696 .map(|tail| if tail.is_empty() { "default" } else { tail })
1697 .and_then(|cat_name| workspace_catalogs.get(cat_name))
1698 .and_then(|cat| cat.get(override_key_package_name(k)))
1699 .cloned()
1700 .unwrap_or_else(|| v.clone());
1701 (k.clone(), resolved)
1702 })
1703 .collect()
1704}
1705
1706fn override_key_package_name(key: &str) -> &str {
1713 let last = key.rsplit('>').next().unwrap_or(key);
1714 if let Some(after_scope) = last.strip_prefix('@') {
1715 match after_scope.find('@') {
1716 Some(idx) => &last[..idx + 1],
1717 None => last,
1718 }
1719 } else {
1720 match last.find('@') {
1721 Some(idx) => &last[..idx],
1722 None => last,
1723 }
1724 }
1725}
1726
1727fn overrides_drift_reason(
1733 lockfile: &BTreeMap<String, String>,
1734 manifest: &BTreeMap<String, String>,
1735) -> Option<String> {
1736 for (k, v) in manifest {
1737 match lockfile.get(k) {
1738 None => return Some(format!("overrides: manifest adds {k}@{v}")),
1739 Some(locked) if locked != v => {
1740 return Some(format!("overrides: {k} changed ({locked} → {v})"));
1741 }
1742 Some(_) => {}
1743 }
1744 }
1745 for k in lockfile.keys() {
1746 if !manifest.contains_key(k) {
1747 return Some(format!("overrides: manifest removes {k}"));
1748 }
1749 }
1750 None
1751}
1752
1753fn ignored_optional_drift_reason(
1756 lockfile: &BTreeSet<String>,
1757 manifest: &BTreeSet<String>,
1758) -> Option<String> {
1759 for name in manifest {
1760 if !lockfile.contains(name) {
1761 return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
1762 }
1763 }
1764 for name in lockfile {
1765 if !manifest.contains(name) {
1766 return Some(format!(
1767 "ignoredOptionalDependencies: manifest removes {name}"
1768 ));
1769 }
1770 }
1771 None
1772}
1773
1774#[derive(Debug, Clone, PartialEq, Eq)]
1776pub enum DriftStatus {
1777 Fresh,
1779 Stale { reason: String },
1781}
1782
1783pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
1790 aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
1791}
1792
1793pub fn write_lockfile(
1797 project_dir: &Path,
1798 graph: &LockfileGraph,
1799 manifest: &aube_manifest::PackageJson,
1800) -> Result<(), Error> {
1801 write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
1802 Ok(())
1803}
1804
1805pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
1812 let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
1813 for pkg in graph.packages.values() {
1814 canonical.entry(pkg.spec_key()).or_insert(pkg);
1815 }
1816 canonical
1817}
1818
1819pub fn write_lockfile_preserving_existing(
1824 project_dir: &Path,
1825 graph: &LockfileGraph,
1826 manifest: &aube_manifest::PackageJson,
1827) -> Result<PathBuf, Error> {
1828 let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
1829 write_lockfile_as(project_dir, graph, manifest, kind)
1830}
1831
1832pub fn write_lockfile_as(
1845 project_dir: &Path,
1846 graph: &LockfileGraph,
1847 manifest: &aube_manifest::PackageJson,
1848 kind: LockfileKind,
1849) -> Result<PathBuf, Error> {
1850 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "write")
1851 .with_meta_fn(|| {
1852 format!(
1853 r#"{{"kind":{},"packages":{}}}"#,
1854 aube_util::diag::jstr(&format!("{:?}", kind)),
1855 graph.packages.len()
1856 )
1857 });
1858 let filename = match kind {
1859 LockfileKind::Aube => aube_lock_filename(project_dir),
1860 LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
1861 other => other.filename().to_string(),
1862 };
1863 let path = project_dir.join(&filename);
1864 match kind {
1865 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
1866 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
1867 LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
1868 LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
1869 LockfileKind::Bun => bun::write(&path, graph, manifest)?,
1870 }
1871 Ok(path)
1872}
1873
1874pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
1883 for (path, kind) in lockfile_candidates(project_dir, true) {
1884 if path.exists() {
1885 return Some(refine_yarn_kind(&path, kind));
1886 }
1887 }
1888 None
1889}
1890
1891pub fn aube_lock_filename(project_dir: &Path) -> String {
1907 use std::sync::{Mutex, OnceLock};
1908 static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
1909 let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
1910 if let Ok(map) = cache.lock()
1911 && let Some(hit) = map.get(project_dir)
1912 {
1913 return hit.clone();
1914 }
1915 let resolved = if !git_branch_lockfile_enabled(project_dir) {
1916 "aube-lock.yaml".to_string()
1917 } else {
1918 match current_git_branch(project_dir) {
1919 Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
1920 None => "aube-lock.yaml".to_string(),
1921 }
1922 };
1923 if let Ok(mut map) = cache.lock() {
1924 map.insert(project_dir.to_path_buf(), resolved.clone());
1925 }
1926 resolved
1927}
1928
1929pub fn pnpm_lock_filename(project_dir: &Path) -> String {
1935 let aube_name = aube_lock_filename(project_dir);
1936 aube_name
1939 .strip_prefix("aube-lock.")
1940 .map(|rest| format!("pnpm-lock.{rest}"))
1941 .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
1942}
1943
1944fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
1945 let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
1953 return false;
1954 };
1955 let npmrc: Vec<(String, String)> = Vec::new();
1956 let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
1957 aube_settings::resolved::git_branch_lockfile(&ctx)
1958}
1959
1960pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
1961 let out = std::process::Command::new("git")
1962 .args(["-C"])
1963 .arg(project_dir)
1964 .args(["branch", "--show-current"])
1965 .output()
1966 .ok()?;
1967 if !out.status.success() {
1968 return None;
1969 }
1970 let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
1971 if branch.is_empty() {
1972 None
1973 } else {
1974 Some(branch)
1975 }
1976}
1977
1978pub fn parse_lockfile(
1987 project_dir: &Path,
1988 manifest: &aube_manifest::PackageJson,
1989) -> Result<LockfileGraph, Error> {
1990 let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
1991 Ok(graph)
1992}
1993
1994pub fn parse_lockfile_with_kind(
1996 project_dir: &Path,
1997 manifest: &aube_manifest::PackageJson,
1998) -> Result<(LockfileGraph, LockfileKind), Error> {
1999 reject_bun_binary(project_dir)?;
2000 for (path, kind) in lockfile_candidates(project_dir, true) {
2001 if !path.exists() {
2002 continue;
2003 }
2004 let kind = refine_yarn_kind(&path, kind);
2005 let graph = parse_one(&path, kind, manifest)?;
2006 return Ok((graph, kind));
2007 }
2008 Err(Error::NotFound(project_dir.to_path_buf()))
2009}
2010
2011pub fn parse_for_import(
2018 project_dir: &Path,
2019 manifest: &aube_manifest::PackageJson,
2020) -> Result<(LockfileGraph, LockfileKind), Error> {
2021 reject_bun_binary(project_dir)?;
2022 for (path, kind) in lockfile_candidates(project_dir, false) {
2023 if !path.exists() {
2024 continue;
2025 }
2026 let kind = refine_yarn_kind(&path, kind);
2027 let graph = parse_one(&path, kind, manifest)?;
2028 return Ok((graph, kind));
2029 }
2030 Err(Error::NotFound(project_dir.to_path_buf()))
2031}
2032
2033fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
2036 let lockb = project_dir.join("bun.lockb");
2037 let text = project_dir.join("bun.lock");
2038 if lockb.exists() && !text.exists() {
2039 return Err(Error::parse(
2040 &lockb,
2041 "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",
2042 ));
2043 }
2044 Ok(())
2045}
2046
2047fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
2048 let mut out = Vec::new();
2049 if include_aube {
2050 let branch_name = aube_lock_filename(project_dir);
2054 if branch_name != "aube-lock.yaml" {
2055 out.push((project_dir.join(&branch_name), LockfileKind::Aube));
2056 }
2057 out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
2058 }
2059 let pnpm_branch = {
2064 let mut s = aube_lock_filename(project_dir);
2065 if let Some(rest) = s.strip_prefix("aube-lock.") {
2066 s = format!("pnpm-lock.{rest}");
2067 }
2068 s
2069 };
2070 if pnpm_branch != "pnpm-lock.yaml" {
2071 out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
2072 }
2073 out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
2074 out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
2075 out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
2076 out.push((
2077 project_dir.join("npm-shrinkwrap.json"),
2078 LockfileKind::NpmShrinkwrap,
2079 ));
2080 out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
2081 out
2082}
2083
2084fn parse_one(
2085 path: &Path,
2086 kind: LockfileKind,
2087 manifest: &aube_manifest::PackageJson,
2088) -> Result<LockfileGraph, Error> {
2089 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
2090 .with_meta_fn(|| {
2091 let display = path
2094 .file_name()
2095 .map(|n| n.to_string_lossy().into_owned())
2096 .unwrap_or_default();
2097 format!(
2098 r#"{{"kind":{},"path":{}}}"#,
2099 aube_util::diag::jstr(&format!("{:?}", kind)),
2100 aube_util::diag::jstr(&display)
2101 )
2102 });
2103 match kind {
2104 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
2109 LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
2115 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
2116 LockfileKind::Bun => bun::parse(path),
2117 }
2118}
2119
2120fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
2129 if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
2130 LockfileKind::YarnBerry
2131 } else {
2132 kind
2133 }
2134}
2135
2136#[derive(Debug, thiserror::Error, miette::Diagnostic)]
2137pub enum Error {
2138 #[error("no lockfile found in {0}")]
2139 #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
2140 NotFound(std::path::PathBuf),
2141 #[error("unsupported lockfile format: {0}")]
2142 #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
2143 UnsupportedFormat(String),
2144 #[error("failed to read lockfile {0}: {1}")]
2145 Io(std::path::PathBuf, std::io::Error),
2146 #[error("failed to parse lockfile {0}: {1}")]
2151 #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
2152 Parse(std::path::PathBuf, String),
2153 #[error(transparent)]
2159 #[diagnostic(transparent)]
2160 ParseDiag(Box<aube_manifest::ParseError>),
2161}
2162
2163pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
2165 std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
2166}
2167
2168pub fn parse_json<T: serde::de::DeserializeOwned>(
2171 path: &std::path::Path,
2172 content: String,
2173) -> Result<T, Error> {
2174 match sonic_rs::from_slice(content.as_bytes()) {
2177 Ok(v) => Ok(v),
2178 Err(_) => match serde_json::from_str(&content) {
2179 Ok(v) => Ok(v),
2180 Err(e) => Err(Error::parse_json_err(path, content, &e)),
2181 },
2182 }
2183}
2184
2185impl Error {
2186 pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
2187 Error::Parse(path.to_path_buf(), msg.into())
2188 }
2189
2190 pub fn parse_json_err(
2191 path: &std::path::Path,
2192 content: String,
2193 err: &serde_json::Error,
2194 ) -> Self {
2195 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
2196 path, content, err,
2197 )))
2198 }
2199
2200 pub fn parse_yaml_err(
2201 path: &std::path::Path,
2202 content: String,
2203 err: &yaml_serde::Error,
2204 ) -> Self {
2205 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
2206 path, content, err,
2207 )))
2208 }
2209}
2210
2211#[cfg(test)]
2212mod parse_diag_tests {
2213 use super::*;
2214 use std::path::Path;
2215
2216 #[test]
2220 fn parse_json_attaches_span_for_bad_input() {
2221 let path = Path::new("package-lock.json");
2222 let content = r#"{"name":"x","#.to_string();
2223 let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
2224 else {
2225 panic!("parse_json must produce ParseDiag on malformed input");
2226 };
2227 let offset: usize = pe.span.offset();
2228 let len: usize = pe.span.len();
2229 assert!(offset + len <= content.len());
2230 assert_eq!(pe.path, path);
2231 }
2232
2233 #[test]
2240 fn parse_yaml_err_attaches_span_for_bad_input() {
2241 let path = Path::new("yarn.lock");
2242 let content = "packages:\n\t- pkg\n".to_string();
2243 let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
2244 .expect_err("tab-indented YAML must fail");
2245 let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
2246 panic!("parse_yaml_err must produce ParseDiag");
2247 };
2248 let offset: usize = pe.span.offset();
2249 let len: usize = pe.span.len();
2250 assert!(offset + len <= content.len());
2251 assert_eq!(pe.path, path);
2252 }
2253}
2254
2255#[cfg(test)]
2256mod looks_like_remote_tarball_url_tests {
2257 use super::*;
2258
2259 #[test]
2260 fn matches_https_tgz() {
2261 assert!(LocalSource::looks_like_remote_tarball_url(
2262 "https://example.com/pkg-1.0.0.tgz"
2263 ));
2264 }
2265
2266 #[test]
2267 fn matches_http_tar_gz() {
2268 assert!(LocalSource::looks_like_remote_tarball_url(
2269 "http://example.com/pkg-1.0.0.tar.gz"
2270 ));
2271 }
2272
2273 #[test]
2274 fn strips_fragment_before_suffix_check() {
2275 assert!(LocalSource::looks_like_remote_tarball_url(
2276 "https://example.com/pkg-1.0.0.tgz#sha512-abc"
2277 ));
2278 }
2279
2280 #[test]
2281 fn strips_query_string_before_suffix_check() {
2282 assert!(LocalSource::looks_like_remote_tarball_url(
2286 "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
2287 ));
2288 assert!(LocalSource::looks_like_remote_tarball_url(
2289 "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
2290 ));
2291 }
2292
2293 #[test]
2294 fn matches_bare_http_url_without_tarball_suffix() {
2295 assert!(LocalSource::looks_like_remote_tarball_url(
2299 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
2300 ));
2301 assert!(LocalSource::looks_like_remote_tarball_url(
2302 "https://codeload.github.com/user/repo/tar.gz/main"
2303 ));
2304 }
2305
2306 #[test]
2307 fn rejects_non_http_schemes() {
2308 assert!(!LocalSource::looks_like_remote_tarball_url(
2309 "ftp://example.com/pkg.tgz"
2310 ));
2311 assert!(!LocalSource::looks_like_remote_tarball_url(
2312 "git://example.com/repo.git"
2313 ));
2314 }
2315
2316 #[test]
2317 fn parse_classifies_bare_http_url_as_remote_tarball() {
2318 use std::path::Path;
2319 let parsed = LocalSource::parse(
2320 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
2321 Path::new(""),
2322 );
2323 assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
2324 }
2325
2326 #[test]
2327 fn parse_prefers_git_over_tarball_for_dot_git_url() {
2328 use std::path::Path;
2329 let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
2330 assert!(matches!(parsed, Some(LocalSource::Git(_))));
2331 }
2332}
2333
2334#[cfg(test)]
2335mod filename_tests {
2336 use super::*;
2337
2338 #[test]
2339 fn defaults_to_plain_lockfile_when_setting_absent() {
2340 let dir = tempfile::tempdir().unwrap();
2341 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2342 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
2343 }
2344
2345 #[test]
2346 fn defaults_to_plain_lockfile_when_setting_explicit_false() {
2347 let dir = tempfile::tempdir().unwrap();
2348 std::fs::write(
2349 dir.path().join("pnpm-workspace.yaml"),
2350 "gitBranchLockfile: false\n",
2351 )
2352 .unwrap();
2353 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2354 }
2355
2356 #[test]
2357 fn uses_branch_filename_when_enabled_inside_git_repo() {
2358 let dir = tempfile::tempdir().unwrap();
2359 std::fs::write(
2360 dir.path().join("pnpm-workspace.yaml"),
2361 "gitBranchLockfile: true\n",
2362 )
2363 .unwrap();
2364 let run = |args: &[&str]| {
2367 std::process::Command::new("git")
2368 .args(["-C"])
2369 .arg(dir.path())
2370 .args(args)
2371 .output()
2372 .unwrap()
2373 };
2374 if run(&["init", "-q"]).status.success() {
2375 run(&["checkout", "-q", "-b", "feature/x"]);
2376 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
2377 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
2378 }
2379 }
2380}
2381
2382#[cfg(test)]
2383mod git_spec_tests {
2384 use super::*;
2385
2386 #[test]
2387 fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
2388 let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
2390 assert_eq!(url, "https://host/user/repo");
2391 assert_eq!(committish, None);
2392 assert_eq!(subpath, None);
2393
2394 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2397 let source = LocalSource::Git(GitSource {
2398 url: url.clone(),
2399 committish: None,
2400 resolved: sha.to_string(),
2401 subpath: None,
2402 });
2403 let lockfile_version = source.specifier();
2404 assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
2405
2406 let (round_url, round_committish, round_subpath) =
2409 parse_git_spec(&lockfile_version).unwrap();
2410 assert_eq!(round_url, "https://host/user/repo");
2411 assert_eq!(round_committish.as_deref(), Some(sha));
2412 assert_eq!(round_subpath, None);
2413 }
2414
2415 #[test]
2416 fn bare_https_without_dot_git_and_no_committish_is_not_git() {
2417 assert!(parse_git_spec("https://example.com/pkg").is_none());
2420 }
2421
2422 #[test]
2423 fn github_shorthand_expands_and_roundtrips() {
2424 let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
2425 assert_eq!(url, "https://github.com/user/repo.git");
2426 }
2427
2428 #[test]
2429 fn bare_user_repo_expands_to_github() {
2430 let (url, committish, subpath) = parse_git_spec("kevva/is-negative").unwrap();
2431 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2432 assert!(committish.is_none());
2433 assert!(subpath.is_none());
2434 }
2435
2436 #[test]
2437 fn bare_user_repo_with_committish_preserved() {
2438 let (url, committish, _) = parse_git_spec("kevva/is-negative#v1.0.0").unwrap();
2439 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2440 assert_eq!(committish.as_deref(), Some("v1.0.0"));
2441 }
2442
2443 #[test]
2444 fn bare_scope_pkg_is_not_git_shorthand() {
2445 assert!(parse_git_spec("@types/node").is_none());
2447 }
2448
2449 #[test]
2450 fn bare_relative_path_is_not_git_shorthand() {
2451 assert!(parse_git_spec("./repo").is_none());
2454 assert!(parse_git_spec("../repo").is_none());
2455 assert!(parse_git_spec("./local/path").is_none());
2458 assert!(parse_git_spec("../local/path").is_none());
2459 }
2460
2461 #[test]
2462 fn bare_path_with_extra_slashes_is_not_git_shorthand() {
2463 assert!(parse_git_spec("path/with/slashes/extra").is_none());
2466 }
2467
2468 #[test]
2469 fn bare_scp_form_unknown_host_is_not_github_shorthand() {
2470 assert!(parse_git_spec("user@host:repo.git").is_none());
2473 }
2474
2475 #[test]
2476 fn scp_form_recognized() {
2477 let (url, committish, _) =
2478 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
2479 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2480 assert!(committish.is_none());
2481 }
2482
2483 #[test]
2484 fn scp_form_with_ref_recognized() {
2485 let (url, committish, _) =
2486 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
2487 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2488 assert_eq!(committish.as_deref(), Some("0.1.5"));
2489 }
2490
2491 #[test]
2492 fn scp_form_bitbucket_recognized() {
2493 let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
2494 assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
2495 }
2496
2497 #[test]
2498 fn scp_form_unknown_host_rejected() {
2499 assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
2501 assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
2502 }
2503
2504 #[test]
2505 fn scp_form_without_user_rejected() {
2506 assert!(parse_git_spec("github.com:user/repo.git").is_none());
2508 }
2509
2510 #[test]
2511 fn commit_selector_fragment_normalizes_to_sha() {
2512 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2513 let (url, committish, _) =
2514 parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
2515 assert_eq!(url, "https://host/user/repo.git");
2516 assert_eq!(committish.as_deref(), Some(sha));
2517 }
2518
2519 #[test]
2520 fn named_selector_fragment_normalizes_to_ref() {
2521 let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
2522 assert_eq!(url, "https://host/user/repo");
2523 assert_eq!(committish.as_deref(), Some("v1.2.3"));
2524 }
2525
2526 #[test]
2527 fn pnpm_path_subpath_extracted_from_fragment() {
2528 let (url, committish, subpath) =
2531 parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
2532 assert_eq!(url, "https://github.com/org/dep.git");
2533 assert_eq!(committish.as_deref(), Some("v0.1.4"));
2534 assert_eq!(subpath.as_deref(), Some("packages/special"));
2535 }
2536
2537 #[test]
2538 fn path_subpath_roundtrips_via_specifier() {
2539 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2540 let source = LocalSource::Git(GitSource {
2541 url: "https://github.com/org/dep.git".to_string(),
2542 committish: None,
2543 resolved: sha.to_string(),
2544 subpath: Some("packages/special".to_string()),
2545 });
2546 let spec = source.specifier();
2547 assert_eq!(
2548 spec,
2549 format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
2550 );
2551 let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
2552 assert_eq!(url, "https://github.com/org/dep.git");
2553 assert_eq!(committish.as_deref(), Some(sha));
2554 assert_eq!(subpath.as_deref(), Some("packages/special"));
2555 }
2556
2557 #[test]
2558 fn parse_hosted_git_recognizes_canonical_forms() {
2559 let canonical = HostedGit {
2563 host: HostedGitHost::GitHub,
2564 owner: "owner".to_string(),
2565 repo: "repo".to_string(),
2566 };
2567 for spec in [
2568 "https://github.com/owner/repo.git",
2569 "https://github.com/owner/repo",
2570 "http://github.com/owner/repo.git",
2571 "git+https://github.com/owner/repo.git",
2572 "git+https://github.com/owner/repo",
2573 "git://github.com/owner/repo.git",
2574 "ssh://git@github.com/owner/repo.git",
2575 "git+ssh://git@github.com/owner/repo.git",
2576 "git@github.com:owner/repo.git",
2577 ] {
2578 assert_eq!(
2579 parse_hosted_git(spec).as_ref(),
2580 Some(&canonical),
2581 "spec {spec} should map to canonical HostedGit",
2582 );
2583 }
2584 }
2585
2586 #[test]
2587 fn parse_hosted_git_returns_none_for_non_hosted() {
2588 for spec in [
2591 "https://example.com/owner/repo.git",
2592 "ssh://git@gitea.internal/owner/repo.git",
2593 "git+ssh://git@gitlab.example.com/group/sub/repo.git",
2594 "https://github.com/owner/repo/sub",
2595 "https://github.com/owner",
2596 ] {
2597 assert!(
2598 parse_hosted_git(spec).is_none(),
2599 "spec {spec} must not match a hosted provider",
2600 );
2601 }
2602 }
2603
2604 #[test]
2605 fn hosted_tarball_url_only_for_full_sha() {
2606 let g = HostedGit {
2607 host: HostedGitHost::GitHub,
2608 owner: "o".to_string(),
2609 repo: "r".to_string(),
2610 };
2611 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2612 assert_eq!(
2613 g.tarball_url(sha).as_deref(),
2614 Some("https://codeload.github.com/o/r/tar.gz/abcdef0123456789abcdef0123456789abcdef01"),
2615 );
2616 assert!(g.tarball_url("main").is_none());
2620 assert!(g.tarball_url("v1.2.3").is_none());
2621 assert!(g.tarball_url("abcdef0").is_none());
2622 }
2623
2624 #[test]
2625 fn hosted_tarball_url_per_provider() {
2626 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2627 let gitlab = HostedGit {
2628 host: HostedGitHost::GitLab,
2629 owner: "g".to_string(),
2630 repo: "r".to_string(),
2631 }
2632 .tarball_url(sha)
2633 .unwrap();
2634 assert!(gitlab.starts_with("https://gitlab.com/g/r/-/archive/"));
2635 assert!(gitlab.ends_with("/r-abcdef0123456789abcdef0123456789abcdef01.tar.gz"));
2636 let bitbucket = HostedGit {
2637 host: HostedGitHost::Bitbucket,
2638 owner: "g".to_string(),
2639 repo: "r".to_string(),
2640 }
2641 .tarball_url(sha)
2642 .unwrap();
2643 assert_eq!(
2644 bitbucket,
2645 "https://bitbucket.org/g/r/get/abcdef0123456789abcdef0123456789abcdef01.tar.gz",
2646 );
2647 }
2648
2649 #[test]
2650 fn hosted_https_url_normalizes() {
2651 let g = parse_hosted_git("git+ssh://git@github.com/owner/repo.git").unwrap();
2652 assert_eq!(g.https_url(), "https://github.com/owner/repo.git");
2653 }
2654
2655 #[test]
2656 fn path_traversal_components_in_subpath_are_rejected() {
2657 let cases = [
2661 "github:org/dep#main&path:/../../etc",
2662 "github:org/dep#main&path:/packages/../../../etc",
2663 "github:org/dep#main&path:/./packages/foo",
2664 "github:org/dep#main&path:/packages//foo",
2665 ];
2666 for spec in cases {
2667 let (_, _, subpath) = parse_git_spec(spec).unwrap();
2668 assert_eq!(subpath, None, "spec should drop subpath: {spec}");
2669 }
2670 }
2671
2672 #[test]
2673 fn dep_path_distinguishes_subpaths_under_same_commit() {
2674 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2678 let a = LocalSource::Git(GitSource {
2679 url: "https://example.com/r.git".to_string(),
2680 committish: None,
2681 resolved: sha.to_string(),
2682 subpath: Some("packages/a".to_string()),
2683 });
2684 let b = LocalSource::Git(GitSource {
2685 url: "https://example.com/r.git".to_string(),
2686 committish: None,
2687 resolved: sha.to_string(),
2688 subpath: Some("packages/b".to_string()),
2689 });
2690 assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
2691 }
2692}
2693
2694#[cfg(test)]
2695mod drift_tests {
2696 use super::*;
2697 use aube_manifest::PackageJson;
2698 use std::collections::BTreeMap;
2699 use std::path::PathBuf;
2700
2701 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
2702 let mut m = PackageJson {
2703 name: Some("test".into()),
2704 version: Some("1.0.0".into()),
2705 dependencies: BTreeMap::new(),
2706 dev_dependencies: BTreeMap::new(),
2707 peer_dependencies: BTreeMap::new(),
2708 optional_dependencies: BTreeMap::new(),
2709 update_config: None,
2710 scripts: BTreeMap::new(),
2711 engines: BTreeMap::new(),
2712 workspaces: None,
2713 bundled_dependencies: None,
2714 extra: BTreeMap::new(),
2715 };
2716 for (name, spec) in deps {
2717 m.dependencies.insert((*name).into(), (*spec).into());
2718 }
2719 m
2720 }
2721
2722 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
2723 let direct: Vec<DirectDep> = deps
2725 .iter()
2726 .map(|(name, spec, dep_path)| DirectDep {
2727 name: (*name).into(),
2728 dep_path: (*dep_path).into(),
2729 dep_type: DepType::Production,
2730 specifier: Some((*spec).into()),
2731 })
2732 .collect();
2733 let mut importers = BTreeMap::new();
2734 importers.insert(".".to_string(), direct);
2735 LockfileGraph {
2736 importers,
2737 packages: BTreeMap::new(),
2738 ..Default::default()
2739 }
2740 }
2741
2742 #[test]
2743 fn stale_when_dep_moves_between_sections() {
2744 let mut manifest = make_manifest(&[]);
2749 manifest
2750 .dev_dependencies
2751 .insert("msw".into(), "catalog:".into());
2752 let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
2753 graph
2754 .importers
2755 .get_mut(".")
2756 .unwrap()
2757 .iter_mut()
2758 .for_each(|d| d.dep_type = DepType::Production);
2759 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2760 DriftStatus::Stale { reason } => {
2761 assert!(reason.contains("msw"), "reason: {reason}");
2762 assert!(reason.contains("devDependencies"), "reason: {reason}");
2763 }
2764 DriftStatus::Fresh => panic!("expected Stale"),
2765 }
2766 }
2767
2768 #[test]
2769 fn fresh_when_specifiers_match() {
2770 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2771 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2772 assert_eq!(
2773 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2774 DriftStatus::Fresh
2775 );
2776 }
2777
2778 #[test]
2779 fn stale_when_specifier_changes() {
2780 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
2781 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2782 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2783 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2784 DriftStatus::Fresh => panic!("expected Stale"),
2785 }
2786 }
2787
2788 #[test]
2789 fn stale_when_manifest_adds_dep() {
2790 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
2791 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2792 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2793 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2794 DriftStatus::Fresh => panic!("expected Stale"),
2795 }
2796 }
2797
2798 #[test]
2799 fn stale_when_manifest_removes_dep() {
2800 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2801 let graph = make_graph(&[
2802 ("lodash", "^4.17.0", "lodash@4.17.21"),
2803 ("express", "^4.18.0", "express@4.18.0"),
2804 ]);
2805 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2806 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2807 DriftStatus::Fresh => panic!("expected Stale"),
2808 }
2809 }
2810
2811 #[test]
2816 fn fresh_when_lockfile_has_auto_hoisted_peer() {
2817 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2818 let mut graph = make_graph(&[
2819 (
2820 "use-sync-external-store",
2821 "1.2.0",
2822 "use-sync-external-store@1.2.0",
2823 ),
2824 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
2827 ]);
2828 let mut declaring_pkg = LockedPackage {
2831 name: "use-sync-external-store".into(),
2832 version: "1.2.0".into(),
2833 dep_path: "use-sync-external-store@1.2.0".into(),
2834 ..Default::default()
2835 };
2836 declaring_pkg
2837 .peer_dependencies
2838 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2839 graph
2840 .packages
2841 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
2842
2843 assert_eq!(
2844 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2845 DriftStatus::Fresh
2846 );
2847 }
2848
2849 #[test]
2855 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
2856 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2859
2860 let mut graph = make_graph(&[
2863 (
2864 "use-sync-external-store",
2865 "1.2.0",
2866 "use-sync-external-store@1.2.0",
2867 ),
2868 ("react", "17.0.2", "react@17.0.2"),
2869 ]);
2870 let mut consumer = LockedPackage {
2875 name: "use-sync-external-store".into(),
2876 version: "1.2.0".into(),
2877 dep_path: "use-sync-external-store@1.2.0".into(),
2878 ..Default::default()
2879 };
2880 consumer
2881 .peer_dependencies
2882 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2883 graph
2884 .packages
2885 .insert("use-sync-external-store@1.2.0".into(), consumer);
2886
2887 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2888 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2889 DriftStatus::Fresh => panic!(
2890 "drift check should flag a removed user-pinned dep as stale, \
2891 even when its name matches a peer declaration"
2892 ),
2893 }
2894 }
2895
2896 #[test]
2899 fn stale_when_lockfile_has_removed_non_peer_dep() {
2900 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2901 let graph = make_graph(&[
2902 ("lodash", "^4.17.0", "lodash@4.17.21"),
2903 ("chalk", "^5.0.0", "chalk@5.0.0"),
2904 ]);
2905 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2906 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
2907 DriftStatus::Fresh => panic!("expected Stale"),
2908 }
2909 }
2910
2911 #[test]
2912 fn workspace_drift_allows_root_links_for_workspace_packages() {
2913 let root_manifest = make_manifest(&[]);
2914 let mut app_manifest = make_manifest(&[]);
2915 app_manifest.name = Some("@scope/app".to_string());
2916
2917 let link = LocalSource::Link(PathBuf::from("packages/app"));
2918 let dep_path = link.dep_path("@scope/app");
2919 let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
2920 graph.packages.insert(
2921 dep_path.clone(),
2922 LockedPackage {
2923 name: "@scope/app".to_string(),
2924 version: "1.0.0".to_string(),
2925 dep_path,
2926 local_source: Some(link),
2927 ..Default::default()
2928 },
2929 );
2930
2931 assert_eq!(
2932 graph.check_drift_workspace(
2933 &[
2934 (".".to_string(), root_manifest),
2935 ("packages/app".to_string(), app_manifest),
2936 ],
2937 &BTreeMap::new(),
2938 &[],
2939 &BTreeMap::new(),
2940 true,
2941 ),
2942 DriftStatus::Fresh
2943 );
2944 }
2945
2946 #[test]
2947 fn fresh_when_no_specifiers_recorded() {
2948 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2951 let graph = LockfileGraph {
2952 importers: {
2953 let mut m = BTreeMap::new();
2954 m.insert(
2955 ".".to_string(),
2956 vec![DirectDep {
2957 name: "lodash".into(),
2958 dep_path: "lodash@4.17.21".into(),
2959 dep_type: DepType::Production,
2960 specifier: None,
2961 }],
2962 );
2963 m
2964 },
2965 packages: BTreeMap::new(),
2966 ..Default::default()
2967 };
2968 assert_eq!(
2969 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2970 DriftStatus::Fresh
2971 );
2972 }
2973
2974 #[test]
2975 fn stale_when_manifest_adds_override() {
2976 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2980 manifest
2981 .extra
2982 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2983 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2984 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2985 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
2986 DriftStatus::Fresh => panic!("expected Stale"),
2987 }
2988 }
2989
2990 #[test]
2991 fn stale_drift_message_names_changed_override_key() {
2992 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2996 manifest
2997 .extra
2998 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
2999 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3000 graph.overrides.insert("lodash".into(), "4.17.21".into());
3001 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3002 DriftStatus::Stale { reason } => {
3003 assert!(reason.contains("lodash"), "expected key in: {reason}");
3004 assert!(
3005 reason.contains("4.17.21"),
3006 "expected old value in: {reason}"
3007 );
3008 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
3009 }
3010 DriftStatus::Fresh => panic!("expected Stale"),
3011 }
3012 }
3013
3014 #[test]
3015 fn stale_when_manifest_removes_override() {
3016 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3017 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3018 graph.overrides.insert("lodash".into(), "4.17.21".into());
3019 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3020 DriftStatus::Stale { reason } => {
3021 assert!(reason.contains("removes"));
3022 assert!(reason.contains("lodash"));
3023 }
3024 DriftStatus::Fresh => panic!("expected Stale"),
3025 }
3026 }
3027
3028 #[test]
3029 fn fresh_when_overrides_match() {
3030 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3031 manifest
3032 .extra
3033 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
3034 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3035 graph.overrides.insert("lodash".into(), "4.17.21".into());
3036 assert_eq!(
3037 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3038 DriftStatus::Fresh
3039 );
3040 }
3041
3042 #[test]
3043 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
3044 let manifest = make_manifest(&[("semver", "^7.5.0")]);
3050 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
3051 graph.overrides.insert("semver".into(), "7.7.1".into());
3052 let mut ws_overrides = BTreeMap::new();
3053 ws_overrides.insert("semver".into(), "7.7.1".into());
3054 assert_eq!(
3055 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3056 DriftStatus::Fresh,
3057 );
3058 }
3059
3060 #[test]
3061 fn workspace_yaml_overrides_win_over_package_json() {
3062 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
3067 manifest
3068 .extra
3069 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
3070 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
3071 graph.overrides.insert("semver".into(), "7.7.1".into());
3072 let mut ws_overrides = BTreeMap::new();
3073 ws_overrides.insert("semver".into(), "7.7.1".into());
3074 assert_eq!(
3075 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3076 DriftStatus::Fresh,
3077 );
3078 }
3079
3080 #[test]
3081 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
3082 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3088 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3089 graph.overrides.insert("lodash".into(), "4.17.21".into());
3090 let mut ws_overrides = BTreeMap::new();
3091 ws_overrides.insert("lodash".into(), "catalog:".into());
3092 let mut catalogs = BTreeMap::new();
3093 let mut default_cat = BTreeMap::new();
3094 default_cat.insert("lodash".into(), "4.17.21".into());
3095 catalogs.insert("default".into(), default_cat);
3096 assert_eq!(
3097 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
3098 DriftStatus::Fresh,
3099 );
3100 }
3101
3102 #[test]
3103 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
3104 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3107 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3108 graph.overrides.insert("lodash".into(), "4.17.21".into());
3109 let mut ws_overrides = BTreeMap::new();
3110 ws_overrides.insert("lodash".into(), "catalog:evens".into());
3111 let mut catalogs = BTreeMap::new();
3112 let mut evens = BTreeMap::new();
3113 evens.insert("lodash".into(), "4.17.21".into());
3114 catalogs.insert("evens".into(), evens);
3115 assert_eq!(
3116 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
3117 DriftStatus::Fresh,
3118 );
3119 }
3120
3121 #[test]
3122 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
3123 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3127 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3128 graph.overrides.insert("lodash".into(), "4.17.21".into());
3129 let mut ws_overrides = BTreeMap::new();
3130 ws_overrides.insert("lodash".into(), "catalog:".into());
3131 let mut catalogs = BTreeMap::new();
3132 let mut default_cat = BTreeMap::new();
3133 default_cat.insert("lodash".into(), "4.17.22".into());
3134 catalogs.insert("default".into(), default_cat);
3135 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
3136 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
3137 other => panic!("expected stale, got {other:?}"),
3138 }
3139 }
3140
3141 #[test]
3142 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
3143 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3150 let mut importers = BTreeMap::new();
3151 importers.insert(
3152 ".".to_string(),
3153 vec![DirectDep {
3154 name: "lodash".into(),
3155 dep_path: "lodash@4.17.21".into(),
3156 dep_type: DepType::Production,
3157 specifier: Some("4.17.21".into()),
3158 }],
3159 );
3160 let mut graph = LockfileGraph {
3161 importers,
3162 ..Default::default()
3163 };
3164 graph.overrides.insert("lodash".into(), "4.17.21".into());
3165 let mut ws_overrides = BTreeMap::new();
3166 ws_overrides.insert("lodash".into(), "4.17.21".into());
3167 assert_eq!(
3168 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3169 DriftStatus::Fresh,
3170 );
3171 }
3172
3173 #[test]
3174 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
3175 let manifest = make_manifest(&[("plist", "^3.0.4")]);
3182 let mut importers = BTreeMap::new();
3183 importers.insert(
3184 ".".to_string(),
3185 vec![DirectDep {
3186 name: "plist".into(),
3187 dep_path: "plist@3.0.6".into(),
3188 dep_type: DepType::Production,
3189 specifier: Some(">=3.0.5".into()),
3190 }],
3191 );
3192 let mut graph = LockfileGraph {
3193 importers,
3194 ..Default::default()
3195 };
3196 graph
3197 .overrides
3198 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
3199 let mut ws_overrides = BTreeMap::new();
3200 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
3201 assert_eq!(
3202 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3203 DriftStatus::Fresh,
3204 );
3205 }
3206
3207 #[test]
3208 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
3209 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3216 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3217 graph
3218 .ignored_optional_dependencies
3219 .insert("fsevents".to_string());
3220 let ws_ignored = vec!["fsevents".to_string()];
3221 assert_eq!(
3222 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
3223 DriftStatus::Fresh,
3224 );
3225 }
3226
3227 #[test]
3228 fn fresh_when_optional_dep_was_recorded_as_skipped() {
3229 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3234 manifest
3235 .optional_dependencies
3236 .insert("fsevents".into(), "^2.3.0".into());
3237 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3238 let mut inner = BTreeMap::new();
3239 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3240 graph
3241 .skipped_optional_dependencies
3242 .insert(".".to_string(), inner);
3243 assert_eq!(
3244 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3245 DriftStatus::Fresh
3246 );
3247 }
3248
3249 #[test]
3250 fn stale_when_new_optional_dep_was_never_seen() {
3251 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3257 manifest
3258 .optional_dependencies
3259 .insert("fsevents".into(), "^2.3.0".into());
3260 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3261 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3262 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3263 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
3264 }
3265 }
3266
3267 #[test]
3268 fn stale_when_skipped_optional_dep_specifier_changes() {
3269 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3273 manifest
3274 .optional_dependencies
3275 .insert("fsevents".into(), "^2.4.0".into());
3276 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3277 let mut inner = BTreeMap::new();
3278 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3279 graph
3280 .skipped_optional_dependencies
3281 .insert(".".to_string(), inner);
3282 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3283 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3284 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
3285 }
3286 }
3287
3288 #[test]
3289 fn stale_when_skipped_optional_is_promoted_to_required() {
3290 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
3295 manifest.optional_dependencies.clear();
3299 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3300 let mut inner = BTreeMap::new();
3301 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3302 graph
3303 .skipped_optional_dependencies
3304 .insert(".".to_string(), inner);
3305 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3306 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3307 DriftStatus::Fresh => {
3308 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
3309 }
3310 }
3311 }
3312
3313 #[test]
3314 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
3315 let mut manifest = make_manifest(&[]);
3318 manifest
3319 .optional_dependencies
3320 .insert("fsevents".into(), "^2.4.0".into());
3321 let mut graph = make_graph(&[]);
3322 graph.importers.get_mut(".").unwrap().push(DirectDep {
3323 name: "fsevents".into(),
3324 dep_path: "fsevents@2.3.0".into(),
3325 dep_type: DepType::Optional,
3326 specifier: Some("^2.3.0".into()),
3327 });
3328 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3329 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3330 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
3331 }
3332 }
3333
3334 #[test]
3335 fn fresh_for_empty_manifest_and_lockfile() {
3336 let manifest = make_manifest(&[]);
3337 let graph = make_graph(&[]);
3338 assert_eq!(
3339 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3340 DriftStatus::Fresh
3341 );
3342 }
3343
3344 #[test]
3345 fn workspace_drift_detects_change_in_non_root_importer() {
3346 let root_dep = DirectDep {
3348 name: "lodash".into(),
3349 dep_path: "lodash@4.17.21".into(),
3350 dep_type: DepType::Production,
3351 specifier: Some("^4.17.0".into()),
3352 };
3353 let app_dep = DirectDep {
3354 name: "express".into(),
3355 dep_path: "express@4.18.0".into(),
3356 dep_type: DepType::Production,
3357 specifier: Some("^4.18.0".into()),
3358 };
3359 let mut importers = BTreeMap::new();
3360 importers.insert(".".to_string(), vec![root_dep]);
3361 importers.insert("packages/app".to_string(), vec![app_dep]);
3362 let graph = LockfileGraph {
3363 importers,
3364 packages: BTreeMap::new(),
3365 ..Default::default()
3366 };
3367
3368 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
3369 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
3371
3372 let workspace_manifests = vec![
3373 (".".to_string(), root_manifest.clone()),
3374 ("packages/app".to_string(), app_manifest),
3375 ];
3376 match graph.check_drift_workspace(
3377 &workspace_manifests,
3378 &BTreeMap::new(),
3379 &[],
3380 &BTreeMap::new(),
3381 true,
3382 ) {
3383 DriftStatus::Stale { reason } => {
3384 assert!(reason.contains("packages/app"));
3385 assert!(reason.contains("express"));
3386 }
3387 DriftStatus::Fresh => panic!("expected Stale"),
3388 }
3389
3390 assert_eq!(
3392 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3393 DriftStatus::Fresh
3394 );
3395 }
3396
3397 #[test]
3398 fn filter_deps_prunes_dev_only_subtree() {
3399 let mut importers = BTreeMap::new();
3403 importers.insert(
3404 ".".to_string(),
3405 vec![
3406 DirectDep {
3407 name: "foo".into(),
3408 dep_path: "foo@1.0.0".into(),
3409 dep_type: DepType::Production,
3410 specifier: Some("^1.0.0".into()),
3411 },
3412 DirectDep {
3413 name: "jest".into(),
3414 dep_path: "jest@29.0.0".into(),
3415 dep_type: DepType::Dev,
3416 specifier: Some("^29.0.0".into()),
3417 },
3418 ],
3419 );
3420
3421 let mut packages = BTreeMap::new();
3422 let mut foo_deps = BTreeMap::new();
3423 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
3424 packages.insert(
3425 "foo@1.0.0".to_string(),
3426 LockedPackage {
3427 name: "foo".into(),
3428 version: "1.0.0".into(),
3429 integrity: None,
3430 dependencies: foo_deps,
3431 dep_path: "foo@1.0.0".into(),
3432 ..Default::default()
3433 },
3434 );
3435 packages.insert(
3436 "bar@2.0.0".to_string(),
3437 LockedPackage {
3438 name: "bar".into(),
3439 version: "2.0.0".into(),
3440 integrity: None,
3441 dependencies: BTreeMap::new(),
3442 dep_path: "bar@2.0.0".into(),
3443 ..Default::default()
3444 },
3445 );
3446 let mut jest_deps = BTreeMap::new();
3447 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
3448 packages.insert(
3449 "jest@29.0.0".to_string(),
3450 LockedPackage {
3451 name: "jest".into(),
3452 version: "29.0.0".into(),
3453 integrity: None,
3454 dependencies: jest_deps,
3455 dep_path: "jest@29.0.0".into(),
3456 ..Default::default()
3457 },
3458 );
3459 packages.insert(
3460 "jest-core@29.0.0".to_string(),
3461 LockedPackage {
3462 name: "jest-core".into(),
3463 version: "29.0.0".into(),
3464 integrity: None,
3465 dependencies: BTreeMap::new(),
3466 dep_path: "jest-core@29.0.0".into(),
3467 ..Default::default()
3468 },
3469 );
3470
3471 let graph = LockfileGraph {
3472 importers,
3473 packages,
3474 ..Default::default()
3475 };
3476
3477 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3478
3479 let roots = prod.root_deps();
3481 assert_eq!(roots.len(), 1);
3482 assert_eq!(roots[0].name, "foo");
3483
3484 assert!(prod.packages.contains_key("foo@1.0.0"));
3486 assert!(prod.packages.contains_key("bar@2.0.0"));
3487 assert!(!prod.packages.contains_key("jest@29.0.0"));
3488 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
3489 }
3490
3491 #[test]
3498 fn filter_deps_preserves_lockfile_settings() {
3499 let graph = LockfileGraph {
3500 importers: BTreeMap::new(),
3501 packages: BTreeMap::new(),
3502 settings: LockfileSettings {
3503 auto_install_peers: false,
3504 exclude_links_from_lockfile: true,
3505 lockfile_include_tarball_url: false,
3506 },
3507 ..Default::default()
3508 };
3509 let filtered = graph.filter_deps(|_| true);
3510 assert!(!filtered.settings.auto_install_peers);
3511 assert!(filtered.settings.exclude_links_from_lockfile);
3512 }
3513
3514 #[test]
3515 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
3516 let mut importers = BTreeMap::new();
3520 importers.insert(
3521 ".".to_string(),
3522 vec![
3523 DirectDep {
3524 name: "foo".into(),
3525 dep_path: "foo@1.0.0".into(),
3526 dep_type: DepType::Production,
3527 specifier: Some("^1.0.0".into()),
3528 },
3529 DirectDep {
3530 name: "jest".into(),
3531 dep_path: "jest@29.0.0".into(),
3532 dep_type: DepType::Dev,
3533 specifier: Some("^29.0.0".into()),
3534 },
3535 ],
3536 );
3537
3538 let mut packages = BTreeMap::new();
3539 for (name, ver, deps) in [
3540 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
3541 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
3542 ("shared", "1.0.0", vec![]),
3543 ] {
3544 let mut dep_map = BTreeMap::new();
3545 for (n, v) in deps {
3546 dep_map.insert(n.to_string(), v.to_string());
3547 }
3548 packages.insert(
3549 format!("{name}@{ver}"),
3550 LockedPackage {
3551 name: name.into(),
3552 version: ver.into(),
3553 integrity: None,
3554 dependencies: dep_map,
3555 dep_path: format!("{name}@{ver}"),
3556 ..Default::default()
3557 },
3558 );
3559 }
3560
3561 let graph = LockfileGraph {
3562 importers,
3563 packages,
3564 ..Default::default()
3565 };
3566 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3567
3568 assert!(prod.packages.contains_key("foo@1.0.0"));
3569 assert!(prod.packages.contains_key("shared@1.0.0"));
3570 assert!(!prod.packages.contains_key("jest@29.0.0"));
3571 }
3572
3573 #[test]
3574 fn subset_to_importer_returns_none_for_missing_importer() {
3575 let graph = LockfileGraph {
3576 importers: BTreeMap::new(),
3577 packages: BTreeMap::new(),
3578 ..Default::default()
3579 };
3580 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
3581 }
3582
3583 #[test]
3584 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
3585 let mut importers = BTreeMap::new();
3592 importers.insert(".".to_string(), vec![]);
3593 importers.insert(
3594 "packages/lib".to_string(),
3595 vec![DirectDep {
3596 name: "is-odd".into(),
3597 dep_path: "is-odd@3.0.1".into(),
3598 dep_type: DepType::Production,
3599 specifier: Some("^3.0.1".into()),
3600 }],
3601 );
3602 importers.insert(
3603 "packages/app".to_string(),
3604 vec![DirectDep {
3605 name: "express".into(),
3606 dep_path: "express@4.18.0".into(),
3607 dep_type: DepType::Production,
3608 specifier: Some("^4.18.0".into()),
3609 }],
3610 );
3611
3612 let mut packages = BTreeMap::new();
3613 let mut is_odd_deps = BTreeMap::new();
3614 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
3615 packages.insert(
3616 "is-odd@3.0.1".to_string(),
3617 LockedPackage {
3618 name: "is-odd".into(),
3619 version: "3.0.1".into(),
3620 dependencies: is_odd_deps,
3621 dep_path: "is-odd@3.0.1".into(),
3622 ..Default::default()
3623 },
3624 );
3625 packages.insert(
3626 "is-number@6.0.0".to_string(),
3627 LockedPackage {
3628 name: "is-number".into(),
3629 version: "6.0.0".into(),
3630 dep_path: "is-number@6.0.0".into(),
3631 ..Default::default()
3632 },
3633 );
3634 packages.insert(
3635 "express@4.18.0".to_string(),
3636 LockedPackage {
3637 name: "express".into(),
3638 version: "4.18.0".into(),
3639 dep_path: "express@4.18.0".into(),
3640 ..Default::default()
3641 },
3642 );
3643
3644 let graph = LockfileGraph {
3645 importers,
3646 packages,
3647 ..Default::default()
3648 };
3649 let subset = graph
3650 .subset_to_importer("packages/lib", |_| true)
3651 .expect("packages/lib importer present");
3652
3653 assert_eq!(subset.importers.len(), 1);
3654 let roots = subset.root_deps();
3655 assert_eq!(roots.len(), 1);
3656 assert_eq!(roots[0].name, "is-odd");
3657
3658 assert!(subset.packages.contains_key("is-odd@3.0.1"));
3659 assert!(subset.packages.contains_key("is-number@6.0.0"));
3660 assert!(!subset.packages.contains_key("express@4.18.0"));
3661 }
3662
3663 #[test]
3664 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
3665 let mut importers = BTreeMap::new();
3671 importers.insert(
3672 "packages/lib".to_string(),
3673 vec![
3674 DirectDep {
3675 name: "is-odd".into(),
3676 dep_path: "is-odd@3.0.1".into(),
3677 dep_type: DepType::Production,
3678 specifier: Some("^3.0.1".into()),
3679 },
3680 DirectDep {
3681 name: "jest".into(),
3682 dep_path: "jest@29.0.0".into(),
3683 dep_type: DepType::Dev,
3684 specifier: Some("^29.0.0".into()),
3685 },
3686 ],
3687 );
3688 let mut packages = BTreeMap::new();
3689 packages.insert(
3690 "is-odd@3.0.1".to_string(),
3691 LockedPackage {
3692 name: "is-odd".into(),
3693 version: "3.0.1".into(),
3694 dep_path: "is-odd@3.0.1".into(),
3695 ..Default::default()
3696 },
3697 );
3698 packages.insert(
3699 "jest@29.0.0".to_string(),
3700 LockedPackage {
3701 name: "jest".into(),
3702 version: "29.0.0".into(),
3703 dep_path: "jest@29.0.0".into(),
3704 ..Default::default()
3705 },
3706 );
3707 let graph = LockfileGraph {
3708 importers,
3709 packages,
3710 ..Default::default()
3711 };
3712
3713 let prod = graph
3714 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
3715 .expect("importer present");
3716 let roots = prod.root_deps();
3717 assert_eq!(roots.len(), 1);
3718 assert_eq!(roots[0].name, "is-odd");
3719 assert!(prod.packages.contains_key("is-odd@3.0.1"));
3720 assert!(!prod.packages.contains_key("jest@29.0.0"));
3721 }
3722
3723 #[test]
3724 fn subset_to_importer_preserves_graph_settings() {
3725 let mut importers = BTreeMap::new();
3731 importers.insert("packages/lib".to_string(), vec![]);
3732 let graph = LockfileGraph {
3733 importers,
3734 packages: BTreeMap::new(),
3735 settings: LockfileSettings {
3736 auto_install_peers: false,
3737 exclude_links_from_lockfile: true,
3738 lockfile_include_tarball_url: true,
3739 },
3740 ..Default::default()
3741 };
3742 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3743 assert!(!subset.settings.auto_install_peers);
3744 assert!(subset.settings.exclude_links_from_lockfile);
3745 assert!(subset.settings.lockfile_include_tarball_url);
3746 }
3747
3748 #[test]
3749 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
3750 let mut importers = BTreeMap::new();
3755 importers.insert("packages/lib".to_string(), vec![]);
3756 importers.insert("packages/app".to_string(), vec![]);
3757 let mut skipped = BTreeMap::new();
3758 let mut lib_skip = BTreeMap::new();
3759 lib_skip.insert("fsevents".to_string(), "^2".to_string());
3760 skipped.insert("packages/lib".to_string(), lib_skip);
3761 let mut app_skip = BTreeMap::new();
3762 app_skip.insert("ghost".to_string(), "*".to_string());
3763 skipped.insert("packages/app".to_string(), app_skip);
3764 let graph = LockfileGraph {
3765 importers,
3766 packages: BTreeMap::new(),
3767 skipped_optional_dependencies: skipped,
3768 ..Default::default()
3769 };
3770 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3771 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
3772 let root = subset.skipped_optional_dependencies.get(".").unwrap();
3773 assert!(root.contains_key("fsevents"));
3774 assert!(!root.contains_key("ghost"));
3775 }
3776
3777 #[test]
3778 fn workspace_drift_fresh_when_all_importers_match() {
3779 let root_dep = DirectDep {
3780 name: "lodash".into(),
3781 dep_path: "lodash@4.17.21".into(),
3782 dep_type: DepType::Production,
3783 specifier: Some("^4.17.0".into()),
3784 };
3785 let app_dep = DirectDep {
3786 name: "express".into(),
3787 dep_path: "express@4.18.0".into(),
3788 dep_type: DepType::Production,
3789 specifier: Some("^4.18.0".into()),
3790 };
3791 let mut importers = BTreeMap::new();
3792 importers.insert(".".to_string(), vec![root_dep]);
3793 importers.insert("packages/app".to_string(), vec![app_dep]);
3794 let graph = LockfileGraph {
3795 importers,
3796 packages: BTreeMap::new(),
3797 ..Default::default()
3798 };
3799
3800 let workspace_manifests = vec![
3801 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
3802 (
3803 "packages/app".to_string(),
3804 make_manifest(&[("express", "^4.18.0")]),
3805 ),
3806 ];
3807 assert_eq!(
3808 graph.check_drift_workspace(
3809 &workspace_manifests,
3810 &BTreeMap::new(),
3811 &[],
3812 &BTreeMap::new(),
3813 true,
3814 ),
3815 DriftStatus::Fresh
3816 );
3817 }
3818
3819 #[allow(clippy::type_complexity)]
3820 fn mk_catalogs(
3821 entries: &[(&str, &[(&str, &str, &str)])],
3822 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
3823 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
3824 for (cat, pkgs) in entries {
3825 let mut inner = BTreeMap::new();
3826 for (pkg, spec, ver) in *pkgs {
3827 inner.insert(
3828 (*pkg).to_string(),
3829 CatalogEntry {
3830 specifier: (*spec).to_string(),
3831 version: (*ver).to_string(),
3832 },
3833 );
3834 }
3835 out.insert((*cat).to_string(), inner);
3836 }
3837 out
3838 }
3839
3840 fn mk_workspace_catalogs(
3841 entries: &[(&str, &[(&str, &str)])],
3842 ) -> BTreeMap<String, BTreeMap<String, String>> {
3843 entries
3844 .iter()
3845 .map(|(cat, pkgs)| {
3846 (
3847 (*cat).to_string(),
3848 pkgs.iter()
3849 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
3850 .collect(),
3851 )
3852 })
3853 .collect()
3854 }
3855
3856 #[test]
3857 fn catalog_drift_fresh_when_specifiers_match() {
3858 let graph = LockfileGraph {
3859 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3860 ..Default::default()
3861 };
3862 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
3863 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3864 }
3865
3866 #[test]
3867 fn catalog_drift_stale_on_changed_specifier() {
3868 let graph = LockfileGraph {
3869 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3870 ..Default::default()
3871 };
3872 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
3873 match graph.check_catalogs_drift(&ws) {
3874 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
3875 other => panic!("expected stale, got {other:?}"),
3876 }
3877 }
3878
3879 #[test]
3880 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
3881 let graph = LockfileGraph::default();
3885 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
3886 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3887 }
3888
3889 #[test]
3890 fn catalog_drift_stale_on_removed_workspace_entry() {
3891 let graph = LockfileGraph {
3892 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
3893 ..Default::default()
3894 };
3895 let ws = mk_workspace_catalogs(&[]);
3896 assert!(matches!(
3897 graph.check_catalogs_drift(&ws),
3898 DriftStatus::Stale { .. }
3899 ));
3900 }
3901}