1use crate::{
2 DepType, DirectDep, LocalSource, LockfileGraph, LockfileKind, dep_type_label, override_match,
3};
4use std::collections::{BTreeMap, BTreeSet};
5
6impl LockfileGraph {
7 pub fn check_drift(
45 &self,
46 manifest: &aube_manifest::PackageJson,
47 workspace_overrides: &BTreeMap<String, String>,
48 workspace_ignored_optional: &[String],
49 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
50 ) -> DriftStatus {
51 self.check_drift_with_options(
52 manifest,
53 workspace_overrides,
54 workspace_ignored_optional,
55 workspace_catalogs,
56 true,
57 )
58 }
59
60 pub fn check_drift_for_kind(
61 &self,
62 manifest: &aube_manifest::PackageJson,
63 workspace_overrides: &BTreeMap<String, String>,
64 workspace_ignored_optional: &[String],
65 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
66 kind: LockfileKind,
67 ) -> DriftStatus {
68 self.check_drift_with_options(
69 manifest,
70 workspace_overrides,
71 workspace_ignored_optional,
72 workspace_catalogs,
73 kind_records_resolution_metadata(kind),
74 )
75 }
76
77 pub fn check_drift_workspace(
88 &self,
89 manifests: &[(String, aube_manifest::PackageJson)],
90 workspace_overrides: &BTreeMap<String, String>,
91 workspace_ignored_optional: &[String],
92 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
93 is_workspace_install: bool,
94 ) -> DriftStatus {
95 self.check_drift_workspace_with_options(
96 manifests,
97 workspace_overrides,
98 workspace_ignored_optional,
99 workspace_catalogs,
100 is_workspace_install,
101 true,
102 )
103 }
104
105 pub fn check_drift_workspace_for_kind(
106 &self,
107 manifests: &[(String, aube_manifest::PackageJson)],
108 workspace_overrides: &BTreeMap<String, String>,
109 workspace_ignored_optional: &[String],
110 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
111 is_workspace_install: bool,
112 kind: LockfileKind,
113 ) -> DriftStatus {
114 self.check_drift_workspace_with_options(
115 manifests,
116 workspace_overrides,
117 workspace_ignored_optional,
118 workspace_catalogs,
119 is_workspace_install,
120 kind_records_resolution_metadata(kind),
121 )
122 }
123
124 fn check_drift_with_options(
125 &self,
126 manifest: &aube_manifest::PackageJson,
127 workspace_overrides: &BTreeMap<String, String>,
128 workspace_ignored_optional: &[String],
129 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
130 check_resolution_metadata: bool,
131 ) -> DriftStatus {
132 let effective = resolve_catalog_refs_in_overrides(
133 &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
134 workspace_catalogs,
135 );
136 if check_resolution_metadata
137 && let Some(reason) = self.resolution_metadata_drift_reason(
138 manifest,
139 workspace_overrides,
140 workspace_ignored_optional,
141 workspace_catalogs,
142 )
143 {
144 return DriftStatus::Stale { reason };
145 }
146 self.check_drift_for_importer(".", manifest, &effective)
147 }
148
149 fn check_drift_workspace_with_options(
150 &self,
151 manifests: &[(String, aube_manifest::PackageJson)],
152 workspace_overrides: &BTreeMap<String, String>,
153 workspace_ignored_optional: &[String],
154 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
155 is_workspace_install: bool,
156 check_resolution_metadata: bool,
157 ) -> DriftStatus {
158 let effective_overrides = match manifests.iter().find(|(p, _)| p == ".") {
163 Some((_, root_manifest)) => {
164 let effective = resolve_catalog_refs_in_overrides(
165 &merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides),
166 workspace_catalogs,
167 );
168 if check_resolution_metadata
169 && let Some(reason) = self.resolution_metadata_drift_reason(
170 root_manifest,
171 workspace_overrides,
172 workspace_ignored_optional,
173 workspace_catalogs,
174 )
175 {
176 return DriftStatus::Stale { reason };
177 }
178 effective
179 }
180 None => BTreeMap::new(),
181 };
182 let workspace_link_names: std::collections::HashSet<&str> = manifests
183 .iter()
184 .filter(|(path, _)| path != ".")
185 .filter_map(|(_, manifest)| manifest.name.as_deref())
186 .collect();
187 for (importer_path, manifest) in manifests {
188 match self.check_drift_for_importer_with_workspace_links(
189 importer_path,
190 manifest,
191 &effective_overrides,
192 &workspace_link_names,
193 ) {
194 DriftStatus::Fresh => continue,
195 stale => return stale,
196 }
197 }
198 if is_workspace_install {
217 let current_importers: std::collections::HashSet<&str> =
218 manifests.iter().map(|(p, _)| p.as_str()).collect();
219 for importer_path in self.importers.keys() {
220 if !current_importers.contains(importer_path.as_str()) {
221 return DriftStatus::Stale {
222 reason: format!(
223 "workspace importer {importer_path} is in the lockfile but not in the workspace"
224 ),
225 };
226 }
227 }
228 }
229 DriftStatus::Fresh
230 }
231
232 fn resolution_metadata_drift_reason(
233 &self,
234 manifest: &aube_manifest::PackageJson,
235 workspace_overrides: &BTreeMap<String, String>,
236 workspace_ignored_optional: &[String],
237 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
238 ) -> Option<String> {
239 let effective = resolve_catalog_refs_in_overrides(
240 &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
241 workspace_catalogs,
242 );
243 let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
244 overrides_drift_reason(&locked, &effective).or_else(|| {
245 let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
246 effective_ignored.extend(workspace_ignored_optional.iter().cloned());
247 ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored)
248 })
249 }
250
251 pub fn check_catalogs_drift(
273 &self,
274 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
275 ) -> DriftStatus {
276 for (cat_name, cat) in workspace_catalogs {
277 let Some(locked) = self.catalogs.get(cat_name) else {
278 continue;
279 };
280 for (pkg, spec) in cat {
281 if let Some(entry) = locked.get(pkg)
282 && entry.specifier != *spec
283 {
284 return DriftStatus::Stale {
285 reason: format!(
286 "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
287 entry.specifier
288 ),
289 };
290 }
291 }
292 }
293 for (cat_name, cat) in &self.catalogs {
294 let workspace_cat = workspace_catalogs.get(cat_name);
295 for pkg in cat.keys() {
296 if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
297 return DriftStatus::Stale {
298 reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
299 };
300 }
301 }
302 }
303 DriftStatus::Fresh
304 }
305
306 fn check_drift_for_importer(
312 &self,
313 importer_path: &str,
314 manifest: &aube_manifest::PackageJson,
315 effective_overrides: &BTreeMap<String, String>,
316 ) -> DriftStatus {
317 self.check_drift_for_importer_with_workspace_links(
318 importer_path,
319 manifest,
320 effective_overrides,
321 &std::collections::HashSet::new(),
322 )
323 }
324
325 fn check_drift_for_importer_with_workspace_links(
326 &self,
327 importer_path: &str,
328 manifest: &aube_manifest::PackageJson,
329 effective_overrides: &BTreeMap<String, String>,
330 workspace_link_names: &std::collections::HashSet<&str>,
331 ) -> DriftStatus {
332 let label = if importer_path == "." {
333 String::new()
334 } else {
335 format!("{importer_path}: ")
336 };
337
338 let importer_deps: &[DirectDep] = self
339 .importers
340 .get(importer_path)
341 .map(|v| v.as_slice())
342 .unwrap_or(&[]);
343
344 if importer_deps.iter().all(|d| d.specifier.is_none()) {
346 return DriftStatus::Fresh;
347 }
348 let lockfile_specs: BTreeMap<&str, &str> = importer_deps
349 .iter()
350 .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
351 .collect();
352
353 let override_rules = override_match::compile(effective_overrides);
354
355 let skipped_optionals: BTreeMap<&str, &str> = self
361 .skipped_optional_dependencies
362 .get(importer_path)
363 .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
364 .unwrap_or_default();
365
366 let ignored = &self.ignored_optional_dependencies;
380 let manifest_deps = manifest
381 .dependencies
382 .iter()
383 .map(|(k, v)| (k, v, false))
384 .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
385 .chain(
386 manifest
387 .optional_dependencies
388 .iter()
389 .filter(|(name, _)| !ignored.contains(name.as_str()))
390 .map(|(k, v)| (k, v, true)),
391 );
392
393 for (name, spec, is_optional) in manifest_deps {
394 match lockfile_specs.get(name.as_str()) {
395 None => {
396 if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
406 if *locked_spec == spec {
407 continue;
408 }
409 return DriftStatus::Stale {
410 reason: format!(
411 "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
412 ),
413 };
414 }
415 return DriftStatus::Stale {
416 reason: format!("{label}manifest adds {name}@{spec}"),
417 };
418 }
419 Some(locked_spec) if *locked_spec != spec => {
420 if let Some(override_spec) =
430 override_match::apply(&override_rules, name.as_str(), spec)
431 && override_spec == *locked_spec
432 {
433 continue;
434 }
435 return DriftStatus::Stale {
436 reason: format!(
437 "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
438 ),
439 };
440 }
441 Some(_) => {}
442 }
443 }
444
445 let mut manifest_dep_types: BTreeMap<&str, DepType> = BTreeMap::new();
453 for name in manifest.dependencies.keys() {
454 manifest_dep_types.insert(name.as_str(), DepType::Production);
455 }
456 for name in manifest.dev_dependencies.keys() {
457 manifest_dep_types
458 .entry(name.as_str())
459 .or_insert(DepType::Dev);
460 }
461 for name in manifest.optional_dependencies.keys() {
462 if ignored.contains(name.as_str()) {
463 continue;
464 }
465 manifest_dep_types
466 .entry(name.as_str())
467 .or_insert(DepType::Optional);
468 }
469 for dep in importer_deps {
470 let Some(expected) = manifest_dep_types.get(dep.name.as_str()) else {
471 continue;
472 };
473 if *expected != dep.dep_type {
474 return DriftStatus::Stale {
475 reason: format!(
476 "{label}{}: manifest section is {}, lockfile section is {}",
477 dep.name,
478 dep_type_label(*expected),
479 dep_type_label(dep.dep_type),
480 ),
481 };
482 }
483 }
484
485 let manifest_names: std::collections::HashSet<&str> = manifest
506 .dependencies
507 .keys()
508 .chain(manifest.dev_dependencies.keys())
509 .chain(
510 manifest
511 .optional_dependencies
512 .keys()
513 .filter(|name| !ignored.contains(name.as_str())),
514 )
515 .map(|s| s.as_str())
516 .collect();
517 let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
518 .packages
519 .values()
520 .flat_map(|p| {
521 p.peer_dependencies
522 .iter()
523 .map(|(name, range)| (name.as_str(), range.as_str()))
524 })
525 .collect();
526 for (locked_name, locked_spec) in &lockfile_specs {
527 if manifest_names.contains(locked_name) {
528 continue;
529 }
530 if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
531 continue;
532 }
533 let workspace_link = importer_path == "."
534 && workspace_link_names.contains(locked_name)
535 && importer_deps
536 .iter()
537 .find(|dep| dep.name == *locked_name)
538 .and_then(|dep| self.packages.get(&dep.dep_path))
539 .is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))));
540 if workspace_link {
541 continue;
542 }
543 return DriftStatus::Stale {
544 reason: format!("{label}manifest removed {locked_name}"),
545 };
546 }
547
548 DriftStatus::Fresh
549 }
550}
551
552fn merge_manifest_and_workspace_overrides(
558 manifest: &aube_manifest::PackageJson,
559 workspace_overrides: &BTreeMap<String, String>,
560) -> BTreeMap<String, String> {
561 let mut out = manifest.overrides_map();
562 for (k, v) in workspace_overrides {
563 out.insert(k.clone(), v.clone());
564 }
565 out
566}
567
568fn resolve_catalog_refs_in_overrides(
578 overrides: &BTreeMap<String, String>,
579 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
580) -> BTreeMap<String, String> {
581 overrides
582 .iter()
583 .map(|(k, v)| {
584 let resolved = v
585 .strip_prefix("catalog:")
586 .map(|tail| if tail.is_empty() { "default" } else { tail })
587 .and_then(|cat_name| workspace_catalogs.get(cat_name))
588 .and_then(|cat| cat.get(override_key_package_name(k)))
589 .cloned()
590 .unwrap_or_else(|| v.clone());
591 (k.clone(), resolved)
592 })
593 .collect()
594}
595
596fn override_key_package_name(key: &str) -> &str {
603 let last = key.rsplit('>').next().unwrap_or(key);
604 if let Some(after_scope) = last.strip_prefix('@') {
605 match after_scope.find('@') {
606 Some(idx) => &last[..idx + 1],
607 None => last,
608 }
609 } else {
610 match last.find('@') {
611 Some(idx) => &last[..idx],
612 None => last,
613 }
614 }
615}
616
617fn overrides_drift_reason(
623 lockfile: &BTreeMap<String, String>,
624 manifest: &BTreeMap<String, String>,
625) -> Option<String> {
626 for (k, v) in manifest {
627 match lockfile.get(k) {
628 None => return Some(format!("overrides: manifest adds {k}@{v}")),
629 Some(locked) if locked != v => {
630 return Some(format!("overrides: {k} changed ({locked} → {v})"));
631 }
632 Some(_) => {}
633 }
634 }
635 for k in lockfile.keys() {
636 if !manifest.contains_key(k) {
637 return Some(format!("overrides: manifest removes {k}"));
638 }
639 }
640 None
641}
642
643fn ignored_optional_drift_reason(
646 lockfile: &BTreeSet<String>,
647 manifest: &BTreeSet<String>,
648) -> Option<String> {
649 for name in manifest {
650 if !lockfile.contains(name) {
651 return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
652 }
653 }
654 for name in lockfile {
655 if !manifest.contains(name) {
656 return Some(format!(
657 "ignoredOptionalDependencies: manifest removes {name}"
658 ));
659 }
660 }
661 None
662}
663
664#[derive(Debug, Clone, PartialEq, Eq)]
666pub enum DriftStatus {
667 Fresh,
669 Stale { reason: String },
671}
672
673fn kind_records_resolution_metadata(kind: LockfileKind) -> bool {
674 matches!(
675 kind,
676 LockfileKind::Aube | LockfileKind::Pnpm | LockfileKind::Bun
677 )
678}
679
680#[cfg(test)]
681mod drift_tests {
682 use super::*;
683 use crate::{CatalogEntry, LockedPackage, LockfileSettings};
684 use aube_manifest::PackageJson;
685 use std::collections::BTreeMap;
686 use std::path::PathBuf;
687
688 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
689 let mut m = PackageJson {
690 name: Some("test".into()),
691 version: Some("1.0.0".into()),
692 dependencies: BTreeMap::new(),
693 dev_dependencies: BTreeMap::new(),
694 peer_dependencies: BTreeMap::new(),
695 optional_dependencies: BTreeMap::new(),
696 update_config: None,
697 scripts: BTreeMap::new(),
698 engines: BTreeMap::new(),
699 workspaces: None,
700 bundled_dependencies: None,
701 extra: BTreeMap::new(),
702 };
703 for (name, spec) in deps {
704 m.dependencies.insert((*name).into(), (*spec).into());
705 }
706 m
707 }
708
709 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
710 let direct: Vec<DirectDep> = deps
712 .iter()
713 .map(|(name, spec, dep_path)| DirectDep {
714 name: (*name).into(),
715 dep_path: (*dep_path).into(),
716 dep_type: DepType::Production,
717 specifier: Some((*spec).into()),
718 })
719 .collect();
720 let mut importers = BTreeMap::new();
721 importers.insert(".".to_string(), direct);
722 LockfileGraph {
723 importers,
724 packages: BTreeMap::new(),
725 ..Default::default()
726 }
727 }
728
729 #[test]
730 fn stale_when_dep_moves_between_sections() {
731 let mut manifest = make_manifest(&[]);
736 manifest
737 .dev_dependencies
738 .insert("msw".into(), "catalog:".into());
739 let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
740 graph
741 .importers
742 .get_mut(".")
743 .unwrap()
744 .iter_mut()
745 .for_each(|d| d.dep_type = DepType::Production);
746 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
747 DriftStatus::Stale { reason } => {
748 assert!(reason.contains("msw"), "reason: {reason}");
749 assert!(reason.contains("devDependencies"), "reason: {reason}");
750 }
751 DriftStatus::Fresh => panic!("expected Stale"),
752 }
753 }
754
755 #[test]
756 fn fresh_when_specifiers_match() {
757 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
758 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
759 assert_eq!(
760 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
761 DriftStatus::Fresh
762 );
763 }
764
765 #[test]
766 fn stale_when_specifier_changes() {
767 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
768 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
769 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
770 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
771 DriftStatus::Fresh => panic!("expected Stale"),
772 }
773 }
774
775 #[test]
776 fn stale_when_manifest_adds_dep() {
777 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
778 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
779 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
780 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
781 DriftStatus::Fresh => panic!("expected Stale"),
782 }
783 }
784
785 #[test]
786 fn stale_when_manifest_removes_dep() {
787 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
788 let graph = make_graph(&[
789 ("lodash", "^4.17.0", "lodash@4.17.21"),
790 ("express", "^4.18.0", "express@4.18.0"),
791 ]);
792 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
793 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
794 DriftStatus::Fresh => panic!("expected Stale"),
795 }
796 }
797
798 #[test]
803 fn fresh_when_lockfile_has_auto_hoisted_peer() {
804 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
805 let mut graph = make_graph(&[
806 (
807 "use-sync-external-store",
808 "1.2.0",
809 "use-sync-external-store@1.2.0",
810 ),
811 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
814 ]);
815 let mut declaring_pkg = LockedPackage {
818 name: "use-sync-external-store".into(),
819 version: "1.2.0".into(),
820 dep_path: "use-sync-external-store@1.2.0".into(),
821 ..Default::default()
822 };
823 declaring_pkg
824 .peer_dependencies
825 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
826 graph
827 .packages
828 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
829
830 assert_eq!(
831 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
832 DriftStatus::Fresh
833 );
834 }
835
836 #[test]
842 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
843 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
846
847 let mut graph = make_graph(&[
850 (
851 "use-sync-external-store",
852 "1.2.0",
853 "use-sync-external-store@1.2.0",
854 ),
855 ("react", "17.0.2", "react@17.0.2"),
856 ]);
857 let mut consumer = LockedPackage {
862 name: "use-sync-external-store".into(),
863 version: "1.2.0".into(),
864 dep_path: "use-sync-external-store@1.2.0".into(),
865 ..Default::default()
866 };
867 consumer
868 .peer_dependencies
869 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
870 graph
871 .packages
872 .insert("use-sync-external-store@1.2.0".into(), consumer);
873
874 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
875 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
876 DriftStatus::Fresh => panic!(
877 "drift check should flag a removed user-pinned dep as stale, \
878 even when its name matches a peer declaration"
879 ),
880 }
881 }
882
883 #[test]
886 fn stale_when_lockfile_has_removed_non_peer_dep() {
887 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
888 let graph = make_graph(&[
889 ("lodash", "^4.17.0", "lodash@4.17.21"),
890 ("chalk", "^5.0.0", "chalk@5.0.0"),
891 ]);
892 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
893 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
894 DriftStatus::Fresh => panic!("expected Stale"),
895 }
896 }
897
898 #[test]
899 fn workspace_drift_allows_root_links_for_workspace_packages() {
900 let root_manifest = make_manifest(&[]);
901 let mut app_manifest = make_manifest(&[]);
902 app_manifest.name = Some("@scope/app".to_string());
903
904 let link = LocalSource::Link(PathBuf::from("packages/app"));
905 let dep_path = link.dep_path("@scope/app");
906 let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
907 graph.packages.insert(
908 dep_path.clone(),
909 LockedPackage {
910 name: "@scope/app".to_string(),
911 version: "1.0.0".to_string(),
912 dep_path,
913 local_source: Some(link),
914 ..Default::default()
915 },
916 );
917
918 assert_eq!(
919 graph.check_drift_workspace(
920 &[
921 (".".to_string(), root_manifest),
922 ("packages/app".to_string(), app_manifest),
923 ],
924 &BTreeMap::new(),
925 &[],
926 &BTreeMap::new(),
927 true,
928 ),
929 DriftStatus::Fresh
930 );
931 }
932
933 #[test]
934 fn fresh_when_no_specifiers_recorded() {
935 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
938 let graph = LockfileGraph {
939 importers: {
940 let mut m = BTreeMap::new();
941 m.insert(
942 ".".to_string(),
943 vec![DirectDep {
944 name: "lodash".into(),
945 dep_path: "lodash@4.17.21".into(),
946 dep_type: DepType::Production,
947 specifier: None,
948 }],
949 );
950 m
951 },
952 packages: BTreeMap::new(),
953 ..Default::default()
954 };
955 assert_eq!(
956 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
957 DriftStatus::Fresh
958 );
959 }
960
961 #[test]
962 fn stale_when_manifest_adds_override() {
963 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
967 manifest
968 .extra
969 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
970 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
971 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
972 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
973 DriftStatus::Fresh => panic!("expected Stale"),
974 }
975 }
976
977 #[test]
978 fn fresh_when_npm_lockfile_cannot_record_overrides() {
979 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
984 manifest
985 .extra
986 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
987 let graph = LockfileGraph {
988 importers: {
989 let mut m = BTreeMap::new();
990 m.insert(
991 ".".to_string(),
992 vec![DirectDep {
993 name: "lodash".into(),
994 dep_path: "lodash@4.17.21".into(),
995 dep_type: DepType::Production,
996 specifier: None,
997 }],
998 );
999 m
1000 },
1001 packages: BTreeMap::new(),
1002 ..Default::default()
1003 };
1004 assert_eq!(
1005 graph.check_drift_for_kind(
1006 &manifest,
1007 &BTreeMap::new(),
1008 &[],
1009 &BTreeMap::new(),
1010 LockfileKind::Npm,
1011 ),
1012 DriftStatus::Fresh
1013 );
1014 }
1015
1016 #[test]
1017 fn stale_when_bun_lockfile_can_record_overrides() {
1018 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1019 manifest
1020 .extra
1021 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1022 let graph = LockfileGraph {
1023 importers: {
1024 let mut m = BTreeMap::new();
1025 m.insert(
1026 ".".to_string(),
1027 vec![DirectDep {
1028 name: "lodash".into(),
1029 dep_path: "lodash@4.17.21".into(),
1030 dep_type: DepType::Production,
1031 specifier: None,
1032 }],
1033 );
1034 m
1035 },
1036 packages: BTreeMap::new(),
1037 ..Default::default()
1038 };
1039 match graph.check_drift_for_kind(
1040 &manifest,
1041 &BTreeMap::new(),
1042 &[],
1043 &BTreeMap::new(),
1044 LockfileKind::Bun,
1045 ) {
1046 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1047 DriftStatus::Fresh => panic!("expected Stale"),
1048 }
1049 }
1050
1051 #[test]
1052 fn stale_drift_message_names_changed_override_key() {
1053 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1057 manifest
1058 .extra
1059 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
1060 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1061 graph.overrides.insert("lodash".into(), "4.17.21".into());
1062 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1063 DriftStatus::Stale { reason } => {
1064 assert!(reason.contains("lodash"), "expected key in: {reason}");
1065 assert!(
1066 reason.contains("4.17.21"),
1067 "expected old value in: {reason}"
1068 );
1069 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
1070 }
1071 DriftStatus::Fresh => panic!("expected Stale"),
1072 }
1073 }
1074
1075 #[test]
1076 fn stale_when_manifest_removes_override() {
1077 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1078 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1079 graph.overrides.insert("lodash".into(), "4.17.21".into());
1080 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1081 DriftStatus::Stale { reason } => {
1082 assert!(reason.contains("removes"));
1083 assert!(reason.contains("lodash"));
1084 }
1085 DriftStatus::Fresh => panic!("expected Stale"),
1086 }
1087 }
1088
1089 #[test]
1090 fn fresh_when_overrides_match() {
1091 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1092 manifest
1093 .extra
1094 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1095 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1096 graph.overrides.insert("lodash".into(), "4.17.21".into());
1097 assert_eq!(
1098 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1099 DriftStatus::Fresh
1100 );
1101 }
1102
1103 #[test]
1104 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
1105 let manifest = make_manifest(&[("semver", "^7.5.0")]);
1111 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1112 graph.overrides.insert("semver".into(), "7.7.1".into());
1113 let mut ws_overrides = BTreeMap::new();
1114 ws_overrides.insert("semver".into(), "7.7.1".into());
1115 assert_eq!(
1116 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1117 DriftStatus::Fresh,
1118 );
1119 }
1120
1121 #[test]
1122 fn workspace_yaml_overrides_win_over_package_json() {
1123 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
1128 manifest
1129 .extra
1130 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
1131 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1132 graph.overrides.insert("semver".into(), "7.7.1".into());
1133 let mut ws_overrides = BTreeMap::new();
1134 ws_overrides.insert("semver".into(), "7.7.1".into());
1135 assert_eq!(
1136 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1137 DriftStatus::Fresh,
1138 );
1139 }
1140
1141 #[test]
1142 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
1143 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1149 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1150 graph.overrides.insert("lodash".into(), "4.17.21".into());
1151 let mut ws_overrides = BTreeMap::new();
1152 ws_overrides.insert("lodash".into(), "catalog:".into());
1153 let mut catalogs = BTreeMap::new();
1154 let mut default_cat = BTreeMap::new();
1155 default_cat.insert("lodash".into(), "4.17.21".into());
1156 catalogs.insert("default".into(), default_cat);
1157 assert_eq!(
1158 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1159 DriftStatus::Fresh,
1160 );
1161 }
1162
1163 #[test]
1164 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
1165 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1168 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1169 graph.overrides.insert("lodash".into(), "4.17.21".into());
1170 let mut ws_overrides = BTreeMap::new();
1171 ws_overrides.insert("lodash".into(), "catalog:evens".into());
1172 let mut catalogs = BTreeMap::new();
1173 let mut evens = BTreeMap::new();
1174 evens.insert("lodash".into(), "4.17.21".into());
1175 catalogs.insert("evens".into(), evens);
1176 assert_eq!(
1177 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1178 DriftStatus::Fresh,
1179 );
1180 }
1181
1182 #[test]
1183 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
1184 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1188 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1189 graph.overrides.insert("lodash".into(), "4.17.21".into());
1190 let mut ws_overrides = BTreeMap::new();
1191 ws_overrides.insert("lodash".into(), "catalog:".into());
1192 let mut catalogs = BTreeMap::new();
1193 let mut default_cat = BTreeMap::new();
1194 default_cat.insert("lodash".into(), "4.17.22".into());
1195 catalogs.insert("default".into(), default_cat);
1196 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
1197 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
1198 other => panic!("expected stale, got {other:?}"),
1199 }
1200 }
1201
1202 #[test]
1203 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
1204 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1211 let mut importers = BTreeMap::new();
1212 importers.insert(
1213 ".".to_string(),
1214 vec![DirectDep {
1215 name: "lodash".into(),
1216 dep_path: "lodash@4.17.21".into(),
1217 dep_type: DepType::Production,
1218 specifier: Some("4.17.21".into()),
1219 }],
1220 );
1221 let mut graph = LockfileGraph {
1222 importers,
1223 ..Default::default()
1224 };
1225 graph.overrides.insert("lodash".into(), "4.17.21".into());
1226 let mut ws_overrides = BTreeMap::new();
1227 ws_overrides.insert("lodash".into(), "4.17.21".into());
1228 assert_eq!(
1229 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1230 DriftStatus::Fresh,
1231 );
1232 }
1233
1234 #[test]
1235 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
1236 let manifest = make_manifest(&[("plist", "^3.0.4")]);
1243 let mut importers = BTreeMap::new();
1244 importers.insert(
1245 ".".to_string(),
1246 vec![DirectDep {
1247 name: "plist".into(),
1248 dep_path: "plist@3.0.6".into(),
1249 dep_type: DepType::Production,
1250 specifier: Some(">=3.0.5".into()),
1251 }],
1252 );
1253 let mut graph = LockfileGraph {
1254 importers,
1255 ..Default::default()
1256 };
1257 graph
1258 .overrides
1259 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
1260 let mut ws_overrides = BTreeMap::new();
1261 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
1262 assert_eq!(
1263 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1264 DriftStatus::Fresh,
1265 );
1266 }
1267
1268 #[test]
1269 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
1270 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1277 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1278 graph
1279 .ignored_optional_dependencies
1280 .insert("fsevents".to_string());
1281 let ws_ignored = vec!["fsevents".to_string()];
1282 assert_eq!(
1283 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
1284 DriftStatus::Fresh,
1285 );
1286 }
1287
1288 #[test]
1289 fn fresh_when_optional_dep_was_recorded_as_skipped() {
1290 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1295 manifest
1296 .optional_dependencies
1297 .insert("fsevents".into(), "^2.3.0".into());
1298 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1299 let mut inner = BTreeMap::new();
1300 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1301 graph
1302 .skipped_optional_dependencies
1303 .insert(".".to_string(), inner);
1304 assert_eq!(
1305 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1306 DriftStatus::Fresh
1307 );
1308 }
1309
1310 #[test]
1311 fn stale_when_new_optional_dep_was_never_seen() {
1312 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1318 manifest
1319 .optional_dependencies
1320 .insert("fsevents".into(), "^2.3.0".into());
1321 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1322 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1323 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1324 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
1325 }
1326 }
1327
1328 #[test]
1329 fn stale_when_skipped_optional_dep_specifier_changes() {
1330 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1334 manifest
1335 .optional_dependencies
1336 .insert("fsevents".into(), "^2.4.0".into());
1337 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1338 let mut inner = BTreeMap::new();
1339 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1340 graph
1341 .skipped_optional_dependencies
1342 .insert(".".to_string(), inner);
1343 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1344 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1345 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
1346 }
1347 }
1348
1349 #[test]
1350 fn stale_when_skipped_optional_is_promoted_to_required() {
1351 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1356 manifest.optional_dependencies.clear();
1360 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1361 let mut inner = BTreeMap::new();
1362 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1363 graph
1364 .skipped_optional_dependencies
1365 .insert(".".to_string(), inner);
1366 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1367 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1368 DriftStatus::Fresh => {
1369 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
1370 }
1371 }
1372 }
1373
1374 #[test]
1375 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
1376 let mut manifest = make_manifest(&[]);
1379 manifest
1380 .optional_dependencies
1381 .insert("fsevents".into(), "^2.4.0".into());
1382 let mut graph = make_graph(&[]);
1383 graph.importers.get_mut(".").unwrap().push(DirectDep {
1384 name: "fsevents".into(),
1385 dep_path: "fsevents@2.3.0".into(),
1386 dep_type: DepType::Optional,
1387 specifier: Some("^2.3.0".into()),
1388 });
1389 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1390 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1391 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
1392 }
1393 }
1394
1395 #[test]
1396 fn fresh_for_empty_manifest_and_lockfile() {
1397 let manifest = make_manifest(&[]);
1398 let graph = make_graph(&[]);
1399 assert_eq!(
1400 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1401 DriftStatus::Fresh
1402 );
1403 }
1404
1405 #[test]
1406 fn workspace_drift_detects_change_in_non_root_importer() {
1407 let root_dep = DirectDep {
1409 name: "lodash".into(),
1410 dep_path: "lodash@4.17.21".into(),
1411 dep_type: DepType::Production,
1412 specifier: Some("^4.17.0".into()),
1413 };
1414 let app_dep = DirectDep {
1415 name: "express".into(),
1416 dep_path: "express@4.18.0".into(),
1417 dep_type: DepType::Production,
1418 specifier: Some("^4.18.0".into()),
1419 };
1420 let mut importers = BTreeMap::new();
1421 importers.insert(".".to_string(), vec![root_dep]);
1422 importers.insert("packages/app".to_string(), vec![app_dep]);
1423 let graph = LockfileGraph {
1424 importers,
1425 packages: BTreeMap::new(),
1426 ..Default::default()
1427 };
1428
1429 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
1430 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
1432
1433 let workspace_manifests = vec![
1434 (".".to_string(), root_manifest.clone()),
1435 ("packages/app".to_string(), app_manifest),
1436 ];
1437 match graph.check_drift_workspace(
1438 &workspace_manifests,
1439 &BTreeMap::new(),
1440 &[],
1441 &BTreeMap::new(),
1442 true,
1443 ) {
1444 DriftStatus::Stale { reason } => {
1445 assert!(reason.contains("packages/app"));
1446 assert!(reason.contains("express"));
1447 }
1448 DriftStatus::Fresh => panic!("expected Stale"),
1449 }
1450
1451 assert_eq!(
1453 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1454 DriftStatus::Fresh
1455 );
1456 }
1457
1458 #[test]
1459 fn filter_deps_prunes_dev_only_subtree() {
1460 let mut importers = BTreeMap::new();
1464 importers.insert(
1465 ".".to_string(),
1466 vec![
1467 DirectDep {
1468 name: "foo".into(),
1469 dep_path: "foo@1.0.0".into(),
1470 dep_type: DepType::Production,
1471 specifier: Some("^1.0.0".into()),
1472 },
1473 DirectDep {
1474 name: "jest".into(),
1475 dep_path: "jest@29.0.0".into(),
1476 dep_type: DepType::Dev,
1477 specifier: Some("^29.0.0".into()),
1478 },
1479 ],
1480 );
1481
1482 let mut packages = BTreeMap::new();
1483 let mut foo_deps = BTreeMap::new();
1484 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
1485 packages.insert(
1486 "foo@1.0.0".to_string(),
1487 LockedPackage {
1488 name: "foo".into(),
1489 version: "1.0.0".into(),
1490 integrity: None,
1491 dependencies: foo_deps,
1492 dep_path: "foo@1.0.0".into(),
1493 ..Default::default()
1494 },
1495 );
1496 packages.insert(
1497 "bar@2.0.0".to_string(),
1498 LockedPackage {
1499 name: "bar".into(),
1500 version: "2.0.0".into(),
1501 integrity: None,
1502 dependencies: BTreeMap::new(),
1503 dep_path: "bar@2.0.0".into(),
1504 ..Default::default()
1505 },
1506 );
1507 let mut jest_deps = BTreeMap::new();
1508 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
1509 packages.insert(
1510 "jest@29.0.0".to_string(),
1511 LockedPackage {
1512 name: "jest".into(),
1513 version: "29.0.0".into(),
1514 integrity: None,
1515 dependencies: jest_deps,
1516 dep_path: "jest@29.0.0".into(),
1517 ..Default::default()
1518 },
1519 );
1520 packages.insert(
1521 "jest-core@29.0.0".to_string(),
1522 LockedPackage {
1523 name: "jest-core".into(),
1524 version: "29.0.0".into(),
1525 integrity: None,
1526 dependencies: BTreeMap::new(),
1527 dep_path: "jest-core@29.0.0".into(),
1528 ..Default::default()
1529 },
1530 );
1531
1532 let graph = LockfileGraph {
1533 importers,
1534 packages,
1535 ..Default::default()
1536 };
1537
1538 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1539
1540 let roots = prod.root_deps();
1542 assert_eq!(roots.len(), 1);
1543 assert_eq!(roots[0].name, "foo");
1544
1545 assert!(prod.packages.contains_key("foo@1.0.0"));
1547 assert!(prod.packages.contains_key("bar@2.0.0"));
1548 assert!(!prod.packages.contains_key("jest@29.0.0"));
1549 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
1550 }
1551
1552 #[test]
1559 fn filter_deps_preserves_lockfile_settings() {
1560 let graph = LockfileGraph {
1561 importers: BTreeMap::new(),
1562 packages: BTreeMap::new(),
1563 settings: LockfileSettings {
1564 auto_install_peers: false,
1565 exclude_links_from_lockfile: true,
1566 lockfile_include_tarball_url: false,
1567 },
1568 ..Default::default()
1569 };
1570 let filtered = graph.filter_deps(|_| true);
1571 assert!(!filtered.settings.auto_install_peers);
1572 assert!(filtered.settings.exclude_links_from_lockfile);
1573 }
1574
1575 #[test]
1576 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
1577 let mut importers = BTreeMap::new();
1581 importers.insert(
1582 ".".to_string(),
1583 vec![
1584 DirectDep {
1585 name: "foo".into(),
1586 dep_path: "foo@1.0.0".into(),
1587 dep_type: DepType::Production,
1588 specifier: Some("^1.0.0".into()),
1589 },
1590 DirectDep {
1591 name: "jest".into(),
1592 dep_path: "jest@29.0.0".into(),
1593 dep_type: DepType::Dev,
1594 specifier: Some("^29.0.0".into()),
1595 },
1596 ],
1597 );
1598
1599 let mut packages = BTreeMap::new();
1600 for (name, ver, deps) in [
1601 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
1602 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
1603 ("shared", "1.0.0", vec![]),
1604 ] {
1605 let mut dep_map = BTreeMap::new();
1606 for (n, v) in deps {
1607 dep_map.insert(n.to_string(), v.to_string());
1608 }
1609 packages.insert(
1610 format!("{name}@{ver}"),
1611 LockedPackage {
1612 name: name.into(),
1613 version: ver.into(),
1614 integrity: None,
1615 dependencies: dep_map,
1616 dep_path: format!("{name}@{ver}"),
1617 ..Default::default()
1618 },
1619 );
1620 }
1621
1622 let graph = LockfileGraph {
1623 importers,
1624 packages,
1625 ..Default::default()
1626 };
1627 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1628
1629 assert!(prod.packages.contains_key("foo@1.0.0"));
1630 assert!(prod.packages.contains_key("shared@1.0.0"));
1631 assert!(!prod.packages.contains_key("jest@29.0.0"));
1632 }
1633
1634 #[test]
1635 fn subset_to_importer_returns_none_for_missing_importer() {
1636 let graph = LockfileGraph {
1637 importers: BTreeMap::new(),
1638 packages: BTreeMap::new(),
1639 ..Default::default()
1640 };
1641 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
1642 }
1643
1644 #[test]
1645 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
1646 let mut importers = BTreeMap::new();
1653 importers.insert(".".to_string(), vec![]);
1654 importers.insert(
1655 "packages/lib".to_string(),
1656 vec![DirectDep {
1657 name: "is-odd".into(),
1658 dep_path: "is-odd@3.0.1".into(),
1659 dep_type: DepType::Production,
1660 specifier: Some("^3.0.1".into()),
1661 }],
1662 );
1663 importers.insert(
1664 "packages/app".to_string(),
1665 vec![DirectDep {
1666 name: "express".into(),
1667 dep_path: "express@4.18.0".into(),
1668 dep_type: DepType::Production,
1669 specifier: Some("^4.18.0".into()),
1670 }],
1671 );
1672
1673 let mut packages = BTreeMap::new();
1674 let mut is_odd_deps = BTreeMap::new();
1675 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
1676 packages.insert(
1677 "is-odd@3.0.1".to_string(),
1678 LockedPackage {
1679 name: "is-odd".into(),
1680 version: "3.0.1".into(),
1681 dependencies: is_odd_deps,
1682 dep_path: "is-odd@3.0.1".into(),
1683 ..Default::default()
1684 },
1685 );
1686 packages.insert(
1687 "is-number@6.0.0".to_string(),
1688 LockedPackage {
1689 name: "is-number".into(),
1690 version: "6.0.0".into(),
1691 dep_path: "is-number@6.0.0".into(),
1692 ..Default::default()
1693 },
1694 );
1695 packages.insert(
1696 "express@4.18.0".to_string(),
1697 LockedPackage {
1698 name: "express".into(),
1699 version: "4.18.0".into(),
1700 dep_path: "express@4.18.0".into(),
1701 ..Default::default()
1702 },
1703 );
1704
1705 let graph = LockfileGraph {
1706 importers,
1707 packages,
1708 ..Default::default()
1709 };
1710 let subset = graph
1711 .subset_to_importer("packages/lib", |_| true)
1712 .expect("packages/lib importer present");
1713
1714 assert_eq!(subset.importers.len(), 1);
1715 let roots = subset.root_deps();
1716 assert_eq!(roots.len(), 1);
1717 assert_eq!(roots[0].name, "is-odd");
1718
1719 assert!(subset.packages.contains_key("is-odd@3.0.1"));
1720 assert!(subset.packages.contains_key("is-number@6.0.0"));
1721 assert!(!subset.packages.contains_key("express@4.18.0"));
1722 }
1723
1724 #[test]
1725 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
1726 let mut importers = BTreeMap::new();
1732 importers.insert(
1733 "packages/lib".to_string(),
1734 vec![
1735 DirectDep {
1736 name: "is-odd".into(),
1737 dep_path: "is-odd@3.0.1".into(),
1738 dep_type: DepType::Production,
1739 specifier: Some("^3.0.1".into()),
1740 },
1741 DirectDep {
1742 name: "jest".into(),
1743 dep_path: "jest@29.0.0".into(),
1744 dep_type: DepType::Dev,
1745 specifier: Some("^29.0.0".into()),
1746 },
1747 ],
1748 );
1749 let mut packages = BTreeMap::new();
1750 packages.insert(
1751 "is-odd@3.0.1".to_string(),
1752 LockedPackage {
1753 name: "is-odd".into(),
1754 version: "3.0.1".into(),
1755 dep_path: "is-odd@3.0.1".into(),
1756 ..Default::default()
1757 },
1758 );
1759 packages.insert(
1760 "jest@29.0.0".to_string(),
1761 LockedPackage {
1762 name: "jest".into(),
1763 version: "29.0.0".into(),
1764 dep_path: "jest@29.0.0".into(),
1765 ..Default::default()
1766 },
1767 );
1768 let graph = LockfileGraph {
1769 importers,
1770 packages,
1771 ..Default::default()
1772 };
1773
1774 let prod = graph
1775 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
1776 .expect("importer present");
1777 let roots = prod.root_deps();
1778 assert_eq!(roots.len(), 1);
1779 assert_eq!(roots[0].name, "is-odd");
1780 assert!(prod.packages.contains_key("is-odd@3.0.1"));
1781 assert!(!prod.packages.contains_key("jest@29.0.0"));
1782 }
1783
1784 #[test]
1785 fn subset_to_importer_preserves_graph_settings() {
1786 let mut importers = BTreeMap::new();
1792 importers.insert("packages/lib".to_string(), vec![]);
1793 let graph = LockfileGraph {
1794 importers,
1795 packages: BTreeMap::new(),
1796 settings: LockfileSettings {
1797 auto_install_peers: false,
1798 exclude_links_from_lockfile: true,
1799 lockfile_include_tarball_url: true,
1800 },
1801 ..Default::default()
1802 };
1803 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1804 assert!(!subset.settings.auto_install_peers);
1805 assert!(subset.settings.exclude_links_from_lockfile);
1806 assert!(subset.settings.lockfile_include_tarball_url);
1807 }
1808
1809 #[test]
1810 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
1811 let mut importers = BTreeMap::new();
1816 importers.insert("packages/lib".to_string(), vec![]);
1817 importers.insert("packages/app".to_string(), vec![]);
1818 let mut skipped = BTreeMap::new();
1819 let mut lib_skip = BTreeMap::new();
1820 lib_skip.insert("fsevents".to_string(), "^2".to_string());
1821 skipped.insert("packages/lib".to_string(), lib_skip);
1822 let mut app_skip = BTreeMap::new();
1823 app_skip.insert("ghost".to_string(), "*".to_string());
1824 skipped.insert("packages/app".to_string(), app_skip);
1825 let graph = LockfileGraph {
1826 importers,
1827 packages: BTreeMap::new(),
1828 skipped_optional_dependencies: skipped,
1829 ..Default::default()
1830 };
1831 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1832 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
1833 let root = subset.skipped_optional_dependencies.get(".").unwrap();
1834 assert!(root.contains_key("fsevents"));
1835 assert!(!root.contains_key("ghost"));
1836 }
1837
1838 #[test]
1839 fn workspace_drift_fresh_when_all_importers_match() {
1840 let root_dep = DirectDep {
1841 name: "lodash".into(),
1842 dep_path: "lodash@4.17.21".into(),
1843 dep_type: DepType::Production,
1844 specifier: Some("^4.17.0".into()),
1845 };
1846 let app_dep = DirectDep {
1847 name: "express".into(),
1848 dep_path: "express@4.18.0".into(),
1849 dep_type: DepType::Production,
1850 specifier: Some("^4.18.0".into()),
1851 };
1852 let mut importers = BTreeMap::new();
1853 importers.insert(".".to_string(), vec![root_dep]);
1854 importers.insert("packages/app".to_string(), vec![app_dep]);
1855 let graph = LockfileGraph {
1856 importers,
1857 packages: BTreeMap::new(),
1858 ..Default::default()
1859 };
1860
1861 let workspace_manifests = vec![
1862 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
1863 (
1864 "packages/app".to_string(),
1865 make_manifest(&[("express", "^4.18.0")]),
1866 ),
1867 ];
1868 assert_eq!(
1869 graph.check_drift_workspace(
1870 &workspace_manifests,
1871 &BTreeMap::new(),
1872 &[],
1873 &BTreeMap::new(),
1874 true,
1875 ),
1876 DriftStatus::Fresh
1877 );
1878 }
1879
1880 #[allow(clippy::type_complexity)]
1881 fn mk_catalogs(
1882 entries: &[(&str, &[(&str, &str, &str)])],
1883 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
1884 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
1885 for (cat, pkgs) in entries {
1886 let mut inner = BTreeMap::new();
1887 for (pkg, spec, ver) in *pkgs {
1888 inner.insert(
1889 (*pkg).to_string(),
1890 CatalogEntry {
1891 specifier: (*spec).to_string(),
1892 version: (*ver).to_string(),
1893 },
1894 );
1895 }
1896 out.insert((*cat).to_string(), inner);
1897 }
1898 out
1899 }
1900
1901 fn mk_workspace_catalogs(
1902 entries: &[(&str, &[(&str, &str)])],
1903 ) -> BTreeMap<String, BTreeMap<String, String>> {
1904 entries
1905 .iter()
1906 .map(|(cat, pkgs)| {
1907 (
1908 (*cat).to_string(),
1909 pkgs.iter()
1910 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
1911 .collect(),
1912 )
1913 })
1914 .collect()
1915 }
1916
1917 #[test]
1918 fn catalog_drift_fresh_when_specifiers_match() {
1919 let graph = LockfileGraph {
1920 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
1921 ..Default::default()
1922 };
1923 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
1924 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
1925 }
1926
1927 #[test]
1928 fn catalog_drift_stale_on_changed_specifier() {
1929 let graph = LockfileGraph {
1930 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
1931 ..Default::default()
1932 };
1933 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
1934 match graph.check_catalogs_drift(&ws) {
1935 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1936 other => panic!("expected stale, got {other:?}"),
1937 }
1938 }
1939
1940 #[test]
1941 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
1942 let graph = LockfileGraph::default();
1946 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
1947 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
1948 }
1949
1950 #[test]
1951 fn catalog_drift_stale_on_removed_workspace_entry() {
1952 let graph = LockfileGraph {
1953 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
1954 ..Default::default()
1955 };
1956 let ws = mk_workspace_catalogs(&[]);
1957 assert!(matches!(
1958 graph.check_catalogs_drift(&ws),
1959 DriftStatus::Stale { .. }
1960 ));
1961 }
1962}