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| cat.get(override_key_package_name(k)))
622 .cloned()
623 .unwrap_or_else(|| v.clone());
624 (k.clone(), resolved)
625 })
626 .collect()
627}
628
629fn override_key_package_name(key: &str) -> &str {
636 let last = key.rsplit('>').next().unwrap_or(key);
637 if let Some(after_scope) = last.strip_prefix('@') {
638 match after_scope.find('@') {
639 Some(idx) => &last[..idx + 1],
640 None => last,
641 }
642 } else {
643 match last.find('@') {
644 Some(idx) => &last[..idx],
645 None => last,
646 }
647 }
648}
649
650fn overrides_drift_reason(
656 lockfile: &BTreeMap<String, String>,
657 manifest: &BTreeMap<String, String>,
658) -> Option<String> {
659 for (k, v) in manifest {
660 match lockfile.get(k) {
661 None => return Some(format!("overrides: manifest adds {k}@{v}")),
662 Some(locked) if locked != v => {
663 return Some(format!("overrides: {k} changed ({locked} → {v})"));
664 }
665 Some(_) => {}
666 }
667 }
668 for k in lockfile.keys() {
669 if !manifest.contains_key(k) {
670 return Some(format!("overrides: manifest removes {k}"));
671 }
672 }
673 None
674}
675
676fn ignored_optional_drift_reason(
679 lockfile: &BTreeSet<String>,
680 manifest: &BTreeSet<String>,
681) -> Option<String> {
682 for name in manifest {
683 if !lockfile.contains(name) {
684 return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
685 }
686 }
687 for name in lockfile {
688 if !manifest.contains(name) {
689 return Some(format!(
690 "ignoredOptionalDependencies: manifest removes {name}"
691 ));
692 }
693 }
694 None
695}
696
697fn runtime_drift_reason(
707 runtimes: &BTreeMap<String, crate::RuntimePin>,
708 manifest: &aube_manifest::PackageJson,
709) -> Option<String> {
710 for (name, pin) in runtimes {
711 let entry = manifest
712 .dev_engines
713 .as_ref()
714 .and_then(|d| d.runtime.iter().find(|r| r.name == *name));
715 match entry {
716 None => {
717 return Some(format!(
718 "devEngines.runtime: manifest no longer pins {name} (lockfile records {})",
719 pin.version
720 ));
721 }
722 Some(entry) => match entry.version.as_deref() {
728 None => {}
729 Some(range) if range != pin.specifier => {
730 return Some(format!(
731 "devEngines.runtime: {name} changed ({} → {range})",
732 pin.specifier
733 ));
734 }
735 Some(_) => {}
736 },
737 }
738 }
739 None
740}
741
742#[derive(Debug, Clone, PartialEq, Eq)]
744pub enum DriftStatus {
745 Fresh,
747 Stale { reason: String },
749}
750
751fn kind_records_resolution_metadata(kind: LockfileKind) -> bool {
752 matches!(
753 kind,
754 LockfileKind::Aube | LockfileKind::Pnpm | LockfileKind::Bun
755 )
756}
757
758fn is_local_source_spec(spec: &str) -> bool {
765 spec.starts_with("link:")
766 || spec.starts_with("file:")
767 || spec.starts_with("portal:")
768 || spec.starts_with("exec:")
769}
770
771#[cfg(test)]
772mod drift_tests {
773 use super::*;
774 use crate::{CatalogEntry, LockedPackage, LockfileSettings};
775 use aube_manifest::PackageJson;
776 use std::collections::BTreeMap;
777 use std::path::PathBuf;
778
779 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
780 let mut m = PackageJson {
781 name: Some("test".into()),
782 version: Some("1.0.0".into()),
783 dependencies: BTreeMap::new(),
784 dev_dependencies: BTreeMap::new(),
785 peer_dependencies: BTreeMap::new(),
786 optional_dependencies: BTreeMap::new(),
787 update_config: None,
788 scripts: BTreeMap::new(),
789 engines: BTreeMap::new(),
790 dev_engines: None,
791 workspaces: None,
792 bundled_dependencies: None,
793 extra: BTreeMap::new(),
794 };
795 for (name, spec) in deps {
796 m.dependencies.insert((*name).into(), (*spec).into());
797 }
798 m
799 }
800
801 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
802 let direct: Vec<DirectDep> = deps
804 .iter()
805 .map(|(name, spec, dep_path)| DirectDep {
806 name: (*name).into(),
807 dep_path: (*dep_path).into(),
808 dep_type: DepType::Production,
809 specifier: Some((*spec).into()),
810 })
811 .collect();
812 let mut importers = BTreeMap::new();
813 importers.insert(".".to_string(), direct);
814 LockfileGraph {
815 importers,
816 packages: BTreeMap::new(),
817 ..Default::default()
818 }
819 }
820
821 #[test]
822 fn stale_when_dep_moves_between_sections() {
823 let mut manifest = make_manifest(&[]);
828 manifest
829 .dev_dependencies
830 .insert("msw".into(), "catalog:".into());
831 let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
832 graph
833 .importers
834 .get_mut(".")
835 .unwrap()
836 .iter_mut()
837 .for_each(|d| d.dep_type = DepType::Production);
838 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
839 DriftStatus::Stale { reason } => {
840 assert!(reason.contains("msw"), "reason: {reason}");
841 assert!(reason.contains("devDependencies"), "reason: {reason}");
842 }
843 DriftStatus::Fresh => panic!("expected Stale"),
844 }
845 }
846
847 #[test]
848 fn fresh_when_specifiers_match() {
849 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
850 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
851 assert_eq!(
852 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
853 DriftStatus::Fresh
854 );
855 }
856
857 #[test]
858 fn stale_when_specifier_changes() {
859 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
860 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
861 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
862 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
863 DriftStatus::Fresh => panic!("expected Stale"),
864 }
865 }
866
867 #[test]
868 fn stale_when_manifest_adds_dep() {
869 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
870 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
871 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
872 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
873 DriftStatus::Fresh => panic!("expected Stale"),
874 }
875 }
876
877 #[test]
878 fn stale_when_manifest_removes_dep() {
879 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
880 let graph = make_graph(&[
881 ("lodash", "^4.17.0", "lodash@4.17.21"),
882 ("express", "^4.18.0", "express@4.18.0"),
883 ]);
884 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
885 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
886 DriftStatus::Fresh => panic!("expected Stale"),
887 }
888 }
889
890 #[test]
891 fn fresh_when_pnpmfile_hook_rewrites_dep_to_link() {
892 let manifest = make_manifest(&[("@scope/api", "*")]);
901 let mut graph = make_graph(&[(
902 "@scope/api",
903 "link:../api/dist",
904 "@scope/api@link:../api/dist",
905 )]);
906 graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
907 assert_eq!(
908 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
909 DriftStatus::Fresh
910 );
911 }
912
913 #[test]
914 fn stale_when_link_importer_spec_has_no_pnpmfile_checksum() {
915 let manifest = make_manifest(&[("@scope/api", "*")]);
920 let graph = make_graph(&[(
921 "@scope/api",
922 "link:../api/dist",
923 "@scope/api@link:../api/dist",
924 )]);
925 assert!(matches!(
926 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
927 DriftStatus::Stale { .. }
928 ));
929 }
930
931 #[test]
932 fn stale_when_manifest_link_repointed_even_with_pnpmfile_checksum() {
933 let manifest = make_manifest(&[("@scope/api", "link:../api/old")]);
939 let mut graph = make_graph(&[(
940 "@scope/api",
941 "link:../api/new",
942 "@scope/api@link:../api/new",
943 )]);
944 graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
945 assert!(matches!(
946 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
947 DriftStatus::Stale { .. }
948 ));
949 }
950
951 #[test]
956 fn fresh_when_lockfile_has_auto_hoisted_peer() {
957 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
958 let mut graph = make_graph(&[
959 (
960 "use-sync-external-store",
961 "1.2.0",
962 "use-sync-external-store@1.2.0",
963 ),
964 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
967 ]);
968 let mut declaring_pkg = LockedPackage {
971 name: "use-sync-external-store".into(),
972 version: "1.2.0".into(),
973 dep_path: "use-sync-external-store@1.2.0".into(),
974 ..Default::default()
975 };
976 declaring_pkg
977 .peer_dependencies
978 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
979 graph
980 .packages
981 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
982
983 assert_eq!(
984 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
985 DriftStatus::Fresh
986 );
987 }
988
989 #[test]
995 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
996 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
999
1000 let mut graph = make_graph(&[
1003 (
1004 "use-sync-external-store",
1005 "1.2.0",
1006 "use-sync-external-store@1.2.0",
1007 ),
1008 ("react", "17.0.2", "react@17.0.2"),
1009 ]);
1010 let mut consumer = LockedPackage {
1015 name: "use-sync-external-store".into(),
1016 version: "1.2.0".into(),
1017 dep_path: "use-sync-external-store@1.2.0".into(),
1018 ..Default::default()
1019 };
1020 consumer
1021 .peer_dependencies
1022 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
1023 graph
1024 .packages
1025 .insert("use-sync-external-store@1.2.0".into(), consumer);
1026
1027 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1028 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1029 DriftStatus::Fresh => panic!(
1030 "drift check should flag a removed user-pinned dep as stale, \
1031 even when its name matches a peer declaration"
1032 ),
1033 }
1034 }
1035
1036 #[test]
1039 fn stale_when_lockfile_has_removed_non_peer_dep() {
1040 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1041 let graph = make_graph(&[
1042 ("lodash", "^4.17.0", "lodash@4.17.21"),
1043 ("chalk", "^5.0.0", "chalk@5.0.0"),
1044 ]);
1045 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1046 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
1047 DriftStatus::Fresh => panic!("expected Stale"),
1048 }
1049 }
1050
1051 #[test]
1052 fn workspace_drift_allows_root_links_for_workspace_packages() {
1053 let root_manifest = make_manifest(&[]);
1054 let mut app_manifest = make_manifest(&[]);
1055 app_manifest.name = Some("@scope/app".to_string());
1056
1057 let link = LocalSource::Link(PathBuf::from("packages/app"));
1058 let dep_path = link.dep_path("@scope/app");
1059 let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
1060 graph.packages.insert(
1061 dep_path.clone(),
1062 LockedPackage {
1063 name: "@scope/app".to_string(),
1064 version: "1.0.0".to_string(),
1065 dep_path,
1066 local_source: Some(link),
1067 ..Default::default()
1068 },
1069 );
1070
1071 assert_eq!(
1072 graph.check_drift_workspace(
1073 &[
1074 (".".to_string(), root_manifest),
1075 ("packages/app".to_string(), app_manifest),
1076 ],
1077 &BTreeMap::new(),
1078 &[],
1079 &BTreeMap::new(),
1080 true,
1081 ),
1082 DriftStatus::Fresh
1083 );
1084 }
1085
1086 #[test]
1087 fn fresh_when_no_specifiers_recorded() {
1088 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1091 let graph = LockfileGraph {
1092 importers: {
1093 let mut m = BTreeMap::new();
1094 m.insert(
1095 ".".to_string(),
1096 vec![DirectDep {
1097 name: "lodash".into(),
1098 dep_path: "lodash@4.17.21".into(),
1099 dep_type: DepType::Production,
1100 specifier: None,
1101 }],
1102 );
1103 m
1104 },
1105 packages: BTreeMap::new(),
1106 ..Default::default()
1107 };
1108 assert_eq!(
1109 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1110 DriftStatus::Fresh
1111 );
1112 }
1113
1114 #[test]
1115 fn stale_when_manifest_adds_override() {
1116 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1120 manifest
1121 .extra
1122 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1123 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1124 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1125 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1126 DriftStatus::Fresh => panic!("expected Stale"),
1127 }
1128 }
1129
1130 #[test]
1131 fn fresh_when_npm_lockfile_cannot_record_overrides() {
1132 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1137 manifest
1138 .extra
1139 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1140 let graph = LockfileGraph {
1141 importers: {
1142 let mut m = BTreeMap::new();
1143 m.insert(
1144 ".".to_string(),
1145 vec![DirectDep {
1146 name: "lodash".into(),
1147 dep_path: "lodash@4.17.21".into(),
1148 dep_type: DepType::Production,
1149 specifier: None,
1150 }],
1151 );
1152 m
1153 },
1154 packages: BTreeMap::new(),
1155 ..Default::default()
1156 };
1157 assert_eq!(
1158 graph.check_drift_for_kind(
1159 &manifest,
1160 &BTreeMap::new(),
1161 &[],
1162 &BTreeMap::new(),
1163 LockfileKind::Npm,
1164 ),
1165 DriftStatus::Fresh
1166 );
1167 }
1168
1169 #[test]
1170 fn stale_when_bun_lockfile_can_record_overrides() {
1171 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1172 manifest
1173 .extra
1174 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1175 let graph = LockfileGraph {
1176 importers: {
1177 let mut m = BTreeMap::new();
1178 m.insert(
1179 ".".to_string(),
1180 vec![DirectDep {
1181 name: "lodash".into(),
1182 dep_path: "lodash@4.17.21".into(),
1183 dep_type: DepType::Production,
1184 specifier: None,
1185 }],
1186 );
1187 m
1188 },
1189 packages: BTreeMap::new(),
1190 ..Default::default()
1191 };
1192 match graph.check_drift_for_kind(
1193 &manifest,
1194 &BTreeMap::new(),
1195 &[],
1196 &BTreeMap::new(),
1197 LockfileKind::Bun,
1198 ) {
1199 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1200 DriftStatus::Fresh => panic!("expected Stale"),
1201 }
1202 }
1203
1204 #[test]
1205 fn stale_drift_message_names_changed_override_key() {
1206 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1210 manifest
1211 .extra
1212 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.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("lodash"), "expected key in: {reason}");
1218 assert!(
1219 reason.contains("4.17.21"),
1220 "expected old value in: {reason}"
1221 );
1222 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
1223 }
1224 DriftStatus::Fresh => panic!("expected Stale"),
1225 }
1226 }
1227
1228 #[test]
1229 fn stale_when_manifest_removes_override() {
1230 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1231 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1232 graph.overrides.insert("lodash".into(), "4.17.21".into());
1233 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1234 DriftStatus::Stale { reason } => {
1235 assert!(reason.contains("removes"));
1236 assert!(reason.contains("lodash"));
1237 }
1238 DriftStatus::Fresh => panic!("expected Stale"),
1239 }
1240 }
1241
1242 #[test]
1243 fn fresh_when_overrides_match() {
1244 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1245 manifest
1246 .extra
1247 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1248 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1249 graph.overrides.insert("lodash".into(), "4.17.21".into());
1250 assert_eq!(
1251 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1252 DriftStatus::Fresh
1253 );
1254 }
1255
1256 #[test]
1257 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
1258 let manifest = make_manifest(&[("semver", "^7.5.0")]);
1264 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1265 graph.overrides.insert("semver".into(), "7.7.1".into());
1266 let mut ws_overrides = BTreeMap::new();
1267 ws_overrides.insert("semver".into(), "7.7.1".into());
1268 assert_eq!(
1269 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1270 DriftStatus::Fresh,
1271 );
1272 }
1273
1274 #[test]
1275 fn workspace_yaml_overrides_win_over_package_json() {
1276 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
1281 manifest
1282 .extra
1283 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
1284 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1285 graph.overrides.insert("semver".into(), "7.7.1".into());
1286 let mut ws_overrides = BTreeMap::new();
1287 ws_overrides.insert("semver".into(), "7.7.1".into());
1288 assert_eq!(
1289 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1290 DriftStatus::Fresh,
1291 );
1292 }
1293
1294 #[test]
1295 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
1296 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1302 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1303 graph.overrides.insert("lodash".into(), "4.17.21".into());
1304 let mut ws_overrides = BTreeMap::new();
1305 ws_overrides.insert("lodash".into(), "catalog:".into());
1306 let mut catalogs = BTreeMap::new();
1307 let mut default_cat = BTreeMap::new();
1308 default_cat.insert("lodash".into(), "4.17.21".into());
1309 catalogs.insert("default".into(), default_cat);
1310 assert_eq!(
1311 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1312 DriftStatus::Fresh,
1313 );
1314 }
1315
1316 #[test]
1317 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
1318 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1321 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1322 graph.overrides.insert("lodash".into(), "4.17.21".into());
1323 let mut ws_overrides = BTreeMap::new();
1324 ws_overrides.insert("lodash".into(), "catalog:evens".into());
1325 let mut catalogs = BTreeMap::new();
1326 let mut evens = BTreeMap::new();
1327 evens.insert("lodash".into(), "4.17.21".into());
1328 catalogs.insert("evens".into(), evens);
1329 assert_eq!(
1330 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1331 DriftStatus::Fresh,
1332 );
1333 }
1334
1335 #[test]
1336 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
1337 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1341 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1342 graph.overrides.insert("lodash".into(), "4.17.21".into());
1343 let mut ws_overrides = BTreeMap::new();
1344 ws_overrides.insert("lodash".into(), "catalog:".into());
1345 let mut catalogs = BTreeMap::new();
1346 let mut default_cat = BTreeMap::new();
1347 default_cat.insert("lodash".into(), "4.17.22".into());
1348 catalogs.insert("default".into(), default_cat);
1349 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
1350 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
1351 other => panic!("expected stale, got {other:?}"),
1352 }
1353 }
1354
1355 #[test]
1356 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
1357 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1364 let mut importers = BTreeMap::new();
1365 importers.insert(
1366 ".".to_string(),
1367 vec![DirectDep {
1368 name: "lodash".into(),
1369 dep_path: "lodash@4.17.21".into(),
1370 dep_type: DepType::Production,
1371 specifier: Some("4.17.21".into()),
1372 }],
1373 );
1374 let mut graph = LockfileGraph {
1375 importers,
1376 ..Default::default()
1377 };
1378 graph.overrides.insert("lodash".into(), "4.17.21".into());
1379 let mut ws_overrides = BTreeMap::new();
1380 ws_overrides.insert("lodash".into(), "4.17.21".into());
1381 assert_eq!(
1382 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1383 DriftStatus::Fresh,
1384 );
1385 }
1386
1387 #[test]
1388 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
1389 let manifest = make_manifest(&[("plist", "^3.0.4")]);
1396 let mut importers = BTreeMap::new();
1397 importers.insert(
1398 ".".to_string(),
1399 vec![DirectDep {
1400 name: "plist".into(),
1401 dep_path: "plist@3.0.6".into(),
1402 dep_type: DepType::Production,
1403 specifier: Some(">=3.0.5".into()),
1404 }],
1405 );
1406 let mut graph = LockfileGraph {
1407 importers,
1408 ..Default::default()
1409 };
1410 graph
1411 .overrides
1412 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
1413 let mut ws_overrides = BTreeMap::new();
1414 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
1415 assert_eq!(
1416 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1417 DriftStatus::Fresh,
1418 );
1419 }
1420
1421 #[test]
1422 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
1423 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1430 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1431 graph
1432 .ignored_optional_dependencies
1433 .insert("fsevents".to_string());
1434 let ws_ignored = vec!["fsevents".to_string()];
1435 assert_eq!(
1436 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
1437 DriftStatus::Fresh,
1438 );
1439 }
1440
1441 #[test]
1442 fn fresh_when_optional_dep_was_recorded_as_skipped() {
1443 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1448 manifest
1449 .optional_dependencies
1450 .insert("fsevents".into(), "^2.3.0".into());
1451 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1452 let mut inner = BTreeMap::new();
1453 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1454 graph
1455 .skipped_optional_dependencies
1456 .insert(".".to_string(), inner);
1457 assert_eq!(
1458 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1459 DriftStatus::Fresh
1460 );
1461 }
1462
1463 #[test]
1464 fn stale_when_new_optional_dep_was_never_seen() {
1465 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1471 manifest
1472 .optional_dependencies
1473 .insert("fsevents".into(), "^2.3.0".into());
1474 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1475 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1476 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1477 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
1478 }
1479 }
1480
1481 #[test]
1482 fn stale_when_skipped_optional_dep_specifier_changes() {
1483 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1487 manifest
1488 .optional_dependencies
1489 .insert("fsevents".into(), "^2.4.0".into());
1490 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1491 let mut inner = BTreeMap::new();
1492 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1493 graph
1494 .skipped_optional_dependencies
1495 .insert(".".to_string(), inner);
1496 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1497 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1498 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
1499 }
1500 }
1501
1502 #[test]
1503 fn stale_when_skipped_optional_is_promoted_to_required() {
1504 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1509 manifest.optional_dependencies.clear();
1513 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1514 let mut inner = BTreeMap::new();
1515 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1516 graph
1517 .skipped_optional_dependencies
1518 .insert(".".to_string(), inner);
1519 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1520 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1521 DriftStatus::Fresh => {
1522 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
1523 }
1524 }
1525 }
1526
1527 #[test]
1528 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
1529 let mut manifest = make_manifest(&[]);
1532 manifest
1533 .optional_dependencies
1534 .insert("fsevents".into(), "^2.4.0".into());
1535 let mut graph = make_graph(&[]);
1536 graph.importers.get_mut(".").unwrap().push(DirectDep {
1537 name: "fsevents".into(),
1538 dep_path: "fsevents@2.3.0".into(),
1539 dep_type: DepType::Optional,
1540 specifier: Some("^2.3.0".into()),
1541 });
1542 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1543 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1544 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
1545 }
1546 }
1547
1548 #[test]
1549 fn fresh_for_empty_manifest_and_lockfile() {
1550 let manifest = make_manifest(&[]);
1551 let graph = make_graph(&[]);
1552 assert_eq!(
1553 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1554 DriftStatus::Fresh
1555 );
1556 }
1557
1558 #[test]
1559 fn workspace_drift_detects_change_in_non_root_importer() {
1560 let root_dep = DirectDep {
1562 name: "lodash".into(),
1563 dep_path: "lodash@4.17.21".into(),
1564 dep_type: DepType::Production,
1565 specifier: Some("^4.17.0".into()),
1566 };
1567 let app_dep = DirectDep {
1568 name: "express".into(),
1569 dep_path: "express@4.18.0".into(),
1570 dep_type: DepType::Production,
1571 specifier: Some("^4.18.0".into()),
1572 };
1573 let mut importers = BTreeMap::new();
1574 importers.insert(".".to_string(), vec![root_dep]);
1575 importers.insert("packages/app".to_string(), vec![app_dep]);
1576 let graph = LockfileGraph {
1577 importers,
1578 packages: BTreeMap::new(),
1579 ..Default::default()
1580 };
1581
1582 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
1583 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
1585
1586 let workspace_manifests = vec![
1587 (".".to_string(), root_manifest.clone()),
1588 ("packages/app".to_string(), app_manifest),
1589 ];
1590 match graph.check_drift_workspace(
1591 &workspace_manifests,
1592 &BTreeMap::new(),
1593 &[],
1594 &BTreeMap::new(),
1595 true,
1596 ) {
1597 DriftStatus::Stale { reason } => {
1598 assert!(reason.contains("packages/app"));
1599 assert!(reason.contains("express"));
1600 }
1601 DriftStatus::Fresh => panic!("expected Stale"),
1602 }
1603
1604 assert_eq!(
1606 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1607 DriftStatus::Fresh
1608 );
1609 }
1610
1611 #[test]
1612 fn filter_deps_prunes_dev_only_subtree() {
1613 let mut importers = BTreeMap::new();
1617 importers.insert(
1618 ".".to_string(),
1619 vec![
1620 DirectDep {
1621 name: "foo".into(),
1622 dep_path: "foo@1.0.0".into(),
1623 dep_type: DepType::Production,
1624 specifier: Some("^1.0.0".into()),
1625 },
1626 DirectDep {
1627 name: "jest".into(),
1628 dep_path: "jest@29.0.0".into(),
1629 dep_type: DepType::Dev,
1630 specifier: Some("^29.0.0".into()),
1631 },
1632 ],
1633 );
1634
1635 let mut packages = BTreeMap::new();
1636 let mut foo_deps = BTreeMap::new();
1637 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
1638 packages.insert(
1639 "foo@1.0.0".to_string(),
1640 LockedPackage {
1641 name: "foo".into(),
1642 version: "1.0.0".into(),
1643 integrity: None,
1644 dependencies: foo_deps,
1645 dep_path: "foo@1.0.0".into(),
1646 ..Default::default()
1647 },
1648 );
1649 packages.insert(
1650 "bar@2.0.0".to_string(),
1651 LockedPackage {
1652 name: "bar".into(),
1653 version: "2.0.0".into(),
1654 integrity: None,
1655 dependencies: BTreeMap::new(),
1656 dep_path: "bar@2.0.0".into(),
1657 ..Default::default()
1658 },
1659 );
1660 let mut jest_deps = BTreeMap::new();
1661 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
1662 packages.insert(
1663 "jest@29.0.0".to_string(),
1664 LockedPackage {
1665 name: "jest".into(),
1666 version: "29.0.0".into(),
1667 integrity: None,
1668 dependencies: jest_deps,
1669 dep_path: "jest@29.0.0".into(),
1670 ..Default::default()
1671 },
1672 );
1673 packages.insert(
1674 "jest-core@29.0.0".to_string(),
1675 LockedPackage {
1676 name: "jest-core".into(),
1677 version: "29.0.0".into(),
1678 integrity: None,
1679 dependencies: BTreeMap::new(),
1680 dep_path: "jest-core@29.0.0".into(),
1681 ..Default::default()
1682 },
1683 );
1684
1685 let graph = LockfileGraph {
1686 importers,
1687 packages,
1688 ..Default::default()
1689 };
1690
1691 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1692
1693 let roots = prod.root_deps();
1695 assert_eq!(roots.len(), 1);
1696 assert_eq!(roots[0].name, "foo");
1697
1698 assert!(prod.packages.contains_key("foo@1.0.0"));
1700 assert!(prod.packages.contains_key("bar@2.0.0"));
1701 assert!(!prod.packages.contains_key("jest@29.0.0"));
1702 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
1703 }
1704
1705 #[test]
1712 fn filter_deps_preserves_lockfile_settings() {
1713 let graph = LockfileGraph {
1714 importers: BTreeMap::new(),
1715 packages: BTreeMap::new(),
1716 settings: LockfileSettings {
1717 auto_install_peers: false,
1718 exclude_links_from_lockfile: true,
1719 lockfile_include_tarball_url: false,
1720 },
1721 ..Default::default()
1722 };
1723 let filtered = graph.filter_deps(|_| true);
1724 assert!(!filtered.settings.auto_install_peers);
1725 assert!(filtered.settings.exclude_links_from_lockfile);
1726 }
1727
1728 #[test]
1729 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
1730 let mut importers = BTreeMap::new();
1734 importers.insert(
1735 ".".to_string(),
1736 vec![
1737 DirectDep {
1738 name: "foo".into(),
1739 dep_path: "foo@1.0.0".into(),
1740 dep_type: DepType::Production,
1741 specifier: Some("^1.0.0".into()),
1742 },
1743 DirectDep {
1744 name: "jest".into(),
1745 dep_path: "jest@29.0.0".into(),
1746 dep_type: DepType::Dev,
1747 specifier: Some("^29.0.0".into()),
1748 },
1749 ],
1750 );
1751
1752 let mut packages = BTreeMap::new();
1753 for (name, ver, deps) in [
1754 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
1755 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
1756 ("shared", "1.0.0", vec![]),
1757 ] {
1758 let mut dep_map = BTreeMap::new();
1759 for (n, v) in deps {
1760 dep_map.insert(n.to_string(), v.to_string());
1761 }
1762 packages.insert(
1763 format!("{name}@{ver}"),
1764 LockedPackage {
1765 name: name.into(),
1766 version: ver.into(),
1767 integrity: None,
1768 dependencies: dep_map,
1769 dep_path: format!("{name}@{ver}"),
1770 ..Default::default()
1771 },
1772 );
1773 }
1774
1775 let graph = LockfileGraph {
1776 importers,
1777 packages,
1778 ..Default::default()
1779 };
1780 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1781
1782 assert!(prod.packages.contains_key("foo@1.0.0"));
1783 assert!(prod.packages.contains_key("shared@1.0.0"));
1784 assert!(!prod.packages.contains_key("jest@29.0.0"));
1785 }
1786
1787 #[test]
1788 fn subset_to_importer_returns_none_for_missing_importer() {
1789 let graph = LockfileGraph {
1790 importers: BTreeMap::new(),
1791 packages: BTreeMap::new(),
1792 ..Default::default()
1793 };
1794 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
1795 }
1796
1797 #[test]
1798 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
1799 let mut importers = BTreeMap::new();
1806 importers.insert(".".to_string(), vec![]);
1807 importers.insert(
1808 "packages/lib".to_string(),
1809 vec![DirectDep {
1810 name: "is-odd".into(),
1811 dep_path: "is-odd@3.0.1".into(),
1812 dep_type: DepType::Production,
1813 specifier: Some("^3.0.1".into()),
1814 }],
1815 );
1816 importers.insert(
1817 "packages/app".to_string(),
1818 vec![DirectDep {
1819 name: "express".into(),
1820 dep_path: "express@4.18.0".into(),
1821 dep_type: DepType::Production,
1822 specifier: Some("^4.18.0".into()),
1823 }],
1824 );
1825
1826 let mut packages = BTreeMap::new();
1827 let mut is_odd_deps = BTreeMap::new();
1828 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
1829 packages.insert(
1830 "is-odd@3.0.1".to_string(),
1831 LockedPackage {
1832 name: "is-odd".into(),
1833 version: "3.0.1".into(),
1834 dependencies: is_odd_deps,
1835 dep_path: "is-odd@3.0.1".into(),
1836 ..Default::default()
1837 },
1838 );
1839 packages.insert(
1840 "is-number@6.0.0".to_string(),
1841 LockedPackage {
1842 name: "is-number".into(),
1843 version: "6.0.0".into(),
1844 dep_path: "is-number@6.0.0".into(),
1845 ..Default::default()
1846 },
1847 );
1848 packages.insert(
1849 "express@4.18.0".to_string(),
1850 LockedPackage {
1851 name: "express".into(),
1852 version: "4.18.0".into(),
1853 dep_path: "express@4.18.0".into(),
1854 ..Default::default()
1855 },
1856 );
1857
1858 let graph = LockfileGraph {
1859 importers,
1860 packages,
1861 ..Default::default()
1862 };
1863 let subset = graph
1864 .subset_to_importer("packages/lib", |_| true)
1865 .expect("packages/lib importer present");
1866
1867 assert_eq!(subset.importers.len(), 1);
1868 let roots = subset.root_deps();
1869 assert_eq!(roots.len(), 1);
1870 assert_eq!(roots[0].name, "is-odd");
1871
1872 assert!(subset.packages.contains_key("is-odd@3.0.1"));
1873 assert!(subset.packages.contains_key("is-number@6.0.0"));
1874 assert!(!subset.packages.contains_key("express@4.18.0"));
1875 }
1876
1877 #[test]
1878 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
1879 let mut importers = BTreeMap::new();
1885 importers.insert(
1886 "packages/lib".to_string(),
1887 vec![
1888 DirectDep {
1889 name: "is-odd".into(),
1890 dep_path: "is-odd@3.0.1".into(),
1891 dep_type: DepType::Production,
1892 specifier: Some("^3.0.1".into()),
1893 },
1894 DirectDep {
1895 name: "jest".into(),
1896 dep_path: "jest@29.0.0".into(),
1897 dep_type: DepType::Dev,
1898 specifier: Some("^29.0.0".into()),
1899 },
1900 ],
1901 );
1902 let mut packages = BTreeMap::new();
1903 packages.insert(
1904 "is-odd@3.0.1".to_string(),
1905 LockedPackage {
1906 name: "is-odd".into(),
1907 version: "3.0.1".into(),
1908 dep_path: "is-odd@3.0.1".into(),
1909 ..Default::default()
1910 },
1911 );
1912 packages.insert(
1913 "jest@29.0.0".to_string(),
1914 LockedPackage {
1915 name: "jest".into(),
1916 version: "29.0.0".into(),
1917 dep_path: "jest@29.0.0".into(),
1918 ..Default::default()
1919 },
1920 );
1921 let graph = LockfileGraph {
1922 importers,
1923 packages,
1924 ..Default::default()
1925 };
1926
1927 let prod = graph
1928 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
1929 .expect("importer present");
1930 let roots = prod.root_deps();
1931 assert_eq!(roots.len(), 1);
1932 assert_eq!(roots[0].name, "is-odd");
1933 assert!(prod.packages.contains_key("is-odd@3.0.1"));
1934 assert!(!prod.packages.contains_key("jest@29.0.0"));
1935 }
1936
1937 #[test]
1938 fn subset_to_importer_preserves_graph_settings() {
1939 let mut importers = BTreeMap::new();
1945 importers.insert("packages/lib".to_string(), vec![]);
1946 let graph = LockfileGraph {
1947 importers,
1948 packages: BTreeMap::new(),
1949 settings: LockfileSettings {
1950 auto_install_peers: false,
1951 exclude_links_from_lockfile: true,
1952 lockfile_include_tarball_url: true,
1953 },
1954 ..Default::default()
1955 };
1956 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1957 assert!(!subset.settings.auto_install_peers);
1958 assert!(subset.settings.exclude_links_from_lockfile);
1959 assert!(subset.settings.lockfile_include_tarball_url);
1960 }
1961
1962 #[test]
1963 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
1964 let mut importers = BTreeMap::new();
1969 importers.insert("packages/lib".to_string(), vec![]);
1970 importers.insert("packages/app".to_string(), vec![]);
1971 let mut skipped = BTreeMap::new();
1972 let mut lib_skip = BTreeMap::new();
1973 lib_skip.insert("fsevents".to_string(), "^2".to_string());
1974 skipped.insert("packages/lib".to_string(), lib_skip);
1975 let mut app_skip = BTreeMap::new();
1976 app_skip.insert("ghost".to_string(), "*".to_string());
1977 skipped.insert("packages/app".to_string(), app_skip);
1978 let graph = LockfileGraph {
1979 importers,
1980 packages: BTreeMap::new(),
1981 skipped_optional_dependencies: skipped,
1982 ..Default::default()
1983 };
1984 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1985 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
1986 let root = subset.skipped_optional_dependencies.get(".").unwrap();
1987 assert!(root.contains_key("fsevents"));
1988 assert!(!root.contains_key("ghost"));
1989 }
1990
1991 #[test]
1992 fn workspace_drift_fresh_when_all_importers_match() {
1993 let root_dep = DirectDep {
1994 name: "lodash".into(),
1995 dep_path: "lodash@4.17.21".into(),
1996 dep_type: DepType::Production,
1997 specifier: Some("^4.17.0".into()),
1998 };
1999 let app_dep = DirectDep {
2000 name: "express".into(),
2001 dep_path: "express@4.18.0".into(),
2002 dep_type: DepType::Production,
2003 specifier: Some("^4.18.0".into()),
2004 };
2005 let mut importers = BTreeMap::new();
2006 importers.insert(".".to_string(), vec![root_dep]);
2007 importers.insert("packages/app".to_string(), vec![app_dep]);
2008 let graph = LockfileGraph {
2009 importers,
2010 packages: BTreeMap::new(),
2011 ..Default::default()
2012 };
2013
2014 let workspace_manifests = vec![
2015 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
2016 (
2017 "packages/app".to_string(),
2018 make_manifest(&[("express", "^4.18.0")]),
2019 ),
2020 ];
2021 assert_eq!(
2022 graph.check_drift_workspace(
2023 &workspace_manifests,
2024 &BTreeMap::new(),
2025 &[],
2026 &BTreeMap::new(),
2027 true,
2028 ),
2029 DriftStatus::Fresh
2030 );
2031 }
2032
2033 #[allow(clippy::type_complexity)]
2034 fn mk_catalogs(
2035 entries: &[(&str, &[(&str, &str, &str)])],
2036 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
2037 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
2038 for (cat, pkgs) in entries {
2039 let mut inner = BTreeMap::new();
2040 for (pkg, spec, ver) in *pkgs {
2041 inner.insert(
2042 (*pkg).to_string(),
2043 CatalogEntry {
2044 specifier: (*spec).to_string(),
2045 version: (*ver).to_string(),
2046 },
2047 );
2048 }
2049 out.insert((*cat).to_string(), inner);
2050 }
2051 out
2052 }
2053
2054 fn mk_workspace_catalogs(
2055 entries: &[(&str, &[(&str, &str)])],
2056 ) -> BTreeMap<String, BTreeMap<String, String>> {
2057 entries
2058 .iter()
2059 .map(|(cat, pkgs)| {
2060 (
2061 (*cat).to_string(),
2062 pkgs.iter()
2063 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
2064 .collect(),
2065 )
2066 })
2067 .collect()
2068 }
2069
2070 #[test]
2071 fn catalog_drift_fresh_when_specifiers_match() {
2072 let graph = LockfileGraph {
2073 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2074 ..Default::default()
2075 };
2076 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
2077 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2078 }
2079
2080 #[test]
2081 fn catalog_drift_stale_on_changed_specifier() {
2082 let graph = LockfileGraph {
2083 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2084 ..Default::default()
2085 };
2086 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
2087 match graph.check_catalogs_drift(&ws) {
2088 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2089 other => panic!("expected stale, got {other:?}"),
2090 }
2091 }
2092
2093 #[test]
2094 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
2095 let graph = LockfileGraph::default();
2099 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
2100 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2101 }
2102
2103 #[test]
2104 fn catalog_drift_stale_on_removed_workspace_entry() {
2105 let graph = LockfileGraph {
2106 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
2107 ..Default::default()
2108 };
2109 let ws = mk_workspace_catalogs(&[]);
2110 assert!(matches!(
2111 graph.check_catalogs_drift(&ws),
2112 DriftStatus::Stale { .. }
2113 ));
2114 }
2115}