Skip to main content

aube_lockfile/
drift.rs

1use crate::{
2    DepType, DirectDep, LocalSource, LockfileGraph, LockfileKind, dep_type_label, override_match,
3};
4use std::collections::{BTreeMap, BTreeSet};
5
6impl LockfileGraph {
7    /// Compare this lockfile's root importer against a single manifest.
8    ///
9    /// Mirrors pnpm's `prefer-frozen-lockfile` check: a lockfile is "fresh" iff
10    /// every direct dep specifier in `package.json` exactly matches the specifier
11    /// recorded in the lockfile (string compare, not semver). Used to decide
12    /// whether to skip resolution and trust the lockfile (`Fresh`) or fall back
13    /// to a full re-resolve (`Stale { reason }`).
14    ///
15    /// For workspace projects, use [`check_drift_workspace`] instead — this
16    /// method only inspects the root importer.
17    ///
18    /// `workspace_overrides` is the `overrides:` block from
19    /// `pnpm-workspace.yaml` (pnpm v10 moved overrides there). Pass an
20    /// empty map when the project has no workspace-yaml overrides. Keys
21    /// are merged on top of `manifest.overrides_map()` before the drift
22    /// comparison, matching the resolver's effective-override set —
23    /// otherwise a lockfile written with a workspace override
24    /// immediately looks stale on the next `--frozen-lockfile` run.
25    ///
26    /// `workspace_ignored_optional` is the same idea for
27    /// `pnpm-workspace.yaml`'s `ignoredOptionalDependencies` block:
28    /// the resolver unions it with the manifest's list, so the drift
29    /// check has to see the same union or a freshly-written lockfile
30    /// immediately reads as stale.
31    ///
32    /// `workspace_catalogs` is the `catalog:` / `catalogs:` block from
33    /// `pnpm-workspace.yaml`. pnpm resolves `catalog:` references in
34    /// override values against this map before writing the lockfile
35    /// and before comparing on re-install, so both sides of the drift
36    /// check have to see the catalog-resolved form — otherwise a
37    /// `"lodash": "catalog:"` override reads as stale against a
38    /// lockfile that recorded the resolved `"lodash": "4.17.21"`.
39    ///
40    /// Lockfile formats that don't record specifiers (npm, yarn, bun) always
41    /// return `Fresh` since we have no way to detect drift without re-resolving.
42    ///
43    /// [`check_drift_workspace`]: Self::check_drift_workspace
44    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    /// Workspace-aware drift check.
78    ///
79    /// Each entry in `manifests` is `(importer_path, manifest)` — for example
80    /// `(".", root_manifest), ("packages/app", app_manifest), ...`. Every
81    /// importer is checked against its own manifest; the first stale importer
82    /// determines the result.
83    ///
84    /// See [`check_drift`] for the `workspace_overrides` contract.
85    ///
86    /// [`check_drift`]: Self::check_drift
87    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        // Override drift is checked once at the workspace level, against
159        // the root manifest. Workspace-package manifests may declare
160        // their own `overrides` blocks but pnpm only honors the root's,
161        // so we mirror that here.
162        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        // Stale-importer pass: in a workspace install, lockfile
199        // importer entries for workspace projects that no longer
200        // exist on disk must invalidate the lockfile. Without this
201        // guard, the warm-path short-circuit and drift check both
202        // report fresh and the next install carries the orphan
203        // importer/snapshot pair forward in the shared lockfile
204        // until a user explicitly runs `--no-frozen-lockfile`.
205        //
206        // Gated on the caller-supplied `is_workspace_install` flag
207        // (true when `pnpm-workspace.yaml` exists or `package.json`
208        // declares `workspaces`) — the manifests array can collapse
209        // to `[(".", root)]` even in a workspace install when the
210        // last sub-package is removed, so a manifest-shape check
211        // would miss the all-packages-gone case. The flag is also
212        // what tells us we're not in the npm `package-lock.json`
213        // path, where the parser synthesizes importer entries for
214        // every `file:` link and a manifest-shape gate would
215        // false-positive on legitimate single-package installs.
216        if is_workspace_install {
217            let current_importers: std::collections::HashSet<&str> =
218                manifests.iter().map(|(p, _)| p.as_str()).collect();
219            for importer_path in self.importers.keys() {
220                if !current_importers.contains(importer_path.as_str()) {
221                    return DriftStatus::Stale {
222                        reason: format!(
223                            "workspace importer {importer_path} is in the lockfile but not in the workspace"
224                        ),
225                    };
226                }
227            }
228        }
229        DriftStatus::Fresh
230    }
231
232    fn resolution_metadata_drift_reason(
233        &self,
234        manifest: &aube_manifest::PackageJson,
235        workspace_overrides: &BTreeMap<String, String>,
236        workspace_ignored_optional: &[String],
237        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
238    ) -> Option<String> {
239        let effective = resolve_catalog_refs_in_overrides(
240            &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
241            workspace_catalogs,
242        );
243        let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
244        overrides_drift_reason(&locked, &effective).or_else(|| {
245            let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
246            effective_ignored.extend(workspace_ignored_optional.iter().cloned());
247            ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored)
248        })
249    }
250
251    /// Compare this lockfile's catalog snapshot against the current
252    /// `pnpm-workspace.yaml` catalogs.
253    ///
254    /// pnpm only writes catalog entries that at least one importer
255    /// references — unused entries are absent from the lockfile. So
256    /// "missing from lockfile" doesn't mean "added by the user", it
257    /// means "declared but unreferenced", which is not drift. The
258    /// transition from unused → used is caught by the importer-level
259    /// drift check, since a fresh `catalog:` reference shows up as a
260    /// new dep in some `package.json`.
261    ///
262    /// We fire on two cases only:
263    /// - the spec changed for an entry the lockfile already records
264    ///   (the entry is in use, and re-resolution must rerun);
265    /// - the workspace removed an entry that the lockfile records
266    ///   (the importer using `catalog:` now points at nothing).
267    ///
268    /// Resolved versions are deliberately not part of the comparison —
269    /// the version is an *output* of resolution, so a stale lockfile
270    /// version is what re-resolution is supposed to fix. Drift only
271    /// fires on user intent (the specifier).
272    pub fn check_catalogs_drift(
273        &self,
274        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
275    ) -> DriftStatus {
276        for (cat_name, cat) in workspace_catalogs {
277            let Some(locked) = self.catalogs.get(cat_name) else {
278                continue;
279            };
280            for (pkg, spec) in cat {
281                if let Some(entry) = locked.get(pkg)
282                    && entry.specifier != *spec
283                {
284                    return DriftStatus::Stale {
285                        reason: format!(
286                            "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
287                            entry.specifier
288                        ),
289                    };
290                }
291            }
292        }
293        for (cat_name, cat) in &self.catalogs {
294            let workspace_cat = workspace_catalogs.get(cat_name);
295            for pkg in cat.keys() {
296                if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
297                    return DriftStatus::Stale {
298                        reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
299                    };
300                }
301            }
302        }
303        DriftStatus::Fresh
304    }
305
306    /// Compare a single importer's `DirectDep` list against the corresponding
307    /// `package.json`. Used by both [`check_drift`] and [`check_drift_workspace`].
308    ///
309    /// [`check_drift`]: Self::check_drift
310    /// [`check_drift_workspace`]: Self::check_drift_workspace
311    fn check_drift_for_importer(
312        &self,
313        importer_path: &str,
314        manifest: &aube_manifest::PackageJson,
315        effective_overrides: &BTreeMap<String, String>,
316    ) -> DriftStatus {
317        self.check_drift_for_importer_with_workspace_links(
318            importer_path,
319            manifest,
320            effective_overrides,
321            &std::collections::HashSet::new(),
322        )
323    }
324
325    fn check_drift_for_importer_with_workspace_links(
326        &self,
327        importer_path: &str,
328        manifest: &aube_manifest::PackageJson,
329        effective_overrides: &BTreeMap<String, String>,
330        workspace_link_names: &std::collections::HashSet<&str>,
331    ) -> DriftStatus {
332        let label = if importer_path == "." {
333            String::new()
334        } else {
335            format!("{importer_path}: ")
336        };
337
338        let importer_deps: &[DirectDep] = self
339            .importers
340            .get(importer_path)
341            .map(|v| v.as_slice())
342            .unwrap_or(&[]);
343
344        // Skip the check entirely if no DirectDep has a specifier (non-pnpm format).
345        if importer_deps.iter().all(|d| d.specifier.is_none()) {
346            return DriftStatus::Fresh;
347        }
348        let lockfile_specs: BTreeMap<&str, &str> = importer_deps
349            .iter()
350            .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
351            .collect();
352
353        let override_rules = override_match::compile(effective_overrides);
354
355        // Optionals the previous resolve recorded as intentionally
356        // skipped on this importer's platform — keyed by name, value
357        // is the specifier captured at that time. Distinct from
358        // `ignored_optional_dependencies`, which is the user's static
359        // ignore list; this map captures *runtime* platform skips.
360        let skipped_optionals: BTreeMap<&str, &str> = self
361            .skipped_optional_dependencies
362            .get(importer_path)
363            .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
364            .unwrap_or_default();
365
366        // Iterate prod / dev / optional with a flag so the
367        // skipped-optional exemption only applies to deps that came
368        // from `optional_dependencies`. Without the flag, moving a
369        // previously-skipped optional into `dependencies` with the same
370        // specifier would silently report Fresh and the dep would
371        // never install as a required dep.
372        //
373        // Optionals named in `ignored_optional_dependencies` are
374        // dropped from the manifest-side scan: the resolver never
375        // enqueues them, so the lockfile importer never has them
376        // either, and the loop would otherwise report drift on every
377        // install. (Their *spec* is still verified separately by the
378        // round-tripped `ignored_optional_dependencies` block below.)
379        let ignored = &self.ignored_optional_dependencies;
380        let manifest_deps = manifest
381            .dependencies
382            .iter()
383            .map(|(k, v)| (k, v, false))
384            .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
385            .chain(
386                manifest
387                    .optional_dependencies
388                    .iter()
389                    .filter(|(name, _)| !ignored.contains(name.as_str()))
390                    .map(|(k, v)| (k, v, true)),
391            );
392
393        for (name, spec, is_optional) in manifest_deps {
394            match lockfile_specs.get(name.as_str()) {
395                None => {
396                    // A *missing* optional dep is only "fresh" if the
397                    // previous resolve recorded it as intentionally
398                    // skipped (platform mismatch or
399                    // `pnpm.ignoredOptionalDependencies`) AND the
400                    // recorded specifier still matches what's in the
401                    // manifest. A genuinely *new* optional that the
402                    // resolver has never seen is real drift — without
403                    // that branch, adding `fsevents` to a fresh manifest
404                    // would silently never get installed.
405                    if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
406                        if *locked_spec == spec {
407                            continue;
408                        }
409                        return DriftStatus::Stale {
410                            reason: format!(
411                                "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
412                            ),
413                        };
414                    }
415                    return DriftStatus::Stale {
416                        reason: format!("{label}manifest adds {name}@{spec}"),
417                    };
418                }
419                Some(locked_spec) if *locked_spec != spec => {
420                    // pnpm rewrites the importer specifier to the
421                    // override-applied value when an override fires on
422                    // a direct dep, so a pnpm-generated lockfile shows
423                    // `specifier: ">=3.0.5"` even though `package.json`
424                    // still reads `^3.0.4`. Accept that as fresh when
425                    // an override for this name (bare or version-keyed)
426                    // resolves to the lockfile's recorded spec —
427                    // otherwise any pnpm-written lockfile with
428                    // overrides reads stale on every frozen install.
429                    if let Some(override_spec) =
430                        override_match::apply(&override_rules, name.as_str(), spec)
431                        && override_spec == *locked_spec
432                    {
433                        continue;
434                    }
435                    return DriftStatus::Stale {
436                        reason: format!(
437                            "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
438                        ),
439                    };
440                }
441                Some(_) => {}
442            }
443        }
444
445        // Detect dep-type drift: a name kept in the manifest but moved
446        // between sections (e.g. `dependencies` → `devDependencies`)
447        // keeps the same specifier, so the spec-only checks above
448        // report Fresh and the warm path short-circuits without
449        // rewriting the lockfile. The resolver's priority is
450        // `dependencies` > `devDependencies` > `optionalDependencies`,
451        // matching `seed_direct_deps` in aube-resolver.
452        let mut manifest_dep_types: BTreeMap<&str, DepType> = BTreeMap::new();
453        for name in manifest.dependencies.keys() {
454            manifest_dep_types.insert(name.as_str(), DepType::Production);
455        }
456        for name in manifest.dev_dependencies.keys() {
457            manifest_dep_types
458                .entry(name.as_str())
459                .or_insert(DepType::Dev);
460        }
461        for name in manifest.optional_dependencies.keys() {
462            if ignored.contains(name.as_str()) {
463                continue;
464            }
465            manifest_dep_types
466                .entry(name.as_str())
467                .or_insert(DepType::Optional);
468        }
469        for dep in importer_deps {
470            let Some(expected) = manifest_dep_types.get(dep.name.as_str()) else {
471                continue;
472            };
473            if *expected != dep.dep_type {
474                return DriftStatus::Stale {
475                    reason: format!(
476                        "{label}{}: manifest section is {}, lockfile section is {}",
477                        dep.name,
478                        dep_type_label(*expected),
479                        dep_type_label(dep.dep_type),
480                    ),
481                };
482            }
483        }
484
485        // Anything in the lockfile but missing from the manifest is stale
486        // — UNLESS it was auto-hoisted as a peer by the resolver. pnpm-style
487        // `auto-install-peers=true` puts peers into the importer's
488        // `dependencies` without the user having written them in
489        // `package.json`, so we have to recognize those as derived state
490        // rather than user intent.
491        //
492        // Critically, we identify an auto-hoisted entry by matching its
493        // *recorded specifier* against peer ranges declared in the graph,
494        // not just by name. A name-only check would silently exempt a
495        // user-pinned `react` that the user later removed (if any package
496        // anywhere in the graph peer-declares react, the name match would
497        // fire and we'd report Fresh forever — defeating the drift check).
498        //
499        // The rule: a lockfile entry whose (name, specifier) pair exactly
500        // matches some package's declared (peer_name, peer_range) is
501        // auto-hoisted. If the user had pinned react with a different
502        // specifier string and then removed it, the (name, specifier)
503        // pair no longer matches any peer range, and drift correctly
504        // fires so the resolver re-runs and rewrites the lockfile.
505        let manifest_names: std::collections::HashSet<&str> = manifest
506            .dependencies
507            .keys()
508            .chain(manifest.dev_dependencies.keys())
509            .chain(
510                manifest
511                    .optional_dependencies
512                    .keys()
513                    .filter(|name| !ignored.contains(name.as_str())),
514            )
515            .map(|s| s.as_str())
516            .collect();
517        let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
518            .packages
519            .values()
520            .flat_map(|p| {
521                p.peer_dependencies
522                    .iter()
523                    .map(|(name, range)| (name.as_str(), range.as_str()))
524            })
525            .collect();
526        for (locked_name, locked_spec) in &lockfile_specs {
527            if manifest_names.contains(locked_name) {
528                continue;
529            }
530            if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
531                continue;
532            }
533            let workspace_link = importer_path == "."
534                && workspace_link_names.contains(locked_name)
535                && importer_deps
536                    .iter()
537                    .find(|dep| dep.name == *locked_name)
538                    .and_then(|dep| self.packages.get(&dep.dep_path))
539                    .is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))));
540            if workspace_link {
541                continue;
542            }
543            return DriftStatus::Stale {
544                reason: format!("{label}manifest removed {locked_name}"),
545            };
546        }
547
548        DriftStatus::Fresh
549    }
550}
551
552/// Merge `pnpm-workspace.yaml` overrides on top of the manifest's
553/// `overrides_map()`. Workspace entries win on key conflict, matching
554/// pnpm v10's behavior where the workspace yaml is the canonical
555/// home for overrides. Callers pass this into `overrides_drift_reason`
556/// so the drift check sees the same effective map the resolver used.
557fn merge_manifest_and_workspace_overrides(
558    manifest: &aube_manifest::PackageJson,
559    workspace_overrides: &BTreeMap<String, String>,
560) -> BTreeMap<String, String> {
561    let mut out = manifest.overrides_map();
562    for (k, v) in workspace_overrides {
563        out.insert(k.clone(), v.clone());
564    }
565    out
566}
567
568/// Rewrite `catalog:` / `catalog:<name>` override values to the catalog's
569/// resolved range. pnpm writes resolved override values into the lockfile
570/// and compares against the resolved form on re-install, so both sides
571/// of the drift check have to see the catalog-substituted map — otherwise
572/// a `"lodash": "catalog:"` workspace-yaml override reads as stale against
573/// a lockfile that recorded `"lodash": "4.17.21"`. Unresolvable references
574/// (missing catalog or missing entry) pass through untouched; the caller
575/// would have errored at resolve time if this ever reached a real install,
576/// so a drift-mismatch here is fine.
577fn resolve_catalog_refs_in_overrides(
578    overrides: &BTreeMap<String, String>,
579    workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
580) -> BTreeMap<String, String> {
581    overrides
582        .iter()
583        .map(|(k, v)| {
584            let resolved = v
585                .strip_prefix("catalog:")
586                .map(|tail| if tail.is_empty() { "default" } else { tail })
587                .and_then(|cat_name| workspace_catalogs.get(cat_name))
588                .and_then(|cat| cat.get(override_key_package_name(k)))
589                .cloned()
590                .unwrap_or_else(|| v.clone());
591            (k.clone(), resolved)
592        })
593        .collect()
594}
595
596/// Extract the package name from an override selector key so the catalog
597/// can be looked up by pkg name. Handles bare (`lodash`), scoped
598/// (`@babel/core`), ranged (`lodash@<5`), ancestor-chained
599/// (`parent>lodash`), and combinations. Unparseable keys return the
600/// input unchanged; the catalog lookup will then miss and leave the
601/// value as-is.
602fn override_key_package_name(key: &str) -> &str {
603    let last = key.rsplit('>').next().unwrap_or(key);
604    if let Some(after_scope) = last.strip_prefix('@') {
605        match after_scope.find('@') {
606            Some(idx) => &last[..idx + 1],
607            None => last,
608        }
609    } else {
610        match last.find('@') {
611            Some(idx) => &last[..idx],
612            None => last,
613        }
614    }
615}
616
617/// Compare two override maps and return a human-readable reason
618/// describing the first difference, or `None` if they're identical.
619/// Drift messages cite the offending key by name so users can act on
620/// them — `(lockfile: N entries, manifest: M entries)` is useless
621/// when N == M but a value changed.
622fn overrides_drift_reason(
623    lockfile: &BTreeMap<String, String>,
624    manifest: &BTreeMap<String, String>,
625) -> Option<String> {
626    for (k, v) in manifest {
627        match lockfile.get(k) {
628            None => return Some(format!("overrides: manifest adds {k}@{v}")),
629            Some(locked) if locked != v => {
630                return Some(format!("overrides: {k} changed ({locked} → {v})"));
631            }
632            Some(_) => {}
633        }
634    }
635    for k in lockfile.keys() {
636        if !manifest.contains_key(k) {
637            return Some(format!("overrides: manifest removes {k}"));
638        }
639    }
640    None
641}
642
643/// Compare two `ignoredOptionalDependencies` sets and return a drift
644/// reason string for the first difference, or `None` if identical.
645fn ignored_optional_drift_reason(
646    lockfile: &BTreeSet<String>,
647    manifest: &BTreeSet<String>,
648) -> Option<String> {
649    for name in manifest {
650        if !lockfile.contains(name) {
651            return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
652        }
653    }
654    for name in lockfile {
655        if !manifest.contains(name) {
656            return Some(format!(
657                "ignoredOptionalDependencies: manifest removes {name}"
658            ));
659        }
660    }
661    None
662}
663
664/// Result of comparing a lockfile against a manifest.
665#[derive(Debug, Clone, PartialEq, Eq)]
666pub enum DriftStatus {
667    /// The lockfile is in sync with the manifest. Safe to use without re-resolving.
668    Fresh,
669    /// The lockfile is out of date. The reason describes the first mismatch found.
670    Stale { reason: String },
671}
672
673fn kind_records_resolution_metadata(kind: LockfileKind) -> bool {
674    matches!(
675        kind,
676        LockfileKind::Aube | LockfileKind::Pnpm | LockfileKind::Bun
677    )
678}
679
680#[cfg(test)]
681mod drift_tests {
682    use super::*;
683    use crate::{CatalogEntry, LockedPackage, LockfileSettings};
684    use aube_manifest::PackageJson;
685    use std::collections::BTreeMap;
686    use std::path::PathBuf;
687
688    fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
689        let mut m = PackageJson {
690            name: Some("test".into()),
691            version: Some("1.0.0".into()),
692            dependencies: BTreeMap::new(),
693            dev_dependencies: BTreeMap::new(),
694            peer_dependencies: BTreeMap::new(),
695            optional_dependencies: BTreeMap::new(),
696            update_config: None,
697            scripts: BTreeMap::new(),
698            engines: BTreeMap::new(),
699            workspaces: None,
700            bundled_dependencies: None,
701            extra: BTreeMap::new(),
702        };
703        for (name, spec) in deps {
704            m.dependencies.insert((*name).into(), (*spec).into());
705        }
706        m
707    }
708
709    fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
710        // (name, specifier, dep_path)
711        let direct: Vec<DirectDep> = deps
712            .iter()
713            .map(|(name, spec, dep_path)| DirectDep {
714                name: (*name).into(),
715                dep_path: (*dep_path).into(),
716                dep_type: DepType::Production,
717                specifier: Some((*spec).into()),
718            })
719            .collect();
720        let mut importers = BTreeMap::new();
721        importers.insert(".".to_string(), direct);
722        LockfileGraph {
723            importers,
724            packages: BTreeMap::new(),
725            ..Default::default()
726        }
727    }
728
729    #[test]
730    fn stale_when_dep_moves_between_sections() {
731        // Discussion #602: moving a dep between `dependencies` and
732        // `devDependencies` keeps the same specifier, so the spec-only
733        // checks reported Fresh and the warm path short-circuited
734        // without rewriting the lockfile.
735        let mut manifest = make_manifest(&[]);
736        manifest
737            .dev_dependencies
738            .insert("msw".into(), "catalog:".into());
739        let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
740        graph
741            .importers
742            .get_mut(".")
743            .unwrap()
744            .iter_mut()
745            .for_each(|d| d.dep_type = DepType::Production);
746        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
747            DriftStatus::Stale { reason } => {
748                assert!(reason.contains("msw"), "reason: {reason}");
749                assert!(reason.contains("devDependencies"), "reason: {reason}");
750            }
751            DriftStatus::Fresh => panic!("expected Stale"),
752        }
753    }
754
755    #[test]
756    fn fresh_when_specifiers_match() {
757        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
758        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
759        assert_eq!(
760            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
761            DriftStatus::Fresh
762        );
763    }
764
765    #[test]
766    fn stale_when_specifier_changes() {
767        let manifest = make_manifest(&[("lodash", "^4.18.0")]);
768        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
769        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
770            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
771            DriftStatus::Fresh => panic!("expected Stale"),
772        }
773    }
774
775    #[test]
776    fn stale_when_manifest_adds_dep() {
777        let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
778        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
779        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
780            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
781            DriftStatus::Fresh => panic!("expected Stale"),
782        }
783    }
784
785    #[test]
786    fn stale_when_manifest_removes_dep() {
787        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
788        let graph = make_graph(&[
789            ("lodash", "^4.17.0", "lodash@4.17.21"),
790            ("express", "^4.18.0", "express@4.18.0"),
791        ]);
792        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
793            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
794            DriftStatus::Fresh => panic!("expected Stale"),
795        }
796    }
797
798    // Regression guard for #42: the drift check must recognize
799    // auto-hoisted peers as derived state, not as "manifest removed X".
800    // Without this, every project that has any peer dep would trigger
801    // a full re-resolve on every install, defeating lockfile caching.
802    #[test]
803    fn fresh_when_lockfile_has_auto_hoisted_peer() {
804        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
805        let mut graph = make_graph(&[
806            (
807                "use-sync-external-store",
808                "1.2.0",
809                "use-sync-external-store@1.2.0",
810            ),
811            // Hoisted peer — in the lockfile importers but not in the
812            // user's package.json.
813            ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
814        ]);
815        // The declaring package must list react as a peer for the
816        // drift check to recognize the hoist. We add that here.
817        let mut declaring_pkg = LockedPackage {
818            name: "use-sync-external-store".into(),
819            version: "1.2.0".into(),
820            dep_path: "use-sync-external-store@1.2.0".into(),
821            ..Default::default()
822        };
823        declaring_pkg
824            .peer_dependencies
825            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
826        graph
827            .packages
828            .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
829
830        assert_eq!(
831            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
832            DriftStatus::Fresh
833        );
834    }
835
836    // Regression: when a user explicitly pinned a dep that also happens
837    // to share its name with a peer declaration elsewhere in the graph,
838    // removing that pin from package.json must still be flagged as
839    // stale — otherwise the old pinned version gets locked forever.
840    // The check must key on (name, specifier), not name alone.
841    #[test]
842    fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
843        // Manifest after the user removed react entirely. Only
844        // use-sync-external-store remains.
845        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
846
847        // Lockfile still has the user's old `react: 17.0.2` pin alongside
848        // use-sync-external-store. Pre-removal state.
849        let mut graph = make_graph(&[
850            (
851                "use-sync-external-store",
852                "1.2.0",
853                "use-sync-external-store@1.2.0",
854            ),
855            ("react", "17.0.2", "react@17.0.2"),
856        ]);
857        // Add the peer declaration on the consumer package. This is
858        // the case that previously defeated the name-only check:
859        // react's specifier "17.0.2" doesn't match the declared peer
860        // range, so the hoist recognizer must reject it.
861        let mut consumer = LockedPackage {
862            name: "use-sync-external-store".into(),
863            version: "1.2.0".into(),
864            dep_path: "use-sync-external-store@1.2.0".into(),
865            ..Default::default()
866        };
867        consumer
868            .peer_dependencies
869            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
870        graph
871            .packages
872            .insert("use-sync-external-store@1.2.0".into(), consumer);
873
874        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
875            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
876            DriftStatus::Fresh => panic!(
877                "drift check should flag a removed user-pinned dep as stale, \
878                 even when its name matches a peer declaration"
879            ),
880        }
881    }
882
883    // But if the lockfile has a user-removed dep that ISN'T declared as a
884    // peer anywhere, we still need to flag it as stale.
885    #[test]
886    fn stale_when_lockfile_has_removed_non_peer_dep() {
887        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
888        let graph = make_graph(&[
889            ("lodash", "^4.17.0", "lodash@4.17.21"),
890            ("chalk", "^5.0.0", "chalk@5.0.0"),
891        ]);
892        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
893            DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
894            DriftStatus::Fresh => panic!("expected Stale"),
895        }
896    }
897
898    #[test]
899    fn workspace_drift_allows_root_links_for_workspace_packages() {
900        let root_manifest = make_manifest(&[]);
901        let mut app_manifest = make_manifest(&[]);
902        app_manifest.name = Some("@scope/app".to_string());
903
904        let link = LocalSource::Link(PathBuf::from("packages/app"));
905        let dep_path = link.dep_path("@scope/app");
906        let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
907        graph.packages.insert(
908            dep_path.clone(),
909            LockedPackage {
910                name: "@scope/app".to_string(),
911                version: "1.0.0".to_string(),
912                dep_path,
913                local_source: Some(link),
914                ..Default::default()
915            },
916        );
917
918        assert_eq!(
919            graph.check_drift_workspace(
920                &[
921                    (".".to_string(), root_manifest),
922                    ("packages/app".to_string(), app_manifest),
923                ],
924                &BTreeMap::new(),
925                &[],
926                &BTreeMap::new(),
927                true,
928            ),
929            DriftStatus::Fresh
930        );
931    }
932
933    #[test]
934    fn fresh_when_no_specifiers_recorded() {
935        // Non-pnpm formats (npm/yarn/bun) don't store specifiers, so we can't
936        // detect drift — we treat them as fresh and let the resolver decide.
937        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
938        let graph = LockfileGraph {
939            importers: {
940                let mut m = BTreeMap::new();
941                m.insert(
942                    ".".to_string(),
943                    vec![DirectDep {
944                        name: "lodash".into(),
945                        dep_path: "lodash@4.17.21".into(),
946                        dep_type: DepType::Production,
947                        specifier: None,
948                    }],
949                );
950                m
951            },
952            packages: BTreeMap::new(),
953            ..Default::default()
954        };
955        assert_eq!(
956            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
957            DriftStatus::Fresh
958        );
959    }
960
961    #[test]
962    fn stale_when_manifest_adds_override() {
963        // Lockfile recorded no overrides; manifest now has one. Drift
964        // must fire so the next install re-runs the resolver and bakes
965        // the override into the graph.
966        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
967        manifest
968            .extra
969            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
970        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
971        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
972            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
973            DriftStatus::Fresh => panic!("expected Stale"),
974        }
975    }
976
977    #[test]
978    fn fresh_when_npm_lockfile_cannot_record_overrides() {
979        // package-lock.json has no top-level override snapshot. Treating
980        // that absence as drift makes aube re-resolve and rewrite npm's
981        // lockfile graph even when the override is unrelated to the
982        // existing packages.
983        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
984        manifest
985            .extra
986            .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
987        let graph = LockfileGraph {
988            importers: {
989                let mut m = BTreeMap::new();
990                m.insert(
991                    ".".to_string(),
992                    vec![DirectDep {
993                        name: "lodash".into(),
994                        dep_path: "lodash@4.17.21".into(),
995                        dep_type: DepType::Production,
996                        specifier: None,
997                    }],
998                );
999                m
1000            },
1001            packages: BTreeMap::new(),
1002            ..Default::default()
1003        };
1004        assert_eq!(
1005            graph.check_drift_for_kind(
1006                &manifest,
1007                &BTreeMap::new(),
1008                &[],
1009                &BTreeMap::new(),
1010                LockfileKind::Npm,
1011            ),
1012            DriftStatus::Fresh
1013        );
1014    }
1015
1016    #[test]
1017    fn stale_when_bun_lockfile_can_record_overrides() {
1018        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1019        manifest
1020            .extra
1021            .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1022        let graph = LockfileGraph {
1023            importers: {
1024                let mut m = BTreeMap::new();
1025                m.insert(
1026                    ".".to_string(),
1027                    vec![DirectDep {
1028                        name: "lodash".into(),
1029                        dep_path: "lodash@4.17.21".into(),
1030                        dep_type: DepType::Production,
1031                        specifier: None,
1032                    }],
1033                );
1034                m
1035            },
1036            packages: BTreeMap::new(),
1037            ..Default::default()
1038        };
1039        match graph.check_drift_for_kind(
1040            &manifest,
1041            &BTreeMap::new(),
1042            &[],
1043            &BTreeMap::new(),
1044            LockfileKind::Bun,
1045        ) {
1046            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1047            DriftStatus::Fresh => panic!("expected Stale"),
1048        }
1049    }
1050
1051    #[test]
1052    fn stale_drift_message_names_changed_override_key() {
1053        // Both sides have one entry, but the value differs. The reason
1054        // should name the key — the previous "lockfile: 1 entries,
1055        // manifest: 1 entries" message looked like nothing changed.
1056        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1057        manifest
1058            .extra
1059            .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
1060        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1061        graph.overrides.insert("lodash".into(), "4.17.21".into());
1062        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1063            DriftStatus::Stale { reason } => {
1064                assert!(reason.contains("lodash"), "expected key in: {reason}");
1065                assert!(
1066                    reason.contains("4.17.21"),
1067                    "expected old value in: {reason}"
1068                );
1069                assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
1070            }
1071            DriftStatus::Fresh => panic!("expected Stale"),
1072        }
1073    }
1074
1075    #[test]
1076    fn stale_when_manifest_removes_override() {
1077        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1078        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1079        graph.overrides.insert("lodash".into(), "4.17.21".into());
1080        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1081            DriftStatus::Stale { reason } => {
1082                assert!(reason.contains("removes"));
1083                assert!(reason.contains("lodash"));
1084            }
1085            DriftStatus::Fresh => panic!("expected Stale"),
1086        }
1087    }
1088
1089    #[test]
1090    fn fresh_when_overrides_match() {
1091        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1092        manifest
1093            .extra
1094            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1095        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1096        graph.overrides.insert("lodash".into(), "4.17.21".into());
1097        assert_eq!(
1098            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1099            DriftStatus::Fresh
1100        );
1101    }
1102
1103    #[test]
1104    fn fresh_when_workspace_yaml_overrides_match_lockfile() {
1105        // pnpm v10 moved `overrides` to pnpm-workspace.yaml. When the
1106        // resolver wrote them into `self.overrides`, the drift check
1107        // must see the same map — otherwise the second install run
1108        // rejects the lockfile as stale with "manifest removes ..."
1109        // (reported in discussion #174).
1110        let manifest = make_manifest(&[("semver", "^7.5.0")]);
1111        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1112        graph.overrides.insert("semver".into(), "7.7.1".into());
1113        let mut ws_overrides = BTreeMap::new();
1114        ws_overrides.insert("semver".into(), "7.7.1".into());
1115        assert_eq!(
1116            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1117            DriftStatus::Fresh,
1118        );
1119    }
1120
1121    #[test]
1122    fn workspace_yaml_overrides_win_over_package_json() {
1123        // When both pnpm-workspace.yaml and package.json declare an
1124        // override for the same key, the workspace yaml wins — pnpm
1125        // v10's precedence. The drift check must apply the merged
1126        // effective map.
1127        let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
1128        manifest
1129            .extra
1130            .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
1131        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1132        graph.overrides.insert("semver".into(), "7.7.1".into());
1133        let mut ws_overrides = BTreeMap::new();
1134        ws_overrides.insert("semver".into(), "7.7.1".into());
1135        assert_eq!(
1136            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1137            DriftStatus::Fresh,
1138        );
1139    }
1140
1141    #[test]
1142    fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
1143        // pnpm-workspace.yaml: `overrides: { lodash: "catalog:" }` with
1144        // `catalog: { lodash: 4.17.21 }`. pnpm writes the lockfile with
1145        // the resolved override value (`lodash: 4.17.21`), so a frozen
1146        // install comparing the raw `catalog:` string against the
1147        // resolved form would always read stale (discussion #174).
1148        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1149        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1150        graph.overrides.insert("lodash".into(), "4.17.21".into());
1151        let mut ws_overrides = BTreeMap::new();
1152        ws_overrides.insert("lodash".into(), "catalog:".into());
1153        let mut catalogs = BTreeMap::new();
1154        let mut default_cat = BTreeMap::new();
1155        default_cat.insert("lodash".into(), "4.17.21".into());
1156        catalogs.insert("default".into(), default_cat);
1157        assert_eq!(
1158            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1159            DriftStatus::Fresh,
1160        );
1161    }
1162
1163    #[test]
1164    fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
1165        // Named catalog variant: `overrides: { lodash: "catalog:evens" }`
1166        // resolves against `catalogs.evens.lodash`.
1167        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1168        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1169        graph.overrides.insert("lodash".into(), "4.17.21".into());
1170        let mut ws_overrides = BTreeMap::new();
1171        ws_overrides.insert("lodash".into(), "catalog:evens".into());
1172        let mut catalogs = BTreeMap::new();
1173        let mut evens = BTreeMap::new();
1174        evens.insert("lodash".into(), "4.17.21".into());
1175        catalogs.insert("evens".into(), evens);
1176        assert_eq!(
1177            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1178            DriftStatus::Fresh,
1179        );
1180    }
1181
1182    #[test]
1183    fn stale_when_override_catalog_ref_diverges_from_lockfile() {
1184        // If the catalog moves to a new version, the resolved override
1185        // no longer matches the lockfile — drift must fire, not silently
1186        // accept.
1187        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1188        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1189        graph.overrides.insert("lodash".into(), "4.17.21".into());
1190        let mut ws_overrides = BTreeMap::new();
1191        ws_overrides.insert("lodash".into(), "catalog:".into());
1192        let mut catalogs = BTreeMap::new();
1193        let mut default_cat = BTreeMap::new();
1194        default_cat.insert("lodash".into(), "4.17.22".into());
1195        catalogs.insert("default".into(), default_cat);
1196        match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
1197            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
1198            other => panic!("expected stale, got {other:?}"),
1199        }
1200    }
1201
1202    #[test]
1203    fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
1204        // pnpm rewrites the importer `specifier:` to the post-override
1205        // value when a bare-name override applies, so a pnpm-generated
1206        // lockfile records `specifier: 4.17.21` even though
1207        // `package.json` still reads `^4.17.0`. Without override-aware
1208        // drift, every frozen install against a pnpm lockfile with
1209        // overrides reads stale (discussion #174).
1210        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1211        let mut importers = BTreeMap::new();
1212        importers.insert(
1213            ".".to_string(),
1214            vec![DirectDep {
1215                name: "lodash".into(),
1216                dep_path: "lodash@4.17.21".into(),
1217                dep_type: DepType::Production,
1218                specifier: Some("4.17.21".into()),
1219            }],
1220        );
1221        let mut graph = LockfileGraph {
1222            importers,
1223            ..Default::default()
1224        };
1225        graph.overrides.insert("lodash".into(), "4.17.21".into());
1226        let mut ws_overrides = BTreeMap::new();
1227        ws_overrides.insert("lodash".into(), "4.17.21".into());
1228        assert_eq!(
1229            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1230            DriftStatus::Fresh,
1231        );
1232    }
1233
1234    #[test]
1235    fn fresh_when_version_keyed_override_rewrites_importer_spec() {
1236        // Discussion #352: an override keyed by name+range
1237        // (`plist@<3.0.5` → `>=3.0.5`) rewrites the importer specifier
1238        // the same way bare-name overrides do. The drift check has to
1239        // parse the key and compare-by-rule, not by raw map lookup,
1240        // otherwise pnpm-written lockfiles read stale on every frozen
1241        // install when version-conditional overrides are in play.
1242        let manifest = make_manifest(&[("plist", "^3.0.4")]);
1243        let mut importers = BTreeMap::new();
1244        importers.insert(
1245            ".".to_string(),
1246            vec![DirectDep {
1247                name: "plist".into(),
1248                dep_path: "plist@3.0.6".into(),
1249                dep_type: DepType::Production,
1250                specifier: Some(">=3.0.5".into()),
1251            }],
1252        );
1253        let mut graph = LockfileGraph {
1254            importers,
1255            ..Default::default()
1256        };
1257        graph
1258            .overrides
1259            .insert("plist@<3.0.5".into(), ">=3.0.5".into());
1260        let mut ws_overrides = BTreeMap::new();
1261        ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
1262        assert_eq!(
1263            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1264            DriftStatus::Fresh,
1265        );
1266    }
1267
1268    #[test]
1269    fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
1270        // Same drift-shaped bug as overrides: the resolver unions
1271        // `ignoredOptionalDependencies` from package.json and
1272        // pnpm-workspace.yaml, so the lockfile's
1273        // `ignored_optional_dependencies` carries the union, and the
1274        // drift check has to see the same union or the next
1275        // `--frozen-lockfile` run fails with "manifest removes".
1276        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1277        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1278        graph
1279            .ignored_optional_dependencies
1280            .insert("fsevents".to_string());
1281        let ws_ignored = vec!["fsevents".to_string()];
1282        assert_eq!(
1283            graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
1284            DriftStatus::Fresh,
1285        );
1286    }
1287
1288    #[test]
1289    fn fresh_when_optional_dep_was_recorded_as_skipped() {
1290        // Regression: a platform-skipped optional dep would otherwise
1291        // loop forever as "manifest adds X". When the previous
1292        // resolve recorded it under skipped_optional_dependencies with
1293        // a matching specifier, drift must report Fresh.
1294        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1295        manifest
1296            .optional_dependencies
1297            .insert("fsevents".into(), "^2.3.0".into());
1298        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1299        let mut inner = BTreeMap::new();
1300        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1301        graph
1302            .skipped_optional_dependencies
1303            .insert(".".to_string(), inner);
1304        assert_eq!(
1305            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1306            DriftStatus::Fresh
1307        );
1308    }
1309
1310    #[test]
1311    fn stale_when_new_optional_dep_was_never_seen() {
1312        // Cursor Bugbot regression: a brand-new optional dep that the
1313        // previous resolve never saw must trigger drift, otherwise it
1314        // would silently never get installed. Distinct from a
1315        // platform-skipped optional, which has an entry in
1316        // `skipped_optional_dependencies`.
1317        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1318        manifest
1319            .optional_dependencies
1320            .insert("fsevents".into(), "^2.3.0".into());
1321        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1322        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1323            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1324            DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
1325        }
1326    }
1327
1328    #[test]
1329    fn stale_when_skipped_optional_dep_specifier_changes() {
1330        // The user bumped the range on a previously-skipped optional;
1331        // the recorded specifier no longer matches the manifest, so we
1332        // need to re-resolve.
1333        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1334        manifest
1335            .optional_dependencies
1336            .insert("fsevents".into(), "^2.4.0".into());
1337        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1338        let mut inner = BTreeMap::new();
1339        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1340        graph
1341            .skipped_optional_dependencies
1342            .insert(".".to_string(), inner);
1343        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1344            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1345            DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
1346        }
1347    }
1348
1349    #[test]
1350    fn stale_when_skipped_optional_is_promoted_to_required() {
1351        // Cursor Bugbot regression: if the user moves a previously-
1352        // skipped optional into `dependencies` (same specifier), the
1353        // skipped-list exemption must NOT fire — the dep is now
1354        // required and the lockfile genuinely doesn't include it.
1355        let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1356        // Note: fsevents lives in `dependencies`, not
1357        // `optional_dependencies`, even though the lockfile recorded
1358        // it under skipped optionals from a previous resolve.
1359        manifest.optional_dependencies.clear();
1360        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1361        let mut inner = BTreeMap::new();
1362        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1363        graph
1364            .skipped_optional_dependencies
1365            .insert(".".to_string(), inner);
1366        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1367            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1368            DriftStatus::Fresh => {
1369                panic!("expected Stale: skipped-optional exemption must not apply to required deps")
1370            }
1371        }
1372    }
1373
1374    #[test]
1375    fn stale_when_optional_dep_specifier_changes_in_lockfile() {
1376        // Spec changes on optionals that *are* present must still
1377        // drift, so the resolver re-runs when the user bumps a range.
1378        let mut manifest = make_manifest(&[]);
1379        manifest
1380            .optional_dependencies
1381            .insert("fsevents".into(), "^2.4.0".into());
1382        let mut graph = make_graph(&[]);
1383        graph.importers.get_mut(".").unwrap().push(DirectDep {
1384            name: "fsevents".into(),
1385            dep_path: "fsevents@2.3.0".into(),
1386            dep_type: DepType::Optional,
1387            specifier: Some("^2.3.0".into()),
1388        });
1389        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1390            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1391            DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
1392        }
1393    }
1394
1395    #[test]
1396    fn fresh_for_empty_manifest_and_lockfile() {
1397        let manifest = make_manifest(&[]);
1398        let graph = make_graph(&[]);
1399        assert_eq!(
1400            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1401            DriftStatus::Fresh
1402        );
1403    }
1404
1405    #[test]
1406    fn workspace_drift_detects_change_in_non_root_importer() {
1407        // Build a graph with two importers: root and packages/app.
1408        let root_dep = DirectDep {
1409            name: "lodash".into(),
1410            dep_path: "lodash@4.17.21".into(),
1411            dep_type: DepType::Production,
1412            specifier: Some("^4.17.0".into()),
1413        };
1414        let app_dep = DirectDep {
1415            name: "express".into(),
1416            dep_path: "express@4.18.0".into(),
1417            dep_type: DepType::Production,
1418            specifier: Some("^4.18.0".into()),
1419        };
1420        let mut importers = BTreeMap::new();
1421        importers.insert(".".to_string(), vec![root_dep]);
1422        importers.insert("packages/app".to_string(), vec![app_dep]);
1423        let graph = LockfileGraph {
1424            importers,
1425            packages: BTreeMap::new(),
1426            ..Default::default()
1427        };
1428
1429        let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
1430        // App manifest changed express to ^5.0.0 — should be detected as stale.
1431        let app_manifest = make_manifest(&[("express", "^5.0.0")]);
1432
1433        let workspace_manifests = vec![
1434            (".".to_string(), root_manifest.clone()),
1435            ("packages/app".to_string(), app_manifest),
1436        ];
1437        match graph.check_drift_workspace(
1438            &workspace_manifests,
1439            &BTreeMap::new(),
1440            &[],
1441            &BTreeMap::new(),
1442            true,
1443        ) {
1444            DriftStatus::Stale { reason } => {
1445                assert!(reason.contains("packages/app"));
1446                assert!(reason.contains("express"));
1447            }
1448            DriftStatus::Fresh => panic!("expected Stale"),
1449        }
1450
1451        // Single-importer check_drift on root only would say Fresh.
1452        assert_eq!(
1453            graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1454            DriftStatus::Fresh
1455        );
1456    }
1457
1458    #[test]
1459    fn filter_deps_prunes_dev_only_subtree() {
1460        // Graph: prod-root (foo) + dev-root (jest) with transitive chains.
1461        // After filtering out Dev, jest + its transitives should be pruned,
1462        // foo + its transitives should remain.
1463        let mut importers = BTreeMap::new();
1464        importers.insert(
1465            ".".to_string(),
1466            vec![
1467                DirectDep {
1468                    name: "foo".into(),
1469                    dep_path: "foo@1.0.0".into(),
1470                    dep_type: DepType::Production,
1471                    specifier: Some("^1.0.0".into()),
1472                },
1473                DirectDep {
1474                    name: "jest".into(),
1475                    dep_path: "jest@29.0.0".into(),
1476                    dep_type: DepType::Dev,
1477                    specifier: Some("^29.0.0".into()),
1478                },
1479            ],
1480        );
1481
1482        let mut packages = BTreeMap::new();
1483        let mut foo_deps = BTreeMap::new();
1484        foo_deps.insert("bar".to_string(), "2.0.0".to_string());
1485        packages.insert(
1486            "foo@1.0.0".to_string(),
1487            LockedPackage {
1488                name: "foo".into(),
1489                version: "1.0.0".into(),
1490                integrity: None,
1491                dependencies: foo_deps,
1492                dep_path: "foo@1.0.0".into(),
1493                ..Default::default()
1494            },
1495        );
1496        packages.insert(
1497            "bar@2.0.0".to_string(),
1498            LockedPackage {
1499                name: "bar".into(),
1500                version: "2.0.0".into(),
1501                integrity: None,
1502                dependencies: BTreeMap::new(),
1503                dep_path: "bar@2.0.0".into(),
1504                ..Default::default()
1505            },
1506        );
1507        let mut jest_deps = BTreeMap::new();
1508        jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
1509        packages.insert(
1510            "jest@29.0.0".to_string(),
1511            LockedPackage {
1512                name: "jest".into(),
1513                version: "29.0.0".into(),
1514                integrity: None,
1515                dependencies: jest_deps,
1516                dep_path: "jest@29.0.0".into(),
1517                ..Default::default()
1518            },
1519        );
1520        packages.insert(
1521            "jest-core@29.0.0".to_string(),
1522            LockedPackage {
1523                name: "jest-core".into(),
1524                version: "29.0.0".into(),
1525                integrity: None,
1526                dependencies: BTreeMap::new(),
1527                dep_path: "jest-core@29.0.0".into(),
1528                ..Default::default()
1529            },
1530        );
1531
1532        let graph = LockfileGraph {
1533            importers,
1534            packages,
1535            ..Default::default()
1536        };
1537
1538        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1539
1540        // Direct deps: only foo, jest dropped
1541        let roots = prod.root_deps();
1542        assert_eq!(roots.len(), 1);
1543        assert_eq!(roots[0].name, "foo");
1544
1545        // Reachable packages: foo + bar (transitive), NOT jest or jest-core
1546        assert!(prod.packages.contains_key("foo@1.0.0"));
1547        assert!(prod.packages.contains_key("bar@2.0.0"));
1548        assert!(!prod.packages.contains_key("jest@29.0.0"));
1549        assert!(!prod.packages.contains_key("jest-core@29.0.0"));
1550    }
1551
1552    // Regression for #50 feedback: `filter_deps` is a structural
1553    // operation and must preserve the source graph's `settings:`
1554    // metadata. A filtered graph that's handed to the lockfile writer
1555    // (as `aube prune` does today) would otherwise reset
1556    // `autoInstallPeers` to its default and silently flip the user's
1557    // choice on the next install.
1558    #[test]
1559    fn filter_deps_preserves_lockfile_settings() {
1560        let graph = LockfileGraph {
1561            importers: BTreeMap::new(),
1562            packages: BTreeMap::new(),
1563            settings: LockfileSettings {
1564                auto_install_peers: false,
1565                exclude_links_from_lockfile: true,
1566                lockfile_include_tarball_url: false,
1567            },
1568            ..Default::default()
1569        };
1570        let filtered = graph.filter_deps(|_| true);
1571        assert!(!filtered.settings.auto_install_peers);
1572        assert!(filtered.settings.exclude_links_from_lockfile);
1573    }
1574
1575    #[test]
1576    fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
1577        // Graph: prod foo → shared, dev jest → shared
1578        // Filtering out Dev should still keep `shared` because foo → shared
1579        // keeps it reachable.
1580        let mut importers = BTreeMap::new();
1581        importers.insert(
1582            ".".to_string(),
1583            vec![
1584                DirectDep {
1585                    name: "foo".into(),
1586                    dep_path: "foo@1.0.0".into(),
1587                    dep_type: DepType::Production,
1588                    specifier: Some("^1.0.0".into()),
1589                },
1590                DirectDep {
1591                    name: "jest".into(),
1592                    dep_path: "jest@29.0.0".into(),
1593                    dep_type: DepType::Dev,
1594                    specifier: Some("^29.0.0".into()),
1595                },
1596            ],
1597        );
1598
1599        let mut packages = BTreeMap::new();
1600        for (name, ver, deps) in [
1601            ("foo", "1.0.0", vec![("shared", "1.0.0")]),
1602            ("jest", "29.0.0", vec![("shared", "1.0.0")]),
1603            ("shared", "1.0.0", vec![]),
1604        ] {
1605            let mut dep_map = BTreeMap::new();
1606            for (n, v) in deps {
1607                dep_map.insert(n.to_string(), v.to_string());
1608            }
1609            packages.insert(
1610                format!("{name}@{ver}"),
1611                LockedPackage {
1612                    name: name.into(),
1613                    version: ver.into(),
1614                    integrity: None,
1615                    dependencies: dep_map,
1616                    dep_path: format!("{name}@{ver}"),
1617                    ..Default::default()
1618                },
1619            );
1620        }
1621
1622        let graph = LockfileGraph {
1623            importers,
1624            packages,
1625            ..Default::default()
1626        };
1627        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1628
1629        assert!(prod.packages.contains_key("foo@1.0.0"));
1630        assert!(prod.packages.contains_key("shared@1.0.0"));
1631        assert!(!prod.packages.contains_key("jest@29.0.0"));
1632    }
1633
1634    #[test]
1635    fn subset_to_importer_returns_none_for_missing_importer() {
1636        let graph = LockfileGraph {
1637            importers: BTreeMap::new(),
1638            packages: BTreeMap::new(),
1639            ..Default::default()
1640        };
1641        assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
1642    }
1643
1644    #[test]
1645    fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
1646        // Workspace graph with two importers that own independent
1647        // subtrees: packages/lib pulls is-odd → is-number, packages/app
1648        // pulls express. Subsetting to packages/lib must yield a graph
1649        // rooted at `.` containing only is-odd + is-number, with
1650        // express pruned. Matches what `aube deploy --filter @test/lib`
1651        // should write into the target.
1652        let mut importers = BTreeMap::new();
1653        importers.insert(".".to_string(), vec![]);
1654        importers.insert(
1655            "packages/lib".to_string(),
1656            vec![DirectDep {
1657                name: "is-odd".into(),
1658                dep_path: "is-odd@3.0.1".into(),
1659                dep_type: DepType::Production,
1660                specifier: Some("^3.0.1".into()),
1661            }],
1662        );
1663        importers.insert(
1664            "packages/app".to_string(),
1665            vec![DirectDep {
1666                name: "express".into(),
1667                dep_path: "express@4.18.0".into(),
1668                dep_type: DepType::Production,
1669                specifier: Some("^4.18.0".into()),
1670            }],
1671        );
1672
1673        let mut packages = BTreeMap::new();
1674        let mut is_odd_deps = BTreeMap::new();
1675        is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
1676        packages.insert(
1677            "is-odd@3.0.1".to_string(),
1678            LockedPackage {
1679                name: "is-odd".into(),
1680                version: "3.0.1".into(),
1681                dependencies: is_odd_deps,
1682                dep_path: "is-odd@3.0.1".into(),
1683                ..Default::default()
1684            },
1685        );
1686        packages.insert(
1687            "is-number@6.0.0".to_string(),
1688            LockedPackage {
1689                name: "is-number".into(),
1690                version: "6.0.0".into(),
1691                dep_path: "is-number@6.0.0".into(),
1692                ..Default::default()
1693            },
1694        );
1695        packages.insert(
1696            "express@4.18.0".to_string(),
1697            LockedPackage {
1698                name: "express".into(),
1699                version: "4.18.0".into(),
1700                dep_path: "express@4.18.0".into(),
1701                ..Default::default()
1702            },
1703        );
1704
1705        let graph = LockfileGraph {
1706            importers,
1707            packages,
1708            ..Default::default()
1709        };
1710        let subset = graph
1711            .subset_to_importer("packages/lib", |_| true)
1712            .expect("packages/lib importer present");
1713
1714        assert_eq!(subset.importers.len(), 1);
1715        let roots = subset.root_deps();
1716        assert_eq!(roots.len(), 1);
1717        assert_eq!(roots[0].name, "is-odd");
1718
1719        assert!(subset.packages.contains_key("is-odd@3.0.1"));
1720        assert!(subset.packages.contains_key("is-number@6.0.0"));
1721        assert!(!subset.packages.contains_key("express@4.18.0"));
1722    }
1723
1724    #[test]
1725    fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
1726        // packages/lib has both prod (is-odd) and dev (jest) deps.
1727        // `aube deploy --prod` should pass `|d| d.dep_type != Dev` as
1728        // the keep filter; the resulting subset retains only is-odd
1729        // so drift against the target's dev-stripped manifest stays
1730        // clean.
1731        let mut importers = BTreeMap::new();
1732        importers.insert(
1733            "packages/lib".to_string(),
1734            vec![
1735                DirectDep {
1736                    name: "is-odd".into(),
1737                    dep_path: "is-odd@3.0.1".into(),
1738                    dep_type: DepType::Production,
1739                    specifier: Some("^3.0.1".into()),
1740                },
1741                DirectDep {
1742                    name: "jest".into(),
1743                    dep_path: "jest@29.0.0".into(),
1744                    dep_type: DepType::Dev,
1745                    specifier: Some("^29.0.0".into()),
1746                },
1747            ],
1748        );
1749        let mut packages = BTreeMap::new();
1750        packages.insert(
1751            "is-odd@3.0.1".to_string(),
1752            LockedPackage {
1753                name: "is-odd".into(),
1754                version: "3.0.1".into(),
1755                dep_path: "is-odd@3.0.1".into(),
1756                ..Default::default()
1757            },
1758        );
1759        packages.insert(
1760            "jest@29.0.0".to_string(),
1761            LockedPackage {
1762                name: "jest".into(),
1763                version: "29.0.0".into(),
1764                dep_path: "jest@29.0.0".into(),
1765                ..Default::default()
1766            },
1767        );
1768        let graph = LockfileGraph {
1769            importers,
1770            packages,
1771            ..Default::default()
1772        };
1773
1774        let prod = graph
1775            .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
1776            .expect("importer present");
1777        let roots = prod.root_deps();
1778        assert_eq!(roots.len(), 1);
1779        assert_eq!(roots[0].name, "is-odd");
1780        assert!(prod.packages.contains_key("is-odd@3.0.1"));
1781        assert!(!prod.packages.contains_key("jest@29.0.0"));
1782    }
1783
1784    #[test]
1785    fn subset_to_importer_preserves_graph_settings() {
1786        // Structural pruning, not a resolution-mode reset: a deploy
1787        // into a target that uses the source workspace's settings
1788        // header (autoInstallPeers / lockfileIncludeTarballUrl)
1789        // should write them through unchanged so a frozen install in
1790        // the target sees the same resolution-mode state.
1791        let mut importers = BTreeMap::new();
1792        importers.insert("packages/lib".to_string(), vec![]);
1793        let graph = LockfileGraph {
1794            importers,
1795            packages: BTreeMap::new(),
1796            settings: LockfileSettings {
1797                auto_install_peers: false,
1798                exclude_links_from_lockfile: true,
1799                lockfile_include_tarball_url: true,
1800            },
1801            ..Default::default()
1802        };
1803        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1804        assert!(!subset.settings.auto_install_peers);
1805        assert!(subset.settings.exclude_links_from_lockfile);
1806        assert!(subset.settings.lockfile_include_tarball_url);
1807    }
1808
1809    #[test]
1810    fn subset_to_importer_rekeys_skipped_optionals_to_root() {
1811        // `skipped_optional_dependencies` is per-importer. After
1812        // subsetting, only the retained importer's entry should
1813        // survive — rekeyed to `.` so a frozen install in the target
1814        // (which has exactly one importer) doesn't see ghost entries.
1815        let mut importers = BTreeMap::new();
1816        importers.insert("packages/lib".to_string(), vec![]);
1817        importers.insert("packages/app".to_string(), vec![]);
1818        let mut skipped = BTreeMap::new();
1819        let mut lib_skip = BTreeMap::new();
1820        lib_skip.insert("fsevents".to_string(), "^2".to_string());
1821        skipped.insert("packages/lib".to_string(), lib_skip);
1822        let mut app_skip = BTreeMap::new();
1823        app_skip.insert("ghost".to_string(), "*".to_string());
1824        skipped.insert("packages/app".to_string(), app_skip);
1825        let graph = LockfileGraph {
1826            importers,
1827            packages: BTreeMap::new(),
1828            skipped_optional_dependencies: skipped,
1829            ..Default::default()
1830        };
1831        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1832        assert_eq!(subset.skipped_optional_dependencies.len(), 1);
1833        let root = subset.skipped_optional_dependencies.get(".").unwrap();
1834        assert!(root.contains_key("fsevents"));
1835        assert!(!root.contains_key("ghost"));
1836    }
1837
1838    #[test]
1839    fn workspace_drift_fresh_when_all_importers_match() {
1840        let root_dep = DirectDep {
1841            name: "lodash".into(),
1842            dep_path: "lodash@4.17.21".into(),
1843            dep_type: DepType::Production,
1844            specifier: Some("^4.17.0".into()),
1845        };
1846        let app_dep = DirectDep {
1847            name: "express".into(),
1848            dep_path: "express@4.18.0".into(),
1849            dep_type: DepType::Production,
1850            specifier: Some("^4.18.0".into()),
1851        };
1852        let mut importers = BTreeMap::new();
1853        importers.insert(".".to_string(), vec![root_dep]);
1854        importers.insert("packages/app".to_string(), vec![app_dep]);
1855        let graph = LockfileGraph {
1856            importers,
1857            packages: BTreeMap::new(),
1858            ..Default::default()
1859        };
1860
1861        let workspace_manifests = vec![
1862            (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
1863            (
1864                "packages/app".to_string(),
1865                make_manifest(&[("express", "^4.18.0")]),
1866            ),
1867        ];
1868        assert_eq!(
1869            graph.check_drift_workspace(
1870                &workspace_manifests,
1871                &BTreeMap::new(),
1872                &[],
1873                &BTreeMap::new(),
1874                true,
1875            ),
1876            DriftStatus::Fresh
1877        );
1878    }
1879
1880    #[allow(clippy::type_complexity)]
1881    fn mk_catalogs(
1882        entries: &[(&str, &[(&str, &str, &str)])],
1883    ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
1884        let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
1885        for (cat, pkgs) in entries {
1886            let mut inner = BTreeMap::new();
1887            for (pkg, spec, ver) in *pkgs {
1888                inner.insert(
1889                    (*pkg).to_string(),
1890                    CatalogEntry {
1891                        specifier: (*spec).to_string(),
1892                        version: (*ver).to_string(),
1893                    },
1894                );
1895            }
1896            out.insert((*cat).to_string(), inner);
1897        }
1898        out
1899    }
1900
1901    fn mk_workspace_catalogs(
1902        entries: &[(&str, &[(&str, &str)])],
1903    ) -> BTreeMap<String, BTreeMap<String, String>> {
1904        entries
1905            .iter()
1906            .map(|(cat, pkgs)| {
1907                (
1908                    (*cat).to_string(),
1909                    pkgs.iter()
1910                        .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
1911                        .collect(),
1912                )
1913            })
1914            .collect()
1915    }
1916
1917    #[test]
1918    fn catalog_drift_fresh_when_specifiers_match() {
1919        let graph = LockfileGraph {
1920            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
1921            ..Default::default()
1922        };
1923        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
1924        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
1925    }
1926
1927    #[test]
1928    fn catalog_drift_stale_on_changed_specifier() {
1929        let graph = LockfileGraph {
1930            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
1931            ..Default::default()
1932        };
1933        let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
1934        match graph.check_catalogs_drift(&ws) {
1935            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1936            other => panic!("expected stale, got {other:?}"),
1937        }
1938    }
1939
1940    #[test]
1941    fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
1942        // pnpm only writes referenced entries — an unreferenced
1943        // workspace entry is not drift. The "newly used" transition
1944        // is caught by the importer-level drift check.
1945        let graph = LockfileGraph::default();
1946        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
1947        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
1948    }
1949
1950    #[test]
1951    fn catalog_drift_stale_on_removed_workspace_entry() {
1952        let graph = LockfileGraph {
1953            catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
1954            ..Default::default()
1955        };
1956        let ws = mk_workspace_catalogs(&[]);
1957        assert!(matches!(
1958            graph.check_catalogs_drift(&ws),
1959            DriftStatus::Stale { .. }
1960        ));
1961    }
1962}