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 .chain(
398 self.settings
399 .auto_install_peers
400 .then_some(&manifest.peer_dependencies)
401 .into_iter()
402 .flatten()
403 .filter(|(name, _)| !manifest.peer_dependency_is_optional(name))
404 .filter(|(name, _)| {
405 !manifest.dependencies.contains_key(*name)
406 && !manifest.dev_dependencies.contains_key(*name)
407 && !manifest.optional_dependencies.contains_key(*name)
408 })
409 .map(|(k, v)| (k, v, false)),
410 );
411
412 for (name, spec, is_optional) in manifest_deps {
413 match lockfile_specs.get(name.as_str()) {
414 None => {
415 if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
425 if *locked_spec == spec {
426 continue;
427 }
428 return DriftStatus::Stale {
429 reason: format!(
430 "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
431 ),
432 };
433 }
434 return DriftStatus::Stale {
435 reason: format!("{label}manifest adds {name}@{spec}"),
436 };
437 }
438 Some(locked_spec) if *locked_spec != spec => {
439 let importer_peer_only = !manifest.dependencies.contains_key(name)
440 && !manifest.dev_dependencies.contains_key(name)
441 && !manifest.optional_dependencies.contains_key(name);
442 if importer_peer_only && retained_peer_spec_is_compatible(spec, locked_spec) {
443 continue;
444 }
445 if let Some(override_spec) =
455 override_match::apply(&override_rules, name.as_str(), spec)
456 && override_spec == *locked_spec
457 {
458 continue;
459 }
460 if self.pnpmfile_checksum.is_some()
483 && is_local_source_spec(locked_spec)
484 && !is_local_source_spec(spec)
485 {
486 continue;
487 }
488 return DriftStatus::Stale {
489 reason: format!(
490 "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
491 ),
492 };
493 }
494 Some(_) => {}
495 }
496 }
497
498 let mut manifest_dep_types: BTreeMap<&str, DepType> = BTreeMap::new();
506 for name in manifest.dependencies.keys() {
507 manifest_dep_types.insert(name.as_str(), DepType::Production);
508 }
509 for name in manifest.dev_dependencies.keys() {
510 manifest_dep_types
511 .entry(name.as_str())
512 .or_insert(DepType::Dev);
513 }
514 for name in manifest.optional_dependencies.keys() {
515 if ignored.contains(name.as_str()) {
516 continue;
517 }
518 manifest_dep_types
519 .entry(name.as_str())
520 .or_insert(DepType::Optional);
521 }
522 if self.settings.auto_install_peers {
523 for name in manifest.peer_dependencies.keys() {
524 if manifest.peer_dependency_is_optional(name) {
525 continue;
526 }
527 manifest_dep_types
528 .entry(name.as_str())
529 .or_insert(DepType::Production);
530 }
531 }
532 for dep in importer_deps {
533 let Some(expected) = manifest_dep_types.get(dep.name.as_str()) else {
534 continue;
535 };
536 if *expected != dep.dep_type {
537 return DriftStatus::Stale {
538 reason: format!(
539 "{label}{}: manifest section is {}, lockfile section is {}",
540 dep.name,
541 dep_type_label(*expected),
542 dep_type_label(dep.dep_type),
543 ),
544 };
545 }
546 }
547
548 let manifest_names: std::collections::HashSet<&str> = manifest
554 .dependencies
555 .keys()
556 .chain(manifest.dev_dependencies.keys())
557 .chain(
558 manifest
559 .optional_dependencies
560 .keys()
561 .filter(|name| !ignored.contains(name.as_str())),
562 )
563 .chain(
564 self.settings
565 .auto_install_peers
566 .then_some(&manifest.peer_dependencies)
567 .into_iter()
568 .flatten()
569 .filter(|(name, _)| !manifest.peer_dependency_is_optional(name))
570 .map(|(name, _)| name),
571 )
572 .map(|s| s.as_str())
573 .collect();
574 for (locked_name, locked_spec) in &lockfile_specs {
575 if manifest_names.contains(locked_name) {
576 continue;
577 }
578 if manifest
585 .peer_dependencies
586 .get(*locked_name)
587 .is_some_and(|peer_range| retained_peer_spec_is_compatible(peer_range, locked_spec))
588 {
589 continue;
590 }
591 let workspace_link = importer_path == "."
592 && workspace_link_names.contains(locked_name)
593 && importer_deps
594 .iter()
595 .find(|dep| dep.name == *locked_name)
596 .and_then(|dep| self.packages.get(&dep.dep_path))
597 .is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))));
598 if workspace_link {
599 continue;
600 }
601 return DriftStatus::Stale {
602 reason: format!("{label}manifest removed {locked_name}"),
603 };
604 }
605
606 DriftStatus::Fresh
607 }
608}
609
610fn retained_peer_spec_is_compatible(peer_range: &str, locked_spec: &str) -> bool {
611 if peer_range == locked_spec {
612 return true;
613 }
614 let normalized_range = if peer_range.trim().is_empty() {
615 "*"
616 } else {
617 peer_range
618 };
619 node_semver::Version::parse(locked_spec)
620 .ok()
621 .zip(node_semver::Range::parse(normalized_range).ok())
622 .is_some_and(|(version, range)| version.satisfies(&range))
623}
624
625fn merge_manifest_and_workspace_overrides(
631 manifest: &aube_manifest::PackageJson,
632 workspace_overrides: &BTreeMap<String, String>,
633) -> BTreeMap<String, String> {
634 let mut out = manifest.overrides_map();
635 for (k, v) in workspace_overrides {
636 out.insert(k.clone(), v.clone());
637 }
638 out
639}
640
641fn resolve_catalog_refs_in_overrides(
651 overrides: &BTreeMap<String, String>,
652 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
653) -> BTreeMap<String, String> {
654 overrides
655 .iter()
656 .map(|(k, v)| {
657 let resolved = v
658 .strip_prefix("catalog:")
659 .map(|tail| if tail.is_empty() { "default" } else { tail })
660 .and_then(|cat_name| workspace_catalogs.get(cat_name))
661 .and_then(|cat| {
662 override_match::target_package_name(k)
663 .and_then(|package_name| cat.get(&package_name))
664 })
665 .cloned()
666 .unwrap_or_else(|| v.clone());
667 (k.clone(), resolved)
668 })
669 .collect()
670}
671
672fn overrides_drift_reason(
678 lockfile: &BTreeMap<String, String>,
679 manifest: &BTreeMap<String, String>,
680) -> Option<String> {
681 for (k, v) in manifest {
682 match lockfile.get(k) {
683 None => return Some(format!("overrides: manifest adds {k}@{v}")),
684 Some(locked) if locked != v => {
685 return Some(format!("overrides: {k} changed ({locked} → {v})"));
686 }
687 Some(_) => {}
688 }
689 }
690 for k in lockfile.keys() {
691 if !manifest.contains_key(k) {
692 return Some(format!("overrides: manifest removes {k}"));
693 }
694 }
695 None
696}
697
698fn ignored_optional_drift_reason(
701 lockfile: &BTreeSet<String>,
702 manifest: &BTreeSet<String>,
703) -> Option<String> {
704 for name in manifest {
705 if !lockfile.contains(name) {
706 return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
707 }
708 }
709 for name in lockfile {
710 if !manifest.contains(name) {
711 return Some(format!(
712 "ignoredOptionalDependencies: manifest removes {name}"
713 ));
714 }
715 }
716 None
717}
718
719fn runtime_drift_reason(
729 runtimes: &BTreeMap<String, crate::RuntimePin>,
730 manifest: &aube_manifest::PackageJson,
731) -> Option<String> {
732 for (name, pin) in runtimes {
733 let entry = manifest
734 .dev_engines
735 .as_ref()
736 .and_then(|d| d.runtime.iter().find(|r| r.name == *name));
737 match entry {
738 None => {
739 return Some(format!(
740 "devEngines.runtime: manifest no longer pins {name} (lockfile records {})",
741 pin.version
742 ));
743 }
744 Some(entry) => match entry.version.as_deref() {
750 None => {}
751 Some(range) if range != pin.specifier => {
752 return Some(format!(
753 "devEngines.runtime: {name} changed ({} → {range})",
754 pin.specifier
755 ));
756 }
757 Some(_) => {}
758 },
759 }
760 }
761 None
762}
763
764#[derive(Debug, Clone, PartialEq, Eq)]
766pub enum DriftStatus {
767 Fresh,
769 Stale { reason: String },
771}
772
773fn kind_records_resolution_metadata(kind: LockfileKind) -> bool {
774 matches!(
775 kind,
776 LockfileKind::Aube | LockfileKind::Pnpm | LockfileKind::Bun
777 )
778}
779
780fn is_local_source_spec(spec: &str) -> bool {
787 spec.starts_with("link:")
788 || spec.starts_with("file:")
789 || spec.starts_with("portal:")
790 || spec.starts_with("exec:")
791}
792
793#[cfg(test)]
794mod drift_tests {
795 use super::*;
796 use crate::{CatalogEntry, LockedPackage, LockfileSettings};
797 use aube_manifest::PackageJson;
798 use std::collections::BTreeMap;
799 use std::path::PathBuf;
800
801 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
802 let mut m = PackageJson {
803 name: Some("test".into()),
804 version: Some("1.0.0".into()),
805 dependencies: BTreeMap::new(),
806 dev_dependencies: BTreeMap::new(),
807 peer_dependencies: BTreeMap::new(),
808 optional_dependencies: BTreeMap::new(),
809 update_config: None,
810 scripts: BTreeMap::new(),
811 engines: BTreeMap::new(),
812 dev_engines: None,
813 workspaces: None,
814 bundled_dependencies: None,
815 extra: BTreeMap::new(),
816 };
817 for (name, spec) in deps {
818 m.dependencies.insert((*name).into(), (*spec).into());
819 }
820 m
821 }
822
823 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
824 let direct: Vec<DirectDep> = deps
826 .iter()
827 .map(|(name, spec, dep_path)| DirectDep {
828 name: (*name).into(),
829 dep_path: (*dep_path).into(),
830 dep_type: DepType::Production,
831 specifier: Some((*spec).into()),
832 })
833 .collect();
834 let mut importers = BTreeMap::new();
835 importers.insert(".".to_string(), direct);
836 LockfileGraph {
837 importers,
838 packages: BTreeMap::new(),
839 ..Default::default()
840 }
841 }
842
843 #[test]
844 fn stale_when_dep_moves_between_sections() {
845 let mut manifest = make_manifest(&[]);
850 manifest
851 .dev_dependencies
852 .insert("msw".into(), "catalog:".into());
853 let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
854 graph
855 .importers
856 .get_mut(".")
857 .unwrap()
858 .iter_mut()
859 .for_each(|d| d.dep_type = DepType::Production);
860 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
861 DriftStatus::Stale { reason } => {
862 assert!(reason.contains("msw"), "reason: {reason}");
863 assert!(reason.contains("devDependencies"), "reason: {reason}");
864 }
865 DriftStatus::Fresh => panic!("expected Stale"),
866 }
867 }
868
869 #[test]
870 fn fresh_when_specifiers_match() {
871 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
872 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
873 assert_eq!(
874 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
875 DriftStatus::Fresh
876 );
877 }
878
879 #[test]
880 fn stale_when_specifier_changes() {
881 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
882 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
883 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
884 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
885 DriftStatus::Fresh => panic!("expected Stale"),
886 }
887 }
888
889 #[test]
890 fn stale_when_manifest_adds_dep() {
891 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
892 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
893 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
894 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
895 DriftStatus::Fresh => panic!("expected Stale"),
896 }
897 }
898
899 #[test]
900 fn stale_when_manifest_removes_dep() {
901 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
902 let graph = make_graph(&[
903 ("lodash", "^4.17.0", "lodash@4.17.21"),
904 ("express", "^4.18.0", "express@4.18.0"),
905 ]);
906 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
907 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
908 DriftStatus::Fresh => panic!("expected Stale"),
909 }
910 }
911
912 #[test]
913 fn fresh_when_pnpmfile_hook_rewrites_dep_to_link() {
914 let manifest = make_manifest(&[("@scope/api", "*")]);
923 let mut graph = make_graph(&[(
924 "@scope/api",
925 "link:../api/dist",
926 "@scope/api@link:../api/dist",
927 )]);
928 graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
929 assert_eq!(
930 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
931 DriftStatus::Fresh
932 );
933 }
934
935 #[test]
936 fn stale_when_link_importer_spec_has_no_pnpmfile_checksum() {
937 let manifest = make_manifest(&[("@scope/api", "*")]);
942 let graph = make_graph(&[(
943 "@scope/api",
944 "link:../api/dist",
945 "@scope/api@link:../api/dist",
946 )]);
947 assert!(matches!(
948 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
949 DriftStatus::Stale { .. }
950 ));
951 }
952
953 #[test]
954 fn stale_when_manifest_link_repointed_even_with_pnpmfile_checksum() {
955 let manifest = make_manifest(&[("@scope/api", "link:../api/old")]);
961 let mut graph = make_graph(&[(
962 "@scope/api",
963 "link:../api/new",
964 "@scope/api@link:../api/new",
965 )]);
966 graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
967 assert!(matches!(
968 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
969 DriftStatus::Stale { .. }
970 ));
971 }
972
973 #[test]
977 fn stale_when_dependency_peer_was_hoisted_into_importer() {
978 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
979 let mut graph = make_graph(&[
980 (
981 "use-sync-external-store",
982 "1.2.0",
983 "use-sync-external-store@1.2.0",
984 ),
985 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
988 ]);
989 let mut declaring_pkg = LockedPackage {
990 name: "use-sync-external-store".into(),
991 version: "1.2.0".into(),
992 dep_path: "use-sync-external-store@1.2.0".into(),
993 ..Default::default()
994 };
995 declaring_pkg
996 .peer_dependencies
997 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
998 graph
999 .packages
1000 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
1001
1002 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1003 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1004 DriftStatus::Fresh => panic!("dependency peer must not remain in the importer"),
1005 }
1006 }
1007
1008 #[test]
1009 fn fresh_when_importers_own_peer_is_auto_installed() {
1010 let mut manifest = make_manifest(&[]);
1011 manifest
1012 .peer_dependencies
1013 .insert("react".into(), "19.2.0".into());
1014 let graph = make_graph(&[("react", "19.2.0", "react@19.2.0")]);
1015
1016 assert_eq!(
1017 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1018 DriftStatus::Fresh
1019 );
1020 }
1021
1022 #[test]
1023 fn fresh_when_lockfile_retains_importer_peer_with_matching_range() {
1024 let mut manifest = make_manifest(&[]);
1025 manifest
1026 .peer_dependencies
1027 .insert("@tanstack/react-table".into(), "^8".into());
1028 let graph = make_graph(&[(
1029 "@tanstack/react-table",
1030 "^8",
1031 "@tanstack/react-table@8.21.3",
1032 )]);
1033
1034 assert_eq!(
1035 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1036 DriftStatus::Fresh
1037 );
1038 }
1039
1040 #[test]
1041 fn fresh_when_lockfile_retains_importer_peer_with_satisfying_exact_version() {
1042 let mut manifest = make_manifest(&[]);
1043 manifest
1044 .peer_dependencies
1045 .insert("react-router-dom".into(), "^5".into());
1046 let graph = make_graph(&[("react-router-dom", "5.3.4", "react-router-dom@5.3.4")]);
1047
1048 assert_eq!(
1049 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1050 DriftStatus::Fresh
1051 );
1052 }
1053
1054 #[test]
1055 fn fresh_when_retained_importer_peer_range_is_empty() {
1056 for peer_range in ["", " \t"] {
1057 let mut manifest = make_manifest(&[]);
1058 manifest
1059 .peer_dependencies
1060 .insert("react-router-dom".into(), peer_range.into());
1061 let graph = make_graph(&[("react-router-dom", "5.3.4", "react-router-dom@5.3.4")]);
1062
1063 assert_eq!(
1064 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1065 DriftStatus::Fresh
1066 );
1067 }
1068 }
1069
1070 #[test]
1071 fn stale_when_retained_importer_peer_version_does_not_satisfy_range() {
1072 let mut manifest = make_manifest(&[]);
1073 manifest
1074 .peer_dependencies
1075 .insert("react-router-dom".into(), "^6".into());
1076 let graph = make_graph(&[("react-router-dom", "5.3.4", "react-router-dom@5.3.4")]);
1077
1078 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1079 DriftStatus::Stale { reason } => assert!(reason.contains("react-router-dom")),
1080 DriftStatus::Fresh => panic!("an incompatible retained peer must be stale"),
1081 }
1082 }
1083
1084 #[test]
1090 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
1091 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
1094
1095 let mut graph = make_graph(&[
1098 (
1099 "use-sync-external-store",
1100 "1.2.0",
1101 "use-sync-external-store@1.2.0",
1102 ),
1103 ("react", "17.0.2", "react@17.0.2"),
1104 ]);
1105 let mut consumer = LockedPackage {
1110 name: "use-sync-external-store".into(),
1111 version: "1.2.0".into(),
1112 dep_path: "use-sync-external-store@1.2.0".into(),
1113 ..Default::default()
1114 };
1115 consumer
1116 .peer_dependencies
1117 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
1118 graph
1119 .packages
1120 .insert("use-sync-external-store@1.2.0".into(), consumer);
1121
1122 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1123 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1124 DriftStatus::Fresh => panic!(
1125 "drift check should flag a removed user-pinned dep as stale, \
1126 even when its name matches a peer declaration"
1127 ),
1128 }
1129 }
1130
1131 #[test]
1134 fn stale_when_lockfile_has_removed_non_peer_dep() {
1135 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1136 let graph = make_graph(&[
1137 ("lodash", "^4.17.0", "lodash@4.17.21"),
1138 ("chalk", "^5.0.0", "chalk@5.0.0"),
1139 ]);
1140 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1141 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
1142 DriftStatus::Fresh => panic!("expected Stale"),
1143 }
1144 }
1145
1146 #[test]
1147 fn workspace_drift_allows_root_links_for_workspace_packages() {
1148 let root_manifest = make_manifest(&[]);
1149 let mut app_manifest = make_manifest(&[]);
1150 app_manifest.name = Some("@scope/app".to_string());
1151
1152 let link = LocalSource::Link(PathBuf::from("packages/app"));
1153 let dep_path = link.dep_path("@scope/app");
1154 let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
1155 graph.packages.insert(
1156 dep_path.clone(),
1157 LockedPackage {
1158 name: "@scope/app".to_string(),
1159 version: "1.0.0".to_string(),
1160 dep_path,
1161 local_source: Some(link),
1162 ..Default::default()
1163 },
1164 );
1165
1166 assert_eq!(
1167 graph.check_drift_workspace(
1168 &[
1169 (".".to_string(), root_manifest),
1170 ("packages/app".to_string(), app_manifest),
1171 ],
1172 &BTreeMap::new(),
1173 &[],
1174 &BTreeMap::new(),
1175 true,
1176 ),
1177 DriftStatus::Fresh
1178 );
1179 }
1180
1181 #[test]
1182 fn fresh_when_no_specifiers_recorded() {
1183 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1186 let graph = LockfileGraph {
1187 importers: {
1188 let mut m = BTreeMap::new();
1189 m.insert(
1190 ".".to_string(),
1191 vec![DirectDep {
1192 name: "lodash".into(),
1193 dep_path: "lodash@4.17.21".into(),
1194 dep_type: DepType::Production,
1195 specifier: None,
1196 }],
1197 );
1198 m
1199 },
1200 packages: BTreeMap::new(),
1201 ..Default::default()
1202 };
1203 assert_eq!(
1204 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1205 DriftStatus::Fresh
1206 );
1207 }
1208
1209 #[test]
1210 fn stale_when_manifest_adds_override() {
1211 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1215 manifest
1216 .extra
1217 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1218 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1219 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1220 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1221 DriftStatus::Fresh => panic!("expected Stale"),
1222 }
1223 }
1224
1225 #[test]
1226 fn fresh_when_npm_lockfile_cannot_record_overrides() {
1227 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1232 manifest
1233 .extra
1234 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1235 let graph = LockfileGraph {
1236 importers: {
1237 let mut m = BTreeMap::new();
1238 m.insert(
1239 ".".to_string(),
1240 vec![DirectDep {
1241 name: "lodash".into(),
1242 dep_path: "lodash@4.17.21".into(),
1243 dep_type: DepType::Production,
1244 specifier: None,
1245 }],
1246 );
1247 m
1248 },
1249 packages: BTreeMap::new(),
1250 ..Default::default()
1251 };
1252 assert_eq!(
1253 graph.check_drift_for_kind(
1254 &manifest,
1255 &BTreeMap::new(),
1256 &[],
1257 &BTreeMap::new(),
1258 LockfileKind::Npm,
1259 ),
1260 DriftStatus::Fresh
1261 );
1262 }
1263
1264 #[test]
1265 fn stale_when_bun_lockfile_can_record_overrides() {
1266 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1267 manifest
1268 .extra
1269 .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1270 let graph = LockfileGraph {
1271 importers: {
1272 let mut m = BTreeMap::new();
1273 m.insert(
1274 ".".to_string(),
1275 vec![DirectDep {
1276 name: "lodash".into(),
1277 dep_path: "lodash@4.17.21".into(),
1278 dep_type: DepType::Production,
1279 specifier: None,
1280 }],
1281 );
1282 m
1283 },
1284 packages: BTreeMap::new(),
1285 ..Default::default()
1286 };
1287 match graph.check_drift_for_kind(
1288 &manifest,
1289 &BTreeMap::new(),
1290 &[],
1291 &BTreeMap::new(),
1292 LockfileKind::Bun,
1293 ) {
1294 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1295 DriftStatus::Fresh => panic!("expected Stale"),
1296 }
1297 }
1298
1299 #[test]
1300 fn stale_drift_message_names_changed_override_key() {
1301 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1305 manifest
1306 .extra
1307 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
1308 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1309 graph.overrides.insert("lodash".into(), "4.17.21".into());
1310 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1311 DriftStatus::Stale { reason } => {
1312 assert!(reason.contains("lodash"), "expected key in: {reason}");
1313 assert!(
1314 reason.contains("4.17.21"),
1315 "expected old value in: {reason}"
1316 );
1317 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
1318 }
1319 DriftStatus::Fresh => panic!("expected Stale"),
1320 }
1321 }
1322
1323 #[test]
1324 fn stale_when_manifest_removes_override() {
1325 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1326 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1327 graph.overrides.insert("lodash".into(), "4.17.21".into());
1328 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1329 DriftStatus::Stale { reason } => {
1330 assert!(reason.contains("removes"));
1331 assert!(reason.contains("lodash"));
1332 }
1333 DriftStatus::Fresh => panic!("expected Stale"),
1334 }
1335 }
1336
1337 #[test]
1338 fn fresh_when_overrides_match() {
1339 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1340 manifest
1341 .extra
1342 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1343 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1344 graph.overrides.insert("lodash".into(), "4.17.21".into());
1345 assert_eq!(
1346 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1347 DriftStatus::Fresh
1348 );
1349 }
1350
1351 #[test]
1352 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
1353 let manifest = make_manifest(&[("semver", "^7.5.0")]);
1359 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1360 graph.overrides.insert("semver".into(), "7.7.1".into());
1361 let mut ws_overrides = BTreeMap::new();
1362 ws_overrides.insert("semver".into(), "7.7.1".into());
1363 assert_eq!(
1364 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1365 DriftStatus::Fresh,
1366 );
1367 }
1368
1369 #[test]
1370 fn workspace_yaml_overrides_win_over_package_json() {
1371 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
1376 manifest
1377 .extra
1378 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
1379 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1380 graph.overrides.insert("semver".into(), "7.7.1".into());
1381 let mut ws_overrides = BTreeMap::new();
1382 ws_overrides.insert("semver".into(), "7.7.1".into());
1383 assert_eq!(
1384 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1385 DriftStatus::Fresh,
1386 );
1387 }
1388
1389 #[test]
1390 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
1391 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1397 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1398 graph.overrides.insert("lodash".into(), "4.17.21".into());
1399 let mut ws_overrides = BTreeMap::new();
1400 ws_overrides.insert("lodash".into(), "catalog:".into());
1401 let mut catalogs = BTreeMap::new();
1402 let mut default_cat = BTreeMap::new();
1403 default_cat.insert("lodash".into(), "4.17.21".into());
1404 catalogs.insert("default".into(), default_cat);
1405 assert_eq!(
1406 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1407 DriftStatus::Fresh,
1408 );
1409 }
1410
1411 #[test]
1412 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
1413 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1416 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1417 graph.overrides.insert("lodash".into(), "4.17.21".into());
1418 let mut ws_overrides = BTreeMap::new();
1419 ws_overrides.insert("lodash".into(), "catalog:evens".into());
1420 let mut catalogs = BTreeMap::new();
1421 let mut evens = BTreeMap::new();
1422 evens.insert("lodash".into(), "4.17.21".into());
1423 catalogs.insert("evens".into(), evens);
1424 assert_eq!(
1425 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1426 DriftStatus::Fresh,
1427 );
1428 }
1429
1430 #[test]
1431 fn fresh_when_yarn_ancestor_override_catalog_ref_matches_lockfile() {
1432 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1433 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1434 graph
1435 .overrides
1436 .insert("parent/lodash".into(), "4.17.21".into());
1437 let ws_overrides = BTreeMap::from([("parent/lodash".to_string(), "catalog:".to_string())]);
1438 let catalogs = BTreeMap::from([(
1439 "default".to_string(),
1440 BTreeMap::from([("lodash".to_string(), "4.17.21".to_string())]),
1441 )]);
1442
1443 assert_eq!(
1444 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1445 DriftStatus::Fresh,
1446 );
1447 }
1448
1449 #[test]
1450 fn catalog_overrides_resolve_slash_targets_with_comparators() {
1451 let overrides = BTreeMap::from([
1452 ("parent/lodash@>=4.0.0".to_string(), "catalog:".to_string()),
1453 (
1454 "parent/@scope/pkg@>1.0.0".to_string(),
1455 "catalog:".to_string(),
1456 ),
1457 ("parent@^1>123numeric".to_string(), "catalog:".to_string()),
1458 ]);
1459 let catalogs = BTreeMap::from([(
1460 "default".to_string(),
1461 BTreeMap::from([
1462 ("lodash".to_string(), "4.17.21".to_string()),
1463 ("@scope/pkg".to_string(), "2.0.0".to_string()),
1464 ("123numeric".to_string(), "1.0.0".to_string()),
1465 ]),
1466 )]);
1467
1468 assert_eq!(
1469 resolve_catalog_refs_in_overrides(&overrides, &catalogs),
1470 BTreeMap::from([
1471 ("parent/lodash@>=4.0.0".to_string(), "4.17.21".to_string(),),
1472 ("parent/@scope/pkg@>1.0.0".to_string(), "2.0.0".to_string(),),
1473 ("parent@^1>123numeric".to_string(), "1.0.0".to_string()),
1474 ])
1475 );
1476 }
1477
1478 #[test]
1479 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
1480 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1484 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1485 graph.overrides.insert("lodash".into(), "4.17.21".into());
1486 let mut ws_overrides = BTreeMap::new();
1487 ws_overrides.insert("lodash".into(), "catalog:".into());
1488 let mut catalogs = BTreeMap::new();
1489 let mut default_cat = BTreeMap::new();
1490 default_cat.insert("lodash".into(), "4.17.22".into());
1491 catalogs.insert("default".into(), default_cat);
1492 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
1493 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
1494 other => panic!("expected stale, got {other:?}"),
1495 }
1496 }
1497
1498 #[test]
1499 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
1500 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1507 let mut importers = BTreeMap::new();
1508 importers.insert(
1509 ".".to_string(),
1510 vec![DirectDep {
1511 name: "lodash".into(),
1512 dep_path: "lodash@4.17.21".into(),
1513 dep_type: DepType::Production,
1514 specifier: Some("4.17.21".into()),
1515 }],
1516 );
1517 let mut graph = LockfileGraph {
1518 importers,
1519 ..Default::default()
1520 };
1521 graph.overrides.insert("lodash".into(), "4.17.21".into());
1522 let mut ws_overrides = BTreeMap::new();
1523 ws_overrides.insert("lodash".into(), "4.17.21".into());
1524 assert_eq!(
1525 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1526 DriftStatus::Fresh,
1527 );
1528 }
1529
1530 #[test]
1531 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
1532 let manifest = make_manifest(&[("plist", "^3.0.4")]);
1539 let mut importers = BTreeMap::new();
1540 importers.insert(
1541 ".".to_string(),
1542 vec![DirectDep {
1543 name: "plist".into(),
1544 dep_path: "plist@3.0.6".into(),
1545 dep_type: DepType::Production,
1546 specifier: Some(">=3.0.5".into()),
1547 }],
1548 );
1549 let mut graph = LockfileGraph {
1550 importers,
1551 ..Default::default()
1552 };
1553 graph
1554 .overrides
1555 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
1556 let mut ws_overrides = BTreeMap::new();
1557 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
1558 assert_eq!(
1559 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1560 DriftStatus::Fresh,
1561 );
1562 }
1563
1564 #[test]
1565 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
1566 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1573 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1574 graph
1575 .ignored_optional_dependencies
1576 .insert("fsevents".to_string());
1577 let ws_ignored = vec!["fsevents".to_string()];
1578 assert_eq!(
1579 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
1580 DriftStatus::Fresh,
1581 );
1582 }
1583
1584 #[test]
1585 fn fresh_when_optional_dep_was_recorded_as_skipped() {
1586 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1591 manifest
1592 .optional_dependencies
1593 .insert("fsevents".into(), "^2.3.0".into());
1594 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1595 let mut inner = BTreeMap::new();
1596 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1597 graph
1598 .skipped_optional_dependencies
1599 .insert(".".to_string(), inner);
1600 assert_eq!(
1601 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1602 DriftStatus::Fresh
1603 );
1604 }
1605
1606 #[test]
1607 fn stale_when_new_optional_dep_was_never_seen() {
1608 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1614 manifest
1615 .optional_dependencies
1616 .insert("fsevents".into(), "^2.3.0".into());
1617 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1618 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1619 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1620 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
1621 }
1622 }
1623
1624 #[test]
1625 fn stale_when_skipped_optional_dep_specifier_changes() {
1626 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1630 manifest
1631 .optional_dependencies
1632 .insert("fsevents".into(), "^2.4.0".into());
1633 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1634 let mut inner = BTreeMap::new();
1635 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1636 graph
1637 .skipped_optional_dependencies
1638 .insert(".".to_string(), inner);
1639 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1640 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1641 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
1642 }
1643 }
1644
1645 #[test]
1646 fn stale_when_skipped_optional_is_promoted_to_required() {
1647 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1652 manifest.optional_dependencies.clear();
1656 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1657 let mut inner = BTreeMap::new();
1658 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1659 graph
1660 .skipped_optional_dependencies
1661 .insert(".".to_string(), inner);
1662 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1663 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1664 DriftStatus::Fresh => {
1665 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
1666 }
1667 }
1668 }
1669
1670 #[test]
1671 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
1672 let mut manifest = make_manifest(&[]);
1675 manifest
1676 .optional_dependencies
1677 .insert("fsevents".into(), "^2.4.0".into());
1678 let mut graph = make_graph(&[]);
1679 graph.importers.get_mut(".").unwrap().push(DirectDep {
1680 name: "fsevents".into(),
1681 dep_path: "fsevents@2.3.0".into(),
1682 dep_type: DepType::Optional,
1683 specifier: Some("^2.3.0".into()),
1684 });
1685 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1686 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1687 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
1688 }
1689 }
1690
1691 #[test]
1692 fn fresh_for_empty_manifest_and_lockfile() {
1693 let manifest = make_manifest(&[]);
1694 let graph = make_graph(&[]);
1695 assert_eq!(
1696 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1697 DriftStatus::Fresh
1698 );
1699 }
1700
1701 #[test]
1702 fn workspace_drift_detects_change_in_non_root_importer() {
1703 let root_dep = DirectDep {
1705 name: "lodash".into(),
1706 dep_path: "lodash@4.17.21".into(),
1707 dep_type: DepType::Production,
1708 specifier: Some("^4.17.0".into()),
1709 };
1710 let app_dep = DirectDep {
1711 name: "express".into(),
1712 dep_path: "express@4.18.0".into(),
1713 dep_type: DepType::Production,
1714 specifier: Some("^4.18.0".into()),
1715 };
1716 let mut importers = BTreeMap::new();
1717 importers.insert(".".to_string(), vec![root_dep]);
1718 importers.insert("packages/app".to_string(), vec![app_dep]);
1719 let graph = LockfileGraph {
1720 importers,
1721 packages: BTreeMap::new(),
1722 ..Default::default()
1723 };
1724
1725 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
1726 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
1728
1729 let workspace_manifests = vec![
1730 (".".to_string(), root_manifest.clone()),
1731 ("packages/app".to_string(), app_manifest),
1732 ];
1733 match graph.check_drift_workspace(
1734 &workspace_manifests,
1735 &BTreeMap::new(),
1736 &[],
1737 &BTreeMap::new(),
1738 true,
1739 ) {
1740 DriftStatus::Stale { reason } => {
1741 assert!(reason.contains("packages/app"));
1742 assert!(reason.contains("express"));
1743 }
1744 DriftStatus::Fresh => panic!("expected Stale"),
1745 }
1746
1747 assert_eq!(
1749 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1750 DriftStatus::Fresh
1751 );
1752 }
1753
1754 #[test]
1755 fn filter_deps_prunes_dev_only_subtree() {
1756 let mut importers = BTreeMap::new();
1760 importers.insert(
1761 ".".to_string(),
1762 vec![
1763 DirectDep {
1764 name: "foo".into(),
1765 dep_path: "foo@1.0.0".into(),
1766 dep_type: DepType::Production,
1767 specifier: Some("^1.0.0".into()),
1768 },
1769 DirectDep {
1770 name: "jest".into(),
1771 dep_path: "jest@29.0.0".into(),
1772 dep_type: DepType::Dev,
1773 specifier: Some("^29.0.0".into()),
1774 },
1775 ],
1776 );
1777
1778 let mut packages = BTreeMap::new();
1779 let mut foo_deps = BTreeMap::new();
1780 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
1781 packages.insert(
1782 "foo@1.0.0".to_string(),
1783 LockedPackage {
1784 name: "foo".into(),
1785 version: "1.0.0".into(),
1786 integrity: None,
1787 dependencies: foo_deps,
1788 dep_path: "foo@1.0.0".into(),
1789 ..Default::default()
1790 },
1791 );
1792 packages.insert(
1793 "bar@2.0.0".to_string(),
1794 LockedPackage {
1795 name: "bar".into(),
1796 version: "2.0.0".into(),
1797 integrity: None,
1798 dependencies: BTreeMap::new(),
1799 dep_path: "bar@2.0.0".into(),
1800 ..Default::default()
1801 },
1802 );
1803 let mut jest_deps = BTreeMap::new();
1804 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
1805 packages.insert(
1806 "jest@29.0.0".to_string(),
1807 LockedPackage {
1808 name: "jest".into(),
1809 version: "29.0.0".into(),
1810 integrity: None,
1811 dependencies: jest_deps,
1812 dep_path: "jest@29.0.0".into(),
1813 ..Default::default()
1814 },
1815 );
1816 packages.insert(
1817 "jest-core@29.0.0".to_string(),
1818 LockedPackage {
1819 name: "jest-core".into(),
1820 version: "29.0.0".into(),
1821 integrity: None,
1822 dependencies: BTreeMap::new(),
1823 dep_path: "jest-core@29.0.0".into(),
1824 ..Default::default()
1825 },
1826 );
1827
1828 let graph = LockfileGraph {
1829 importers,
1830 packages,
1831 ..Default::default()
1832 };
1833
1834 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1835
1836 let roots = prod.root_deps();
1838 assert_eq!(roots.len(), 1);
1839 assert_eq!(roots[0].name, "foo");
1840
1841 assert!(prod.packages.contains_key("foo@1.0.0"));
1843 assert!(prod.packages.contains_key("bar@2.0.0"));
1844 assert!(!prod.packages.contains_key("jest@29.0.0"));
1845 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
1846 }
1847
1848 #[test]
1855 fn filter_deps_preserves_lockfile_settings() {
1856 let graph = LockfileGraph {
1857 importers: BTreeMap::new(),
1858 packages: BTreeMap::new(),
1859 settings: LockfileSettings {
1860 auto_install_peers: false,
1861 exclude_links_from_lockfile: true,
1862 lockfile_include_tarball_url: false,
1863 },
1864 ..Default::default()
1865 };
1866 let filtered = graph.filter_deps(|_| true);
1867 assert!(!filtered.settings.auto_install_peers);
1868 assert!(filtered.settings.exclude_links_from_lockfile);
1869 }
1870
1871 #[test]
1872 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
1873 let mut importers = BTreeMap::new();
1877 importers.insert(
1878 ".".to_string(),
1879 vec![
1880 DirectDep {
1881 name: "foo".into(),
1882 dep_path: "foo@1.0.0".into(),
1883 dep_type: DepType::Production,
1884 specifier: Some("^1.0.0".into()),
1885 },
1886 DirectDep {
1887 name: "jest".into(),
1888 dep_path: "jest@29.0.0".into(),
1889 dep_type: DepType::Dev,
1890 specifier: Some("^29.0.0".into()),
1891 },
1892 ],
1893 );
1894
1895 let mut packages = BTreeMap::new();
1896 for (name, ver, deps) in [
1897 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
1898 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
1899 ("shared", "1.0.0", vec![]),
1900 ] {
1901 let mut dep_map = BTreeMap::new();
1902 for (n, v) in deps {
1903 dep_map.insert(n.to_string(), v.to_string());
1904 }
1905 packages.insert(
1906 format!("{name}@{ver}"),
1907 LockedPackage {
1908 name: name.into(),
1909 version: ver.into(),
1910 integrity: None,
1911 dependencies: dep_map,
1912 dep_path: format!("{name}@{ver}"),
1913 ..Default::default()
1914 },
1915 );
1916 }
1917
1918 let graph = LockfileGraph {
1919 importers,
1920 packages,
1921 ..Default::default()
1922 };
1923 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1924
1925 assert!(prod.packages.contains_key("foo@1.0.0"));
1926 assert!(prod.packages.contains_key("shared@1.0.0"));
1927 assert!(!prod.packages.contains_key("jest@29.0.0"));
1928 }
1929
1930 #[test]
1931 fn subset_to_importer_returns_none_for_missing_importer() {
1932 let graph = LockfileGraph {
1933 importers: BTreeMap::new(),
1934 packages: BTreeMap::new(),
1935 ..Default::default()
1936 };
1937 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
1938 }
1939
1940 #[test]
1941 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
1942 let mut importers = BTreeMap::new();
1949 importers.insert(".".to_string(), vec![]);
1950 importers.insert(
1951 "packages/lib".to_string(),
1952 vec![DirectDep {
1953 name: "is-odd".into(),
1954 dep_path: "is-odd@3.0.1".into(),
1955 dep_type: DepType::Production,
1956 specifier: Some("^3.0.1".into()),
1957 }],
1958 );
1959 importers.insert(
1960 "packages/app".to_string(),
1961 vec![DirectDep {
1962 name: "express".into(),
1963 dep_path: "express@4.18.0".into(),
1964 dep_type: DepType::Production,
1965 specifier: Some("^4.18.0".into()),
1966 }],
1967 );
1968
1969 let mut packages = BTreeMap::new();
1970 let mut is_odd_deps = BTreeMap::new();
1971 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
1972 packages.insert(
1973 "is-odd@3.0.1".to_string(),
1974 LockedPackage {
1975 name: "is-odd".into(),
1976 version: "3.0.1".into(),
1977 dependencies: is_odd_deps,
1978 dep_path: "is-odd@3.0.1".into(),
1979 ..Default::default()
1980 },
1981 );
1982 packages.insert(
1983 "is-number@6.0.0".to_string(),
1984 LockedPackage {
1985 name: "is-number".into(),
1986 version: "6.0.0".into(),
1987 dep_path: "is-number@6.0.0".into(),
1988 ..Default::default()
1989 },
1990 );
1991 packages.insert(
1992 "express@4.18.0".to_string(),
1993 LockedPackage {
1994 name: "express".into(),
1995 version: "4.18.0".into(),
1996 dep_path: "express@4.18.0".into(),
1997 ..Default::default()
1998 },
1999 );
2000
2001 let graph = LockfileGraph {
2002 importers,
2003 packages,
2004 ..Default::default()
2005 };
2006 let subset = graph
2007 .subset_to_importer("packages/lib", |_| true)
2008 .expect("packages/lib importer present");
2009
2010 assert_eq!(subset.importers.len(), 1);
2011 let roots = subset.root_deps();
2012 assert_eq!(roots.len(), 1);
2013 assert_eq!(roots[0].name, "is-odd");
2014
2015 assert!(subset.packages.contains_key("is-odd@3.0.1"));
2016 assert!(subset.packages.contains_key("is-number@6.0.0"));
2017 assert!(!subset.packages.contains_key("express@4.18.0"));
2018 }
2019
2020 #[test]
2021 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
2022 let mut importers = BTreeMap::new();
2028 importers.insert(
2029 "packages/lib".to_string(),
2030 vec![
2031 DirectDep {
2032 name: "is-odd".into(),
2033 dep_path: "is-odd@3.0.1".into(),
2034 dep_type: DepType::Production,
2035 specifier: Some("^3.0.1".into()),
2036 },
2037 DirectDep {
2038 name: "jest".into(),
2039 dep_path: "jest@29.0.0".into(),
2040 dep_type: DepType::Dev,
2041 specifier: Some("^29.0.0".into()),
2042 },
2043 ],
2044 );
2045 let mut packages = BTreeMap::new();
2046 packages.insert(
2047 "is-odd@3.0.1".to_string(),
2048 LockedPackage {
2049 name: "is-odd".into(),
2050 version: "3.0.1".into(),
2051 dep_path: "is-odd@3.0.1".into(),
2052 ..Default::default()
2053 },
2054 );
2055 packages.insert(
2056 "jest@29.0.0".to_string(),
2057 LockedPackage {
2058 name: "jest".into(),
2059 version: "29.0.0".into(),
2060 dep_path: "jest@29.0.0".into(),
2061 ..Default::default()
2062 },
2063 );
2064 let graph = LockfileGraph {
2065 importers,
2066 packages,
2067 ..Default::default()
2068 };
2069
2070 let prod = graph
2071 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
2072 .expect("importer present");
2073 let roots = prod.root_deps();
2074 assert_eq!(roots.len(), 1);
2075 assert_eq!(roots[0].name, "is-odd");
2076 assert!(prod.packages.contains_key("is-odd@3.0.1"));
2077 assert!(!prod.packages.contains_key("jest@29.0.0"));
2078 }
2079
2080 #[test]
2081 fn subset_to_importer_preserves_graph_settings() {
2082 let mut importers = BTreeMap::new();
2088 importers.insert("packages/lib".to_string(), vec![]);
2089 let graph = LockfileGraph {
2090 importers,
2091 packages: BTreeMap::new(),
2092 settings: LockfileSettings {
2093 auto_install_peers: false,
2094 exclude_links_from_lockfile: true,
2095 lockfile_include_tarball_url: true,
2096 },
2097 ..Default::default()
2098 };
2099 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
2100 assert!(!subset.settings.auto_install_peers);
2101 assert!(subset.settings.exclude_links_from_lockfile);
2102 assert!(subset.settings.lockfile_include_tarball_url);
2103 }
2104
2105 #[test]
2106 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
2107 let mut importers = BTreeMap::new();
2112 importers.insert("packages/lib".to_string(), vec![]);
2113 importers.insert("packages/app".to_string(), vec![]);
2114 let mut skipped = BTreeMap::new();
2115 let mut lib_skip = BTreeMap::new();
2116 lib_skip.insert("fsevents".to_string(), "^2".to_string());
2117 skipped.insert("packages/lib".to_string(), lib_skip);
2118 let mut app_skip = BTreeMap::new();
2119 app_skip.insert("ghost".to_string(), "*".to_string());
2120 skipped.insert("packages/app".to_string(), app_skip);
2121 let graph = LockfileGraph {
2122 importers,
2123 packages: BTreeMap::new(),
2124 skipped_optional_dependencies: skipped,
2125 ..Default::default()
2126 };
2127 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
2128 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
2129 let root = subset.skipped_optional_dependencies.get(".").unwrap();
2130 assert!(root.contains_key("fsevents"));
2131 assert!(!root.contains_key("ghost"));
2132 }
2133
2134 #[test]
2135 fn workspace_drift_fresh_when_all_importers_match() {
2136 let root_dep = DirectDep {
2137 name: "lodash".into(),
2138 dep_path: "lodash@4.17.21".into(),
2139 dep_type: DepType::Production,
2140 specifier: Some("^4.17.0".into()),
2141 };
2142 let app_dep = DirectDep {
2143 name: "express".into(),
2144 dep_path: "express@4.18.0".into(),
2145 dep_type: DepType::Production,
2146 specifier: Some("^4.18.0".into()),
2147 };
2148 let mut importers = BTreeMap::new();
2149 importers.insert(".".to_string(), vec![root_dep]);
2150 importers.insert("packages/app".to_string(), vec![app_dep]);
2151 let graph = LockfileGraph {
2152 importers,
2153 packages: BTreeMap::new(),
2154 ..Default::default()
2155 };
2156
2157 let workspace_manifests = vec![
2158 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
2159 (
2160 "packages/app".to_string(),
2161 make_manifest(&[("express", "^4.18.0")]),
2162 ),
2163 ];
2164 assert_eq!(
2165 graph.check_drift_workspace(
2166 &workspace_manifests,
2167 &BTreeMap::new(),
2168 &[],
2169 &BTreeMap::new(),
2170 true,
2171 ),
2172 DriftStatus::Fresh
2173 );
2174 }
2175
2176 #[allow(clippy::type_complexity)]
2177 fn mk_catalogs(
2178 entries: &[(&str, &[(&str, &str, &str)])],
2179 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
2180 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
2181 for (cat, pkgs) in entries {
2182 let mut inner = BTreeMap::new();
2183 for (pkg, spec, ver) in *pkgs {
2184 inner.insert(
2185 (*pkg).to_string(),
2186 CatalogEntry {
2187 specifier: (*spec).to_string(),
2188 version: (*ver).to_string(),
2189 },
2190 );
2191 }
2192 out.insert((*cat).to_string(), inner);
2193 }
2194 out
2195 }
2196
2197 fn mk_workspace_catalogs(
2198 entries: &[(&str, &[(&str, &str)])],
2199 ) -> BTreeMap<String, BTreeMap<String, String>> {
2200 entries
2201 .iter()
2202 .map(|(cat, pkgs)| {
2203 (
2204 (*cat).to_string(),
2205 pkgs.iter()
2206 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
2207 .collect(),
2208 )
2209 })
2210 .collect()
2211 }
2212
2213 #[test]
2214 fn catalog_drift_fresh_when_specifiers_match() {
2215 let graph = LockfileGraph {
2216 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2217 ..Default::default()
2218 };
2219 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
2220 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2221 }
2222
2223 #[test]
2224 fn catalog_drift_stale_on_changed_specifier() {
2225 let graph = LockfileGraph {
2226 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2227 ..Default::default()
2228 };
2229 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
2230 match graph.check_catalogs_drift(&ws) {
2231 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2232 other => panic!("expected stale, got {other:?}"),
2233 }
2234 }
2235
2236 #[test]
2237 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
2238 let graph = LockfileGraph::default();
2242 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
2243 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2244 }
2245
2246 #[test]
2247 fn catalog_drift_stale_on_removed_workspace_entry() {
2248 let graph = LockfileGraph {
2249 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
2250 ..Default::default()
2251 };
2252 let ws = mk_workspace_catalogs(&[]);
2253 assert!(matches!(
2254 graph.check_catalogs_drift(&ws),
2255 DriftStatus::Stale { .. }
2256 ));
2257 }
2258}