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
177#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum LocalSource {
185 Directory(PathBuf),
189 Tarball(PathBuf),
192 Link(PathBuf),
196 Git(GitSource),
205 RemoteTarball(RemoteTarballSource),
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct RemoteTarballSource {
216 pub url: String,
217 pub integrity: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct GitSource {
223 pub url: String,
224 pub committish: Option<String>,
225 pub resolved: String,
226 pub subpath: Option<String>,
232}
233
234impl LocalSource {
235 pub fn path(&self) -> Option<&Path> {
238 match self {
239 LocalSource::Directory(p) | LocalSource::Tarball(p) | LocalSource::Link(p) => Some(p),
240 LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
241 }
242 }
243
244 pub fn kind_str(&self) -> &'static str {
246 match self {
247 LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
248 LocalSource::Link(_) => "link",
249 LocalSource::Git(_) => "git",
250 LocalSource::RemoteTarball(_) => "url",
251 }
252 }
253
254 pub fn path_posix(&self) -> String {
263 self.path()
264 .map(|p| p.to_string_lossy().replace('\\', "/"))
265 .unwrap_or_default()
266 }
267
268 pub fn specifier(&self) -> String {
276 match self {
277 LocalSource::Git(g) => match &g.subpath {
278 Some(sub) => format!("{}#{}&path:/{}", g.url, g.resolved, sub),
279 None => format!("{}#{}", g.url, g.resolved),
280 },
281 LocalSource::RemoteTarball(t) => t.url.clone(),
282 _ => format!("{}:{}", self.kind_str(), self.path_posix()),
283 }
284 }
285
286 pub fn dep_path(&self, name: &str) -> String {
302 use sha2::{Digest, Sha256};
303 let mut hasher = Sha256::new();
304 match self {
305 LocalSource::Git(g) => {
306 hasher.update(g.url.as_bytes());
307 hasher.update(b"#");
308 hasher.update(g.resolved.as_bytes());
309 if let Some(sub) = &g.subpath {
310 hasher.update(b"&path:/");
311 hasher.update(sub.as_bytes());
312 }
313 }
314 LocalSource::RemoteTarball(t) => {
315 hasher.update(t.url.as_bytes());
316 }
317 _ => hasher.update(self.path_posix().as_bytes()),
318 }
319 let digest = hasher.finalize();
320 let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
321 format!("{name}@{}+{short}", self.kind_str())
322 }
323
324 pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
330 if let Some((url, committish, subpath)) = parse_git_spec(spec) {
334 return Some(LocalSource::Git(GitSource {
339 url,
340 committish,
341 resolved: String::new(),
342 subpath,
343 }));
344 }
345 if Self::looks_like_remote_tarball_url(spec) {
351 return Some(LocalSource::RemoteTarball(RemoteTarballSource {
352 url: spec.to_string(),
353 integrity: String::new(),
354 }));
355 }
356 let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
357 ("file", r)
358 } else if let Some(r) = spec.strip_prefix("link:") {
359 ("link", r)
360 } else {
361 return None;
362 };
363 let rel = PathBuf::from(rest);
364 let abs = project_root.join(&rel);
365 if kind == "link" {
366 return Some(LocalSource::Link(rel));
367 }
368 if abs.is_file() && Self::path_looks_like_tarball(&rel) {
369 return Some(LocalSource::Tarball(rel));
370 }
371 Some(LocalSource::Directory(rel))
372 }
373
374 pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
383 spec.starts_with("https://") || spec.starts_with("http://")
384 }
385
386 pub fn path_looks_like_tarball(path: &Path) -> bool {
387 let name = match path.file_name().and_then(|n| n.to_str()) {
388 Some(n) => n,
389 None => return false,
390 };
391 let lower = name.to_ascii_lowercase();
392 lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
393 }
394}
395
396pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>, Option<String>)> {
415 let (body, committish, subpath) = match spec.find('#') {
416 Some(idx) => {
417 let (c, s) = parse_git_fragment(&spec[idx + 1..]);
418 (&spec[..idx], c, s)
419 }
420 None => (spec, None, None),
421 };
422 let is_bare_transport = body.starts_with("https://")
423 || body.starts_with("http://")
424 || body.starts_with("ssh://")
425 || body.starts_with("file://");
426 let url = if let Some(rest) = body.strip_prefix("git+") {
427 rest.to_string()
430 } else if body.starts_with("git://") {
431 body.to_string()
432 } else if let Some(scp) = parse_scp_url(body) {
433 scp
434 } else if let Some(path) = body.strip_prefix("github:") {
435 format!("https://github.com/{path}.git")
436 } else if let Some(path) = body.strip_prefix("gitlab:") {
437 format!("https://gitlab.com/{path}.git")
438 } else if let Some(path) = body.strip_prefix("bitbucket:") {
439 format!("https://bitbucket.org/{path}.git")
440 } else if is_bare_transport && body.ends_with(".git") {
441 body.to_string()
442 } else if is_bare_transport
443 && committish
444 .as_deref()
445 .is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
446 {
447 body.to_string()
452 } else if is_bare_github_shorthand(body) {
453 format!("https://github.com/{body}.git")
457 } else {
458 return None;
459 };
460 Some((url, committish, subpath))
461}
462
463fn is_bare_github_shorthand(body: &str) -> bool {
469 let Some((owner, repo)) = body.split_once('/') else {
470 return false;
471 };
472 !owner.is_empty()
473 && !owner.starts_with('.')
474 && !repo.is_empty()
475 && !repo.contains('/')
476 && owner
477 .bytes()
478 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
479 && repo
480 .bytes()
481 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct HostedGit {
494 pub host: HostedGitHost,
495 pub owner: String,
496 pub repo: String,
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum HostedGitHost {
501 GitHub,
502 GitLab,
503 Bitbucket,
504}
505
506impl HostedGit {
507 pub fn https_url(&self) -> String {
512 let host = self.host.host_domain();
513 format!("https://{host}/{}/{}.git", self.owner, self.repo)
514 }
515
516 pub fn tarball_url(&self, committish: &str) -> Option<String> {
523 if committish.len() != 40 || !committish.chars().all(|c| c.is_ascii_hexdigit()) {
524 return None;
525 }
526 let sha = committish.to_ascii_lowercase();
527 Some(match self.host {
528 HostedGitHost::GitHub => format!(
529 "https://codeload.github.com/{}/{}/tar.gz/{sha}",
530 self.owner, self.repo
531 ),
532 HostedGitHost::GitLab => format!(
533 "https://gitlab.com/{}/{}/-/archive/{sha}/{}-{sha}.tar.gz",
534 self.owner, self.repo, self.repo
535 ),
536 HostedGitHost::Bitbucket => format!(
537 "https://bitbucket.org/{}/{}/get/{sha}.tar.gz",
538 self.owner, self.repo
539 ),
540 })
541 }
542}
543
544impl HostedGitHost {
545 fn from_domain(domain: &str) -> Option<Self> {
546 match domain {
547 "github.com" => Some(HostedGitHost::GitHub),
548 "gitlab.com" => Some(HostedGitHost::GitLab),
549 "bitbucket.org" => Some(HostedGitHost::Bitbucket),
550 _ => None,
551 }
552 }
553
554 pub fn host_domain(self) -> &'static str {
555 match self {
556 HostedGitHost::GitHub => "github.com",
557 HostedGitHost::GitLab => "gitlab.com",
558 HostedGitHost::Bitbucket => "bitbucket.org",
559 }
560 }
561}
562
563pub fn parse_hosted_git(url: &str) -> Option<HostedGit> {
580 let body = url.strip_prefix("git+").unwrap_or(url);
581 let after_scheme = if let Some(rest) = body.strip_prefix("https://") {
582 rest
583 } else if let Some(rest) = body.strip_prefix("http://") {
584 rest
585 } else if let Some(rest) = body.strip_prefix("ssh://") {
586 rest
587 } else if let Some(rest) = body.strip_prefix("git://") {
588 rest
589 } else {
590 let scp_path = parse_scp_url(body)?;
594 return parse_hosted_git(&scp_path);
595 };
596 let host_and_path = match after_scheme.split_once('@') {
598 Some((_, rest)) => rest,
599 None => after_scheme,
600 };
601 let (host, path) = host_and_path.split_once('/')?;
602 let host = HostedGitHost::from_domain(host)?;
603 let mut segs = path.splitn(3, '/');
608 let owner = segs.next()?;
609 let repo = segs.next()?;
610 if owner.is_empty() || repo.is_empty() || segs.next().is_some() {
611 return None;
612 }
613 let repo = repo
614 .strip_suffix(".git")
615 .unwrap_or(repo)
616 .trim_end_matches('/');
617 if repo.is_empty() {
618 return None;
619 }
620 Some(HostedGit {
621 host,
622 owner: owner.to_string(),
623 repo: repo.to_string(),
624 })
625}
626
627fn parse_scp_url(body: &str) -> Option<String> {
628 if body.contains("://") {
629 return None;
630 }
631 let colon = body.find(':')?;
632 let before = &body[..colon];
633 let path = &body[colon + 1..];
634 if before.is_empty() || path.is_empty() {
635 return None;
636 }
637 if path.starts_with('/') {
638 return None;
639 }
640 let at = before.find('@')?;
641 let user = &before[..at];
642 let host = &before[at + 1..];
643 if user.is_empty() || host.is_empty() || host.contains('/') || host.contains('@') {
644 return None;
645 }
646 if !matches!(host, "github.com" | "gitlab.com" | "bitbucket.org") {
650 return None;
651 }
652 Some(format!("ssh://{user}@{host}/{path}"))
653}
654
655pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
663 parse_git_fragment(fragment).0
664}
665
666pub(crate) fn parse_git_fragment(fragment: &str) -> (Option<String>, Option<String>) {
674 if fragment.is_empty() {
675 return (None, None);
676 }
677
678 let mut fallback: Option<&str> = None;
679 let mut preferred: Option<&str> = None;
680 let mut subpath: Option<String> = None;
681 for part in fragment.split('&') {
682 if part.is_empty() {
683 continue;
684 }
685 let split = part.split_once('=').or_else(|| {
691 part.split_once(':')
692 .filter(|(k, _)| matches!(*k, "commit" | "tag" | "head" | "branch" | "path"))
693 });
694 let (key, value) = split.unwrap_or(("", part));
695 if value.is_empty() {
696 continue;
697 }
698 match key {
699 "commit" => {
700 preferred.get_or_insert(value);
701 }
702 "tag" | "head" | "branch" => {
703 fallback.get_or_insert(value);
704 }
705 "path" => {
706 if subpath.is_some() {
712 continue;
714 }
715 let trimmed = value.trim_start_matches('/');
716 if trimmed.is_empty() {
717 continue;
718 }
719 if trimmed
720 .split('/')
721 .any(|c| c.is_empty() || c == "." || c == "..")
722 {
723 continue;
724 }
725 subpath = Some(trimmed.to_string());
726 }
727 "" => {
728 fallback.get_or_insert(value);
729 }
730 _ => {}
731 }
732 }
733
734 (preferred.or(fallback).map(ToString::to_string), subpath)
735}
736
737#[derive(Debug, Clone, Default)]
746pub struct LockedPackage {
747 pub name: String,
749 pub version: String,
751 pub integrity: Option<String>,
753 pub dependencies: BTreeMap<String, String>,
755 pub optional_dependencies: BTreeMap<String, String>,
760 pub peer_dependencies: BTreeMap<String, String>,
764 pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
766 pub dep_path: String,
770 pub local_source: Option<LocalSource>,
775 pub os: PlatformList,
780 pub cpu: PlatformList,
781 pub libc: PlatformList,
782 pub bundled_dependencies: Vec<String>,
791 pub tarball_url: Option<String>,
799 pub alias_of: Option<String>,
814 pub yarn_checksum: Option<String>,
825 pub engines: BTreeMap<String, String>,
830 pub bin: BTreeMap<String, String>,
839 pub declared_dependencies: BTreeMap<String, String>,
857 pub license: Option<String>,
862 pub funding_url: Option<String>,
867 pub optional: bool,
875 pub transitive_peer_dependencies: Vec<String>,
884 pub extra_meta: BTreeMap<String, serde_json::Value>,
892}
893
894impl LockedPackage {
895 pub fn registry_name(&self) -> &str {
901 self.alias_of.as_deref().unwrap_or(&self.name)
902 }
903
904 pub fn spec_key(&self) -> String {
908 format!("{}@{}", self.name, self.version)
909 }
910}
911
912#[derive(Debug, Clone, Default, PartialEq, Eq)]
915pub struct PeerDepMeta {
916 pub optional: bool,
918}
919
920#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922pub enum LockfileKind {
923 Aube,
927 Pnpm,
930 Npm,
931 Yarn,
934 YarnBerry,
940 NpmShrinkwrap,
941 Bun,
942}
943
944impl LockfileKind {
945 pub fn filename(self) -> &'static str {
946 match self {
947 LockfileKind::Aube => "aube-lock.yaml",
948 LockfileKind::Pnpm => "pnpm-lock.yaml",
949 LockfileKind::Npm => "package-lock.json",
950 LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
951 LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
952 LockfileKind::Bun => "bun.lock",
953 }
954 }
955}
956
957impl LockfileGraph {
958 pub fn root_deps(&self) -> &[DirectDep] {
960 self.importers.get(".").map(|v| v.as_slice()).unwrap_or(&[])
961 }
962
963 pub fn get_package(&self, dep_path: &str) -> Option<&LockedPackage> {
965 self.packages.get(dep_path)
966 }
967
968 fn transitive_closure<'a>(
979 &self,
980 roots: impl IntoIterator<Item = &'a str>,
981 ) -> std::collections::HashSet<String> {
982 let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
983 let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
984 for root in roots {
985 if reachable.insert(root.to_string()) {
986 queue.push_back(root.to_string());
987 }
988 }
989 while let Some(dep_path) = queue.pop_front() {
990 let Some(pkg) = self.packages.get(&dep_path) else {
991 continue;
992 };
993 for (child_name, child_version) in &pkg.dependencies {
994 let child_key = format!("{child_name}@{child_version}");
995 if reachable.insert(child_key.clone()) {
996 queue.push_back(child_key);
997 }
998 }
999 }
1000 reachable
1001 }
1002
1003 fn packages_restricted_to(
1007 &self,
1008 reachable: &std::collections::HashSet<String>,
1009 ) -> BTreeMap<String, LockedPackage> {
1010 self.packages
1011 .iter()
1012 .filter(|(dep_path, _)| reachable.contains(*dep_path))
1013 .map(|(k, v)| (k.clone(), v.clone()))
1014 .collect()
1015 }
1016
1017 pub fn filter_deps<F>(&self, keep: F) -> LockfileGraph
1030 where
1031 F: Fn(&DirectDep) -> bool,
1032 {
1033 let importers: BTreeMap<String, Vec<DirectDep>> = self
1035 .importers
1036 .iter()
1037 .map(|(path, deps)| {
1038 let filtered: Vec<DirectDep> = deps.iter().filter(|d| keep(d)).cloned().collect();
1039 (path.clone(), filtered)
1040 })
1041 .collect();
1042
1043 let reachable = self.transitive_closure(
1045 importers
1046 .values()
1047 .flat_map(|deps| deps.iter().map(|d| d.dep_path.as_str())),
1048 );
1049 let packages = self.packages_restricted_to(&reachable);
1050
1051 LockfileGraph {
1052 importers,
1053 packages,
1054 settings: self.settings.clone(),
1059 overrides: self.overrides.clone(),
1062 ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
1063 times: self.times.clone(),
1067 skipped_optional_dependencies: self.skipped_optional_dependencies.clone(),
1068 catalogs: self.catalogs.clone(),
1069 bun_config_version: self.bun_config_version,
1070 patched_dependencies: self.patched_dependencies.clone(),
1071 trusted_dependencies: self.trusted_dependencies.clone(),
1072 extra_fields: self.extra_fields.clone(),
1073 workspace_extra_fields: self.workspace_extra_fields.clone(),
1074 }
1075 }
1076
1077 pub fn subset_to_importer<F>(&self, importer_path: &str, keep: F) -> Option<LockfileGraph>
1098 where
1099 F: Fn(&DirectDep) -> bool,
1100 {
1101 let src_deps = self.importers.get(importer_path)?;
1102 let kept: Vec<DirectDep> = src_deps.iter().filter(|d| keep(d)).cloned().collect();
1103
1104 let reachable = self.transitive_closure(kept.iter().map(|d| d.dep_path.as_str()));
1107 let packages = self.packages_restricted_to(&reachable);
1108
1109 let mut skipped_optional_dependencies = BTreeMap::new();
1113 if let Some(skipped) = self.skipped_optional_dependencies.get(importer_path) {
1114 skipped_optional_dependencies.insert(".".to_string(), skipped.clone());
1115 }
1116
1117 let mut importers = BTreeMap::new();
1118 importers.insert(".".to_string(), kept);
1119
1120 Some(LockfileGraph {
1121 importers,
1122 packages,
1123 settings: self.settings.clone(),
1124 overrides: self.overrides.clone(),
1125 ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
1126 times: self.times.clone(),
1127 skipped_optional_dependencies,
1128 catalogs: self.catalogs.clone(),
1129 bun_config_version: self.bun_config_version,
1130 patched_dependencies: self.patched_dependencies.clone(),
1131 trusted_dependencies: self.trusted_dependencies.clone(),
1132 extra_fields: self.extra_fields.clone(),
1133 workspace_extra_fields: self.workspace_extra_fields.clone(),
1134 })
1135 }
1136
1137 pub fn overlay_metadata_from(&mut self, prior: &LockfileGraph) {
1151 let prior_index = build_canonical_map(prior);
1155 for pkg in self.packages.values_mut() {
1156 let key = pkg.spec_key();
1157 let Some(prior_pkg) = prior_index.get(&key) else {
1158 continue;
1159 };
1160 if pkg.license.is_none() && prior_pkg.license.is_some() {
1161 pkg.license = prior_pkg.license.clone();
1162 }
1163 if pkg.funding_url.is_none() && prior_pkg.funding_url.is_some() {
1164 pkg.funding_url = prior_pkg.funding_url.clone();
1165 }
1166 for (k, v) in &prior_pkg.extra_meta {
1172 pkg.extra_meta.entry(k.clone()).or_insert_with(|| v.clone());
1173 }
1174 }
1175 if self.bun_config_version.is_none() {
1176 self.bun_config_version = prior.bun_config_version;
1177 }
1178 if self.patched_dependencies.is_empty() {
1179 self.patched_dependencies = prior.patched_dependencies.clone();
1180 }
1181 if self.trusted_dependencies.is_empty() {
1182 self.trusted_dependencies = prior.trusted_dependencies.clone();
1183 }
1184 if self.extra_fields.is_empty() {
1185 self.extra_fields = prior.extra_fields.clone();
1186 }
1187 if self.workspace_extra_fields.is_empty() {
1188 self.workspace_extra_fields = prior.workspace_extra_fields.clone();
1189 }
1190 }
1191
1192 pub fn check_drift(
1230 &self,
1231 manifest: &aube_manifest::PackageJson,
1232 workspace_overrides: &BTreeMap<String, String>,
1233 workspace_ignored_optional: &[String],
1234 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1235 ) -> DriftStatus {
1236 let effective = resolve_catalog_refs_in_overrides(
1237 &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
1238 workspace_catalogs,
1239 );
1240 let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
1241 if let Some(reason) = overrides_drift_reason(&locked, &effective) {
1242 return DriftStatus::Stale { reason };
1243 }
1244 let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
1245 effective_ignored.extend(workspace_ignored_optional.iter().cloned());
1246 if let Some(reason) =
1247 ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored)
1248 {
1249 return DriftStatus::Stale { reason };
1250 }
1251 self.check_drift_for_importer(".", manifest, &effective)
1252 }
1253
1254 pub fn check_drift_workspace(
1265 &self,
1266 manifests: &[(String, aube_manifest::PackageJson)],
1267 workspace_overrides: &BTreeMap<String, String>,
1268 workspace_ignored_optional: &[String],
1269 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1270 is_workspace_install: bool,
1271 ) -> DriftStatus {
1272 let effective_overrides = match manifests.iter().find(|(p, _)| p == ".") {
1277 Some((_, root_manifest)) => {
1278 let effective = resolve_catalog_refs_in_overrides(
1279 &merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides),
1280 workspace_catalogs,
1281 );
1282 let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
1283 if let Some(reason) = overrides_drift_reason(&locked, &effective) {
1284 return DriftStatus::Stale { reason };
1285 }
1286 let mut effective_ignored = root_manifest.pnpm_ignored_optional_dependencies();
1287 effective_ignored.extend(workspace_ignored_optional.iter().cloned());
1288 if let Some(reason) = ignored_optional_drift_reason(
1289 &self.ignored_optional_dependencies,
1290 &effective_ignored,
1291 ) {
1292 return DriftStatus::Stale { reason };
1293 }
1294 effective
1295 }
1296 None => BTreeMap::new(),
1297 };
1298 let workspace_link_names: std::collections::HashSet<&str> = manifests
1299 .iter()
1300 .filter(|(path, _)| path != ".")
1301 .filter_map(|(_, manifest)| manifest.name.as_deref())
1302 .collect();
1303 for (importer_path, manifest) in manifests {
1304 match self.check_drift_for_importer_with_workspace_links(
1305 importer_path,
1306 manifest,
1307 &effective_overrides,
1308 &workspace_link_names,
1309 ) {
1310 DriftStatus::Fresh => continue,
1311 stale => return stale,
1312 }
1313 }
1314 if is_workspace_install {
1333 let current_importers: std::collections::HashSet<&str> =
1334 manifests.iter().map(|(p, _)| p.as_str()).collect();
1335 for importer_path in self.importers.keys() {
1336 if !current_importers.contains(importer_path.as_str()) {
1337 return DriftStatus::Stale {
1338 reason: format!(
1339 "workspace importer {importer_path} is in the lockfile but not in the workspace"
1340 ),
1341 };
1342 }
1343 }
1344 }
1345 DriftStatus::Fresh
1346 }
1347
1348 pub fn check_catalogs_drift(
1370 &self,
1371 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1372 ) -> DriftStatus {
1373 for (cat_name, cat) in workspace_catalogs {
1374 let Some(locked) = self.catalogs.get(cat_name) else {
1375 continue;
1376 };
1377 for (pkg, spec) in cat {
1378 if let Some(entry) = locked.get(pkg)
1379 && entry.specifier != *spec
1380 {
1381 return DriftStatus::Stale {
1382 reason: format!(
1383 "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
1384 entry.specifier
1385 ),
1386 };
1387 }
1388 }
1389 }
1390 for (cat_name, cat) in &self.catalogs {
1391 let workspace_cat = workspace_catalogs.get(cat_name);
1392 for pkg in cat.keys() {
1393 if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
1394 return DriftStatus::Stale {
1395 reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
1396 };
1397 }
1398 }
1399 }
1400 DriftStatus::Fresh
1401 }
1402
1403 fn check_drift_for_importer(
1409 &self,
1410 importer_path: &str,
1411 manifest: &aube_manifest::PackageJson,
1412 effective_overrides: &BTreeMap<String, String>,
1413 ) -> DriftStatus {
1414 self.check_drift_for_importer_with_workspace_links(
1415 importer_path,
1416 manifest,
1417 effective_overrides,
1418 &std::collections::HashSet::new(),
1419 )
1420 }
1421
1422 fn check_drift_for_importer_with_workspace_links(
1423 &self,
1424 importer_path: &str,
1425 manifest: &aube_manifest::PackageJson,
1426 effective_overrides: &BTreeMap<String, String>,
1427 workspace_link_names: &std::collections::HashSet<&str>,
1428 ) -> DriftStatus {
1429 let label = if importer_path == "." {
1430 String::new()
1431 } else {
1432 format!("{importer_path}: ")
1433 };
1434
1435 let importer_deps: &[DirectDep] = self
1436 .importers
1437 .get(importer_path)
1438 .map(|v| v.as_slice())
1439 .unwrap_or(&[]);
1440
1441 if importer_deps.iter().all(|d| d.specifier.is_none()) {
1443 return DriftStatus::Fresh;
1444 }
1445 let lockfile_specs: BTreeMap<&str, &str> = importer_deps
1446 .iter()
1447 .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
1448 .collect();
1449
1450 let override_rules = override_match::compile(effective_overrides);
1451
1452 let skipped_optionals: BTreeMap<&str, &str> = self
1458 .skipped_optional_dependencies
1459 .get(importer_path)
1460 .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
1461 .unwrap_or_default();
1462
1463 let ignored = &self.ignored_optional_dependencies;
1477 let manifest_deps = manifest
1478 .dependencies
1479 .iter()
1480 .map(|(k, v)| (k, v, false))
1481 .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
1482 .chain(
1483 manifest
1484 .optional_dependencies
1485 .iter()
1486 .filter(|(name, _)| !ignored.contains(name.as_str()))
1487 .map(|(k, v)| (k, v, true)),
1488 );
1489
1490 for (name, spec, is_optional) in manifest_deps {
1491 match lockfile_specs.get(name.as_str()) {
1492 None => {
1493 if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
1503 if *locked_spec == spec {
1504 continue;
1505 }
1506 return DriftStatus::Stale {
1507 reason: format!(
1508 "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
1509 ),
1510 };
1511 }
1512 return DriftStatus::Stale {
1513 reason: format!("{label}manifest adds {name}@{spec}"),
1514 };
1515 }
1516 Some(locked_spec) if *locked_spec != spec => {
1517 if let Some(override_spec) =
1527 override_match::apply(&override_rules, name.as_str(), spec)
1528 && override_spec == *locked_spec
1529 {
1530 continue;
1531 }
1532 return DriftStatus::Stale {
1533 reason: format!(
1534 "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
1535 ),
1536 };
1537 }
1538 Some(_) => {}
1539 }
1540 }
1541
1542 let manifest_names: std::collections::HashSet<&str> = manifest
1563 .dependencies
1564 .keys()
1565 .chain(manifest.dev_dependencies.keys())
1566 .chain(
1567 manifest
1568 .optional_dependencies
1569 .keys()
1570 .filter(|name| !ignored.contains(name.as_str())),
1571 )
1572 .map(|s| s.as_str())
1573 .collect();
1574 let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
1575 .packages
1576 .values()
1577 .flat_map(|p| {
1578 p.peer_dependencies
1579 .iter()
1580 .map(|(name, range)| (name.as_str(), range.as_str()))
1581 })
1582 .collect();
1583 for (locked_name, locked_spec) in &lockfile_specs {
1584 if manifest_names.contains(locked_name) {
1585 continue;
1586 }
1587 if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
1588 continue;
1589 }
1590 let workspace_link = importer_path == "."
1591 && workspace_link_names.contains(locked_name)
1592 && importer_deps
1593 .iter()
1594 .find(|dep| dep.name == *locked_name)
1595 .and_then(|dep| self.packages.get(&dep.dep_path))
1596 .is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))));
1597 if workspace_link {
1598 continue;
1599 }
1600 return DriftStatus::Stale {
1601 reason: format!("{label}manifest removed {locked_name}"),
1602 };
1603 }
1604
1605 DriftStatus::Fresh
1606 }
1607}
1608
1609fn merge_manifest_and_workspace_overrides(
1615 manifest: &aube_manifest::PackageJson,
1616 workspace_overrides: &BTreeMap<String, String>,
1617) -> BTreeMap<String, String> {
1618 let mut out = manifest.overrides_map();
1619 for (k, v) in workspace_overrides {
1620 out.insert(k.clone(), v.clone());
1621 }
1622 out
1623}
1624
1625fn resolve_catalog_refs_in_overrides(
1635 overrides: &BTreeMap<String, String>,
1636 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1637) -> BTreeMap<String, String> {
1638 overrides
1639 .iter()
1640 .map(|(k, v)| {
1641 let resolved = v
1642 .strip_prefix("catalog:")
1643 .map(|tail| if tail.is_empty() { "default" } else { tail })
1644 .and_then(|cat_name| workspace_catalogs.get(cat_name))
1645 .and_then(|cat| cat.get(override_key_package_name(k)))
1646 .cloned()
1647 .unwrap_or_else(|| v.clone());
1648 (k.clone(), resolved)
1649 })
1650 .collect()
1651}
1652
1653fn override_key_package_name(key: &str) -> &str {
1660 let last = key.rsplit('>').next().unwrap_or(key);
1661 if let Some(after_scope) = last.strip_prefix('@') {
1662 match after_scope.find('@') {
1663 Some(idx) => &last[..idx + 1],
1664 None => last,
1665 }
1666 } else {
1667 match last.find('@') {
1668 Some(idx) => &last[..idx],
1669 None => last,
1670 }
1671 }
1672}
1673
1674fn overrides_drift_reason(
1680 lockfile: &BTreeMap<String, String>,
1681 manifest: &BTreeMap<String, String>,
1682) -> Option<String> {
1683 for (k, v) in manifest {
1684 match lockfile.get(k) {
1685 None => return Some(format!("overrides: manifest adds {k}@{v}")),
1686 Some(locked) if locked != v => {
1687 return Some(format!("overrides: {k} changed ({locked} → {v})"));
1688 }
1689 Some(_) => {}
1690 }
1691 }
1692 for k in lockfile.keys() {
1693 if !manifest.contains_key(k) {
1694 return Some(format!("overrides: manifest removes {k}"));
1695 }
1696 }
1697 None
1698}
1699
1700fn ignored_optional_drift_reason(
1703 lockfile: &BTreeSet<String>,
1704 manifest: &BTreeSet<String>,
1705) -> Option<String> {
1706 for name in manifest {
1707 if !lockfile.contains(name) {
1708 return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
1709 }
1710 }
1711 for name in lockfile {
1712 if !manifest.contains(name) {
1713 return Some(format!(
1714 "ignoredOptionalDependencies: manifest removes {name}"
1715 ));
1716 }
1717 }
1718 None
1719}
1720
1721#[derive(Debug, Clone, PartialEq, Eq)]
1723pub enum DriftStatus {
1724 Fresh,
1726 Stale { reason: String },
1728}
1729
1730pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
1737 aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
1738}
1739
1740pub fn write_lockfile(
1744 project_dir: &Path,
1745 graph: &LockfileGraph,
1746 manifest: &aube_manifest::PackageJson,
1747) -> Result<(), Error> {
1748 write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
1749 Ok(())
1750}
1751
1752pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
1759 let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
1760 for pkg in graph.packages.values() {
1761 canonical.entry(pkg.spec_key()).or_insert(pkg);
1762 }
1763 canonical
1764}
1765
1766pub fn write_lockfile_preserving_existing(
1771 project_dir: &Path,
1772 graph: &LockfileGraph,
1773 manifest: &aube_manifest::PackageJson,
1774) -> Result<PathBuf, Error> {
1775 let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
1776 write_lockfile_as(project_dir, graph, manifest, kind)
1777}
1778
1779pub fn write_lockfile_as(
1792 project_dir: &Path,
1793 graph: &LockfileGraph,
1794 manifest: &aube_manifest::PackageJson,
1795 kind: LockfileKind,
1796) -> Result<PathBuf, Error> {
1797 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "write")
1798 .with_meta_fn(|| {
1799 format!(
1800 r#"{{"kind":{},"packages":{}}}"#,
1801 aube_util::diag::jstr(&format!("{:?}", kind)),
1802 graph.packages.len()
1803 )
1804 });
1805 let filename = match kind {
1806 LockfileKind::Aube => aube_lock_filename(project_dir),
1807 LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
1808 other => other.filename().to_string(),
1809 };
1810 let path = project_dir.join(&filename);
1811 match kind {
1812 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
1813 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
1814 LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
1815 LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
1816 LockfileKind::Bun => bun::write(&path, graph, manifest)?,
1817 }
1818 Ok(path)
1819}
1820
1821pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
1830 for (path, kind) in lockfile_candidates(project_dir, true) {
1831 if path.exists() {
1832 return Some(refine_yarn_kind(&path, kind));
1833 }
1834 }
1835 None
1836}
1837
1838pub fn aube_lock_filename(project_dir: &Path) -> String {
1854 use std::sync::{Mutex, OnceLock};
1855 static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
1856 let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
1857 if let Ok(map) = cache.lock()
1858 && let Some(hit) = map.get(project_dir)
1859 {
1860 return hit.clone();
1861 }
1862 let resolved = if !git_branch_lockfile_enabled(project_dir) {
1863 "aube-lock.yaml".to_string()
1864 } else {
1865 match current_git_branch(project_dir) {
1866 Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
1867 None => "aube-lock.yaml".to_string(),
1868 }
1869 };
1870 if let Ok(mut map) = cache.lock() {
1871 map.insert(project_dir.to_path_buf(), resolved.clone());
1872 }
1873 resolved
1874}
1875
1876pub fn pnpm_lock_filename(project_dir: &Path) -> String {
1882 let aube_name = aube_lock_filename(project_dir);
1883 aube_name
1886 .strip_prefix("aube-lock.")
1887 .map(|rest| format!("pnpm-lock.{rest}"))
1888 .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
1889}
1890
1891fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
1892 let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
1900 return false;
1901 };
1902 let npmrc: Vec<(String, String)> = Vec::new();
1903 let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
1904 aube_settings::resolved::git_branch_lockfile(&ctx)
1905}
1906
1907pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
1908 let out = std::process::Command::new("git")
1909 .args(["-C"])
1910 .arg(project_dir)
1911 .args(["branch", "--show-current"])
1912 .output()
1913 .ok()?;
1914 if !out.status.success() {
1915 return None;
1916 }
1917 let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
1918 if branch.is_empty() {
1919 None
1920 } else {
1921 Some(branch)
1922 }
1923}
1924
1925pub fn parse_lockfile(
1934 project_dir: &Path,
1935 manifest: &aube_manifest::PackageJson,
1936) -> Result<LockfileGraph, Error> {
1937 let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
1938 Ok(graph)
1939}
1940
1941pub fn parse_lockfile_with_kind(
1943 project_dir: &Path,
1944 manifest: &aube_manifest::PackageJson,
1945) -> Result<(LockfileGraph, LockfileKind), Error> {
1946 reject_bun_binary(project_dir)?;
1947 for (path, kind) in lockfile_candidates(project_dir, true) {
1948 if !path.exists() {
1949 continue;
1950 }
1951 let kind = refine_yarn_kind(&path, kind);
1952 let graph = parse_one(&path, kind, manifest)?;
1953 return Ok((graph, kind));
1954 }
1955 Err(Error::NotFound(project_dir.to_path_buf()))
1956}
1957
1958pub fn parse_for_import(
1965 project_dir: &Path,
1966 manifest: &aube_manifest::PackageJson,
1967) -> Result<(LockfileGraph, LockfileKind), Error> {
1968 reject_bun_binary(project_dir)?;
1969 for (path, kind) in lockfile_candidates(project_dir, false) {
1970 if !path.exists() {
1971 continue;
1972 }
1973 let kind = refine_yarn_kind(&path, kind);
1974 let graph = parse_one(&path, kind, manifest)?;
1975 return Ok((graph, kind));
1976 }
1977 Err(Error::NotFound(project_dir.to_path_buf()))
1978}
1979
1980fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
1983 let lockb = project_dir.join("bun.lockb");
1984 let text = project_dir.join("bun.lock");
1985 if lockb.exists() && !text.exists() {
1986 return Err(Error::parse(
1987 &lockb,
1988 "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",
1989 ));
1990 }
1991 Ok(())
1992}
1993
1994fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
1995 let mut out = Vec::new();
1996 if include_aube {
1997 let branch_name = aube_lock_filename(project_dir);
2001 if branch_name != "aube-lock.yaml" {
2002 out.push((project_dir.join(&branch_name), LockfileKind::Aube));
2003 }
2004 out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
2005 }
2006 let pnpm_branch = {
2011 let mut s = aube_lock_filename(project_dir);
2012 if let Some(rest) = s.strip_prefix("aube-lock.") {
2013 s = format!("pnpm-lock.{rest}");
2014 }
2015 s
2016 };
2017 if pnpm_branch != "pnpm-lock.yaml" {
2018 out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
2019 }
2020 out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
2021 out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
2022 out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
2023 out.push((
2024 project_dir.join("npm-shrinkwrap.json"),
2025 LockfileKind::NpmShrinkwrap,
2026 ));
2027 out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
2028 out
2029}
2030
2031fn parse_one(
2032 path: &Path,
2033 kind: LockfileKind,
2034 manifest: &aube_manifest::PackageJson,
2035) -> Result<LockfileGraph, Error> {
2036 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
2037 .with_meta_fn(|| {
2038 let display = path
2041 .file_name()
2042 .map(|n| n.to_string_lossy().into_owned())
2043 .unwrap_or_default();
2044 format!(
2045 r#"{{"kind":{},"path":{}}}"#,
2046 aube_util::diag::jstr(&format!("{:?}", kind)),
2047 aube_util::diag::jstr(&display)
2048 )
2049 });
2050 match kind {
2051 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
2056 LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
2062 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
2063 LockfileKind::Bun => bun::parse(path),
2064 }
2065}
2066
2067fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
2076 if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
2077 LockfileKind::YarnBerry
2078 } else {
2079 kind
2080 }
2081}
2082
2083#[derive(Debug, thiserror::Error, miette::Diagnostic)]
2084pub enum Error {
2085 #[error("no lockfile found in {0}")]
2086 #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
2087 NotFound(std::path::PathBuf),
2088 #[error("unsupported lockfile format: {0}")]
2089 #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
2090 UnsupportedFormat(String),
2091 #[error("failed to read lockfile {0}: {1}")]
2092 Io(std::path::PathBuf, std::io::Error),
2093 #[error("failed to parse lockfile {0}: {1}")]
2098 #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
2099 Parse(std::path::PathBuf, String),
2100 #[error(transparent)]
2106 #[diagnostic(transparent)]
2107 ParseDiag(Box<aube_manifest::ParseError>),
2108}
2109
2110pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
2112 std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
2113}
2114
2115pub fn parse_json<T: serde::de::DeserializeOwned>(
2118 path: &std::path::Path,
2119 content: String,
2120) -> Result<T, Error> {
2121 match sonic_rs::from_slice(content.as_bytes()) {
2124 Ok(v) => Ok(v),
2125 Err(_) => match serde_json::from_str(&content) {
2126 Ok(v) => Ok(v),
2127 Err(e) => Err(Error::parse_json_err(path, content, &e)),
2128 },
2129 }
2130}
2131
2132impl Error {
2133 pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
2134 Error::Parse(path.to_path_buf(), msg.into())
2135 }
2136
2137 pub fn parse_json_err(
2138 path: &std::path::Path,
2139 content: String,
2140 err: &serde_json::Error,
2141 ) -> Self {
2142 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
2143 path, content, err,
2144 )))
2145 }
2146
2147 pub fn parse_yaml_err(
2148 path: &std::path::Path,
2149 content: String,
2150 err: &yaml_serde::Error,
2151 ) -> Self {
2152 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
2153 path, content, err,
2154 )))
2155 }
2156}
2157
2158#[cfg(test)]
2159mod parse_diag_tests {
2160 use super::*;
2161 use std::path::Path;
2162
2163 #[test]
2167 fn parse_json_attaches_span_for_bad_input() {
2168 let path = Path::new("package-lock.json");
2169 let content = r#"{"name":"x","#.to_string();
2170 let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
2171 else {
2172 panic!("parse_json must produce ParseDiag on malformed input");
2173 };
2174 let offset: usize = pe.span.offset();
2175 let len: usize = pe.span.len();
2176 assert!(offset + len <= content.len());
2177 assert_eq!(pe.path, path);
2178 }
2179
2180 #[test]
2187 fn parse_yaml_err_attaches_span_for_bad_input() {
2188 let path = Path::new("yarn.lock");
2189 let content = "packages:\n\t- pkg\n".to_string();
2190 let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
2191 .expect_err("tab-indented YAML must fail");
2192 let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
2193 panic!("parse_yaml_err must produce ParseDiag");
2194 };
2195 let offset: usize = pe.span.offset();
2196 let len: usize = pe.span.len();
2197 assert!(offset + len <= content.len());
2198 assert_eq!(pe.path, path);
2199 }
2200}
2201
2202#[cfg(test)]
2203mod looks_like_remote_tarball_url_tests {
2204 use super::*;
2205
2206 #[test]
2207 fn matches_https_tgz() {
2208 assert!(LocalSource::looks_like_remote_tarball_url(
2209 "https://example.com/pkg-1.0.0.tgz"
2210 ));
2211 }
2212
2213 #[test]
2214 fn matches_http_tar_gz() {
2215 assert!(LocalSource::looks_like_remote_tarball_url(
2216 "http://example.com/pkg-1.0.0.tar.gz"
2217 ));
2218 }
2219
2220 #[test]
2221 fn strips_fragment_before_suffix_check() {
2222 assert!(LocalSource::looks_like_remote_tarball_url(
2223 "https://example.com/pkg-1.0.0.tgz#sha512-abc"
2224 ));
2225 }
2226
2227 #[test]
2228 fn strips_query_string_before_suffix_check() {
2229 assert!(LocalSource::looks_like_remote_tarball_url(
2233 "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
2234 ));
2235 assert!(LocalSource::looks_like_remote_tarball_url(
2236 "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
2237 ));
2238 }
2239
2240 #[test]
2241 fn matches_bare_http_url_without_tarball_suffix() {
2242 assert!(LocalSource::looks_like_remote_tarball_url(
2246 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
2247 ));
2248 assert!(LocalSource::looks_like_remote_tarball_url(
2249 "https://codeload.github.com/user/repo/tar.gz/main"
2250 ));
2251 }
2252
2253 #[test]
2254 fn rejects_non_http_schemes() {
2255 assert!(!LocalSource::looks_like_remote_tarball_url(
2256 "ftp://example.com/pkg.tgz"
2257 ));
2258 assert!(!LocalSource::looks_like_remote_tarball_url(
2259 "git://example.com/repo.git"
2260 ));
2261 }
2262
2263 #[test]
2264 fn parse_classifies_bare_http_url_as_remote_tarball() {
2265 use std::path::Path;
2266 let parsed = LocalSource::parse(
2267 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
2268 Path::new(""),
2269 );
2270 assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
2271 }
2272
2273 #[test]
2274 fn parse_prefers_git_over_tarball_for_dot_git_url() {
2275 use std::path::Path;
2276 let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
2277 assert!(matches!(parsed, Some(LocalSource::Git(_))));
2278 }
2279}
2280
2281#[cfg(test)]
2282mod filename_tests {
2283 use super::*;
2284
2285 #[test]
2286 fn defaults_to_plain_lockfile_when_setting_absent() {
2287 let dir = tempfile::tempdir().unwrap();
2288 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2289 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
2290 }
2291
2292 #[test]
2293 fn defaults_to_plain_lockfile_when_setting_explicit_false() {
2294 let dir = tempfile::tempdir().unwrap();
2295 std::fs::write(
2296 dir.path().join("pnpm-workspace.yaml"),
2297 "gitBranchLockfile: false\n",
2298 )
2299 .unwrap();
2300 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2301 }
2302
2303 #[test]
2304 fn uses_branch_filename_when_enabled_inside_git_repo() {
2305 let dir = tempfile::tempdir().unwrap();
2306 std::fs::write(
2307 dir.path().join("pnpm-workspace.yaml"),
2308 "gitBranchLockfile: true\n",
2309 )
2310 .unwrap();
2311 let run = |args: &[&str]| {
2314 std::process::Command::new("git")
2315 .args(["-C"])
2316 .arg(dir.path())
2317 .args(args)
2318 .output()
2319 .unwrap()
2320 };
2321 if run(&["init", "-q"]).status.success() {
2322 run(&["checkout", "-q", "-b", "feature/x"]);
2323 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
2324 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
2325 }
2326 }
2327}
2328
2329#[cfg(test)]
2330mod git_spec_tests {
2331 use super::*;
2332
2333 #[test]
2334 fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
2335 let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
2337 assert_eq!(url, "https://host/user/repo");
2338 assert_eq!(committish, None);
2339 assert_eq!(subpath, None);
2340
2341 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2344 let source = LocalSource::Git(GitSource {
2345 url: url.clone(),
2346 committish: None,
2347 resolved: sha.to_string(),
2348 subpath: None,
2349 });
2350 let lockfile_version = source.specifier();
2351 assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
2352
2353 let (round_url, round_committish, round_subpath) =
2356 parse_git_spec(&lockfile_version).unwrap();
2357 assert_eq!(round_url, "https://host/user/repo");
2358 assert_eq!(round_committish.as_deref(), Some(sha));
2359 assert_eq!(round_subpath, None);
2360 }
2361
2362 #[test]
2363 fn bare_https_without_dot_git_and_no_committish_is_not_git() {
2364 assert!(parse_git_spec("https://example.com/pkg").is_none());
2367 }
2368
2369 #[test]
2370 fn github_shorthand_expands_and_roundtrips() {
2371 let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
2372 assert_eq!(url, "https://github.com/user/repo.git");
2373 }
2374
2375 #[test]
2376 fn bare_user_repo_expands_to_github() {
2377 let (url, committish, subpath) = parse_git_spec("kevva/is-negative").unwrap();
2378 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2379 assert!(committish.is_none());
2380 assert!(subpath.is_none());
2381 }
2382
2383 #[test]
2384 fn bare_user_repo_with_committish_preserved() {
2385 let (url, committish, _) = parse_git_spec("kevva/is-negative#v1.0.0").unwrap();
2386 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2387 assert_eq!(committish.as_deref(), Some("v1.0.0"));
2388 }
2389
2390 #[test]
2391 fn bare_scope_pkg_is_not_git_shorthand() {
2392 assert!(parse_git_spec("@types/node").is_none());
2394 }
2395
2396 #[test]
2397 fn bare_relative_path_is_not_git_shorthand() {
2398 assert!(parse_git_spec("./repo").is_none());
2401 assert!(parse_git_spec("../repo").is_none());
2402 assert!(parse_git_spec("./local/path").is_none());
2405 assert!(parse_git_spec("../local/path").is_none());
2406 }
2407
2408 #[test]
2409 fn bare_path_with_extra_slashes_is_not_git_shorthand() {
2410 assert!(parse_git_spec("path/with/slashes/extra").is_none());
2413 }
2414
2415 #[test]
2416 fn bare_scp_form_unknown_host_is_not_github_shorthand() {
2417 assert!(parse_git_spec("user@host:repo.git").is_none());
2420 }
2421
2422 #[test]
2423 fn scp_form_recognized() {
2424 let (url, committish, _) =
2425 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
2426 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2427 assert!(committish.is_none());
2428 }
2429
2430 #[test]
2431 fn scp_form_with_ref_recognized() {
2432 let (url, committish, _) =
2433 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
2434 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2435 assert_eq!(committish.as_deref(), Some("0.1.5"));
2436 }
2437
2438 #[test]
2439 fn scp_form_bitbucket_recognized() {
2440 let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
2441 assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
2442 }
2443
2444 #[test]
2445 fn scp_form_unknown_host_rejected() {
2446 assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
2448 assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
2449 }
2450
2451 #[test]
2452 fn scp_form_without_user_rejected() {
2453 assert!(parse_git_spec("github.com:user/repo.git").is_none());
2455 }
2456
2457 #[test]
2458 fn commit_selector_fragment_normalizes_to_sha() {
2459 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2460 let (url, committish, _) =
2461 parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
2462 assert_eq!(url, "https://host/user/repo.git");
2463 assert_eq!(committish.as_deref(), Some(sha));
2464 }
2465
2466 #[test]
2467 fn named_selector_fragment_normalizes_to_ref() {
2468 let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
2469 assert_eq!(url, "https://host/user/repo");
2470 assert_eq!(committish.as_deref(), Some("v1.2.3"));
2471 }
2472
2473 #[test]
2474 fn pnpm_path_subpath_extracted_from_fragment() {
2475 let (url, committish, subpath) =
2478 parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
2479 assert_eq!(url, "https://github.com/org/dep.git");
2480 assert_eq!(committish.as_deref(), Some("v0.1.4"));
2481 assert_eq!(subpath.as_deref(), Some("packages/special"));
2482 }
2483
2484 #[test]
2485 fn path_subpath_roundtrips_via_specifier() {
2486 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2487 let source = LocalSource::Git(GitSource {
2488 url: "https://github.com/org/dep.git".to_string(),
2489 committish: None,
2490 resolved: sha.to_string(),
2491 subpath: Some("packages/special".to_string()),
2492 });
2493 let spec = source.specifier();
2494 assert_eq!(
2495 spec,
2496 format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
2497 );
2498 let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
2499 assert_eq!(url, "https://github.com/org/dep.git");
2500 assert_eq!(committish.as_deref(), Some(sha));
2501 assert_eq!(subpath.as_deref(), Some("packages/special"));
2502 }
2503
2504 #[test]
2505 fn parse_hosted_git_recognizes_canonical_forms() {
2506 let canonical = HostedGit {
2510 host: HostedGitHost::GitHub,
2511 owner: "owner".to_string(),
2512 repo: "repo".to_string(),
2513 };
2514 for spec in [
2515 "https://github.com/owner/repo.git",
2516 "https://github.com/owner/repo",
2517 "http://github.com/owner/repo.git",
2518 "git+https://github.com/owner/repo.git",
2519 "git+https://github.com/owner/repo",
2520 "git://github.com/owner/repo.git",
2521 "ssh://git@github.com/owner/repo.git",
2522 "git+ssh://git@github.com/owner/repo.git",
2523 "git@github.com:owner/repo.git",
2524 ] {
2525 assert_eq!(
2526 parse_hosted_git(spec).as_ref(),
2527 Some(&canonical),
2528 "spec {spec} should map to canonical HostedGit",
2529 );
2530 }
2531 }
2532
2533 #[test]
2534 fn parse_hosted_git_returns_none_for_non_hosted() {
2535 for spec in [
2538 "https://example.com/owner/repo.git",
2539 "ssh://git@gitea.internal/owner/repo.git",
2540 "git+ssh://git@gitlab.example.com/group/sub/repo.git",
2541 "https://github.com/owner/repo/sub",
2542 "https://github.com/owner",
2543 ] {
2544 assert!(
2545 parse_hosted_git(spec).is_none(),
2546 "spec {spec} must not match a hosted provider",
2547 );
2548 }
2549 }
2550
2551 #[test]
2552 fn hosted_tarball_url_only_for_full_sha() {
2553 let g = HostedGit {
2554 host: HostedGitHost::GitHub,
2555 owner: "o".to_string(),
2556 repo: "r".to_string(),
2557 };
2558 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2559 assert_eq!(
2560 g.tarball_url(sha).as_deref(),
2561 Some("https://codeload.github.com/o/r/tar.gz/abcdef0123456789abcdef0123456789abcdef01"),
2562 );
2563 assert!(g.tarball_url("main").is_none());
2567 assert!(g.tarball_url("v1.2.3").is_none());
2568 assert!(g.tarball_url("abcdef0").is_none());
2569 }
2570
2571 #[test]
2572 fn hosted_tarball_url_per_provider() {
2573 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2574 let gitlab = HostedGit {
2575 host: HostedGitHost::GitLab,
2576 owner: "g".to_string(),
2577 repo: "r".to_string(),
2578 }
2579 .tarball_url(sha)
2580 .unwrap();
2581 assert!(gitlab.starts_with("https://gitlab.com/g/r/-/archive/"));
2582 assert!(gitlab.ends_with("/r-abcdef0123456789abcdef0123456789abcdef01.tar.gz"));
2583 let bitbucket = HostedGit {
2584 host: HostedGitHost::Bitbucket,
2585 owner: "g".to_string(),
2586 repo: "r".to_string(),
2587 }
2588 .tarball_url(sha)
2589 .unwrap();
2590 assert_eq!(
2591 bitbucket,
2592 "https://bitbucket.org/g/r/get/abcdef0123456789abcdef0123456789abcdef01.tar.gz",
2593 );
2594 }
2595
2596 #[test]
2597 fn hosted_https_url_normalizes() {
2598 let g = parse_hosted_git("git+ssh://git@github.com/owner/repo.git").unwrap();
2599 assert_eq!(g.https_url(), "https://github.com/owner/repo.git");
2600 }
2601
2602 #[test]
2603 fn path_traversal_components_in_subpath_are_rejected() {
2604 let cases = [
2608 "github:org/dep#main&path:/../../etc",
2609 "github:org/dep#main&path:/packages/../../../etc",
2610 "github:org/dep#main&path:/./packages/foo",
2611 "github:org/dep#main&path:/packages//foo",
2612 ];
2613 for spec in cases {
2614 let (_, _, subpath) = parse_git_spec(spec).unwrap();
2615 assert_eq!(subpath, None, "spec should drop subpath: {spec}");
2616 }
2617 }
2618
2619 #[test]
2620 fn dep_path_distinguishes_subpaths_under_same_commit() {
2621 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2625 let a = LocalSource::Git(GitSource {
2626 url: "https://example.com/r.git".to_string(),
2627 committish: None,
2628 resolved: sha.to_string(),
2629 subpath: Some("packages/a".to_string()),
2630 });
2631 let b = LocalSource::Git(GitSource {
2632 url: "https://example.com/r.git".to_string(),
2633 committish: None,
2634 resolved: sha.to_string(),
2635 subpath: Some("packages/b".to_string()),
2636 });
2637 assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
2638 }
2639}
2640
2641#[cfg(test)]
2642mod drift_tests {
2643 use super::*;
2644 use aube_manifest::PackageJson;
2645 use std::collections::BTreeMap;
2646 use std::path::PathBuf;
2647
2648 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
2649 let mut m = PackageJson {
2650 name: Some("test".into()),
2651 version: Some("1.0.0".into()),
2652 dependencies: BTreeMap::new(),
2653 dev_dependencies: BTreeMap::new(),
2654 peer_dependencies: BTreeMap::new(),
2655 optional_dependencies: BTreeMap::new(),
2656 update_config: None,
2657 scripts: BTreeMap::new(),
2658 engines: BTreeMap::new(),
2659 workspaces: None,
2660 bundled_dependencies: None,
2661 extra: BTreeMap::new(),
2662 };
2663 for (name, spec) in deps {
2664 m.dependencies.insert((*name).into(), (*spec).into());
2665 }
2666 m
2667 }
2668
2669 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
2670 let direct: Vec<DirectDep> = deps
2672 .iter()
2673 .map(|(name, spec, dep_path)| DirectDep {
2674 name: (*name).into(),
2675 dep_path: (*dep_path).into(),
2676 dep_type: DepType::Production,
2677 specifier: Some((*spec).into()),
2678 })
2679 .collect();
2680 let mut importers = BTreeMap::new();
2681 importers.insert(".".to_string(), direct);
2682 LockfileGraph {
2683 importers,
2684 packages: BTreeMap::new(),
2685 ..Default::default()
2686 }
2687 }
2688
2689 #[test]
2690 fn fresh_when_specifiers_match() {
2691 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2692 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2693 assert_eq!(
2694 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2695 DriftStatus::Fresh
2696 );
2697 }
2698
2699 #[test]
2700 fn stale_when_specifier_changes() {
2701 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
2702 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2703 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2704 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2705 DriftStatus::Fresh => panic!("expected Stale"),
2706 }
2707 }
2708
2709 #[test]
2710 fn stale_when_manifest_adds_dep() {
2711 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
2712 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2713 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2714 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2715 DriftStatus::Fresh => panic!("expected Stale"),
2716 }
2717 }
2718
2719 #[test]
2720 fn stale_when_manifest_removes_dep() {
2721 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2722 let graph = make_graph(&[
2723 ("lodash", "^4.17.0", "lodash@4.17.21"),
2724 ("express", "^4.18.0", "express@4.18.0"),
2725 ]);
2726 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2727 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2728 DriftStatus::Fresh => panic!("expected Stale"),
2729 }
2730 }
2731
2732 #[test]
2737 fn fresh_when_lockfile_has_auto_hoisted_peer() {
2738 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2739 let mut graph = make_graph(&[
2740 (
2741 "use-sync-external-store",
2742 "1.2.0",
2743 "use-sync-external-store@1.2.0",
2744 ),
2745 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
2748 ]);
2749 let mut declaring_pkg = LockedPackage {
2752 name: "use-sync-external-store".into(),
2753 version: "1.2.0".into(),
2754 dep_path: "use-sync-external-store@1.2.0".into(),
2755 ..Default::default()
2756 };
2757 declaring_pkg
2758 .peer_dependencies
2759 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2760 graph
2761 .packages
2762 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
2763
2764 assert_eq!(
2765 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2766 DriftStatus::Fresh
2767 );
2768 }
2769
2770 #[test]
2776 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
2777 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2780
2781 let mut graph = make_graph(&[
2784 (
2785 "use-sync-external-store",
2786 "1.2.0",
2787 "use-sync-external-store@1.2.0",
2788 ),
2789 ("react", "17.0.2", "react@17.0.2"),
2790 ]);
2791 let mut consumer = LockedPackage {
2796 name: "use-sync-external-store".into(),
2797 version: "1.2.0".into(),
2798 dep_path: "use-sync-external-store@1.2.0".into(),
2799 ..Default::default()
2800 };
2801 consumer
2802 .peer_dependencies
2803 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2804 graph
2805 .packages
2806 .insert("use-sync-external-store@1.2.0".into(), consumer);
2807
2808 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2809 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2810 DriftStatus::Fresh => panic!(
2811 "drift check should flag a removed user-pinned dep as stale, \
2812 even when its name matches a peer declaration"
2813 ),
2814 }
2815 }
2816
2817 #[test]
2820 fn stale_when_lockfile_has_removed_non_peer_dep() {
2821 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2822 let graph = make_graph(&[
2823 ("lodash", "^4.17.0", "lodash@4.17.21"),
2824 ("chalk", "^5.0.0", "chalk@5.0.0"),
2825 ]);
2826 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2827 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
2828 DriftStatus::Fresh => panic!("expected Stale"),
2829 }
2830 }
2831
2832 #[test]
2833 fn workspace_drift_allows_root_links_for_workspace_packages() {
2834 let root_manifest = make_manifest(&[]);
2835 let mut app_manifest = make_manifest(&[]);
2836 app_manifest.name = Some("@scope/app".to_string());
2837
2838 let link = LocalSource::Link(PathBuf::from("packages/app"));
2839 let dep_path = link.dep_path("@scope/app");
2840 let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
2841 graph.packages.insert(
2842 dep_path.clone(),
2843 LockedPackage {
2844 name: "@scope/app".to_string(),
2845 version: "1.0.0".to_string(),
2846 dep_path,
2847 local_source: Some(link),
2848 ..Default::default()
2849 },
2850 );
2851
2852 assert_eq!(
2853 graph.check_drift_workspace(
2854 &[
2855 (".".to_string(), root_manifest),
2856 ("packages/app".to_string(), app_manifest),
2857 ],
2858 &BTreeMap::new(),
2859 &[],
2860 &BTreeMap::new(),
2861 true,
2862 ),
2863 DriftStatus::Fresh
2864 );
2865 }
2866
2867 #[test]
2868 fn fresh_when_no_specifiers_recorded() {
2869 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2872 let graph = LockfileGraph {
2873 importers: {
2874 let mut m = BTreeMap::new();
2875 m.insert(
2876 ".".to_string(),
2877 vec![DirectDep {
2878 name: "lodash".into(),
2879 dep_path: "lodash@4.17.21".into(),
2880 dep_type: DepType::Production,
2881 specifier: None,
2882 }],
2883 );
2884 m
2885 },
2886 packages: BTreeMap::new(),
2887 ..Default::default()
2888 };
2889 assert_eq!(
2890 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2891 DriftStatus::Fresh
2892 );
2893 }
2894
2895 #[test]
2896 fn stale_when_manifest_adds_override() {
2897 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2901 manifest
2902 .extra
2903 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2904 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2905 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2906 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
2907 DriftStatus::Fresh => panic!("expected Stale"),
2908 }
2909 }
2910
2911 #[test]
2912 fn stale_drift_message_names_changed_override_key() {
2913 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2917 manifest
2918 .extra
2919 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
2920 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2921 graph.overrides.insert("lodash".into(), "4.17.21".into());
2922 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2923 DriftStatus::Stale { reason } => {
2924 assert!(reason.contains("lodash"), "expected key in: {reason}");
2925 assert!(
2926 reason.contains("4.17.21"),
2927 "expected old value in: {reason}"
2928 );
2929 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
2930 }
2931 DriftStatus::Fresh => panic!("expected Stale"),
2932 }
2933 }
2934
2935 #[test]
2936 fn stale_when_manifest_removes_override() {
2937 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2938 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2939 graph.overrides.insert("lodash".into(), "4.17.21".into());
2940 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2941 DriftStatus::Stale { reason } => {
2942 assert!(reason.contains("removes"));
2943 assert!(reason.contains("lodash"));
2944 }
2945 DriftStatus::Fresh => panic!("expected Stale"),
2946 }
2947 }
2948
2949 #[test]
2950 fn fresh_when_overrides_match() {
2951 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2952 manifest
2953 .extra
2954 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2955 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2956 graph.overrides.insert("lodash".into(), "4.17.21".into());
2957 assert_eq!(
2958 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2959 DriftStatus::Fresh
2960 );
2961 }
2962
2963 #[test]
2964 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
2965 let manifest = make_manifest(&[("semver", "^7.5.0")]);
2971 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2972 graph.overrides.insert("semver".into(), "7.7.1".into());
2973 let mut ws_overrides = BTreeMap::new();
2974 ws_overrides.insert("semver".into(), "7.7.1".into());
2975 assert_eq!(
2976 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2977 DriftStatus::Fresh,
2978 );
2979 }
2980
2981 #[test]
2982 fn workspace_yaml_overrides_win_over_package_json() {
2983 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
2988 manifest
2989 .extra
2990 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
2991 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2992 graph.overrides.insert("semver".into(), "7.7.1".into());
2993 let mut ws_overrides = BTreeMap::new();
2994 ws_overrides.insert("semver".into(), "7.7.1".into());
2995 assert_eq!(
2996 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2997 DriftStatus::Fresh,
2998 );
2999 }
3000
3001 #[test]
3002 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
3003 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3009 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3010 graph.overrides.insert("lodash".into(), "4.17.21".into());
3011 let mut ws_overrides = BTreeMap::new();
3012 ws_overrides.insert("lodash".into(), "catalog:".into());
3013 let mut catalogs = BTreeMap::new();
3014 let mut default_cat = BTreeMap::new();
3015 default_cat.insert("lodash".into(), "4.17.21".into());
3016 catalogs.insert("default".into(), default_cat);
3017 assert_eq!(
3018 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
3019 DriftStatus::Fresh,
3020 );
3021 }
3022
3023 #[test]
3024 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
3025 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3028 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3029 graph.overrides.insert("lodash".into(), "4.17.21".into());
3030 let mut ws_overrides = BTreeMap::new();
3031 ws_overrides.insert("lodash".into(), "catalog:evens".into());
3032 let mut catalogs = BTreeMap::new();
3033 let mut evens = BTreeMap::new();
3034 evens.insert("lodash".into(), "4.17.21".into());
3035 catalogs.insert("evens".into(), evens);
3036 assert_eq!(
3037 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
3038 DriftStatus::Fresh,
3039 );
3040 }
3041
3042 #[test]
3043 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
3044 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3048 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3049 graph.overrides.insert("lodash".into(), "4.17.21".into());
3050 let mut ws_overrides = BTreeMap::new();
3051 ws_overrides.insert("lodash".into(), "catalog:".into());
3052 let mut catalogs = BTreeMap::new();
3053 let mut default_cat = BTreeMap::new();
3054 default_cat.insert("lodash".into(), "4.17.22".into());
3055 catalogs.insert("default".into(), default_cat);
3056 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
3057 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
3058 other => panic!("expected stale, got {other:?}"),
3059 }
3060 }
3061
3062 #[test]
3063 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
3064 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3071 let mut importers = BTreeMap::new();
3072 importers.insert(
3073 ".".to_string(),
3074 vec![DirectDep {
3075 name: "lodash".into(),
3076 dep_path: "lodash@4.17.21".into(),
3077 dep_type: DepType::Production,
3078 specifier: Some("4.17.21".into()),
3079 }],
3080 );
3081 let mut graph = LockfileGraph {
3082 importers,
3083 ..Default::default()
3084 };
3085 graph.overrides.insert("lodash".into(), "4.17.21".into());
3086 let mut ws_overrides = BTreeMap::new();
3087 ws_overrides.insert("lodash".into(), "4.17.21".into());
3088 assert_eq!(
3089 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3090 DriftStatus::Fresh,
3091 );
3092 }
3093
3094 #[test]
3095 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
3096 let manifest = make_manifest(&[("plist", "^3.0.4")]);
3103 let mut importers = BTreeMap::new();
3104 importers.insert(
3105 ".".to_string(),
3106 vec![DirectDep {
3107 name: "plist".into(),
3108 dep_path: "plist@3.0.6".into(),
3109 dep_type: DepType::Production,
3110 specifier: Some(">=3.0.5".into()),
3111 }],
3112 );
3113 let mut graph = LockfileGraph {
3114 importers,
3115 ..Default::default()
3116 };
3117 graph
3118 .overrides
3119 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
3120 let mut ws_overrides = BTreeMap::new();
3121 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
3122 assert_eq!(
3123 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3124 DriftStatus::Fresh,
3125 );
3126 }
3127
3128 #[test]
3129 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
3130 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3137 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3138 graph
3139 .ignored_optional_dependencies
3140 .insert("fsevents".to_string());
3141 let ws_ignored = vec!["fsevents".to_string()];
3142 assert_eq!(
3143 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
3144 DriftStatus::Fresh,
3145 );
3146 }
3147
3148 #[test]
3149 fn fresh_when_optional_dep_was_recorded_as_skipped() {
3150 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3155 manifest
3156 .optional_dependencies
3157 .insert("fsevents".into(), "^2.3.0".into());
3158 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3159 let mut inner = BTreeMap::new();
3160 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3161 graph
3162 .skipped_optional_dependencies
3163 .insert(".".to_string(), inner);
3164 assert_eq!(
3165 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3166 DriftStatus::Fresh
3167 );
3168 }
3169
3170 #[test]
3171 fn stale_when_new_optional_dep_was_never_seen() {
3172 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3178 manifest
3179 .optional_dependencies
3180 .insert("fsevents".into(), "^2.3.0".into());
3181 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3182 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3183 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3184 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
3185 }
3186 }
3187
3188 #[test]
3189 fn stale_when_skipped_optional_dep_specifier_changes() {
3190 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3194 manifest
3195 .optional_dependencies
3196 .insert("fsevents".into(), "^2.4.0".into());
3197 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3198 let mut inner = BTreeMap::new();
3199 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3200 graph
3201 .skipped_optional_dependencies
3202 .insert(".".to_string(), inner);
3203 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3204 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3205 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
3206 }
3207 }
3208
3209 #[test]
3210 fn stale_when_skipped_optional_is_promoted_to_required() {
3211 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
3216 manifest.optional_dependencies.clear();
3220 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3221 let mut inner = BTreeMap::new();
3222 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3223 graph
3224 .skipped_optional_dependencies
3225 .insert(".".to_string(), inner);
3226 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3227 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3228 DriftStatus::Fresh => {
3229 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
3230 }
3231 }
3232 }
3233
3234 #[test]
3235 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
3236 let mut manifest = make_manifest(&[]);
3239 manifest
3240 .optional_dependencies
3241 .insert("fsevents".into(), "^2.4.0".into());
3242 let mut graph = make_graph(&[]);
3243 graph.importers.get_mut(".").unwrap().push(DirectDep {
3244 name: "fsevents".into(),
3245 dep_path: "fsevents@2.3.0".into(),
3246 dep_type: DepType::Optional,
3247 specifier: Some("^2.3.0".into()),
3248 });
3249 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3250 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3251 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
3252 }
3253 }
3254
3255 #[test]
3256 fn fresh_for_empty_manifest_and_lockfile() {
3257 let manifest = make_manifest(&[]);
3258 let graph = make_graph(&[]);
3259 assert_eq!(
3260 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3261 DriftStatus::Fresh
3262 );
3263 }
3264
3265 #[test]
3266 fn workspace_drift_detects_change_in_non_root_importer() {
3267 let root_dep = DirectDep {
3269 name: "lodash".into(),
3270 dep_path: "lodash@4.17.21".into(),
3271 dep_type: DepType::Production,
3272 specifier: Some("^4.17.0".into()),
3273 };
3274 let app_dep = DirectDep {
3275 name: "express".into(),
3276 dep_path: "express@4.18.0".into(),
3277 dep_type: DepType::Production,
3278 specifier: Some("^4.18.0".into()),
3279 };
3280 let mut importers = BTreeMap::new();
3281 importers.insert(".".to_string(), vec![root_dep]);
3282 importers.insert("packages/app".to_string(), vec![app_dep]);
3283 let graph = LockfileGraph {
3284 importers,
3285 packages: BTreeMap::new(),
3286 ..Default::default()
3287 };
3288
3289 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
3290 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
3292
3293 let workspace_manifests = vec![
3294 (".".to_string(), root_manifest.clone()),
3295 ("packages/app".to_string(), app_manifest),
3296 ];
3297 match graph.check_drift_workspace(
3298 &workspace_manifests,
3299 &BTreeMap::new(),
3300 &[],
3301 &BTreeMap::new(),
3302 true,
3303 ) {
3304 DriftStatus::Stale { reason } => {
3305 assert!(reason.contains("packages/app"));
3306 assert!(reason.contains("express"));
3307 }
3308 DriftStatus::Fresh => panic!("expected Stale"),
3309 }
3310
3311 assert_eq!(
3313 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3314 DriftStatus::Fresh
3315 );
3316 }
3317
3318 #[test]
3319 fn filter_deps_prunes_dev_only_subtree() {
3320 let mut importers = BTreeMap::new();
3324 importers.insert(
3325 ".".to_string(),
3326 vec![
3327 DirectDep {
3328 name: "foo".into(),
3329 dep_path: "foo@1.0.0".into(),
3330 dep_type: DepType::Production,
3331 specifier: Some("^1.0.0".into()),
3332 },
3333 DirectDep {
3334 name: "jest".into(),
3335 dep_path: "jest@29.0.0".into(),
3336 dep_type: DepType::Dev,
3337 specifier: Some("^29.0.0".into()),
3338 },
3339 ],
3340 );
3341
3342 let mut packages = BTreeMap::new();
3343 let mut foo_deps = BTreeMap::new();
3344 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
3345 packages.insert(
3346 "foo@1.0.0".to_string(),
3347 LockedPackage {
3348 name: "foo".into(),
3349 version: "1.0.0".into(),
3350 integrity: None,
3351 dependencies: foo_deps,
3352 dep_path: "foo@1.0.0".into(),
3353 ..Default::default()
3354 },
3355 );
3356 packages.insert(
3357 "bar@2.0.0".to_string(),
3358 LockedPackage {
3359 name: "bar".into(),
3360 version: "2.0.0".into(),
3361 integrity: None,
3362 dependencies: BTreeMap::new(),
3363 dep_path: "bar@2.0.0".into(),
3364 ..Default::default()
3365 },
3366 );
3367 let mut jest_deps = BTreeMap::new();
3368 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
3369 packages.insert(
3370 "jest@29.0.0".to_string(),
3371 LockedPackage {
3372 name: "jest".into(),
3373 version: "29.0.0".into(),
3374 integrity: None,
3375 dependencies: jest_deps,
3376 dep_path: "jest@29.0.0".into(),
3377 ..Default::default()
3378 },
3379 );
3380 packages.insert(
3381 "jest-core@29.0.0".to_string(),
3382 LockedPackage {
3383 name: "jest-core".into(),
3384 version: "29.0.0".into(),
3385 integrity: None,
3386 dependencies: BTreeMap::new(),
3387 dep_path: "jest-core@29.0.0".into(),
3388 ..Default::default()
3389 },
3390 );
3391
3392 let graph = LockfileGraph {
3393 importers,
3394 packages,
3395 ..Default::default()
3396 };
3397
3398 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3399
3400 let roots = prod.root_deps();
3402 assert_eq!(roots.len(), 1);
3403 assert_eq!(roots[0].name, "foo");
3404
3405 assert!(prod.packages.contains_key("foo@1.0.0"));
3407 assert!(prod.packages.contains_key("bar@2.0.0"));
3408 assert!(!prod.packages.contains_key("jest@29.0.0"));
3409 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
3410 }
3411
3412 #[test]
3419 fn filter_deps_preserves_lockfile_settings() {
3420 let graph = LockfileGraph {
3421 importers: BTreeMap::new(),
3422 packages: BTreeMap::new(),
3423 settings: LockfileSettings {
3424 auto_install_peers: false,
3425 exclude_links_from_lockfile: true,
3426 lockfile_include_tarball_url: false,
3427 },
3428 ..Default::default()
3429 };
3430 let filtered = graph.filter_deps(|_| true);
3431 assert!(!filtered.settings.auto_install_peers);
3432 assert!(filtered.settings.exclude_links_from_lockfile);
3433 }
3434
3435 #[test]
3436 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
3437 let mut importers = BTreeMap::new();
3441 importers.insert(
3442 ".".to_string(),
3443 vec![
3444 DirectDep {
3445 name: "foo".into(),
3446 dep_path: "foo@1.0.0".into(),
3447 dep_type: DepType::Production,
3448 specifier: Some("^1.0.0".into()),
3449 },
3450 DirectDep {
3451 name: "jest".into(),
3452 dep_path: "jest@29.0.0".into(),
3453 dep_type: DepType::Dev,
3454 specifier: Some("^29.0.0".into()),
3455 },
3456 ],
3457 );
3458
3459 let mut packages = BTreeMap::new();
3460 for (name, ver, deps) in [
3461 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
3462 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
3463 ("shared", "1.0.0", vec![]),
3464 ] {
3465 let mut dep_map = BTreeMap::new();
3466 for (n, v) in deps {
3467 dep_map.insert(n.to_string(), v.to_string());
3468 }
3469 packages.insert(
3470 format!("{name}@{ver}"),
3471 LockedPackage {
3472 name: name.into(),
3473 version: ver.into(),
3474 integrity: None,
3475 dependencies: dep_map,
3476 dep_path: format!("{name}@{ver}"),
3477 ..Default::default()
3478 },
3479 );
3480 }
3481
3482 let graph = LockfileGraph {
3483 importers,
3484 packages,
3485 ..Default::default()
3486 };
3487 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3488
3489 assert!(prod.packages.contains_key("foo@1.0.0"));
3490 assert!(prod.packages.contains_key("shared@1.0.0"));
3491 assert!(!prod.packages.contains_key("jest@29.0.0"));
3492 }
3493
3494 #[test]
3495 fn subset_to_importer_returns_none_for_missing_importer() {
3496 let graph = LockfileGraph {
3497 importers: BTreeMap::new(),
3498 packages: BTreeMap::new(),
3499 ..Default::default()
3500 };
3501 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
3502 }
3503
3504 #[test]
3505 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
3506 let mut importers = BTreeMap::new();
3513 importers.insert(".".to_string(), vec![]);
3514 importers.insert(
3515 "packages/lib".to_string(),
3516 vec![DirectDep {
3517 name: "is-odd".into(),
3518 dep_path: "is-odd@3.0.1".into(),
3519 dep_type: DepType::Production,
3520 specifier: Some("^3.0.1".into()),
3521 }],
3522 );
3523 importers.insert(
3524 "packages/app".to_string(),
3525 vec![DirectDep {
3526 name: "express".into(),
3527 dep_path: "express@4.18.0".into(),
3528 dep_type: DepType::Production,
3529 specifier: Some("^4.18.0".into()),
3530 }],
3531 );
3532
3533 let mut packages = BTreeMap::new();
3534 let mut is_odd_deps = BTreeMap::new();
3535 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
3536 packages.insert(
3537 "is-odd@3.0.1".to_string(),
3538 LockedPackage {
3539 name: "is-odd".into(),
3540 version: "3.0.1".into(),
3541 dependencies: is_odd_deps,
3542 dep_path: "is-odd@3.0.1".into(),
3543 ..Default::default()
3544 },
3545 );
3546 packages.insert(
3547 "is-number@6.0.0".to_string(),
3548 LockedPackage {
3549 name: "is-number".into(),
3550 version: "6.0.0".into(),
3551 dep_path: "is-number@6.0.0".into(),
3552 ..Default::default()
3553 },
3554 );
3555 packages.insert(
3556 "express@4.18.0".to_string(),
3557 LockedPackage {
3558 name: "express".into(),
3559 version: "4.18.0".into(),
3560 dep_path: "express@4.18.0".into(),
3561 ..Default::default()
3562 },
3563 );
3564
3565 let graph = LockfileGraph {
3566 importers,
3567 packages,
3568 ..Default::default()
3569 };
3570 let subset = graph
3571 .subset_to_importer("packages/lib", |_| true)
3572 .expect("packages/lib importer present");
3573
3574 assert_eq!(subset.importers.len(), 1);
3575 let roots = subset.root_deps();
3576 assert_eq!(roots.len(), 1);
3577 assert_eq!(roots[0].name, "is-odd");
3578
3579 assert!(subset.packages.contains_key("is-odd@3.0.1"));
3580 assert!(subset.packages.contains_key("is-number@6.0.0"));
3581 assert!(!subset.packages.contains_key("express@4.18.0"));
3582 }
3583
3584 #[test]
3585 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
3586 let mut importers = BTreeMap::new();
3592 importers.insert(
3593 "packages/lib".to_string(),
3594 vec![
3595 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 DirectDep {
3602 name: "jest".into(),
3603 dep_path: "jest@29.0.0".into(),
3604 dep_type: DepType::Dev,
3605 specifier: Some("^29.0.0".into()),
3606 },
3607 ],
3608 );
3609 let mut packages = BTreeMap::new();
3610 packages.insert(
3611 "is-odd@3.0.1".to_string(),
3612 LockedPackage {
3613 name: "is-odd".into(),
3614 version: "3.0.1".into(),
3615 dep_path: "is-odd@3.0.1".into(),
3616 ..Default::default()
3617 },
3618 );
3619 packages.insert(
3620 "jest@29.0.0".to_string(),
3621 LockedPackage {
3622 name: "jest".into(),
3623 version: "29.0.0".into(),
3624 dep_path: "jest@29.0.0".into(),
3625 ..Default::default()
3626 },
3627 );
3628 let graph = LockfileGraph {
3629 importers,
3630 packages,
3631 ..Default::default()
3632 };
3633
3634 let prod = graph
3635 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
3636 .expect("importer present");
3637 let roots = prod.root_deps();
3638 assert_eq!(roots.len(), 1);
3639 assert_eq!(roots[0].name, "is-odd");
3640 assert!(prod.packages.contains_key("is-odd@3.0.1"));
3641 assert!(!prod.packages.contains_key("jest@29.0.0"));
3642 }
3643
3644 #[test]
3645 fn subset_to_importer_preserves_graph_settings() {
3646 let mut importers = BTreeMap::new();
3652 importers.insert("packages/lib".to_string(), vec![]);
3653 let graph = LockfileGraph {
3654 importers,
3655 packages: BTreeMap::new(),
3656 settings: LockfileSettings {
3657 auto_install_peers: false,
3658 exclude_links_from_lockfile: true,
3659 lockfile_include_tarball_url: true,
3660 },
3661 ..Default::default()
3662 };
3663 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3664 assert!(!subset.settings.auto_install_peers);
3665 assert!(subset.settings.exclude_links_from_lockfile);
3666 assert!(subset.settings.lockfile_include_tarball_url);
3667 }
3668
3669 #[test]
3670 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
3671 let mut importers = BTreeMap::new();
3676 importers.insert("packages/lib".to_string(), vec![]);
3677 importers.insert("packages/app".to_string(), vec![]);
3678 let mut skipped = BTreeMap::new();
3679 let mut lib_skip = BTreeMap::new();
3680 lib_skip.insert("fsevents".to_string(), "^2".to_string());
3681 skipped.insert("packages/lib".to_string(), lib_skip);
3682 let mut app_skip = BTreeMap::new();
3683 app_skip.insert("ghost".to_string(), "*".to_string());
3684 skipped.insert("packages/app".to_string(), app_skip);
3685 let graph = LockfileGraph {
3686 importers,
3687 packages: BTreeMap::new(),
3688 skipped_optional_dependencies: skipped,
3689 ..Default::default()
3690 };
3691 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3692 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
3693 let root = subset.skipped_optional_dependencies.get(".").unwrap();
3694 assert!(root.contains_key("fsevents"));
3695 assert!(!root.contains_key("ghost"));
3696 }
3697
3698 #[test]
3699 fn workspace_drift_fresh_when_all_importers_match() {
3700 let root_dep = DirectDep {
3701 name: "lodash".into(),
3702 dep_path: "lodash@4.17.21".into(),
3703 dep_type: DepType::Production,
3704 specifier: Some("^4.17.0".into()),
3705 };
3706 let app_dep = DirectDep {
3707 name: "express".into(),
3708 dep_path: "express@4.18.0".into(),
3709 dep_type: DepType::Production,
3710 specifier: Some("^4.18.0".into()),
3711 };
3712 let mut importers = BTreeMap::new();
3713 importers.insert(".".to_string(), vec![root_dep]);
3714 importers.insert("packages/app".to_string(), vec![app_dep]);
3715 let graph = LockfileGraph {
3716 importers,
3717 packages: BTreeMap::new(),
3718 ..Default::default()
3719 };
3720
3721 let workspace_manifests = vec![
3722 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
3723 (
3724 "packages/app".to_string(),
3725 make_manifest(&[("express", "^4.18.0")]),
3726 ),
3727 ];
3728 assert_eq!(
3729 graph.check_drift_workspace(
3730 &workspace_manifests,
3731 &BTreeMap::new(),
3732 &[],
3733 &BTreeMap::new(),
3734 true,
3735 ),
3736 DriftStatus::Fresh
3737 );
3738 }
3739
3740 #[allow(clippy::type_complexity)]
3741 fn mk_catalogs(
3742 entries: &[(&str, &[(&str, &str, &str)])],
3743 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
3744 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
3745 for (cat, pkgs) in entries {
3746 let mut inner = BTreeMap::new();
3747 for (pkg, spec, ver) in *pkgs {
3748 inner.insert(
3749 (*pkg).to_string(),
3750 CatalogEntry {
3751 specifier: (*spec).to_string(),
3752 version: (*ver).to_string(),
3753 },
3754 );
3755 }
3756 out.insert((*cat).to_string(), inner);
3757 }
3758 out
3759 }
3760
3761 fn mk_workspace_catalogs(
3762 entries: &[(&str, &[(&str, &str)])],
3763 ) -> BTreeMap<String, BTreeMap<String, String>> {
3764 entries
3765 .iter()
3766 .map(|(cat, pkgs)| {
3767 (
3768 (*cat).to_string(),
3769 pkgs.iter()
3770 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
3771 .collect(),
3772 )
3773 })
3774 .collect()
3775 }
3776
3777 #[test]
3778 fn catalog_drift_fresh_when_specifiers_match() {
3779 let graph = LockfileGraph {
3780 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3781 ..Default::default()
3782 };
3783 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
3784 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3785 }
3786
3787 #[test]
3788 fn catalog_drift_stale_on_changed_specifier() {
3789 let graph = LockfileGraph {
3790 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3791 ..Default::default()
3792 };
3793 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
3794 match graph.check_catalogs_drift(&ws) {
3795 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
3796 other => panic!("expected stale, got {other:?}"),
3797 }
3798 }
3799
3800 #[test]
3801 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
3802 let graph = LockfileGraph::default();
3806 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
3807 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3808 }
3809
3810 #[test]
3811 fn catalog_drift_stale_on_removed_workspace_entry() {
3812 let graph = LockfileGraph {
3813 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
3814 ..Default::default()
3815 };
3816 let ws = mk_workspace_catalogs(&[]);
3817 assert!(matches!(
3818 graph.check_catalogs_drift(&ws),
3819 DriftStatus::Stale { .. }
3820 ));
3821 }
3822}