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 let mut buf = content.clone().into_bytes();
2129 match simd_json::serde::from_slice(&mut buf) {
2130 Ok(v) => Ok(v),
2131 Err(_) => match serde_json::from_str(&content) {
2132 Ok(v) => Ok(v),
2133 Err(e) => Err(Error::parse_json_err(path, content, &e)),
2134 },
2135 }
2136}
2137
2138impl Error {
2139 pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
2140 Error::Parse(path.to_path_buf(), msg.into())
2141 }
2142
2143 pub fn parse_json_err(
2144 path: &std::path::Path,
2145 content: String,
2146 err: &serde_json::Error,
2147 ) -> Self {
2148 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
2149 path, content, err,
2150 )))
2151 }
2152
2153 pub fn parse_yaml_err(
2154 path: &std::path::Path,
2155 content: String,
2156 err: &yaml_serde::Error,
2157 ) -> Self {
2158 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
2159 path, content, err,
2160 )))
2161 }
2162}
2163
2164#[cfg(test)]
2165mod parse_diag_tests {
2166 use super::*;
2167 use std::path::Path;
2168
2169 #[test]
2173 fn parse_json_attaches_span_for_bad_input() {
2174 let path = Path::new("package-lock.json");
2175 let content = r#"{"name":"x","#.to_string();
2176 let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
2177 else {
2178 panic!("parse_json must produce ParseDiag on malformed input");
2179 };
2180 let offset: usize = pe.span.offset();
2181 let len: usize = pe.span.len();
2182 assert!(offset + len <= content.len());
2183 assert_eq!(pe.path, path);
2184 }
2185
2186 #[test]
2193 fn parse_yaml_err_attaches_span_for_bad_input() {
2194 let path = Path::new("yarn.lock");
2195 let content = "packages:\n\t- pkg\n".to_string();
2196 let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
2197 .expect_err("tab-indented YAML must fail");
2198 let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
2199 panic!("parse_yaml_err must produce ParseDiag");
2200 };
2201 let offset: usize = pe.span.offset();
2202 let len: usize = pe.span.len();
2203 assert!(offset + len <= content.len());
2204 assert_eq!(pe.path, path);
2205 }
2206}
2207
2208#[cfg(test)]
2209mod looks_like_remote_tarball_url_tests {
2210 use super::*;
2211
2212 #[test]
2213 fn matches_https_tgz() {
2214 assert!(LocalSource::looks_like_remote_tarball_url(
2215 "https://example.com/pkg-1.0.0.tgz"
2216 ));
2217 }
2218
2219 #[test]
2220 fn matches_http_tar_gz() {
2221 assert!(LocalSource::looks_like_remote_tarball_url(
2222 "http://example.com/pkg-1.0.0.tar.gz"
2223 ));
2224 }
2225
2226 #[test]
2227 fn strips_fragment_before_suffix_check() {
2228 assert!(LocalSource::looks_like_remote_tarball_url(
2229 "https://example.com/pkg-1.0.0.tgz#sha512-abc"
2230 ));
2231 }
2232
2233 #[test]
2234 fn strips_query_string_before_suffix_check() {
2235 assert!(LocalSource::looks_like_remote_tarball_url(
2239 "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
2240 ));
2241 assert!(LocalSource::looks_like_remote_tarball_url(
2242 "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
2243 ));
2244 }
2245
2246 #[test]
2247 fn matches_bare_http_url_without_tarball_suffix() {
2248 assert!(LocalSource::looks_like_remote_tarball_url(
2252 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
2253 ));
2254 assert!(LocalSource::looks_like_remote_tarball_url(
2255 "https://codeload.github.com/user/repo/tar.gz/main"
2256 ));
2257 }
2258
2259 #[test]
2260 fn rejects_non_http_schemes() {
2261 assert!(!LocalSource::looks_like_remote_tarball_url(
2262 "ftp://example.com/pkg.tgz"
2263 ));
2264 assert!(!LocalSource::looks_like_remote_tarball_url(
2265 "git://example.com/repo.git"
2266 ));
2267 }
2268
2269 #[test]
2270 fn parse_classifies_bare_http_url_as_remote_tarball() {
2271 use std::path::Path;
2272 let parsed = LocalSource::parse(
2273 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
2274 Path::new(""),
2275 );
2276 assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
2277 }
2278
2279 #[test]
2280 fn parse_prefers_git_over_tarball_for_dot_git_url() {
2281 use std::path::Path;
2282 let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
2283 assert!(matches!(parsed, Some(LocalSource::Git(_))));
2284 }
2285}
2286
2287#[cfg(test)]
2288mod filename_tests {
2289 use super::*;
2290
2291 #[test]
2292 fn defaults_to_plain_lockfile_when_setting_absent() {
2293 let dir = tempfile::tempdir().unwrap();
2294 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2295 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
2296 }
2297
2298 #[test]
2299 fn defaults_to_plain_lockfile_when_setting_explicit_false() {
2300 let dir = tempfile::tempdir().unwrap();
2301 std::fs::write(
2302 dir.path().join("pnpm-workspace.yaml"),
2303 "gitBranchLockfile: false\n",
2304 )
2305 .unwrap();
2306 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2307 }
2308
2309 #[test]
2310 fn uses_branch_filename_when_enabled_inside_git_repo() {
2311 let dir = tempfile::tempdir().unwrap();
2312 std::fs::write(
2313 dir.path().join("pnpm-workspace.yaml"),
2314 "gitBranchLockfile: true\n",
2315 )
2316 .unwrap();
2317 let run = |args: &[&str]| {
2320 std::process::Command::new("git")
2321 .args(["-C"])
2322 .arg(dir.path())
2323 .args(args)
2324 .output()
2325 .unwrap()
2326 };
2327 if run(&["init", "-q"]).status.success() {
2328 run(&["checkout", "-q", "-b", "feature/x"]);
2329 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
2330 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
2331 }
2332 }
2333}
2334
2335#[cfg(test)]
2336mod git_spec_tests {
2337 use super::*;
2338
2339 #[test]
2340 fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
2341 let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
2343 assert_eq!(url, "https://host/user/repo");
2344 assert_eq!(committish, None);
2345 assert_eq!(subpath, None);
2346
2347 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2350 let source = LocalSource::Git(GitSource {
2351 url: url.clone(),
2352 committish: None,
2353 resolved: sha.to_string(),
2354 subpath: None,
2355 });
2356 let lockfile_version = source.specifier();
2357 assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
2358
2359 let (round_url, round_committish, round_subpath) =
2362 parse_git_spec(&lockfile_version).unwrap();
2363 assert_eq!(round_url, "https://host/user/repo");
2364 assert_eq!(round_committish.as_deref(), Some(sha));
2365 assert_eq!(round_subpath, None);
2366 }
2367
2368 #[test]
2369 fn bare_https_without_dot_git_and_no_committish_is_not_git() {
2370 assert!(parse_git_spec("https://example.com/pkg").is_none());
2373 }
2374
2375 #[test]
2376 fn github_shorthand_expands_and_roundtrips() {
2377 let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
2378 assert_eq!(url, "https://github.com/user/repo.git");
2379 }
2380
2381 #[test]
2382 fn bare_user_repo_expands_to_github() {
2383 let (url, committish, subpath) = parse_git_spec("kevva/is-negative").unwrap();
2384 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2385 assert!(committish.is_none());
2386 assert!(subpath.is_none());
2387 }
2388
2389 #[test]
2390 fn bare_user_repo_with_committish_preserved() {
2391 let (url, committish, _) = parse_git_spec("kevva/is-negative#v1.0.0").unwrap();
2392 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2393 assert_eq!(committish.as_deref(), Some("v1.0.0"));
2394 }
2395
2396 #[test]
2397 fn bare_scope_pkg_is_not_git_shorthand() {
2398 assert!(parse_git_spec("@types/node").is_none());
2400 }
2401
2402 #[test]
2403 fn bare_relative_path_is_not_git_shorthand() {
2404 assert!(parse_git_spec("./repo").is_none());
2407 assert!(parse_git_spec("../repo").is_none());
2408 assert!(parse_git_spec("./local/path").is_none());
2411 assert!(parse_git_spec("../local/path").is_none());
2412 }
2413
2414 #[test]
2415 fn bare_path_with_extra_slashes_is_not_git_shorthand() {
2416 assert!(parse_git_spec("path/with/slashes/extra").is_none());
2419 }
2420
2421 #[test]
2422 fn bare_scp_form_unknown_host_is_not_github_shorthand() {
2423 assert!(parse_git_spec("user@host:repo.git").is_none());
2426 }
2427
2428 #[test]
2429 fn scp_form_recognized() {
2430 let (url, committish, _) =
2431 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
2432 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2433 assert!(committish.is_none());
2434 }
2435
2436 #[test]
2437 fn scp_form_with_ref_recognized() {
2438 let (url, committish, _) =
2439 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
2440 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2441 assert_eq!(committish.as_deref(), Some("0.1.5"));
2442 }
2443
2444 #[test]
2445 fn scp_form_bitbucket_recognized() {
2446 let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
2447 assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
2448 }
2449
2450 #[test]
2451 fn scp_form_unknown_host_rejected() {
2452 assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
2454 assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
2455 }
2456
2457 #[test]
2458 fn scp_form_without_user_rejected() {
2459 assert!(parse_git_spec("github.com:user/repo.git").is_none());
2461 }
2462
2463 #[test]
2464 fn commit_selector_fragment_normalizes_to_sha() {
2465 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2466 let (url, committish, _) =
2467 parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
2468 assert_eq!(url, "https://host/user/repo.git");
2469 assert_eq!(committish.as_deref(), Some(sha));
2470 }
2471
2472 #[test]
2473 fn named_selector_fragment_normalizes_to_ref() {
2474 let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
2475 assert_eq!(url, "https://host/user/repo");
2476 assert_eq!(committish.as_deref(), Some("v1.2.3"));
2477 }
2478
2479 #[test]
2480 fn pnpm_path_subpath_extracted_from_fragment() {
2481 let (url, committish, subpath) =
2484 parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
2485 assert_eq!(url, "https://github.com/org/dep.git");
2486 assert_eq!(committish.as_deref(), Some("v0.1.4"));
2487 assert_eq!(subpath.as_deref(), Some("packages/special"));
2488 }
2489
2490 #[test]
2491 fn path_subpath_roundtrips_via_specifier() {
2492 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2493 let source = LocalSource::Git(GitSource {
2494 url: "https://github.com/org/dep.git".to_string(),
2495 committish: None,
2496 resolved: sha.to_string(),
2497 subpath: Some("packages/special".to_string()),
2498 });
2499 let spec = source.specifier();
2500 assert_eq!(
2501 spec,
2502 format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
2503 );
2504 let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
2505 assert_eq!(url, "https://github.com/org/dep.git");
2506 assert_eq!(committish.as_deref(), Some(sha));
2507 assert_eq!(subpath.as_deref(), Some("packages/special"));
2508 }
2509
2510 #[test]
2511 fn parse_hosted_git_recognizes_canonical_forms() {
2512 let canonical = HostedGit {
2516 host: HostedGitHost::GitHub,
2517 owner: "owner".to_string(),
2518 repo: "repo".to_string(),
2519 };
2520 for spec in [
2521 "https://github.com/owner/repo.git",
2522 "https://github.com/owner/repo",
2523 "http://github.com/owner/repo.git",
2524 "git+https://github.com/owner/repo.git",
2525 "git+https://github.com/owner/repo",
2526 "git://github.com/owner/repo.git",
2527 "ssh://git@github.com/owner/repo.git",
2528 "git+ssh://git@github.com/owner/repo.git",
2529 "git@github.com:owner/repo.git",
2530 ] {
2531 assert_eq!(
2532 parse_hosted_git(spec).as_ref(),
2533 Some(&canonical),
2534 "spec {spec} should map to canonical HostedGit",
2535 );
2536 }
2537 }
2538
2539 #[test]
2540 fn parse_hosted_git_returns_none_for_non_hosted() {
2541 for spec in [
2544 "https://example.com/owner/repo.git",
2545 "ssh://git@gitea.internal/owner/repo.git",
2546 "git+ssh://git@gitlab.example.com/group/sub/repo.git",
2547 "https://github.com/owner/repo/sub",
2548 "https://github.com/owner",
2549 ] {
2550 assert!(
2551 parse_hosted_git(spec).is_none(),
2552 "spec {spec} must not match a hosted provider",
2553 );
2554 }
2555 }
2556
2557 #[test]
2558 fn hosted_tarball_url_only_for_full_sha() {
2559 let g = HostedGit {
2560 host: HostedGitHost::GitHub,
2561 owner: "o".to_string(),
2562 repo: "r".to_string(),
2563 };
2564 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2565 assert_eq!(
2566 g.tarball_url(sha).as_deref(),
2567 Some("https://codeload.github.com/o/r/tar.gz/abcdef0123456789abcdef0123456789abcdef01"),
2568 );
2569 assert!(g.tarball_url("main").is_none());
2573 assert!(g.tarball_url("v1.2.3").is_none());
2574 assert!(g.tarball_url("abcdef0").is_none());
2575 }
2576
2577 #[test]
2578 fn hosted_tarball_url_per_provider() {
2579 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2580 let gitlab = HostedGit {
2581 host: HostedGitHost::GitLab,
2582 owner: "g".to_string(),
2583 repo: "r".to_string(),
2584 }
2585 .tarball_url(sha)
2586 .unwrap();
2587 assert!(gitlab.starts_with("https://gitlab.com/g/r/-/archive/"));
2588 assert!(gitlab.ends_with("/r-abcdef0123456789abcdef0123456789abcdef01.tar.gz"));
2589 let bitbucket = HostedGit {
2590 host: HostedGitHost::Bitbucket,
2591 owner: "g".to_string(),
2592 repo: "r".to_string(),
2593 }
2594 .tarball_url(sha)
2595 .unwrap();
2596 assert_eq!(
2597 bitbucket,
2598 "https://bitbucket.org/g/r/get/abcdef0123456789abcdef0123456789abcdef01.tar.gz",
2599 );
2600 }
2601
2602 #[test]
2603 fn hosted_https_url_normalizes() {
2604 let g = parse_hosted_git("git+ssh://git@github.com/owner/repo.git").unwrap();
2605 assert_eq!(g.https_url(), "https://github.com/owner/repo.git");
2606 }
2607
2608 #[test]
2609 fn path_traversal_components_in_subpath_are_rejected() {
2610 let cases = [
2614 "github:org/dep#main&path:/../../etc",
2615 "github:org/dep#main&path:/packages/../../../etc",
2616 "github:org/dep#main&path:/./packages/foo",
2617 "github:org/dep#main&path:/packages//foo",
2618 ];
2619 for spec in cases {
2620 let (_, _, subpath) = parse_git_spec(spec).unwrap();
2621 assert_eq!(subpath, None, "spec should drop subpath: {spec}");
2622 }
2623 }
2624
2625 #[test]
2626 fn dep_path_distinguishes_subpaths_under_same_commit() {
2627 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2631 let a = LocalSource::Git(GitSource {
2632 url: "https://example.com/r.git".to_string(),
2633 committish: None,
2634 resolved: sha.to_string(),
2635 subpath: Some("packages/a".to_string()),
2636 });
2637 let b = LocalSource::Git(GitSource {
2638 url: "https://example.com/r.git".to_string(),
2639 committish: None,
2640 resolved: sha.to_string(),
2641 subpath: Some("packages/b".to_string()),
2642 });
2643 assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
2644 }
2645}
2646
2647#[cfg(test)]
2648mod drift_tests {
2649 use super::*;
2650 use aube_manifest::PackageJson;
2651 use std::collections::BTreeMap;
2652 use std::path::PathBuf;
2653
2654 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
2655 let mut m = PackageJson {
2656 name: Some("test".into()),
2657 version: Some("1.0.0".into()),
2658 dependencies: BTreeMap::new(),
2659 dev_dependencies: BTreeMap::new(),
2660 peer_dependencies: BTreeMap::new(),
2661 optional_dependencies: BTreeMap::new(),
2662 update_config: None,
2663 scripts: BTreeMap::new(),
2664 engines: BTreeMap::new(),
2665 workspaces: None,
2666 bundled_dependencies: None,
2667 extra: BTreeMap::new(),
2668 };
2669 for (name, spec) in deps {
2670 m.dependencies.insert((*name).into(), (*spec).into());
2671 }
2672 m
2673 }
2674
2675 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
2676 let direct: Vec<DirectDep> = deps
2678 .iter()
2679 .map(|(name, spec, dep_path)| DirectDep {
2680 name: (*name).into(),
2681 dep_path: (*dep_path).into(),
2682 dep_type: DepType::Production,
2683 specifier: Some((*spec).into()),
2684 })
2685 .collect();
2686 let mut importers = BTreeMap::new();
2687 importers.insert(".".to_string(), direct);
2688 LockfileGraph {
2689 importers,
2690 packages: BTreeMap::new(),
2691 ..Default::default()
2692 }
2693 }
2694
2695 #[test]
2696 fn fresh_when_specifiers_match() {
2697 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2698 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2699 assert_eq!(
2700 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2701 DriftStatus::Fresh
2702 );
2703 }
2704
2705 #[test]
2706 fn stale_when_specifier_changes() {
2707 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
2708 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2709 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2710 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2711 DriftStatus::Fresh => panic!("expected Stale"),
2712 }
2713 }
2714
2715 #[test]
2716 fn stale_when_manifest_adds_dep() {
2717 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
2718 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2719 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2720 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2721 DriftStatus::Fresh => panic!("expected Stale"),
2722 }
2723 }
2724
2725 #[test]
2726 fn stale_when_manifest_removes_dep() {
2727 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2728 let graph = make_graph(&[
2729 ("lodash", "^4.17.0", "lodash@4.17.21"),
2730 ("express", "^4.18.0", "express@4.18.0"),
2731 ]);
2732 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2733 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2734 DriftStatus::Fresh => panic!("expected Stale"),
2735 }
2736 }
2737
2738 #[test]
2743 fn fresh_when_lockfile_has_auto_hoisted_peer() {
2744 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2745 let mut graph = make_graph(&[
2746 (
2747 "use-sync-external-store",
2748 "1.2.0",
2749 "use-sync-external-store@1.2.0",
2750 ),
2751 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
2754 ]);
2755 let mut declaring_pkg = LockedPackage {
2758 name: "use-sync-external-store".into(),
2759 version: "1.2.0".into(),
2760 dep_path: "use-sync-external-store@1.2.0".into(),
2761 ..Default::default()
2762 };
2763 declaring_pkg
2764 .peer_dependencies
2765 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2766 graph
2767 .packages
2768 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
2769
2770 assert_eq!(
2771 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2772 DriftStatus::Fresh
2773 );
2774 }
2775
2776 #[test]
2782 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
2783 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2786
2787 let mut graph = make_graph(&[
2790 (
2791 "use-sync-external-store",
2792 "1.2.0",
2793 "use-sync-external-store@1.2.0",
2794 ),
2795 ("react", "17.0.2", "react@17.0.2"),
2796 ]);
2797 let mut consumer = LockedPackage {
2802 name: "use-sync-external-store".into(),
2803 version: "1.2.0".into(),
2804 dep_path: "use-sync-external-store@1.2.0".into(),
2805 ..Default::default()
2806 };
2807 consumer
2808 .peer_dependencies
2809 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2810 graph
2811 .packages
2812 .insert("use-sync-external-store@1.2.0".into(), consumer);
2813
2814 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2815 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2816 DriftStatus::Fresh => panic!(
2817 "drift check should flag a removed user-pinned dep as stale, \
2818 even when its name matches a peer declaration"
2819 ),
2820 }
2821 }
2822
2823 #[test]
2826 fn stale_when_lockfile_has_removed_non_peer_dep() {
2827 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2828 let graph = make_graph(&[
2829 ("lodash", "^4.17.0", "lodash@4.17.21"),
2830 ("chalk", "^5.0.0", "chalk@5.0.0"),
2831 ]);
2832 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2833 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
2834 DriftStatus::Fresh => panic!("expected Stale"),
2835 }
2836 }
2837
2838 #[test]
2839 fn workspace_drift_allows_root_links_for_workspace_packages() {
2840 let root_manifest = make_manifest(&[]);
2841 let mut app_manifest = make_manifest(&[]);
2842 app_manifest.name = Some("@scope/app".to_string());
2843
2844 let link = LocalSource::Link(PathBuf::from("packages/app"));
2845 let dep_path = link.dep_path("@scope/app");
2846 let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
2847 graph.packages.insert(
2848 dep_path.clone(),
2849 LockedPackage {
2850 name: "@scope/app".to_string(),
2851 version: "1.0.0".to_string(),
2852 dep_path,
2853 local_source: Some(link),
2854 ..Default::default()
2855 },
2856 );
2857
2858 assert_eq!(
2859 graph.check_drift_workspace(
2860 &[
2861 (".".to_string(), root_manifest),
2862 ("packages/app".to_string(), app_manifest),
2863 ],
2864 &BTreeMap::new(),
2865 &[],
2866 &BTreeMap::new(),
2867 true,
2868 ),
2869 DriftStatus::Fresh
2870 );
2871 }
2872
2873 #[test]
2874 fn fresh_when_no_specifiers_recorded() {
2875 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2878 let graph = LockfileGraph {
2879 importers: {
2880 let mut m = BTreeMap::new();
2881 m.insert(
2882 ".".to_string(),
2883 vec![DirectDep {
2884 name: "lodash".into(),
2885 dep_path: "lodash@4.17.21".into(),
2886 dep_type: DepType::Production,
2887 specifier: None,
2888 }],
2889 );
2890 m
2891 },
2892 packages: BTreeMap::new(),
2893 ..Default::default()
2894 };
2895 assert_eq!(
2896 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2897 DriftStatus::Fresh
2898 );
2899 }
2900
2901 #[test]
2902 fn stale_when_manifest_adds_override() {
2903 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2907 manifest
2908 .extra
2909 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2910 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2911 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2912 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
2913 DriftStatus::Fresh => panic!("expected Stale"),
2914 }
2915 }
2916
2917 #[test]
2918 fn stale_drift_message_names_changed_override_key() {
2919 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2923 manifest
2924 .extra
2925 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
2926 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2927 graph.overrides.insert("lodash".into(), "4.17.21".into());
2928 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2929 DriftStatus::Stale { reason } => {
2930 assert!(reason.contains("lodash"), "expected key in: {reason}");
2931 assert!(
2932 reason.contains("4.17.21"),
2933 "expected old value in: {reason}"
2934 );
2935 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
2936 }
2937 DriftStatus::Fresh => panic!("expected Stale"),
2938 }
2939 }
2940
2941 #[test]
2942 fn stale_when_manifest_removes_override() {
2943 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2944 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2945 graph.overrides.insert("lodash".into(), "4.17.21".into());
2946 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2947 DriftStatus::Stale { reason } => {
2948 assert!(reason.contains("removes"));
2949 assert!(reason.contains("lodash"));
2950 }
2951 DriftStatus::Fresh => panic!("expected Stale"),
2952 }
2953 }
2954
2955 #[test]
2956 fn fresh_when_overrides_match() {
2957 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2958 manifest
2959 .extra
2960 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2961 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2962 graph.overrides.insert("lodash".into(), "4.17.21".into());
2963 assert_eq!(
2964 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2965 DriftStatus::Fresh
2966 );
2967 }
2968
2969 #[test]
2970 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
2971 let manifest = make_manifest(&[("semver", "^7.5.0")]);
2977 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2978 graph.overrides.insert("semver".into(), "7.7.1".into());
2979 let mut ws_overrides = BTreeMap::new();
2980 ws_overrides.insert("semver".into(), "7.7.1".into());
2981 assert_eq!(
2982 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2983 DriftStatus::Fresh,
2984 );
2985 }
2986
2987 #[test]
2988 fn workspace_yaml_overrides_win_over_package_json() {
2989 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
2994 manifest
2995 .extra
2996 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
2997 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2998 graph.overrides.insert("semver".into(), "7.7.1".into());
2999 let mut ws_overrides = BTreeMap::new();
3000 ws_overrides.insert("semver".into(), "7.7.1".into());
3001 assert_eq!(
3002 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3003 DriftStatus::Fresh,
3004 );
3005 }
3006
3007 #[test]
3008 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
3009 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3015 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3016 graph.overrides.insert("lodash".into(), "4.17.21".into());
3017 let mut ws_overrides = BTreeMap::new();
3018 ws_overrides.insert("lodash".into(), "catalog:".into());
3019 let mut catalogs = BTreeMap::new();
3020 let mut default_cat = BTreeMap::new();
3021 default_cat.insert("lodash".into(), "4.17.21".into());
3022 catalogs.insert("default".into(), default_cat);
3023 assert_eq!(
3024 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
3025 DriftStatus::Fresh,
3026 );
3027 }
3028
3029 #[test]
3030 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
3031 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
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 let mut ws_overrides = BTreeMap::new();
3037 ws_overrides.insert("lodash".into(), "catalog:evens".into());
3038 let mut catalogs = BTreeMap::new();
3039 let mut evens = BTreeMap::new();
3040 evens.insert("lodash".into(), "4.17.21".into());
3041 catalogs.insert("evens".into(), evens);
3042 assert_eq!(
3043 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
3044 DriftStatus::Fresh,
3045 );
3046 }
3047
3048 #[test]
3049 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
3050 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3054 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3055 graph.overrides.insert("lodash".into(), "4.17.21".into());
3056 let mut ws_overrides = BTreeMap::new();
3057 ws_overrides.insert("lodash".into(), "catalog:".into());
3058 let mut catalogs = BTreeMap::new();
3059 let mut default_cat = BTreeMap::new();
3060 default_cat.insert("lodash".into(), "4.17.22".into());
3061 catalogs.insert("default".into(), default_cat);
3062 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
3063 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
3064 other => panic!("expected stale, got {other:?}"),
3065 }
3066 }
3067
3068 #[test]
3069 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
3070 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3077 let mut importers = BTreeMap::new();
3078 importers.insert(
3079 ".".to_string(),
3080 vec![DirectDep {
3081 name: "lodash".into(),
3082 dep_path: "lodash@4.17.21".into(),
3083 dep_type: DepType::Production,
3084 specifier: Some("4.17.21".into()),
3085 }],
3086 );
3087 let mut graph = LockfileGraph {
3088 importers,
3089 ..Default::default()
3090 };
3091 graph.overrides.insert("lodash".into(), "4.17.21".into());
3092 let mut ws_overrides = BTreeMap::new();
3093 ws_overrides.insert("lodash".into(), "4.17.21".into());
3094 assert_eq!(
3095 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3096 DriftStatus::Fresh,
3097 );
3098 }
3099
3100 #[test]
3101 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
3102 let manifest = make_manifest(&[("plist", "^3.0.4")]);
3109 let mut importers = BTreeMap::new();
3110 importers.insert(
3111 ".".to_string(),
3112 vec![DirectDep {
3113 name: "plist".into(),
3114 dep_path: "plist@3.0.6".into(),
3115 dep_type: DepType::Production,
3116 specifier: Some(">=3.0.5".into()),
3117 }],
3118 );
3119 let mut graph = LockfileGraph {
3120 importers,
3121 ..Default::default()
3122 };
3123 graph
3124 .overrides
3125 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
3126 let mut ws_overrides = BTreeMap::new();
3127 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
3128 assert_eq!(
3129 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3130 DriftStatus::Fresh,
3131 );
3132 }
3133
3134 #[test]
3135 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
3136 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3143 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3144 graph
3145 .ignored_optional_dependencies
3146 .insert("fsevents".to_string());
3147 let ws_ignored = vec!["fsevents".to_string()];
3148 assert_eq!(
3149 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
3150 DriftStatus::Fresh,
3151 );
3152 }
3153
3154 #[test]
3155 fn fresh_when_optional_dep_was_recorded_as_skipped() {
3156 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3161 manifest
3162 .optional_dependencies
3163 .insert("fsevents".into(), "^2.3.0".into());
3164 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3165 let mut inner = BTreeMap::new();
3166 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3167 graph
3168 .skipped_optional_dependencies
3169 .insert(".".to_string(), inner);
3170 assert_eq!(
3171 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3172 DriftStatus::Fresh
3173 );
3174 }
3175
3176 #[test]
3177 fn stale_when_new_optional_dep_was_never_seen() {
3178 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3184 manifest
3185 .optional_dependencies
3186 .insert("fsevents".into(), "^2.3.0".into());
3187 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3188 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3189 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3190 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
3191 }
3192 }
3193
3194 #[test]
3195 fn stale_when_skipped_optional_dep_specifier_changes() {
3196 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3200 manifest
3201 .optional_dependencies
3202 .insert("fsevents".into(), "^2.4.0".into());
3203 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3204 let mut inner = BTreeMap::new();
3205 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3206 graph
3207 .skipped_optional_dependencies
3208 .insert(".".to_string(), inner);
3209 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3210 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3211 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
3212 }
3213 }
3214
3215 #[test]
3216 fn stale_when_skipped_optional_is_promoted_to_required() {
3217 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
3222 manifest.optional_dependencies.clear();
3226 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3227 let mut inner = BTreeMap::new();
3228 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3229 graph
3230 .skipped_optional_dependencies
3231 .insert(".".to_string(), inner);
3232 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3233 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3234 DriftStatus::Fresh => {
3235 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
3236 }
3237 }
3238 }
3239
3240 #[test]
3241 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
3242 let mut manifest = make_manifest(&[]);
3245 manifest
3246 .optional_dependencies
3247 .insert("fsevents".into(), "^2.4.0".into());
3248 let mut graph = make_graph(&[]);
3249 graph.importers.get_mut(".").unwrap().push(DirectDep {
3250 name: "fsevents".into(),
3251 dep_path: "fsevents@2.3.0".into(),
3252 dep_type: DepType::Optional,
3253 specifier: Some("^2.3.0".into()),
3254 });
3255 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3256 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3257 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
3258 }
3259 }
3260
3261 #[test]
3262 fn fresh_for_empty_manifest_and_lockfile() {
3263 let manifest = make_manifest(&[]);
3264 let graph = make_graph(&[]);
3265 assert_eq!(
3266 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3267 DriftStatus::Fresh
3268 );
3269 }
3270
3271 #[test]
3272 fn workspace_drift_detects_change_in_non_root_importer() {
3273 let root_dep = DirectDep {
3275 name: "lodash".into(),
3276 dep_path: "lodash@4.17.21".into(),
3277 dep_type: DepType::Production,
3278 specifier: Some("^4.17.0".into()),
3279 };
3280 let app_dep = DirectDep {
3281 name: "express".into(),
3282 dep_path: "express@4.18.0".into(),
3283 dep_type: DepType::Production,
3284 specifier: Some("^4.18.0".into()),
3285 };
3286 let mut importers = BTreeMap::new();
3287 importers.insert(".".to_string(), vec![root_dep]);
3288 importers.insert("packages/app".to_string(), vec![app_dep]);
3289 let graph = LockfileGraph {
3290 importers,
3291 packages: BTreeMap::new(),
3292 ..Default::default()
3293 };
3294
3295 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
3296 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
3298
3299 let workspace_manifests = vec![
3300 (".".to_string(), root_manifest.clone()),
3301 ("packages/app".to_string(), app_manifest),
3302 ];
3303 match graph.check_drift_workspace(
3304 &workspace_manifests,
3305 &BTreeMap::new(),
3306 &[],
3307 &BTreeMap::new(),
3308 true,
3309 ) {
3310 DriftStatus::Stale { reason } => {
3311 assert!(reason.contains("packages/app"));
3312 assert!(reason.contains("express"));
3313 }
3314 DriftStatus::Fresh => panic!("expected Stale"),
3315 }
3316
3317 assert_eq!(
3319 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3320 DriftStatus::Fresh
3321 );
3322 }
3323
3324 #[test]
3325 fn filter_deps_prunes_dev_only_subtree() {
3326 let mut importers = BTreeMap::new();
3330 importers.insert(
3331 ".".to_string(),
3332 vec![
3333 DirectDep {
3334 name: "foo".into(),
3335 dep_path: "foo@1.0.0".into(),
3336 dep_type: DepType::Production,
3337 specifier: Some("^1.0.0".into()),
3338 },
3339 DirectDep {
3340 name: "jest".into(),
3341 dep_path: "jest@29.0.0".into(),
3342 dep_type: DepType::Dev,
3343 specifier: Some("^29.0.0".into()),
3344 },
3345 ],
3346 );
3347
3348 let mut packages = BTreeMap::new();
3349 let mut foo_deps = BTreeMap::new();
3350 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
3351 packages.insert(
3352 "foo@1.0.0".to_string(),
3353 LockedPackage {
3354 name: "foo".into(),
3355 version: "1.0.0".into(),
3356 integrity: None,
3357 dependencies: foo_deps,
3358 dep_path: "foo@1.0.0".into(),
3359 ..Default::default()
3360 },
3361 );
3362 packages.insert(
3363 "bar@2.0.0".to_string(),
3364 LockedPackage {
3365 name: "bar".into(),
3366 version: "2.0.0".into(),
3367 integrity: None,
3368 dependencies: BTreeMap::new(),
3369 dep_path: "bar@2.0.0".into(),
3370 ..Default::default()
3371 },
3372 );
3373 let mut jest_deps = BTreeMap::new();
3374 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
3375 packages.insert(
3376 "jest@29.0.0".to_string(),
3377 LockedPackage {
3378 name: "jest".into(),
3379 version: "29.0.0".into(),
3380 integrity: None,
3381 dependencies: jest_deps,
3382 dep_path: "jest@29.0.0".into(),
3383 ..Default::default()
3384 },
3385 );
3386 packages.insert(
3387 "jest-core@29.0.0".to_string(),
3388 LockedPackage {
3389 name: "jest-core".into(),
3390 version: "29.0.0".into(),
3391 integrity: None,
3392 dependencies: BTreeMap::new(),
3393 dep_path: "jest-core@29.0.0".into(),
3394 ..Default::default()
3395 },
3396 );
3397
3398 let graph = LockfileGraph {
3399 importers,
3400 packages,
3401 ..Default::default()
3402 };
3403
3404 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3405
3406 let roots = prod.root_deps();
3408 assert_eq!(roots.len(), 1);
3409 assert_eq!(roots[0].name, "foo");
3410
3411 assert!(prod.packages.contains_key("foo@1.0.0"));
3413 assert!(prod.packages.contains_key("bar@2.0.0"));
3414 assert!(!prod.packages.contains_key("jest@29.0.0"));
3415 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
3416 }
3417
3418 #[test]
3425 fn filter_deps_preserves_lockfile_settings() {
3426 let graph = LockfileGraph {
3427 importers: BTreeMap::new(),
3428 packages: BTreeMap::new(),
3429 settings: LockfileSettings {
3430 auto_install_peers: false,
3431 exclude_links_from_lockfile: true,
3432 lockfile_include_tarball_url: false,
3433 },
3434 ..Default::default()
3435 };
3436 let filtered = graph.filter_deps(|_| true);
3437 assert!(!filtered.settings.auto_install_peers);
3438 assert!(filtered.settings.exclude_links_from_lockfile);
3439 }
3440
3441 #[test]
3442 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
3443 let mut importers = BTreeMap::new();
3447 importers.insert(
3448 ".".to_string(),
3449 vec![
3450 DirectDep {
3451 name: "foo".into(),
3452 dep_path: "foo@1.0.0".into(),
3453 dep_type: DepType::Production,
3454 specifier: Some("^1.0.0".into()),
3455 },
3456 DirectDep {
3457 name: "jest".into(),
3458 dep_path: "jest@29.0.0".into(),
3459 dep_type: DepType::Dev,
3460 specifier: Some("^29.0.0".into()),
3461 },
3462 ],
3463 );
3464
3465 let mut packages = BTreeMap::new();
3466 for (name, ver, deps) in [
3467 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
3468 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
3469 ("shared", "1.0.0", vec![]),
3470 ] {
3471 let mut dep_map = BTreeMap::new();
3472 for (n, v) in deps {
3473 dep_map.insert(n.to_string(), v.to_string());
3474 }
3475 packages.insert(
3476 format!("{name}@{ver}"),
3477 LockedPackage {
3478 name: name.into(),
3479 version: ver.into(),
3480 integrity: None,
3481 dependencies: dep_map,
3482 dep_path: format!("{name}@{ver}"),
3483 ..Default::default()
3484 },
3485 );
3486 }
3487
3488 let graph = LockfileGraph {
3489 importers,
3490 packages,
3491 ..Default::default()
3492 };
3493 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3494
3495 assert!(prod.packages.contains_key("foo@1.0.0"));
3496 assert!(prod.packages.contains_key("shared@1.0.0"));
3497 assert!(!prod.packages.contains_key("jest@29.0.0"));
3498 }
3499
3500 #[test]
3501 fn subset_to_importer_returns_none_for_missing_importer() {
3502 let graph = LockfileGraph {
3503 importers: BTreeMap::new(),
3504 packages: BTreeMap::new(),
3505 ..Default::default()
3506 };
3507 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
3508 }
3509
3510 #[test]
3511 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
3512 let mut importers = BTreeMap::new();
3519 importers.insert(".".to_string(), vec![]);
3520 importers.insert(
3521 "packages/lib".to_string(),
3522 vec![DirectDep {
3523 name: "is-odd".into(),
3524 dep_path: "is-odd@3.0.1".into(),
3525 dep_type: DepType::Production,
3526 specifier: Some("^3.0.1".into()),
3527 }],
3528 );
3529 importers.insert(
3530 "packages/app".to_string(),
3531 vec![DirectDep {
3532 name: "express".into(),
3533 dep_path: "express@4.18.0".into(),
3534 dep_type: DepType::Production,
3535 specifier: Some("^4.18.0".into()),
3536 }],
3537 );
3538
3539 let mut packages = BTreeMap::new();
3540 let mut is_odd_deps = BTreeMap::new();
3541 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
3542 packages.insert(
3543 "is-odd@3.0.1".to_string(),
3544 LockedPackage {
3545 name: "is-odd".into(),
3546 version: "3.0.1".into(),
3547 dependencies: is_odd_deps,
3548 dep_path: "is-odd@3.0.1".into(),
3549 ..Default::default()
3550 },
3551 );
3552 packages.insert(
3553 "is-number@6.0.0".to_string(),
3554 LockedPackage {
3555 name: "is-number".into(),
3556 version: "6.0.0".into(),
3557 dep_path: "is-number@6.0.0".into(),
3558 ..Default::default()
3559 },
3560 );
3561 packages.insert(
3562 "express@4.18.0".to_string(),
3563 LockedPackage {
3564 name: "express".into(),
3565 version: "4.18.0".into(),
3566 dep_path: "express@4.18.0".into(),
3567 ..Default::default()
3568 },
3569 );
3570
3571 let graph = LockfileGraph {
3572 importers,
3573 packages,
3574 ..Default::default()
3575 };
3576 let subset = graph
3577 .subset_to_importer("packages/lib", |_| true)
3578 .expect("packages/lib importer present");
3579
3580 assert_eq!(subset.importers.len(), 1);
3581 let roots = subset.root_deps();
3582 assert_eq!(roots.len(), 1);
3583 assert_eq!(roots[0].name, "is-odd");
3584
3585 assert!(subset.packages.contains_key("is-odd@3.0.1"));
3586 assert!(subset.packages.contains_key("is-number@6.0.0"));
3587 assert!(!subset.packages.contains_key("express@4.18.0"));
3588 }
3589
3590 #[test]
3591 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
3592 let mut importers = BTreeMap::new();
3598 importers.insert(
3599 "packages/lib".to_string(),
3600 vec![
3601 DirectDep {
3602 name: "is-odd".into(),
3603 dep_path: "is-odd@3.0.1".into(),
3604 dep_type: DepType::Production,
3605 specifier: Some("^3.0.1".into()),
3606 },
3607 DirectDep {
3608 name: "jest".into(),
3609 dep_path: "jest@29.0.0".into(),
3610 dep_type: DepType::Dev,
3611 specifier: Some("^29.0.0".into()),
3612 },
3613 ],
3614 );
3615 let mut packages = BTreeMap::new();
3616 packages.insert(
3617 "is-odd@3.0.1".to_string(),
3618 LockedPackage {
3619 name: "is-odd".into(),
3620 version: "3.0.1".into(),
3621 dep_path: "is-odd@3.0.1".into(),
3622 ..Default::default()
3623 },
3624 );
3625 packages.insert(
3626 "jest@29.0.0".to_string(),
3627 LockedPackage {
3628 name: "jest".into(),
3629 version: "29.0.0".into(),
3630 dep_path: "jest@29.0.0".into(),
3631 ..Default::default()
3632 },
3633 );
3634 let graph = LockfileGraph {
3635 importers,
3636 packages,
3637 ..Default::default()
3638 };
3639
3640 let prod = graph
3641 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
3642 .expect("importer present");
3643 let roots = prod.root_deps();
3644 assert_eq!(roots.len(), 1);
3645 assert_eq!(roots[0].name, "is-odd");
3646 assert!(prod.packages.contains_key("is-odd@3.0.1"));
3647 assert!(!prod.packages.contains_key("jest@29.0.0"));
3648 }
3649
3650 #[test]
3651 fn subset_to_importer_preserves_graph_settings() {
3652 let mut importers = BTreeMap::new();
3658 importers.insert("packages/lib".to_string(), vec![]);
3659 let graph = LockfileGraph {
3660 importers,
3661 packages: BTreeMap::new(),
3662 settings: LockfileSettings {
3663 auto_install_peers: false,
3664 exclude_links_from_lockfile: true,
3665 lockfile_include_tarball_url: true,
3666 },
3667 ..Default::default()
3668 };
3669 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3670 assert!(!subset.settings.auto_install_peers);
3671 assert!(subset.settings.exclude_links_from_lockfile);
3672 assert!(subset.settings.lockfile_include_tarball_url);
3673 }
3674
3675 #[test]
3676 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
3677 let mut importers = BTreeMap::new();
3682 importers.insert("packages/lib".to_string(), vec![]);
3683 importers.insert("packages/app".to_string(), vec![]);
3684 let mut skipped = BTreeMap::new();
3685 let mut lib_skip = BTreeMap::new();
3686 lib_skip.insert("fsevents".to_string(), "^2".to_string());
3687 skipped.insert("packages/lib".to_string(), lib_skip);
3688 let mut app_skip = BTreeMap::new();
3689 app_skip.insert("ghost".to_string(), "*".to_string());
3690 skipped.insert("packages/app".to_string(), app_skip);
3691 let graph = LockfileGraph {
3692 importers,
3693 packages: BTreeMap::new(),
3694 skipped_optional_dependencies: skipped,
3695 ..Default::default()
3696 };
3697 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3698 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
3699 let root = subset.skipped_optional_dependencies.get(".").unwrap();
3700 assert!(root.contains_key("fsevents"));
3701 assert!(!root.contains_key("ghost"));
3702 }
3703
3704 #[test]
3705 fn workspace_drift_fresh_when_all_importers_match() {
3706 let root_dep = DirectDep {
3707 name: "lodash".into(),
3708 dep_path: "lodash@4.17.21".into(),
3709 dep_type: DepType::Production,
3710 specifier: Some("^4.17.0".into()),
3711 };
3712 let app_dep = DirectDep {
3713 name: "express".into(),
3714 dep_path: "express@4.18.0".into(),
3715 dep_type: DepType::Production,
3716 specifier: Some("^4.18.0".into()),
3717 };
3718 let mut importers = BTreeMap::new();
3719 importers.insert(".".to_string(), vec![root_dep]);
3720 importers.insert("packages/app".to_string(), vec![app_dep]);
3721 let graph = LockfileGraph {
3722 importers,
3723 packages: BTreeMap::new(),
3724 ..Default::default()
3725 };
3726
3727 let workspace_manifests = vec![
3728 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
3729 (
3730 "packages/app".to_string(),
3731 make_manifest(&[("express", "^4.18.0")]),
3732 ),
3733 ];
3734 assert_eq!(
3735 graph.check_drift_workspace(
3736 &workspace_manifests,
3737 &BTreeMap::new(),
3738 &[],
3739 &BTreeMap::new(),
3740 true,
3741 ),
3742 DriftStatus::Fresh
3743 );
3744 }
3745
3746 #[allow(clippy::type_complexity)]
3747 fn mk_catalogs(
3748 entries: &[(&str, &[(&str, &str, &str)])],
3749 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
3750 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
3751 for (cat, pkgs) in entries {
3752 let mut inner = BTreeMap::new();
3753 for (pkg, spec, ver) in *pkgs {
3754 inner.insert(
3755 (*pkg).to_string(),
3756 CatalogEntry {
3757 specifier: (*spec).to_string(),
3758 version: (*ver).to_string(),
3759 },
3760 );
3761 }
3762 out.insert((*cat).to_string(), inner);
3763 }
3764 out
3765 }
3766
3767 fn mk_workspace_catalogs(
3768 entries: &[(&str, &[(&str, &str)])],
3769 ) -> BTreeMap<String, BTreeMap<String, String>> {
3770 entries
3771 .iter()
3772 .map(|(cat, pkgs)| {
3773 (
3774 (*cat).to_string(),
3775 pkgs.iter()
3776 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
3777 .collect(),
3778 )
3779 })
3780 .collect()
3781 }
3782
3783 #[test]
3784 fn catalog_drift_fresh_when_specifiers_match() {
3785 let graph = LockfileGraph {
3786 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3787 ..Default::default()
3788 };
3789 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
3790 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3791 }
3792
3793 #[test]
3794 fn catalog_drift_stale_on_changed_specifier() {
3795 let graph = LockfileGraph {
3796 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3797 ..Default::default()
3798 };
3799 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
3800 match graph.check_catalogs_drift(&ws) {
3801 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
3802 other => panic!("expected stale, got {other:?}"),
3803 }
3804 }
3805
3806 #[test]
3807 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
3808 let graph = LockfileGraph::default();
3812 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
3813 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3814 }
3815
3816 #[test]
3817 fn catalog_drift_stale_on_removed_workspace_entry() {
3818 let graph = LockfileGraph {
3819 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
3820 ..Default::default()
3821 };
3822 let ws = mk_workspace_catalogs(&[]);
3823 assert!(matches!(
3824 graph.check_catalogs_drift(&ws),
3825 DriftStatus::Stale { .. }
3826 ));
3827 }
3828}