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    /// Importers that don't record specifiers return `Fresh` since we have no
41    /// way to detect manifest 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)
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    /// Compare this lockfile's catalog snapshot against the current
257    /// `pnpm-workspace.yaml` catalogs.
258    ///
259    /// pnpm only writes catalog entries that at least one importer
260    /// references — unused entries are absent from the lockfile. So
261    /// "missing from lockfile" doesn't mean "added by the user", it
262    /// means "declared but unreferenced", which is not drift. The
263    /// transition from unused → used is caught by the importer-level
264    /// drift check, since a fresh `catalog:` reference shows up as a
265    /// new dep in some `package.json`.
266    ///
267    /// We fire on two cases only:
268    /// - the spec changed for an entry the lockfile already records
269    ///   (the entry is in use, and re-resolution must rerun);
270    /// - the workspace removed an entry that the lockfile records
271    ///   (the importer using `catalog:` now points at nothing).
272    ///
273    /// Resolved versions are deliberately not part of the comparison —
274    /// the version is an *output* of resolution, so a stale lockfile
275    /// version is what re-resolution is supposed to fix. Drift only
276    /// fires on user intent (the specifier).
277    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    /// Compare a single importer's `DirectDep` list against the corresponding
312    /// `package.json`. Used by both [`check_drift`] and [`check_drift_workspace`].
313    ///
314    /// [`check_drift`]: Self::check_drift
315    /// [`check_drift_workspace`]: Self::check_drift_workspace
316    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        // Skip the check entirely if no DirectDep has a specifier (non-pnpm format).
350        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        // Optionals the previous resolve recorded as intentionally
361        // skipped on this importer's platform — keyed by name, value
362        // is the specifier captured at that time. Distinct from
363        // `ignored_optional_dependencies`, which is the user's static
364        // ignore list; this map captures *runtime* platform skips.
365        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        // Iterate prod / dev / optional with a flag so the
372        // skipped-optional exemption only applies to deps that came
373        // from `optional_dependencies`. Without the flag, moving a
374        // previously-skipped optional into `dependencies` with the same
375        // specifier would silently report Fresh and the dep would
376        // never install as a required dep.
377        //
378        // Optionals named in `ignored_optional_dependencies` are
379        // dropped from the manifest-side scan: the resolver never
380        // enqueues them, so the lockfile importer never has them
381        // either, and the loop would otherwise report drift on every
382        // install. (Their *spec* is still verified separately by the
383        // round-tripped `ignored_optional_dependencies` block below.)
384        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                    // A *missing* optional dep is only "fresh" if the
416                    // previous resolve recorded it as intentionally
417                    // skipped (platform mismatch or
418                    // `pnpm.ignoredOptionalDependencies`) AND the
419                    // recorded specifier still matches what's in the
420                    // manifest. A genuinely *new* optional that the
421                    // resolver has never seen is real drift — without
422                    // that branch, adding `fsevents` to a fresh manifest
423                    // would silently never get installed.
424                    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                    // pnpm rewrites the importer specifier to the
446                    // override-applied value when an override fires on
447                    // a direct dep, so a pnpm-generated lockfile shows
448                    // `specifier: ">=3.0.5"` even though `package.json`
449                    // still reads `^3.0.4`. Accept that as fresh when
450                    // an override for this name (bare or version-keyed)
451                    // resolves to the lockfile's recorded spec —
452                    // otherwise any pnpm-written lockfile with
453                    // overrides reads stale on every frozen install.
454                    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                    // A pnpmfile `readPackage` hook can rewrite an
461                    // importer's own dep spec into a local source — the
462                    // canonical case is wiring a monorepo package to a
463                    // sibling's build output (`"@scope/api": "*"` →
464                    // `link:../api/dist`). pnpm records the *rewritten*
465                    // spec in the lockfile importer (so does aube), then
466                    // re-runs the hook on every install to compare. The
467                    // drift fast path deliberately does not re-run the
468                    // hook, so a raw `manifest says *, lockfile says
469                    // link:...` comparison would read stale forever and
470                    // re-resolve (or hard-fail `--frozen-lockfile`) on
471                    // every install. Trust the lockfile's hook-derived
472                    // local spec when (a) the lockfile was produced with
473                    // a pnpmfile that exports hooks (`pnpmfileChecksum`
474                    // is recorded) and (b) the manifest spec is a plain
475                    // non-local range the hook turned into a
476                    // link/file/portal. A pnpmfile edit changes the
477                    // recorded checksum (busting the warm path
478                    // separately), so this cannot mask a stale link once
479                    // the hook itself changes; and gating on a non-local
480                    // manifest spec keeps a user-authored `link:` that
481                    // was later repointed at the registry detectable.
482                    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        // Detect dep-type drift: a name kept in the manifest but moved
499        // between sections (e.g. `dependencies` → `devDependencies`)
500        // keeps the same specifier, so the spec-only checks above
501        // report Fresh and the warm path short-circuits without
502        // rewriting the lockfile. The resolver's priority is
503        // `dependencies` > `devDependencies` > `optionalDependencies`,
504        // matching `seed_direct_deps` in aube-resolver.
505        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        // Anything in the lockfile but missing from the manifest's owned
549        // dependency sections is stale unless it is retained for an
550        // importer-owned peer. Required importer peers are included in
551        // `manifest_names` when autoInstallPeers is enabled; otherwise a
552        // retained exact version must satisfy the declared peer range.
553        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            // pnpm retains importer entries when an owned dependency is
579            // removed but a peer declaration for the same package remains.
580            // It may preserve the old exact dependency specifier (for
581            // example `5.3.4` for a `^5` peer), so accept either an exact
582            // peer-spec match or an exact locked version satisfying the
583            // importer's declared peer range.
584            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
625/// Merge `pnpm-workspace.yaml` overrides on top of the manifest's
626/// `overrides_map()`. Workspace entries win on key conflict, matching
627/// pnpm v10's behavior where the workspace yaml is the canonical
628/// home for overrides. Callers pass this into `overrides_drift_reason`
629/// so the drift check sees the same effective map the resolver used.
630fn 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
641/// Rewrite `catalog:` / `catalog:<name>` override values to the catalog's
642/// resolved range. pnpm writes resolved override values into the lockfile
643/// and compares against the resolved form on re-install, so both sides
644/// of the drift check have to see the catalog-substituted map — otherwise
645/// a `"lodash": "catalog:"` workspace-yaml override reads as stale against
646/// a lockfile that recorded `"lodash": "4.17.21"`. Unresolvable references
647/// (missing catalog or missing entry) pass through untouched; the caller
648/// would have errored at resolve time if this ever reached a real install,
649/// so a drift-mismatch here is fine.
650fn 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
672/// Compare two override maps and return a human-readable reason
673/// describing the first difference, or `None` if they're identical.
674/// Drift messages cite the offending key by name so users can act on
675/// them — `(lockfile: N entries, manifest: M entries)` is useless
676/// when N == M but a value changed.
677fn 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
698/// Compare two `ignoredOptionalDependencies` sets and return a drift
699/// reason string for the first difference, or `None` if identical.
700fn 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
719/// Compare recorded runtime pins against the manifest's
720/// `devEngines.runtime` declarations.
721///
722/// Only an *existing* pin can drift here: the requested range changed,
723/// or the manifest dropped the devEngines entry the pin came from. The
724/// inverse case — devEngines present but no pin recorded yet — is
725/// deliberately not drift, because formats that can't record runtime
726/// pins (npm/yarn/bun) would read as permanently stale. The install
727/// driver adds the missing pin on formats that support it.
728fn 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            // An entry that names the runtime but declares no
745            // `version` carries no concrete range — resolution treats
746            // it as "no requirement", so it can't contradict the pin.
747            // Flagging it would hard-fail frozen installs over a
748            // field that changes nothing.
749            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/// Result of comparing a lockfile against a manifest.
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub enum DriftStatus {
767    /// The lockfile is in sync with the manifest. Safe to use without re-resolving.
768    Fresh,
769    /// The lockfile is out of date. The reason describes the first mismatch found.
770    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
780/// True for importer specifiers that point at a local on-disk source
781/// (`link:` / `file:` / `portal:` / `exec:`) rather than a registry range
782/// or workspace alias — the same set `LocalSource::parse` recognizes. The
783/// drift check uses this to recognize pnpmfile-`readPackage`-rewritten
784/// importer deps, where the hook turns a plain range into a local link
785/// (e.g. `"*"` → `link:../pkg/dist`).
786fn 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        // (name, specifier, dep_path)
825        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        // Discussion #602: moving a dep between `dependencies` and
846        // `devDependencies` keeps the same specifier, so the spec-only
847        // checks reported Fresh and the warm path short-circuited
848        // without rewriting the lockfile.
849        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        // A pnpmfile `readPackage` hook rewrites `"@scope/api": "*"` to
915        // `link:../api/dist` (wiring a sibling's build output). pnpm and
916        // aube both record the *rewritten* spec in the importer, so the
917        // raw manifest (`*`) never matches the lockfile (`link:...`). With
918        // a `pnpmfileChecksum` recorded — i.e. the lockfile was produced
919        // by a hook-exporting pnpmfile — trust the local spec instead of
920        // re-resolving on every install. Mirrors pnpm, which re-runs the
921        // hook and reports "Already up to date".
922        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        // Without a recorded pnpmfileChecksum there's no hook to attribute
938        // the link to, so a `link:` importer spec the manifest doesn't
939        // contain is genuine drift (e.g. the user hand-edited the lockfile
940        // or repointed a dep) and must re-resolve.
941        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        // The hook exemption only covers a *non-local* manifest range the
956        // hook turned into a link. A user-authored `link:` that changed
957        // target stays a local spec on the manifest side, so the gate
958        // (`!is_local_source_spec(spec)`) keeps it detectable and forces a
959        // re-resolve rather than silently trusting a stale link.
960        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    // Dependency peers belong to the package's peer context, not the
974    // importer. A legacy aube lockfile containing the synthetic importer
975    // row must re-resolve so it converges on pnpm's shape.
976    #[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            // Incorrectly hoisted peer — in the lockfile importer but not
986            // in the user's package.json.
987            ("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    // Regression: when a user explicitly pinned a dep that also happens
1085    // to share its name with a peer declaration elsewhere in the graph,
1086    // removing that pin from package.json must still be flagged as
1087    // stale — otherwise the old pinned version gets locked forever.
1088    // The check must key on (name, specifier), not name alone.
1089    #[test]
1090    fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
1091        // Manifest after the user removed react entirely. Only
1092        // use-sync-external-store remains.
1093        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
1094
1095        // Lockfile still has the user's old `react: 17.0.2` pin alongside
1096        // use-sync-external-store. Pre-removal state.
1097        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        // Add the peer declaration on the consumer package. This is
1106        // the case that previously defeated the name-only check:
1107        // react's specifier "17.0.2" doesn't match the declared peer
1108        // range, so the hoist recognizer must reject it.
1109        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    // But if the lockfile has a user-removed dep that ISN'T declared as a
1132    // peer anywhere, we still need to flag it as stale.
1133    #[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        // Some lockfile importers don't store specifiers, so we can't detect
1184        // drift — we treat them as fresh and let the resolver decide.
1185        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        // Lockfile recorded no overrides; manifest now has one. Drift
1212        // must fire so the next install re-runs the resolver and bakes
1213        // the override into the graph.
1214        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        // package-lock.json has no top-level override snapshot. Treating
1228        // that absence as drift makes aube re-resolve and rewrite npm's
1229        // lockfile graph even when the override is unrelated to the
1230        // existing packages.
1231        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        // Both sides have one entry, but the value differs. The reason
1302        // should name the key — the previous "lockfile: 1 entries,
1303        // manifest: 1 entries" message looked like nothing changed.
1304        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        // pnpm v10 moved `overrides` to pnpm-workspace.yaml. When the
1354        // resolver wrote them into `self.overrides`, the drift check
1355        // must see the same map — otherwise the second install run
1356        // rejects the lockfile as stale with "manifest removes ..."
1357        // (reported in discussion #174).
1358        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        // When both pnpm-workspace.yaml and package.json declare an
1372        // override for the same key, the workspace yaml wins — pnpm
1373        // v10's precedence. The drift check must apply the merged
1374        // effective map.
1375        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        // pnpm-workspace.yaml: `overrides: { lodash: "catalog:" }` with
1392        // `catalog: { lodash: 4.17.21 }`. pnpm writes the lockfile with
1393        // the resolved override value (`lodash: 4.17.21`), so a frozen
1394        // install comparing the raw `catalog:` string against the
1395        // resolved form would always read stale (discussion #174).
1396        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        // Named catalog variant: `overrides: { lodash: "catalog:evens" }`
1414        // resolves against `catalogs.evens.lodash`.
1415        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        // If the catalog moves to a new version, the resolved override
1481        // no longer matches the lockfile — drift must fire, not silently
1482        // accept.
1483        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        // pnpm rewrites the importer `specifier:` to the post-override
1501        // value when a bare-name override applies, so a pnpm-generated
1502        // lockfile records `specifier: 4.17.21` even though
1503        // `package.json` still reads `^4.17.0`. Without override-aware
1504        // drift, every frozen install against a pnpm lockfile with
1505        // overrides reads stale (discussion #174).
1506        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        // Discussion #352: an override keyed by name+range
1533        // (`plist@<3.0.5` → `>=3.0.5`) rewrites the importer specifier
1534        // the same way bare-name overrides do. The drift check has to
1535        // parse the key and compare-by-rule, not by raw map lookup,
1536        // otherwise pnpm-written lockfiles read stale on every frozen
1537        // install when version-conditional overrides are in play.
1538        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        // Same drift-shaped bug as overrides: the resolver unions
1567        // `ignoredOptionalDependencies` from package.json and
1568        // pnpm-workspace.yaml, so the lockfile's
1569        // `ignored_optional_dependencies` carries the union, and the
1570        // drift check has to see the same union or the next
1571        // `--frozen-lockfile` run fails with "manifest removes".
1572        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        // Regression: a platform-skipped optional dep would otherwise
1587        // loop forever as "manifest adds X". When the previous
1588        // resolve recorded it under skipped_optional_dependencies with
1589        // a matching specifier, drift must report Fresh.
1590        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        // Cursor Bugbot regression: a brand-new optional dep that the
1609        // previous resolve never saw must trigger drift, otherwise it
1610        // would silently never get installed. Distinct from a
1611        // platform-skipped optional, which has an entry in
1612        // `skipped_optional_dependencies`.
1613        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        // The user bumped the range on a previously-skipped optional;
1627        // the recorded specifier no longer matches the manifest, so we
1628        // need to re-resolve.
1629        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        // Cursor Bugbot regression: if the user moves a previously-
1648        // skipped optional into `dependencies` (same specifier), the
1649        // skipped-list exemption must NOT fire — the dep is now
1650        // required and the lockfile genuinely doesn't include it.
1651        let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1652        // Note: fsevents lives in `dependencies`, not
1653        // `optional_dependencies`, even though the lockfile recorded
1654        // it under skipped optionals from a previous resolve.
1655        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        // Spec changes on optionals that *are* present must still
1673        // drift, so the resolver re-runs when the user bumps a range.
1674        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        // Build a graph with two importers: root and packages/app.
1704        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        // App manifest changed express to ^5.0.0 — should be detected as stale.
1727        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        // Single-importer check_drift on root only would say Fresh.
1748        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        // Graph: prod-root (foo) + dev-root (jest) with transitive chains.
1757        // After filtering out Dev, jest + its transitives should be pruned,
1758        // foo + its transitives should remain.
1759        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        // Direct deps: only foo, jest dropped
1837        let roots = prod.root_deps();
1838        assert_eq!(roots.len(), 1);
1839        assert_eq!(roots[0].name, "foo");
1840
1841        // Reachable packages: foo + bar (transitive), NOT jest or jest-core
1842        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    // Regression for #50 feedback: `filter_deps` is a structural
1849    // operation and must preserve the source graph's `settings:`
1850    // metadata. A filtered graph that's handed to the lockfile writer
1851    // (as `aube prune` does today) would otherwise reset
1852    // `autoInstallPeers` to its default and silently flip the user's
1853    // choice on the next install.
1854    #[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        // Graph: prod foo → shared, dev jest → shared
1874        // Filtering out Dev should still keep `shared` because foo → shared
1875        // keeps it reachable.
1876        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        // Workspace graph with two importers that own independent
1943        // subtrees: packages/lib pulls is-odd → is-number, packages/app
1944        // pulls express. Subsetting to packages/lib must yield a graph
1945        // rooted at `.` containing only is-odd + is-number, with
1946        // express pruned. Matches what `aube deploy --filter @test/lib`
1947        // should write into the target.
1948        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        // packages/lib has both prod (is-odd) and dev (jest) deps.
2023        // `aube deploy --prod` should pass `|d| d.dep_type != Dev` as
2024        // the keep filter; the resulting subset retains only is-odd
2025        // so drift against the target's dev-stripped manifest stays
2026        // clean.
2027        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        // Structural pruning, not a resolution-mode reset: a deploy
2083        // into a target that uses the source workspace's settings
2084        // header (autoInstallPeers / lockfileIncludeTarballUrl)
2085        // should write them through unchanged so a frozen install in
2086        // the target sees the same resolution-mode state.
2087        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        // `skipped_optional_dependencies` is per-importer. After
2108        // subsetting, only the retained importer's entry should
2109        // survive — rekeyed to `.` so a frozen install in the target
2110        // (which has exactly one importer) doesn't see ghost entries.
2111        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        // pnpm only writes referenced entries — an unreferenced
2239        // workspace entry is not drift. The "newly used" transition
2240        // is caught by the importer-level drift check.
2241        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}