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)
245 .or_else(|| {
246 let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
247 effective_ignored.extend(workspace_ignored_optional.iter().cloned());
248 ignored_optional_drift_reason(
249 &self.ignored_optional_dependencies,
250 &effective_ignored,
251 )
252 })
253 .or_else(|| runtime_drift_reason(&self.runtimes, manifest))
254 }
255
256 pub fn check_catalogs_drift(
278 &self,
279 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
280 ) -> DriftStatus {
281 for (cat_name, cat) in workspace_catalogs {
282 let Some(locked) = self.catalogs.get(cat_name) else {
283 continue;
284 };
285 for (pkg, spec) in cat {
286 if let Some(entry) = locked.get(pkg)
287 && entry.specifier != *spec
288 {
289 return DriftStatus::Stale {
290 reason: format!(
291 "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
292 entry.specifier
293 ),
294 };
295 }
296 }
297 }
298 for (cat_name, cat) in &self.catalogs {
299 let workspace_cat = workspace_catalogs.get(cat_name);
300 for pkg in cat.keys() {
301 if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
302 return DriftStatus::Stale {
303 reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
304 };
305 }
306 }
307 }
308 DriftStatus::Fresh
309 }
310
311 fn check_drift_for_importer(
317 &self,
318 importer_path: &str,
319 manifest: &aube_manifest::PackageJson,
320 effective_overrides: &BTreeMap<String, String>,
321 ) -> DriftStatus {
322 self.check_drift_for_importer_with_workspace_links(
323 importer_path,
324 manifest,
325 effective_overrides,
326 &std::collections::HashSet::new(),
327 )
328 }
329
330 fn check_drift_for_importer_with_workspace_links(
331 &self,
332 importer_path: &str,
333 manifest: &aube_manifest::PackageJson,
334 effective_overrides: &BTreeMap<String, String>,
335 workspace_link_names: &std::collections::HashSet<&str>,
336 ) -> DriftStatus {
337 let label = if importer_path == "." {
338 String::new()
339 } else {
340 format!("{importer_path}: ")
341 };
342
343 let importer_deps: &[DirectDep] = self
344 .importers
345 .get(importer_path)
346 .map(|v| v.as_slice())
347 .unwrap_or(&[]);
348
349 if importer_deps.iter().all(|d| d.specifier.is_none()) {
351 return DriftStatus::Fresh;
352 }
353 let lockfile_specs: BTreeMap<&str, &str> = importer_deps
354 .iter()
355 .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
356 .collect();
357
358 let override_rules = override_match::compile(effective_overrides);
359
360 let skipped_optionals: BTreeMap<&str, &str> = self
366 .skipped_optional_dependencies
367 .get(importer_path)
368 .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
369 .unwrap_or_default();
370
371 let ignored = &self.ignored_optional_dependencies;
385 let manifest_deps = manifest
386 .dependencies
387 .iter()
388 .map(|(k, v)| (k, v, false))
389 .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
390 .chain(
391 manifest
392 .optional_dependencies
393 .iter()
394 .filter(|(name, _)| !ignored.contains(name.as_str()))
395 .map(|(k, v)| (k, v, true)),
396 );
397
398 for (name, spec, is_optional) in manifest_deps {
399 match lockfile_specs.get(name.as_str()) {
400 None => {
401 if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
411 if *locked_spec == spec {
412 continue;
413 }
414 return DriftStatus::Stale {
415 reason: format!(
416 "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
417 ),
418 };
419 }
420 return DriftStatus::Stale {
421 reason: format!("{label}manifest adds {name}@{spec}"),
422 };
423 }
424 Some(locked_spec) if *locked_spec != spec => {
425 if let Some(override_spec) =
435 override_match::apply(&override_rules, name.as_str(), spec)
436 && override_spec == *locked_spec
437 {
438 continue;
439 }
440 if self.pnpmfile_checksum.is_some()
463 && is_local_source_spec(locked_spec)
464 && !is_local_source_spec(spec)
465 {
466 continue;
467 }
468 return DriftStatus::Stale {
469 reason: format!(
470 "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
471 ),
472 };
473 }
474 Some(_) => {}
475 }
476 }
477
478 let mut manifest_dep_types: BTreeMap<&str, DepType> = BTreeMap::new();
486 for name in manifest.dependencies.keys() {
487 manifest_dep_types.insert(name.as_str(), DepType::Production);
488 }
489 for name in manifest.dev_dependencies.keys() {
490 manifest_dep_types
491 .entry(name.as_str())
492 .or_insert(DepType::Dev);
493 }
494 for name in manifest.optional_dependencies.keys() {
495 if ignored.contains(name.as_str()) {
496 continue;
497 }
498 manifest_dep_types
499 .entry(name.as_str())
500 .or_insert(DepType::Optional);
501 }
502 for dep in importer_deps {
503 let Some(expected) = manifest_dep_types.get(dep.name.as_str()) else {
504 continue;
505 };
506 if *expected != dep.dep_type {
507 return DriftStatus::Stale {
508 reason: format!(
509 "{label}{}: manifest section is {}, lockfile section is {}",
510 dep.name,
511 dep_type_label(*expected),
512 dep_type_label(dep.dep_type),
513 ),
514 };
515 }
516 }
517
518 let manifest_names: std::collections::HashSet<&str> = manifest
539 .dependencies
540 .keys()
541 .chain(manifest.dev_dependencies.keys())
542 .chain(
543 manifest
544 .optional_dependencies
545 .keys()
546 .filter(|name| !ignored.contains(name.as_str())),
547 )
548 .map(|s| s.as_str())
549 .collect();
550 let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
551 .packages
552 .values()
553 .flat_map(|p| {
554 p.peer_dependencies
555 .iter()
556 .map(|(name, range)| (name.as_str(), range.as_str()))
557 })
558 .collect();
559 for (locked_name, locked_spec) in &lockfile_specs {
560 if manifest_names.contains(locked_name) {
561 continue;
562 }
563 if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
564 continue;
565 }
566 let workspace_link = importer_path == "."
567 && workspace_link_names.contains(locked_name)
568 && importer_deps
569 .iter()
570 .find(|dep| dep.name == *locked_name)
571 .and_then(|dep| self.packages.get(&dep.dep_path))
572 .is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))));
573 if workspace_link {
574 continue;
575 }
576 return DriftStatus::Stale {
577 reason: format!("{label}manifest removed {locked_name}"),
578 };
579 }
580
581 DriftStatus::Fresh
582 }
583}
584
585fn merge_manifest_and_workspace_overrides(
591 manifest: &aube_manifest::PackageJson,
592 workspace_overrides: &BTreeMap<String, String>,
593) -> BTreeMap<String, String> {
594 let mut out = manifest.overrides_map();
595 for (k, v) in workspace_overrides {
596 out.insert(k.clone(), v.clone());
597 }
598 out
599}
600
601fn resolve_catalog_refs_in_overrides(
611 overrides: &BTreeMap<String, String>,
612 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
613) -> BTreeMap<String, String> {
614 overrides
615 .iter()
616 .map(|(k, v)| {
617 let resolved = v
618 .strip_prefix("catalog:")
619 .map(|tail| if tail.is_empty() { "default" } else { tail })
620 .and_then(|cat_name| workspace_catalogs.get(cat_name))
621 .and_then(|cat| {
622 override_match::target_package_name(k)
623 .and_then(|package_name| cat.get(&package_name))
624 })
625 .cloned()
626 .unwrap_or_else(|| v.clone());
627 (k.clone(), resolved)
628 })
629 .collect()
630}
631
632fn overrides_drift_reason(
638 lockfile: &BTreeMap<String, String>,
639 manifest: &BTreeMap<String, String>,
640) -> Option<String> {
641 for (k, v) in manifest {
642 match lockfile.get(k) {
643 None => return Some(format!("overrides: manifest adds {k}@{v}")),
644 Some(locked) if locked != v => {
645 return Some(format!("overrides: {k} changed ({locked} → {v})"));
646 }
647 Some(_) => {}
648 }
649 }
650 for k in lockfile.keys() {
651 if !manifest.contains_key(k) {
652 return Some(format!("overrides: manifest removes {k}"));
653 }
654 }
655 None
656}
657
658fn ignored_optional_drift_reason(
661 lockfile: &BTreeSet<String>,
662 manifest: &BTreeSet<String>,
663) -> Option<String> {
664 for name in manifest {
665 if !lockfile.contains(name) {
666 return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
667 }
668 }
669 for name in lockfile {
670 if !manifest.contains(name) {
671 return Some(format!(
672 "ignoredOptionalDependencies: manifest removes {name}"
673 ));
674 }
675 }
676 None
677}
678
679fn runtime_drift_reason(
689 runtimes: &BTreeMap<String, crate::RuntimePin>,
690 manifest: &aube_manifest::PackageJson,
691) -> Option<String> {
692 for (name, pin) in runtimes {
693 let entry = manifest
694 .dev_engines
695 .as_ref()
696 .and_then(|d| d.runtime.iter().find(|r| r.name == *name));
697 match entry {
698 None => {
699 return Some(format!(
700 "devEngines.runtime: manifest no longer pins {name} (lockfile records {})",
701 pin.version
702 ));
703 }
704 Some(entry) => match entry.version.as_deref() {
710 None => {}
711 Some(range) if range != pin.specifier => {
712 return Some(format!(
713 "devEngines.runtime: {name} changed ({} → {range})",
714 pin.specifier
715 ));
716 }
717 Some(_) => {}
718 },
719 }
720 }
721 None
722}
723
724#[derive(Debug, Clone, PartialEq, Eq)]
726pub enum DriftStatus {
727 Fresh,
729 Stale { reason: String },
731}
732
733fn kind_records_resolution_metadata(kind: LockfileKind) -> bool {
734 matches!(
735 kind,
736 LockfileKind::Aube | LockfileKind::Pnpm | LockfileKind::Bun
737 )
738}
739
740fn is_local_source_spec(spec: &str) -> bool {
747 spec.starts_with("link:")
748 || spec.starts_with("file:")
749 || spec.starts_with("portal:")
750 || spec.starts_with("exec:")
751}
752
753#[cfg(test)]
754mod drift_tests {
755 use super::*;
756 use crate::{CatalogEntry, LockedPackage, LockfileSettings};
757 use aube_manifest::PackageJson;
758 use std::collections::BTreeMap;
759 use std::path::PathBuf;
760
761 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
762 let mut m = PackageJson {
763 name: Some("test".into()),
764 version: Some("1.0.0".into()),
765 dependencies: BTreeMap::new(),
766 dev_dependencies: BTreeMap::new(),
767 peer_dependencies: BTreeMap::new(),
768 optional_dependencies: BTreeMap::new(),
769 update_config: None,
770 scripts: BTreeMap::new(),
771 engines: BTreeMap::new(),
772 dev_engines: None,
773 workspaces: None,
774 bundled_dependencies: None,
775 extra: BTreeMap::new(),
776 };
777 for (name, spec) in deps {
778 m.dependencies.insert((*name).into(), (*spec).into());
779 }
780 m
781 }
782
783 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
784 let direct: Vec<DirectDep> = deps
786 .iter()
787 .map(|(name, spec, dep_path)| DirectDep {
788 name: (*name).into(),
789 dep_path: (*dep_path).into(),
790 dep_type: DepType::Production,
791 specifier: Some((*spec).into()),
792 })
793 .collect();
794 let mut importers = BTreeMap::new();
795 importers.insert(".".to_string(), direct);
796 LockfileGraph {
797 importers,
798 packages: BTreeMap::new(),
799 ..Default::default()
800 }
801 }
802
803 #[test]
804 fn stale_when_dep_moves_between_sections() {
805 let mut manifest = make_manifest(&[]);
810 manifest
811 .dev_dependencies
812 .insert("msw".into(), "catalog:".into());
813 let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
814 graph
815 .importers
816 .get_mut(".")
817 .unwrap()
818 .iter_mut()
819 .for_each(|d| d.dep_type = DepType::Production);
820 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
821 DriftStatus::Stale { reason } => {
822 assert!(reason.contains("msw"), "reason: {reason}");
823 assert!(reason.contains("devDependencies"), "reason: {reason}");
824 }
825 DriftStatus::Fresh => panic!("expected Stale"),
826 }
827 }
828
829 #[test]
830 fn fresh_when_specifiers_match() {
831 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
832 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
833 assert_eq!(
834 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
835 DriftStatus::Fresh
836 );
837 }
838
839 #[test]
840 fn stale_when_specifier_changes() {
841 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
842 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
843 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
844 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
845 DriftStatus::Fresh => panic!("expected Stale"),
846 }
847 }
848
849 #[test]
850 fn stale_when_manifest_adds_dep() {
851 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
852 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
853 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
854 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
855 DriftStatus::Fresh => panic!("expected Stale"),
856 }
857 }
858
859 #[test]
860 fn stale_when_manifest_removes_dep() {
861 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
862 let graph = make_graph(&[
863 ("lodash", "^4.17.0", "lodash@4.17.21"),
864 ("express", "^4.18.0", "express@4.18.0"),
865 ]);
866 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
867 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
868 DriftStatus::Fresh => panic!("expected Stale"),
869 }
870 }
871
872 #[test]
873 fn fresh_when_pnpmfile_hook_rewrites_dep_to_link() {
874 let manifest = make_manifest(&[("@scope/api", "*")]);
883 let mut graph = make_graph(&[(
884 "@scope/api",
885 "link:../api/dist",
886 "@scope/api@link:../api/dist",
887 )]);
888 graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
889 assert_eq!(
890 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
891 DriftStatus::Fresh
892 );
893 }
894
895 #[test]
896 fn stale_when_link_importer_spec_has_no_pnpmfile_checksum() {
897 let manifest = make_manifest(&[("@scope/api", "*")]);
902 let graph = make_graph(&[(
903 "@scope/api",
904 "link:../api/dist",
905 "@scope/api@link:../api/dist",
906 )]);
907 assert!(matches!(
908 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
909 DriftStatus::Stale { .. }
910 ));
911 }
912
913 #[test]
914 fn stale_when_manifest_link_repointed_even_with_pnpmfile_checksum() {
915 let manifest = make_manifest(&[("@scope/api", "link:../api/old")]);
921 let mut graph = make_graph(&[(
922 "@scope/api",
923 "link:../api/new",
924 "@scope/api@link:../api/new",
925 )]);
926 graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
927 assert!(matches!(
928 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
929 DriftStatus::Stale { .. }
930 ));
931 }
932
933 #[test]
938 fn fresh_when_lockfile_has_auto_hoisted_peer() {
939 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
940 let mut graph = make_graph(&[
941 (
942 "use-sync-external-store",
943 "1.2.0",
944 "use-sync-external-store@1.2.0",
945 ),
946 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
949 ]);
950 let mut declaring_pkg = LockedPackage {
953 name: "use-sync-external-store".into(),
954 version: "1.2.0".into(),
955 dep_path: "use-sync-external-store@1.2.0".into(),
956 ..Default::default()
957 };
958 declaring_pkg
959 .peer_dependencies
960 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
961 graph
962 .packages
963 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
964
965 assert_eq!(
966 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
967 DriftStatus::Fresh
968 );
969 }
970
971 #[test]
977 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
978 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
981
982 let mut graph = make_graph(&[
985 (
986 "use-sync-external-store",
987 "1.2.0",
988 "use-sync-external-store@1.2.0",
989 ),
990 ("react", "17.0.2", "react@17.0.2"),
991 ]);
992 let mut consumer = LockedPackage {
997 name: "use-sync-external-store".into(),
998 version: "1.2.0".into(),
999 dep_path: "use-sync-external-store@1.2.0".into(),
1000 ..Default::default()
1001 };
1002 consumer
1003 .peer_dependencies
1004 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
1005 graph
1006 .packages
1007 .insert("use-sync-external-store@1.2.0".into(), consumer);
1008
1009 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1010 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1011 DriftStatus::Fresh => panic!(
1012 "drift check should flag a removed user-pinned dep as stale, \
1013 even when its name matches a peer declaration"
1014 ),
1015 }
1016 }
1017
1018 #[test]
1021 fn stale_when_lockfile_has_removed_non_peer_dep() {
1022 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1023 let graph = make_graph(&[
1024 ("lodash", "^4.17.0", "lodash@4.17.21"),
1025 ("chalk", "^5.0.0", "chalk@5.0.0"),
1026 ]);
1027 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1028 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
1029 DriftStatus::Fresh => panic!("expected Stale"),
1030 }
1031 }
1032
1033 #[test]
1034 fn workspace_drift_allows_root_links_for_workspace_packages() {
1035 let root_manifest = make_manifest(&[]);
1036 let mut app_manifest = make_manifest(&[]);
1037 app_manifest.name = Some("@scope/app".to_string());
1038
1039 let link = LocalSource::Link(PathBuf::from("packages/app"));
1040 let dep_path = link.dep_path("@scope/app");
1041 let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
1042 graph.packages.insert(
1043 dep_path.clone(),
1044 LockedPackage {
1045 name: "@scope/app".to_string(),
1046 version: "1.0.0".to_string(),
1047 dep_path,
1048 local_source: Some(link),
1049 ..Default::default()
1050 },
1051 );
1052
1053 assert_eq!(
1054 graph.check_drift_workspace(
1055 &[
1056 (".".to_string(), root_manifest),
1057 ("packages/app".to_string(), app_manifest),
1058 ],
1059 &BTreeMap::new(),
1060 &[],
1061 &BTreeMap::new(),
1062 true,
1063 ),
1064 DriftStatus::Fresh
1065 );
1066 }
1067
1068 #[test]
1069 fn fresh_when_no_specifiers_recorded() {
1070 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1073 let graph = LockfileGraph {
1074 importers: {
1075 let mut m = BTreeMap::new();
1076 m.insert(
1077 ".".to_string(),
1078 vec![DirectDep {
1079 name: "lodash".into(),
1080 dep_path: "lodash@4.17.21".into(),
1081 dep_type: DepType::Production,
1082 specifier: None,
1083 }],
1084 );
1085 m
1086 },
1087 packages: BTreeMap::new(),
1088 ..Default::default()
1089 };
1090 assert_eq!(
1091 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1092 DriftStatus::Fresh
1093 );
1094 }
1095
1096 #[test]
1097 fn stale_when_manifest_adds_override() {
1098 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1102 manifest
1103 .extra
1104 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1105 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1106 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1107 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1108 DriftStatus::Fresh => panic!("expected Stale"),
1109 }
1110 }
1111
1112 #[test]
1113 fn fresh_when_npm_lockfile_cannot_record_overrides() {
1114 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1119 manifest
1120 .extra
1121 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1122 let graph = LockfileGraph {
1123 importers: {
1124 let mut m = BTreeMap::new();
1125 m.insert(
1126 ".".to_string(),
1127 vec![DirectDep {
1128 name: "lodash".into(),
1129 dep_path: "lodash@4.17.21".into(),
1130 dep_type: DepType::Production,
1131 specifier: None,
1132 }],
1133 );
1134 m
1135 },
1136 packages: BTreeMap::new(),
1137 ..Default::default()
1138 };
1139 assert_eq!(
1140 graph.check_drift_for_kind(
1141 &manifest,
1142 &BTreeMap::new(),
1143 &[],
1144 &BTreeMap::new(),
1145 LockfileKind::Npm,
1146 ),
1147 DriftStatus::Fresh
1148 );
1149 }
1150
1151 #[test]
1152 fn stale_when_bun_lockfile_can_record_overrides() {
1153 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1154 manifest
1155 .extra
1156 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1157 let graph = LockfileGraph {
1158 importers: {
1159 let mut m = BTreeMap::new();
1160 m.insert(
1161 ".".to_string(),
1162 vec![DirectDep {
1163 name: "lodash".into(),
1164 dep_path: "lodash@4.17.21".into(),
1165 dep_type: DepType::Production,
1166 specifier: None,
1167 }],
1168 );
1169 m
1170 },
1171 packages: BTreeMap::new(),
1172 ..Default::default()
1173 };
1174 match graph.check_drift_for_kind(
1175 &manifest,
1176 &BTreeMap::new(),
1177 &[],
1178 &BTreeMap::new(),
1179 LockfileKind::Bun,
1180 ) {
1181 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1182 DriftStatus::Fresh => panic!("expected Stale"),
1183 }
1184 }
1185
1186 #[test]
1187 fn stale_drift_message_names_changed_override_key() {
1188 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1192 manifest
1193 .extra
1194 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
1195 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1196 graph.overrides.insert("lodash".into(), "4.17.21".into());
1197 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1198 DriftStatus::Stale { reason } => {
1199 assert!(reason.contains("lodash"), "expected key in: {reason}");
1200 assert!(
1201 reason.contains("4.17.21"),
1202 "expected old value in: {reason}"
1203 );
1204 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
1205 }
1206 DriftStatus::Fresh => panic!("expected Stale"),
1207 }
1208 }
1209
1210 #[test]
1211 fn stale_when_manifest_removes_override() {
1212 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1213 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1214 graph.overrides.insert("lodash".into(), "4.17.21".into());
1215 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1216 DriftStatus::Stale { reason } => {
1217 assert!(reason.contains("removes"));
1218 assert!(reason.contains("lodash"));
1219 }
1220 DriftStatus::Fresh => panic!("expected Stale"),
1221 }
1222 }
1223
1224 #[test]
1225 fn fresh_when_overrides_match() {
1226 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1227 manifest
1228 .extra
1229 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1230 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1231 graph.overrides.insert("lodash".into(), "4.17.21".into());
1232 assert_eq!(
1233 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1234 DriftStatus::Fresh
1235 );
1236 }
1237
1238 #[test]
1239 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
1240 let manifest = make_manifest(&[("semver", "^7.5.0")]);
1246 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1247 graph.overrides.insert("semver".into(), "7.7.1".into());
1248 let mut ws_overrides = BTreeMap::new();
1249 ws_overrides.insert("semver".into(), "7.7.1".into());
1250 assert_eq!(
1251 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1252 DriftStatus::Fresh,
1253 );
1254 }
1255
1256 #[test]
1257 fn workspace_yaml_overrides_win_over_package_json() {
1258 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
1263 manifest
1264 .extra
1265 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
1266 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1267 graph.overrides.insert("semver".into(), "7.7.1".into());
1268 let mut ws_overrides = BTreeMap::new();
1269 ws_overrides.insert("semver".into(), "7.7.1".into());
1270 assert_eq!(
1271 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1272 DriftStatus::Fresh,
1273 );
1274 }
1275
1276 #[test]
1277 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
1278 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1284 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1285 graph.overrides.insert("lodash".into(), "4.17.21".into());
1286 let mut ws_overrides = BTreeMap::new();
1287 ws_overrides.insert("lodash".into(), "catalog:".into());
1288 let mut catalogs = BTreeMap::new();
1289 let mut default_cat = BTreeMap::new();
1290 default_cat.insert("lodash".into(), "4.17.21".into());
1291 catalogs.insert("default".into(), default_cat);
1292 assert_eq!(
1293 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1294 DriftStatus::Fresh,
1295 );
1296 }
1297
1298 #[test]
1299 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
1300 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1303 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1304 graph.overrides.insert("lodash".into(), "4.17.21".into());
1305 let mut ws_overrides = BTreeMap::new();
1306 ws_overrides.insert("lodash".into(), "catalog:evens".into());
1307 let mut catalogs = BTreeMap::new();
1308 let mut evens = BTreeMap::new();
1309 evens.insert("lodash".into(), "4.17.21".into());
1310 catalogs.insert("evens".into(), evens);
1311 assert_eq!(
1312 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1313 DriftStatus::Fresh,
1314 );
1315 }
1316
1317 #[test]
1318 fn fresh_when_yarn_ancestor_override_catalog_ref_matches_lockfile() {
1319 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1320 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1321 graph
1322 .overrides
1323 .insert("parent/lodash".into(), "4.17.21".into());
1324 let ws_overrides = BTreeMap::from([("parent/lodash".to_string(), "catalog:".to_string())]);
1325 let catalogs = BTreeMap::from([(
1326 "default".to_string(),
1327 BTreeMap::from([("lodash".to_string(), "4.17.21".to_string())]),
1328 )]);
1329
1330 assert_eq!(
1331 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1332 DriftStatus::Fresh,
1333 );
1334 }
1335
1336 #[test]
1337 fn catalog_overrides_resolve_slash_targets_with_comparators() {
1338 let overrides = BTreeMap::from([
1339 ("parent/lodash@>=4.0.0".to_string(), "catalog:".to_string()),
1340 (
1341 "parent/@scope/pkg@>1.0.0".to_string(),
1342 "catalog:".to_string(),
1343 ),
1344 ("parent@^1>123numeric".to_string(), "catalog:".to_string()),
1345 ]);
1346 let catalogs = BTreeMap::from([(
1347 "default".to_string(),
1348 BTreeMap::from([
1349 ("lodash".to_string(), "4.17.21".to_string()),
1350 ("@scope/pkg".to_string(), "2.0.0".to_string()),
1351 ("123numeric".to_string(), "1.0.0".to_string()),
1352 ]),
1353 )]);
1354
1355 assert_eq!(
1356 resolve_catalog_refs_in_overrides(&overrides, &catalogs),
1357 BTreeMap::from([
1358 ("parent/lodash@>=4.0.0".to_string(), "4.17.21".to_string(),),
1359 ("parent/@scope/pkg@>1.0.0".to_string(), "2.0.0".to_string(),),
1360 ("parent@^1>123numeric".to_string(), "1.0.0".to_string()),
1361 ])
1362 );
1363 }
1364
1365 #[test]
1366 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
1367 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1371 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1372 graph.overrides.insert("lodash".into(), "4.17.21".into());
1373 let mut ws_overrides = BTreeMap::new();
1374 ws_overrides.insert("lodash".into(), "catalog:".into());
1375 let mut catalogs = BTreeMap::new();
1376 let mut default_cat = BTreeMap::new();
1377 default_cat.insert("lodash".into(), "4.17.22".into());
1378 catalogs.insert("default".into(), default_cat);
1379 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
1380 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
1381 other => panic!("expected stale, got {other:?}"),
1382 }
1383 }
1384
1385 #[test]
1386 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
1387 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1394 let mut importers = BTreeMap::new();
1395 importers.insert(
1396 ".".to_string(),
1397 vec![DirectDep {
1398 name: "lodash".into(),
1399 dep_path: "lodash@4.17.21".into(),
1400 dep_type: DepType::Production,
1401 specifier: Some("4.17.21".into()),
1402 }],
1403 );
1404 let mut graph = LockfileGraph {
1405 importers,
1406 ..Default::default()
1407 };
1408 graph.overrides.insert("lodash".into(), "4.17.21".into());
1409 let mut ws_overrides = BTreeMap::new();
1410 ws_overrides.insert("lodash".into(), "4.17.21".into());
1411 assert_eq!(
1412 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1413 DriftStatus::Fresh,
1414 );
1415 }
1416
1417 #[test]
1418 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
1419 let manifest = make_manifest(&[("plist", "^3.0.4")]);
1426 let mut importers = BTreeMap::new();
1427 importers.insert(
1428 ".".to_string(),
1429 vec![DirectDep {
1430 name: "plist".into(),
1431 dep_path: "plist@3.0.6".into(),
1432 dep_type: DepType::Production,
1433 specifier: Some(">=3.0.5".into()),
1434 }],
1435 );
1436 let mut graph = LockfileGraph {
1437 importers,
1438 ..Default::default()
1439 };
1440 graph
1441 .overrides
1442 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
1443 let mut ws_overrides = BTreeMap::new();
1444 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
1445 assert_eq!(
1446 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1447 DriftStatus::Fresh,
1448 );
1449 }
1450
1451 #[test]
1452 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
1453 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1460 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1461 graph
1462 .ignored_optional_dependencies
1463 .insert("fsevents".to_string());
1464 let ws_ignored = vec!["fsevents".to_string()];
1465 assert_eq!(
1466 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
1467 DriftStatus::Fresh,
1468 );
1469 }
1470
1471 #[test]
1472 fn fresh_when_optional_dep_was_recorded_as_skipped() {
1473 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1478 manifest
1479 .optional_dependencies
1480 .insert("fsevents".into(), "^2.3.0".into());
1481 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1482 let mut inner = BTreeMap::new();
1483 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1484 graph
1485 .skipped_optional_dependencies
1486 .insert(".".to_string(), inner);
1487 assert_eq!(
1488 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1489 DriftStatus::Fresh
1490 );
1491 }
1492
1493 #[test]
1494 fn stale_when_new_optional_dep_was_never_seen() {
1495 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1501 manifest
1502 .optional_dependencies
1503 .insert("fsevents".into(), "^2.3.0".into());
1504 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1505 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1506 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1507 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
1508 }
1509 }
1510
1511 #[test]
1512 fn stale_when_skipped_optional_dep_specifier_changes() {
1513 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1517 manifest
1518 .optional_dependencies
1519 .insert("fsevents".into(), "^2.4.0".into());
1520 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1521 let mut inner = BTreeMap::new();
1522 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1523 graph
1524 .skipped_optional_dependencies
1525 .insert(".".to_string(), inner);
1526 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1527 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1528 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
1529 }
1530 }
1531
1532 #[test]
1533 fn stale_when_skipped_optional_is_promoted_to_required() {
1534 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1539 manifest.optional_dependencies.clear();
1543 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1544 let mut inner = BTreeMap::new();
1545 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1546 graph
1547 .skipped_optional_dependencies
1548 .insert(".".to_string(), inner);
1549 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1550 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1551 DriftStatus::Fresh => {
1552 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
1553 }
1554 }
1555 }
1556
1557 #[test]
1558 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
1559 let mut manifest = make_manifest(&[]);
1562 manifest
1563 .optional_dependencies
1564 .insert("fsevents".into(), "^2.4.0".into());
1565 let mut graph = make_graph(&[]);
1566 graph.importers.get_mut(".").unwrap().push(DirectDep {
1567 name: "fsevents".into(),
1568 dep_path: "fsevents@2.3.0".into(),
1569 dep_type: DepType::Optional,
1570 specifier: Some("^2.3.0".into()),
1571 });
1572 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1573 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1574 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
1575 }
1576 }
1577
1578 #[test]
1579 fn fresh_for_empty_manifest_and_lockfile() {
1580 let manifest = make_manifest(&[]);
1581 let graph = make_graph(&[]);
1582 assert_eq!(
1583 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1584 DriftStatus::Fresh
1585 );
1586 }
1587
1588 #[test]
1589 fn workspace_drift_detects_change_in_non_root_importer() {
1590 let root_dep = DirectDep {
1592 name: "lodash".into(),
1593 dep_path: "lodash@4.17.21".into(),
1594 dep_type: DepType::Production,
1595 specifier: Some("^4.17.0".into()),
1596 };
1597 let app_dep = DirectDep {
1598 name: "express".into(),
1599 dep_path: "express@4.18.0".into(),
1600 dep_type: DepType::Production,
1601 specifier: Some("^4.18.0".into()),
1602 };
1603 let mut importers = BTreeMap::new();
1604 importers.insert(".".to_string(), vec![root_dep]);
1605 importers.insert("packages/app".to_string(), vec![app_dep]);
1606 let graph = LockfileGraph {
1607 importers,
1608 packages: BTreeMap::new(),
1609 ..Default::default()
1610 };
1611
1612 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
1613 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
1615
1616 let workspace_manifests = vec![
1617 (".".to_string(), root_manifest.clone()),
1618 ("packages/app".to_string(), app_manifest),
1619 ];
1620 match graph.check_drift_workspace(
1621 &workspace_manifests,
1622 &BTreeMap::new(),
1623 &[],
1624 &BTreeMap::new(),
1625 true,
1626 ) {
1627 DriftStatus::Stale { reason } => {
1628 assert!(reason.contains("packages/app"));
1629 assert!(reason.contains("express"));
1630 }
1631 DriftStatus::Fresh => panic!("expected Stale"),
1632 }
1633
1634 assert_eq!(
1636 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1637 DriftStatus::Fresh
1638 );
1639 }
1640
1641 #[test]
1642 fn filter_deps_prunes_dev_only_subtree() {
1643 let mut importers = BTreeMap::new();
1647 importers.insert(
1648 ".".to_string(),
1649 vec![
1650 DirectDep {
1651 name: "foo".into(),
1652 dep_path: "foo@1.0.0".into(),
1653 dep_type: DepType::Production,
1654 specifier: Some("^1.0.0".into()),
1655 },
1656 DirectDep {
1657 name: "jest".into(),
1658 dep_path: "jest@29.0.0".into(),
1659 dep_type: DepType::Dev,
1660 specifier: Some("^29.0.0".into()),
1661 },
1662 ],
1663 );
1664
1665 let mut packages = BTreeMap::new();
1666 let mut foo_deps = BTreeMap::new();
1667 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
1668 packages.insert(
1669 "foo@1.0.0".to_string(),
1670 LockedPackage {
1671 name: "foo".into(),
1672 version: "1.0.0".into(),
1673 integrity: None,
1674 dependencies: foo_deps,
1675 dep_path: "foo@1.0.0".into(),
1676 ..Default::default()
1677 },
1678 );
1679 packages.insert(
1680 "bar@2.0.0".to_string(),
1681 LockedPackage {
1682 name: "bar".into(),
1683 version: "2.0.0".into(),
1684 integrity: None,
1685 dependencies: BTreeMap::new(),
1686 dep_path: "bar@2.0.0".into(),
1687 ..Default::default()
1688 },
1689 );
1690 let mut jest_deps = BTreeMap::new();
1691 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
1692 packages.insert(
1693 "jest@29.0.0".to_string(),
1694 LockedPackage {
1695 name: "jest".into(),
1696 version: "29.0.0".into(),
1697 integrity: None,
1698 dependencies: jest_deps,
1699 dep_path: "jest@29.0.0".into(),
1700 ..Default::default()
1701 },
1702 );
1703 packages.insert(
1704 "jest-core@29.0.0".to_string(),
1705 LockedPackage {
1706 name: "jest-core".into(),
1707 version: "29.0.0".into(),
1708 integrity: None,
1709 dependencies: BTreeMap::new(),
1710 dep_path: "jest-core@29.0.0".into(),
1711 ..Default::default()
1712 },
1713 );
1714
1715 let graph = LockfileGraph {
1716 importers,
1717 packages,
1718 ..Default::default()
1719 };
1720
1721 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1722
1723 let roots = prod.root_deps();
1725 assert_eq!(roots.len(), 1);
1726 assert_eq!(roots[0].name, "foo");
1727
1728 assert!(prod.packages.contains_key("foo@1.0.0"));
1730 assert!(prod.packages.contains_key("bar@2.0.0"));
1731 assert!(!prod.packages.contains_key("jest@29.0.0"));
1732 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
1733 }
1734
1735 #[test]
1742 fn filter_deps_preserves_lockfile_settings() {
1743 let graph = LockfileGraph {
1744 importers: BTreeMap::new(),
1745 packages: BTreeMap::new(),
1746 settings: LockfileSettings {
1747 auto_install_peers: false,
1748 exclude_links_from_lockfile: true,
1749 lockfile_include_tarball_url: false,
1750 },
1751 ..Default::default()
1752 };
1753 let filtered = graph.filter_deps(|_| true);
1754 assert!(!filtered.settings.auto_install_peers);
1755 assert!(filtered.settings.exclude_links_from_lockfile);
1756 }
1757
1758 #[test]
1759 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
1760 let mut importers = BTreeMap::new();
1764 importers.insert(
1765 ".".to_string(),
1766 vec![
1767 DirectDep {
1768 name: "foo".into(),
1769 dep_path: "foo@1.0.0".into(),
1770 dep_type: DepType::Production,
1771 specifier: Some("^1.0.0".into()),
1772 },
1773 DirectDep {
1774 name: "jest".into(),
1775 dep_path: "jest@29.0.0".into(),
1776 dep_type: DepType::Dev,
1777 specifier: Some("^29.0.0".into()),
1778 },
1779 ],
1780 );
1781
1782 let mut packages = BTreeMap::new();
1783 for (name, ver, deps) in [
1784 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
1785 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
1786 ("shared", "1.0.0", vec![]),
1787 ] {
1788 let mut dep_map = BTreeMap::new();
1789 for (n, v) in deps {
1790 dep_map.insert(n.to_string(), v.to_string());
1791 }
1792 packages.insert(
1793 format!("{name}@{ver}"),
1794 LockedPackage {
1795 name: name.into(),
1796 version: ver.into(),
1797 integrity: None,
1798 dependencies: dep_map,
1799 dep_path: format!("{name}@{ver}"),
1800 ..Default::default()
1801 },
1802 );
1803 }
1804
1805 let graph = LockfileGraph {
1806 importers,
1807 packages,
1808 ..Default::default()
1809 };
1810 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1811
1812 assert!(prod.packages.contains_key("foo@1.0.0"));
1813 assert!(prod.packages.contains_key("shared@1.0.0"));
1814 assert!(!prod.packages.contains_key("jest@29.0.0"));
1815 }
1816
1817 #[test]
1818 fn subset_to_importer_returns_none_for_missing_importer() {
1819 let graph = LockfileGraph {
1820 importers: BTreeMap::new(),
1821 packages: BTreeMap::new(),
1822 ..Default::default()
1823 };
1824 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
1825 }
1826
1827 #[test]
1828 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
1829 let mut importers = BTreeMap::new();
1836 importers.insert(".".to_string(), vec![]);
1837 importers.insert(
1838 "packages/lib".to_string(),
1839 vec![DirectDep {
1840 name: "is-odd".into(),
1841 dep_path: "is-odd@3.0.1".into(),
1842 dep_type: DepType::Production,
1843 specifier: Some("^3.0.1".into()),
1844 }],
1845 );
1846 importers.insert(
1847 "packages/app".to_string(),
1848 vec![DirectDep {
1849 name: "express".into(),
1850 dep_path: "express@4.18.0".into(),
1851 dep_type: DepType::Production,
1852 specifier: Some("^4.18.0".into()),
1853 }],
1854 );
1855
1856 let mut packages = BTreeMap::new();
1857 let mut is_odd_deps = BTreeMap::new();
1858 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
1859 packages.insert(
1860 "is-odd@3.0.1".to_string(),
1861 LockedPackage {
1862 name: "is-odd".into(),
1863 version: "3.0.1".into(),
1864 dependencies: is_odd_deps,
1865 dep_path: "is-odd@3.0.1".into(),
1866 ..Default::default()
1867 },
1868 );
1869 packages.insert(
1870 "is-number@6.0.0".to_string(),
1871 LockedPackage {
1872 name: "is-number".into(),
1873 version: "6.0.0".into(),
1874 dep_path: "is-number@6.0.0".into(),
1875 ..Default::default()
1876 },
1877 );
1878 packages.insert(
1879 "express@4.18.0".to_string(),
1880 LockedPackage {
1881 name: "express".into(),
1882 version: "4.18.0".into(),
1883 dep_path: "express@4.18.0".into(),
1884 ..Default::default()
1885 },
1886 );
1887
1888 let graph = LockfileGraph {
1889 importers,
1890 packages,
1891 ..Default::default()
1892 };
1893 let subset = graph
1894 .subset_to_importer("packages/lib", |_| true)
1895 .expect("packages/lib importer present");
1896
1897 assert_eq!(subset.importers.len(), 1);
1898 let roots = subset.root_deps();
1899 assert_eq!(roots.len(), 1);
1900 assert_eq!(roots[0].name, "is-odd");
1901
1902 assert!(subset.packages.contains_key("is-odd@3.0.1"));
1903 assert!(subset.packages.contains_key("is-number@6.0.0"));
1904 assert!(!subset.packages.contains_key("express@4.18.0"));
1905 }
1906
1907 #[test]
1908 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
1909 let mut importers = BTreeMap::new();
1915 importers.insert(
1916 "packages/lib".to_string(),
1917 vec![
1918 DirectDep {
1919 name: "is-odd".into(),
1920 dep_path: "is-odd@3.0.1".into(),
1921 dep_type: DepType::Production,
1922 specifier: Some("^3.0.1".into()),
1923 },
1924 DirectDep {
1925 name: "jest".into(),
1926 dep_path: "jest@29.0.0".into(),
1927 dep_type: DepType::Dev,
1928 specifier: Some("^29.0.0".into()),
1929 },
1930 ],
1931 );
1932 let mut packages = BTreeMap::new();
1933 packages.insert(
1934 "is-odd@3.0.1".to_string(),
1935 LockedPackage {
1936 name: "is-odd".into(),
1937 version: "3.0.1".into(),
1938 dep_path: "is-odd@3.0.1".into(),
1939 ..Default::default()
1940 },
1941 );
1942 packages.insert(
1943 "jest@29.0.0".to_string(),
1944 LockedPackage {
1945 name: "jest".into(),
1946 version: "29.0.0".into(),
1947 dep_path: "jest@29.0.0".into(),
1948 ..Default::default()
1949 },
1950 );
1951 let graph = LockfileGraph {
1952 importers,
1953 packages,
1954 ..Default::default()
1955 };
1956
1957 let prod = graph
1958 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
1959 .expect("importer present");
1960 let roots = prod.root_deps();
1961 assert_eq!(roots.len(), 1);
1962 assert_eq!(roots[0].name, "is-odd");
1963 assert!(prod.packages.contains_key("is-odd@3.0.1"));
1964 assert!(!prod.packages.contains_key("jest@29.0.0"));
1965 }
1966
1967 #[test]
1968 fn subset_to_importer_preserves_graph_settings() {
1969 let mut importers = BTreeMap::new();
1975 importers.insert("packages/lib".to_string(), vec![]);
1976 let graph = LockfileGraph {
1977 importers,
1978 packages: BTreeMap::new(),
1979 settings: LockfileSettings {
1980 auto_install_peers: false,
1981 exclude_links_from_lockfile: true,
1982 lockfile_include_tarball_url: true,
1983 },
1984 ..Default::default()
1985 };
1986 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1987 assert!(!subset.settings.auto_install_peers);
1988 assert!(subset.settings.exclude_links_from_lockfile);
1989 assert!(subset.settings.lockfile_include_tarball_url);
1990 }
1991
1992 #[test]
1993 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
1994 let mut importers = BTreeMap::new();
1999 importers.insert("packages/lib".to_string(), vec![]);
2000 importers.insert("packages/app".to_string(), vec![]);
2001 let mut skipped = BTreeMap::new();
2002 let mut lib_skip = BTreeMap::new();
2003 lib_skip.insert("fsevents".to_string(), "^2".to_string());
2004 skipped.insert("packages/lib".to_string(), lib_skip);
2005 let mut app_skip = BTreeMap::new();
2006 app_skip.insert("ghost".to_string(), "*".to_string());
2007 skipped.insert("packages/app".to_string(), app_skip);
2008 let graph = LockfileGraph {
2009 importers,
2010 packages: BTreeMap::new(),
2011 skipped_optional_dependencies: skipped,
2012 ..Default::default()
2013 };
2014 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
2015 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
2016 let root = subset.skipped_optional_dependencies.get(".").unwrap();
2017 assert!(root.contains_key("fsevents"));
2018 assert!(!root.contains_key("ghost"));
2019 }
2020
2021 #[test]
2022 fn workspace_drift_fresh_when_all_importers_match() {
2023 let root_dep = DirectDep {
2024 name: "lodash".into(),
2025 dep_path: "lodash@4.17.21".into(),
2026 dep_type: DepType::Production,
2027 specifier: Some("^4.17.0".into()),
2028 };
2029 let app_dep = DirectDep {
2030 name: "express".into(),
2031 dep_path: "express@4.18.0".into(),
2032 dep_type: DepType::Production,
2033 specifier: Some("^4.18.0".into()),
2034 };
2035 let mut importers = BTreeMap::new();
2036 importers.insert(".".to_string(), vec![root_dep]);
2037 importers.insert("packages/app".to_string(), vec![app_dep]);
2038 let graph = LockfileGraph {
2039 importers,
2040 packages: BTreeMap::new(),
2041 ..Default::default()
2042 };
2043
2044 let workspace_manifests = vec![
2045 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
2046 (
2047 "packages/app".to_string(),
2048 make_manifest(&[("express", "^4.18.0")]),
2049 ),
2050 ];
2051 assert_eq!(
2052 graph.check_drift_workspace(
2053 &workspace_manifests,
2054 &BTreeMap::new(),
2055 &[],
2056 &BTreeMap::new(),
2057 true,
2058 ),
2059 DriftStatus::Fresh
2060 );
2061 }
2062
2063 #[allow(clippy::type_complexity)]
2064 fn mk_catalogs(
2065 entries: &[(&str, &[(&str, &str, &str)])],
2066 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
2067 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
2068 for (cat, pkgs) in entries {
2069 let mut inner = BTreeMap::new();
2070 for (pkg, spec, ver) in *pkgs {
2071 inner.insert(
2072 (*pkg).to_string(),
2073 CatalogEntry {
2074 specifier: (*spec).to_string(),
2075 version: (*ver).to_string(),
2076 },
2077 );
2078 }
2079 out.insert((*cat).to_string(), inner);
2080 }
2081 out
2082 }
2083
2084 fn mk_workspace_catalogs(
2085 entries: &[(&str, &[(&str, &str)])],
2086 ) -> BTreeMap<String, BTreeMap<String, String>> {
2087 entries
2088 .iter()
2089 .map(|(cat, pkgs)| {
2090 (
2091 (*cat).to_string(),
2092 pkgs.iter()
2093 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
2094 .collect(),
2095 )
2096 })
2097 .collect()
2098 }
2099
2100 #[test]
2101 fn catalog_drift_fresh_when_specifiers_match() {
2102 let graph = LockfileGraph {
2103 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2104 ..Default::default()
2105 };
2106 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
2107 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2108 }
2109
2110 #[test]
2111 fn catalog_drift_stale_on_changed_specifier() {
2112 let graph = LockfileGraph {
2113 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2114 ..Default::default()
2115 };
2116 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
2117 match graph.check_catalogs_drift(&ws) {
2118 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2119 other => panic!("expected stale, got {other:?}"),
2120 }
2121 }
2122
2123 #[test]
2124 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
2125 let graph = LockfileGraph::default();
2129 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
2130 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2131 }
2132
2133 #[test]
2134 fn catalog_drift_stale_on_removed_workspace_entry() {
2135 let graph = LockfileGraph {
2136 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
2137 ..Default::default()
2138 };
2139 let ws = mk_workspace_catalogs(&[]);
2140 assert!(matches!(
2141 graph.check_catalogs_drift(&ws),
2142 DriftStatus::Stale { .. }
2143 ));
2144 }
2145}