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
398        for (name, spec, is_optional) in manifest_deps {
399            match lockfile_specs.get(name.as_str()) {
400                None => {
401                    // A *missing* optional dep is only "fresh" if the
402                    // previous resolve recorded it as intentionally
403                    // skipped (platform mismatch or
404                    // `pnpm.ignoredOptionalDependencies`) AND the
405                    // recorded specifier still matches what's in the
406                    // manifest. A genuinely *new* optional that the
407                    // resolver has never seen is real drift — without
408                    // that branch, adding `fsevents` to a fresh manifest
409                    // would silently never get installed.
410                    if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
411                        if *locked_spec == spec {
412                            continue;
413                        }
414                        return DriftStatus::Stale {
415                            reason: format!(
416                                "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
417                            ),
418                        };
419                    }
420                    return DriftStatus::Stale {
421                        reason: format!("{label}manifest adds {name}@{spec}"),
422                    };
423                }
424                Some(locked_spec) if *locked_spec != spec => {
425                    // pnpm rewrites the importer specifier to the
426                    // override-applied value when an override fires on
427                    // a direct dep, so a pnpm-generated lockfile shows
428                    // `specifier: ">=3.0.5"` even though `package.json`
429                    // still reads `^3.0.4`. Accept that as fresh when
430                    // an override for this name (bare or version-keyed)
431                    // resolves to the lockfile's recorded spec —
432                    // otherwise any pnpm-written lockfile with
433                    // overrides reads stale on every frozen install.
434                    if let Some(override_spec) =
435                        override_match::apply(&override_rules, name.as_str(), spec)
436                        && override_spec == *locked_spec
437                    {
438                        continue;
439                    }
440                    // A pnpmfile `readPackage` hook can rewrite an
441                    // importer's own dep spec into a local source — the
442                    // canonical case is wiring a monorepo package to a
443                    // sibling's build output (`"@scope/api": "*"` →
444                    // `link:../api/dist`). pnpm records the *rewritten*
445                    // spec in the lockfile importer (so does aube), then
446                    // re-runs the hook on every install to compare. The
447                    // drift fast path deliberately does not re-run the
448                    // hook, so a raw `manifest says *, lockfile says
449                    // link:...` comparison would read stale forever and
450                    // re-resolve (or hard-fail `--frozen-lockfile`) on
451                    // every install. Trust the lockfile's hook-derived
452                    // local spec when (a) the lockfile was produced with
453                    // a pnpmfile that exports hooks (`pnpmfileChecksum`
454                    // is recorded) and (b) the manifest spec is a plain
455                    // non-local range the hook turned into a
456                    // link/file/portal. A pnpmfile edit changes the
457                    // recorded checksum (busting the warm path
458                    // separately), so this cannot mask a stale link once
459                    // the hook itself changes; and gating on a non-local
460                    // manifest spec keeps a user-authored `link:` that
461                    // was later repointed at the registry detectable.
462                    if self.pnpmfile_checksum.is_some()
463                        && is_local_source_spec(locked_spec)
464                        && !is_local_source_spec(spec)
465                    {
466                        continue;
467                    }
468                    return DriftStatus::Stale {
469                        reason: format!(
470                            "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
471                        ),
472                    };
473                }
474                Some(_) => {}
475            }
476        }
477
478        // Detect dep-type drift: a name kept in the manifest but moved
479        // between sections (e.g. `dependencies` → `devDependencies`)
480        // keeps the same specifier, so the spec-only checks above
481        // report Fresh and the warm path short-circuits without
482        // rewriting the lockfile. The resolver's priority is
483        // `dependencies` > `devDependencies` > `optionalDependencies`,
484        // matching `seed_direct_deps` in aube-resolver.
485        let mut manifest_dep_types: BTreeMap<&str, DepType> = BTreeMap::new();
486        for name in manifest.dependencies.keys() {
487            manifest_dep_types.insert(name.as_str(), DepType::Production);
488        }
489        for name in manifest.dev_dependencies.keys() {
490            manifest_dep_types
491                .entry(name.as_str())
492                .or_insert(DepType::Dev);
493        }
494        for name in manifest.optional_dependencies.keys() {
495            if ignored.contains(name.as_str()) {
496                continue;
497            }
498            manifest_dep_types
499                .entry(name.as_str())
500                .or_insert(DepType::Optional);
501        }
502        for dep in importer_deps {
503            let Some(expected) = manifest_dep_types.get(dep.name.as_str()) else {
504                continue;
505            };
506            if *expected != dep.dep_type {
507                return DriftStatus::Stale {
508                    reason: format!(
509                        "{label}{}: manifest section is {}, lockfile section is {}",
510                        dep.name,
511                        dep_type_label(*expected),
512                        dep_type_label(dep.dep_type),
513                    ),
514                };
515            }
516        }
517
518        // Anything in the lockfile but missing from the manifest is stale
519        // — UNLESS it was auto-hoisted as a peer by the resolver. pnpm-style
520        // `auto-install-peers=true` puts peers into the importer's
521        // `dependencies` without the user having written them in
522        // `package.json`, so we have to recognize those as derived state
523        // rather than user intent.
524        //
525        // Critically, we identify an auto-hoisted entry by matching its
526        // *recorded specifier* against peer ranges declared in the graph,
527        // not just by name. A name-only check would silently exempt a
528        // user-pinned `react` that the user later removed (if any package
529        // anywhere in the graph peer-declares react, the name match would
530        // fire and we'd report Fresh forever — defeating the drift check).
531        //
532        // The rule: a lockfile entry whose (name, specifier) pair exactly
533        // matches some package's declared (peer_name, peer_range) is
534        // auto-hoisted. If the user had pinned react with a different
535        // specifier string and then removed it, the (name, specifier)
536        // pair no longer matches any peer range, and drift correctly
537        // fires so the resolver re-runs and rewrites the lockfile.
538        let manifest_names: std::collections::HashSet<&str> = manifest
539            .dependencies
540            .keys()
541            .chain(manifest.dev_dependencies.keys())
542            .chain(
543                manifest
544                    .optional_dependencies
545                    .keys()
546                    .filter(|name| !ignored.contains(name.as_str())),
547            )
548            .map(|s| s.as_str())
549            .collect();
550        let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
551            .packages
552            .values()
553            .flat_map(|p| {
554                p.peer_dependencies
555                    .iter()
556                    .map(|(name, range)| (name.as_str(), range.as_str()))
557            })
558            .collect();
559        for (locked_name, locked_spec) in &lockfile_specs {
560            if manifest_names.contains(locked_name) {
561                continue;
562            }
563            if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
564                continue;
565            }
566            let workspace_link = importer_path == "."
567                && workspace_link_names.contains(locked_name)
568                && importer_deps
569                    .iter()
570                    .find(|dep| dep.name == *locked_name)
571                    .and_then(|dep| self.packages.get(&dep.dep_path))
572                    .is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))));
573            if workspace_link {
574                continue;
575            }
576            return DriftStatus::Stale {
577                reason: format!("{label}manifest removed {locked_name}"),
578            };
579        }
580
581        DriftStatus::Fresh
582    }
583}
584
585/// Merge `pnpm-workspace.yaml` overrides on top of the manifest's
586/// `overrides_map()`. Workspace entries win on key conflict, matching
587/// pnpm v10's behavior where the workspace yaml is the canonical
588/// home for overrides. Callers pass this into `overrides_drift_reason`
589/// so the drift check sees the same effective map the resolver used.
590fn merge_manifest_and_workspace_overrides(
591    manifest: &aube_manifest::PackageJson,
592    workspace_overrides: &BTreeMap<String, String>,
593) -> BTreeMap<String, String> {
594    let mut out = manifest.overrides_map();
595    for (k, v) in workspace_overrides {
596        out.insert(k.clone(), v.clone());
597    }
598    out
599}
600
601/// Rewrite `catalog:` / `catalog:<name>` override values to the catalog's
602/// resolved range. pnpm writes resolved override values into the lockfile
603/// and compares against the resolved form on re-install, so both sides
604/// of the drift check have to see the catalog-substituted map — otherwise
605/// a `"lodash": "catalog:"` workspace-yaml override reads as stale against
606/// a lockfile that recorded `"lodash": "4.17.21"`. Unresolvable references
607/// (missing catalog or missing entry) pass through untouched; the caller
608/// would have errored at resolve time if this ever reached a real install,
609/// so a drift-mismatch here is fine.
610fn resolve_catalog_refs_in_overrides(
611    overrides: &BTreeMap<String, String>,
612    workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
613) -> BTreeMap<String, String> {
614    overrides
615        .iter()
616        .map(|(k, v)| {
617            let resolved = v
618                .strip_prefix("catalog:")
619                .map(|tail| if tail.is_empty() { "default" } else { tail })
620                .and_then(|cat_name| workspace_catalogs.get(cat_name))
621                .and_then(|cat| {
622                    override_match::target_package_name(k)
623                        .and_then(|package_name| cat.get(&package_name))
624                })
625                .cloned()
626                .unwrap_or_else(|| v.clone());
627            (k.clone(), resolved)
628        })
629        .collect()
630}
631
632/// Compare two override maps and return a human-readable reason
633/// describing the first difference, or `None` if they're identical.
634/// Drift messages cite the offending key by name so users can act on
635/// them — `(lockfile: N entries, manifest: M entries)` is useless
636/// when N == M but a value changed.
637fn overrides_drift_reason(
638    lockfile: &BTreeMap<String, String>,
639    manifest: &BTreeMap<String, String>,
640) -> Option<String> {
641    for (k, v) in manifest {
642        match lockfile.get(k) {
643            None => return Some(format!("overrides: manifest adds {k}@{v}")),
644            Some(locked) if locked != v => {
645                return Some(format!("overrides: {k} changed ({locked} → {v})"));
646            }
647            Some(_) => {}
648        }
649    }
650    for k in lockfile.keys() {
651        if !manifest.contains_key(k) {
652            return Some(format!("overrides: manifest removes {k}"));
653        }
654    }
655    None
656}
657
658/// Compare two `ignoredOptionalDependencies` sets and return a drift
659/// reason string for the first difference, or `None` if identical.
660fn ignored_optional_drift_reason(
661    lockfile: &BTreeSet<String>,
662    manifest: &BTreeSet<String>,
663) -> Option<String> {
664    for name in manifest {
665        if !lockfile.contains(name) {
666            return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
667        }
668    }
669    for name in lockfile {
670        if !manifest.contains(name) {
671            return Some(format!(
672                "ignoredOptionalDependencies: manifest removes {name}"
673            ));
674        }
675    }
676    None
677}
678
679/// Compare recorded runtime pins against the manifest's
680/// `devEngines.runtime` declarations.
681///
682/// Only an *existing* pin can drift here: the requested range changed,
683/// or the manifest dropped the devEngines entry the pin came from. The
684/// inverse case — devEngines present but no pin recorded yet — is
685/// deliberately not drift, because formats that can't record runtime
686/// pins (npm/yarn/bun) would read as permanently stale. The install
687/// driver adds the missing pin on formats that support it.
688fn runtime_drift_reason(
689    runtimes: &BTreeMap<String, crate::RuntimePin>,
690    manifest: &aube_manifest::PackageJson,
691) -> Option<String> {
692    for (name, pin) in runtimes {
693        let entry = manifest
694            .dev_engines
695            .as_ref()
696            .and_then(|d| d.runtime.iter().find(|r| r.name == *name));
697        match entry {
698            None => {
699                return Some(format!(
700                    "devEngines.runtime: manifest no longer pins {name} (lockfile records {})",
701                    pin.version
702                ));
703            }
704            // An entry that names the runtime but declares no
705            // `version` carries no concrete range — resolution treats
706            // it as "no requirement", so it can't contradict the pin.
707            // Flagging it would hard-fail frozen installs over a
708            // field that changes nothing.
709            Some(entry) => match entry.version.as_deref() {
710                None => {}
711                Some(range) if range != pin.specifier => {
712                    return Some(format!(
713                        "devEngines.runtime: {name} changed ({} → {range})",
714                        pin.specifier
715                    ));
716                }
717                Some(_) => {}
718            },
719        }
720    }
721    None
722}
723
724/// Result of comparing a lockfile against a manifest.
725#[derive(Debug, Clone, PartialEq, Eq)]
726pub enum DriftStatus {
727    /// The lockfile is in sync with the manifest. Safe to use without re-resolving.
728    Fresh,
729    /// The lockfile is out of date. The reason describes the first mismatch found.
730    Stale { reason: String },
731}
732
733fn kind_records_resolution_metadata(kind: LockfileKind) -> bool {
734    matches!(
735        kind,
736        LockfileKind::Aube | LockfileKind::Pnpm | LockfileKind::Bun
737    )
738}
739
740/// True for importer specifiers that point at a local on-disk source
741/// (`link:` / `file:` / `portal:` / `exec:`) rather than a registry range
742/// or workspace alias — the same set `LocalSource::parse` recognizes. The
743/// drift check uses this to recognize pnpmfile-`readPackage`-rewritten
744/// importer deps, where the hook turns a plain range into a local link
745/// (e.g. `"*"` → `link:../pkg/dist`).
746fn is_local_source_spec(spec: &str) -> bool {
747    spec.starts_with("link:")
748        || spec.starts_with("file:")
749        || spec.starts_with("portal:")
750        || spec.starts_with("exec:")
751}
752
753#[cfg(test)]
754mod drift_tests {
755    use super::*;
756    use crate::{CatalogEntry, LockedPackage, LockfileSettings};
757    use aube_manifest::PackageJson;
758    use std::collections::BTreeMap;
759    use std::path::PathBuf;
760
761    fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
762        let mut m = PackageJson {
763            name: Some("test".into()),
764            version: Some("1.0.0".into()),
765            dependencies: BTreeMap::new(),
766            dev_dependencies: BTreeMap::new(),
767            peer_dependencies: BTreeMap::new(),
768            optional_dependencies: BTreeMap::new(),
769            update_config: None,
770            scripts: BTreeMap::new(),
771            engines: BTreeMap::new(),
772            dev_engines: None,
773            workspaces: None,
774            bundled_dependencies: None,
775            extra: BTreeMap::new(),
776        };
777        for (name, spec) in deps {
778            m.dependencies.insert((*name).into(), (*spec).into());
779        }
780        m
781    }
782
783    fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
784        // (name, specifier, dep_path)
785        let direct: Vec<DirectDep> = deps
786            .iter()
787            .map(|(name, spec, dep_path)| DirectDep {
788                name: (*name).into(),
789                dep_path: (*dep_path).into(),
790                dep_type: DepType::Production,
791                specifier: Some((*spec).into()),
792            })
793            .collect();
794        let mut importers = BTreeMap::new();
795        importers.insert(".".to_string(), direct);
796        LockfileGraph {
797            importers,
798            packages: BTreeMap::new(),
799            ..Default::default()
800        }
801    }
802
803    #[test]
804    fn stale_when_dep_moves_between_sections() {
805        // Discussion #602: moving a dep between `dependencies` and
806        // `devDependencies` keeps the same specifier, so the spec-only
807        // checks reported Fresh and the warm path short-circuited
808        // without rewriting the lockfile.
809        let mut manifest = make_manifest(&[]);
810        manifest
811            .dev_dependencies
812            .insert("msw".into(), "catalog:".into());
813        let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
814        graph
815            .importers
816            .get_mut(".")
817            .unwrap()
818            .iter_mut()
819            .for_each(|d| d.dep_type = DepType::Production);
820        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
821            DriftStatus::Stale { reason } => {
822                assert!(reason.contains("msw"), "reason: {reason}");
823                assert!(reason.contains("devDependencies"), "reason: {reason}");
824            }
825            DriftStatus::Fresh => panic!("expected Stale"),
826        }
827    }
828
829    #[test]
830    fn fresh_when_specifiers_match() {
831        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
832        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
833        assert_eq!(
834            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
835            DriftStatus::Fresh
836        );
837    }
838
839    #[test]
840    fn stale_when_specifier_changes() {
841        let manifest = make_manifest(&[("lodash", "^4.18.0")]);
842        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
843        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
844            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
845            DriftStatus::Fresh => panic!("expected Stale"),
846        }
847    }
848
849    #[test]
850    fn stale_when_manifest_adds_dep() {
851        let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
852        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
853        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
854            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
855            DriftStatus::Fresh => panic!("expected Stale"),
856        }
857    }
858
859    #[test]
860    fn stale_when_manifest_removes_dep() {
861        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
862        let graph = make_graph(&[
863            ("lodash", "^4.17.0", "lodash@4.17.21"),
864            ("express", "^4.18.0", "express@4.18.0"),
865        ]);
866        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
867            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
868            DriftStatus::Fresh => panic!("expected Stale"),
869        }
870    }
871
872    #[test]
873    fn fresh_when_pnpmfile_hook_rewrites_dep_to_link() {
874        // A pnpmfile `readPackage` hook rewrites `"@scope/api": "*"` to
875        // `link:../api/dist` (wiring a sibling's build output). pnpm and
876        // aube both record the *rewritten* spec in the importer, so the
877        // raw manifest (`*`) never matches the lockfile (`link:...`). With
878        // a `pnpmfileChecksum` recorded — i.e. the lockfile was produced
879        // by a hook-exporting pnpmfile — trust the local spec instead of
880        // re-resolving on every install. Mirrors pnpm, which re-runs the
881        // hook and reports "Already up to date".
882        let manifest = make_manifest(&[("@scope/api", "*")]);
883        let mut graph = make_graph(&[(
884            "@scope/api",
885            "link:../api/dist",
886            "@scope/api@link:../api/dist",
887        )]);
888        graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
889        assert_eq!(
890            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
891            DriftStatus::Fresh
892        );
893    }
894
895    #[test]
896    fn stale_when_link_importer_spec_has_no_pnpmfile_checksum() {
897        // Without a recorded pnpmfileChecksum there's no hook to attribute
898        // the link to, so a `link:` importer spec the manifest doesn't
899        // contain is genuine drift (e.g. the user hand-edited the lockfile
900        // or repointed a dep) and must re-resolve.
901        let manifest = make_manifest(&[("@scope/api", "*")]);
902        let graph = make_graph(&[(
903            "@scope/api",
904            "link:../api/dist",
905            "@scope/api@link:../api/dist",
906        )]);
907        assert!(matches!(
908            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
909            DriftStatus::Stale { .. }
910        ));
911    }
912
913    #[test]
914    fn stale_when_manifest_link_repointed_even_with_pnpmfile_checksum() {
915        // The hook exemption only covers a *non-local* manifest range the
916        // hook turned into a link. A user-authored `link:` that changed
917        // target stays a local spec on the manifest side, so the gate
918        // (`!is_local_source_spec(spec)`) keeps it detectable and forces a
919        // re-resolve rather than silently trusting a stale link.
920        let manifest = make_manifest(&[("@scope/api", "link:../api/old")]);
921        let mut graph = make_graph(&[(
922            "@scope/api",
923            "link:../api/new",
924            "@scope/api@link:../api/new",
925        )]);
926        graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
927        assert!(matches!(
928            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
929            DriftStatus::Stale { .. }
930        ));
931    }
932
933    // Regression guard for #42: the drift check must recognize
934    // auto-hoisted peers as derived state, not as "manifest removed X".
935    // Without this, every project that has any peer dep would trigger
936    // a full re-resolve on every install, defeating lockfile caching.
937    #[test]
938    fn fresh_when_lockfile_has_auto_hoisted_peer() {
939        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
940        let mut graph = make_graph(&[
941            (
942                "use-sync-external-store",
943                "1.2.0",
944                "use-sync-external-store@1.2.0",
945            ),
946            // Hoisted peer — in the lockfile importers but not in the
947            // user's package.json.
948            ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
949        ]);
950        // The declaring package must list react as a peer for the
951        // drift check to recognize the hoist. We add that here.
952        let mut declaring_pkg = LockedPackage {
953            name: "use-sync-external-store".into(),
954            version: "1.2.0".into(),
955            dep_path: "use-sync-external-store@1.2.0".into(),
956            ..Default::default()
957        };
958        declaring_pkg
959            .peer_dependencies
960            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
961        graph
962            .packages
963            .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
964
965        assert_eq!(
966            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
967            DriftStatus::Fresh
968        );
969    }
970
971    // Regression: when a user explicitly pinned a dep that also happens
972    // to share its name with a peer declaration elsewhere in the graph,
973    // removing that pin from package.json must still be flagged as
974    // stale — otherwise the old pinned version gets locked forever.
975    // The check must key on (name, specifier), not name alone.
976    #[test]
977    fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
978        // Manifest after the user removed react entirely. Only
979        // use-sync-external-store remains.
980        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
981
982        // Lockfile still has the user's old `react: 17.0.2` pin alongside
983        // use-sync-external-store. Pre-removal state.
984        let mut graph = make_graph(&[
985            (
986                "use-sync-external-store",
987                "1.2.0",
988                "use-sync-external-store@1.2.0",
989            ),
990            ("react", "17.0.2", "react@17.0.2"),
991        ]);
992        // Add the peer declaration on the consumer package. This is
993        // the case that previously defeated the name-only check:
994        // react's specifier "17.0.2" doesn't match the declared peer
995        // range, so the hoist recognizer must reject it.
996        let mut consumer = LockedPackage {
997            name: "use-sync-external-store".into(),
998            version: "1.2.0".into(),
999            dep_path: "use-sync-external-store@1.2.0".into(),
1000            ..Default::default()
1001        };
1002        consumer
1003            .peer_dependencies
1004            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
1005        graph
1006            .packages
1007            .insert("use-sync-external-store@1.2.0".into(), consumer);
1008
1009        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1010            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1011            DriftStatus::Fresh => panic!(
1012                "drift check should flag a removed user-pinned dep as stale, \
1013                 even when its name matches a peer declaration"
1014            ),
1015        }
1016    }
1017
1018    // But if the lockfile has a user-removed dep that ISN'T declared as a
1019    // peer anywhere, we still need to flag it as stale.
1020    #[test]
1021    fn stale_when_lockfile_has_removed_non_peer_dep() {
1022        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1023        let graph = make_graph(&[
1024            ("lodash", "^4.17.0", "lodash@4.17.21"),
1025            ("chalk", "^5.0.0", "chalk@5.0.0"),
1026        ]);
1027        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1028            DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
1029            DriftStatus::Fresh => panic!("expected Stale"),
1030        }
1031    }
1032
1033    #[test]
1034    fn workspace_drift_allows_root_links_for_workspace_packages() {
1035        let root_manifest = make_manifest(&[]);
1036        let mut app_manifest = make_manifest(&[]);
1037        app_manifest.name = Some("@scope/app".to_string());
1038
1039        let link = LocalSource::Link(PathBuf::from("packages/app"));
1040        let dep_path = link.dep_path("@scope/app");
1041        let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
1042        graph.packages.insert(
1043            dep_path.clone(),
1044            LockedPackage {
1045                name: "@scope/app".to_string(),
1046                version: "1.0.0".to_string(),
1047                dep_path,
1048                local_source: Some(link),
1049                ..Default::default()
1050            },
1051        );
1052
1053        assert_eq!(
1054            graph.check_drift_workspace(
1055                &[
1056                    (".".to_string(), root_manifest),
1057                    ("packages/app".to_string(), app_manifest),
1058                ],
1059                &BTreeMap::new(),
1060                &[],
1061                &BTreeMap::new(),
1062                true,
1063            ),
1064            DriftStatus::Fresh
1065        );
1066    }
1067
1068    #[test]
1069    fn fresh_when_no_specifiers_recorded() {
1070        // Some lockfile importers don't store specifiers, so we can't detect
1071        // drift — we treat them as fresh and let the resolver decide.
1072        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1073        let graph = LockfileGraph {
1074            importers: {
1075                let mut m = BTreeMap::new();
1076                m.insert(
1077                    ".".to_string(),
1078                    vec![DirectDep {
1079                        name: "lodash".into(),
1080                        dep_path: "lodash@4.17.21".into(),
1081                        dep_type: DepType::Production,
1082                        specifier: None,
1083                    }],
1084                );
1085                m
1086            },
1087            packages: BTreeMap::new(),
1088            ..Default::default()
1089        };
1090        assert_eq!(
1091            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1092            DriftStatus::Fresh
1093        );
1094    }
1095
1096    #[test]
1097    fn stale_when_manifest_adds_override() {
1098        // Lockfile recorded no overrides; manifest now has one. Drift
1099        // must fire so the next install re-runs the resolver and bakes
1100        // the override into the graph.
1101        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1102        manifest
1103            .extra
1104            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1105        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1106        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1107            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1108            DriftStatus::Fresh => panic!("expected Stale"),
1109        }
1110    }
1111
1112    #[test]
1113    fn fresh_when_npm_lockfile_cannot_record_overrides() {
1114        // package-lock.json has no top-level override snapshot. Treating
1115        // that absence as drift makes aube re-resolve and rewrite npm's
1116        // lockfile graph even when the override is unrelated to the
1117        // existing packages.
1118        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1119        manifest
1120            .extra
1121            .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1122        let graph = LockfileGraph {
1123            importers: {
1124                let mut m = BTreeMap::new();
1125                m.insert(
1126                    ".".to_string(),
1127                    vec![DirectDep {
1128                        name: "lodash".into(),
1129                        dep_path: "lodash@4.17.21".into(),
1130                        dep_type: DepType::Production,
1131                        specifier: None,
1132                    }],
1133                );
1134                m
1135            },
1136            packages: BTreeMap::new(),
1137            ..Default::default()
1138        };
1139        assert_eq!(
1140            graph.check_drift_for_kind(
1141                &manifest,
1142                &BTreeMap::new(),
1143                &[],
1144                &BTreeMap::new(),
1145                LockfileKind::Npm,
1146            ),
1147            DriftStatus::Fresh
1148        );
1149    }
1150
1151    #[test]
1152    fn stale_when_bun_lockfile_can_record_overrides() {
1153        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1154        manifest
1155            .extra
1156            .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1157        let graph = LockfileGraph {
1158            importers: {
1159                let mut m = BTreeMap::new();
1160                m.insert(
1161                    ".".to_string(),
1162                    vec![DirectDep {
1163                        name: "lodash".into(),
1164                        dep_path: "lodash@4.17.21".into(),
1165                        dep_type: DepType::Production,
1166                        specifier: None,
1167                    }],
1168                );
1169                m
1170            },
1171            packages: BTreeMap::new(),
1172            ..Default::default()
1173        };
1174        match graph.check_drift_for_kind(
1175            &manifest,
1176            &BTreeMap::new(),
1177            &[],
1178            &BTreeMap::new(),
1179            LockfileKind::Bun,
1180        ) {
1181            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1182            DriftStatus::Fresh => panic!("expected Stale"),
1183        }
1184    }
1185
1186    #[test]
1187    fn stale_drift_message_names_changed_override_key() {
1188        // Both sides have one entry, but the value differs. The reason
1189        // should name the key — the previous "lockfile: 1 entries,
1190        // manifest: 1 entries" message looked like nothing changed.
1191        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1192        manifest
1193            .extra
1194            .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
1195        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1196        graph.overrides.insert("lodash".into(), "4.17.21".into());
1197        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1198            DriftStatus::Stale { reason } => {
1199                assert!(reason.contains("lodash"), "expected key in: {reason}");
1200                assert!(
1201                    reason.contains("4.17.21"),
1202                    "expected old value in: {reason}"
1203                );
1204                assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
1205            }
1206            DriftStatus::Fresh => panic!("expected Stale"),
1207        }
1208    }
1209
1210    #[test]
1211    fn stale_when_manifest_removes_override() {
1212        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1213        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1214        graph.overrides.insert("lodash".into(), "4.17.21".into());
1215        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1216            DriftStatus::Stale { reason } => {
1217                assert!(reason.contains("removes"));
1218                assert!(reason.contains("lodash"));
1219            }
1220            DriftStatus::Fresh => panic!("expected Stale"),
1221        }
1222    }
1223
1224    #[test]
1225    fn fresh_when_overrides_match() {
1226        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1227        manifest
1228            .extra
1229            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1230        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1231        graph.overrides.insert("lodash".into(), "4.17.21".into());
1232        assert_eq!(
1233            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1234            DriftStatus::Fresh
1235        );
1236    }
1237
1238    #[test]
1239    fn fresh_when_workspace_yaml_overrides_match_lockfile() {
1240        // pnpm v10 moved `overrides` to pnpm-workspace.yaml. When the
1241        // resolver wrote them into `self.overrides`, the drift check
1242        // must see the same map — otherwise the second install run
1243        // rejects the lockfile as stale with "manifest removes ..."
1244        // (reported in discussion #174).
1245        let manifest = make_manifest(&[("semver", "^7.5.0")]);
1246        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1247        graph.overrides.insert("semver".into(), "7.7.1".into());
1248        let mut ws_overrides = BTreeMap::new();
1249        ws_overrides.insert("semver".into(), "7.7.1".into());
1250        assert_eq!(
1251            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1252            DriftStatus::Fresh,
1253        );
1254    }
1255
1256    #[test]
1257    fn workspace_yaml_overrides_win_over_package_json() {
1258        // When both pnpm-workspace.yaml and package.json declare an
1259        // override for the same key, the workspace yaml wins — pnpm
1260        // v10's precedence. The drift check must apply the merged
1261        // effective map.
1262        let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
1263        manifest
1264            .extra
1265            .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
1266        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1267        graph.overrides.insert("semver".into(), "7.7.1".into());
1268        let mut ws_overrides = BTreeMap::new();
1269        ws_overrides.insert("semver".into(), "7.7.1".into());
1270        assert_eq!(
1271            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1272            DriftStatus::Fresh,
1273        );
1274    }
1275
1276    #[test]
1277    fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
1278        // pnpm-workspace.yaml: `overrides: { lodash: "catalog:" }` with
1279        // `catalog: { lodash: 4.17.21 }`. pnpm writes the lockfile with
1280        // the resolved override value (`lodash: 4.17.21`), so a frozen
1281        // install comparing the raw `catalog:` string against the
1282        // resolved form would always read stale (discussion #174).
1283        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1284        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1285        graph.overrides.insert("lodash".into(), "4.17.21".into());
1286        let mut ws_overrides = BTreeMap::new();
1287        ws_overrides.insert("lodash".into(), "catalog:".into());
1288        let mut catalogs = BTreeMap::new();
1289        let mut default_cat = BTreeMap::new();
1290        default_cat.insert("lodash".into(), "4.17.21".into());
1291        catalogs.insert("default".into(), default_cat);
1292        assert_eq!(
1293            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1294            DriftStatus::Fresh,
1295        );
1296    }
1297
1298    #[test]
1299    fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
1300        // Named catalog variant: `overrides: { lodash: "catalog:evens" }`
1301        // resolves against `catalogs.evens.lodash`.
1302        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1303        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1304        graph.overrides.insert("lodash".into(), "4.17.21".into());
1305        let mut ws_overrides = BTreeMap::new();
1306        ws_overrides.insert("lodash".into(), "catalog:evens".into());
1307        let mut catalogs = BTreeMap::new();
1308        let mut evens = BTreeMap::new();
1309        evens.insert("lodash".into(), "4.17.21".into());
1310        catalogs.insert("evens".into(), evens);
1311        assert_eq!(
1312            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1313            DriftStatus::Fresh,
1314        );
1315    }
1316
1317    #[test]
1318    fn fresh_when_yarn_ancestor_override_catalog_ref_matches_lockfile() {
1319        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1320        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1321        graph
1322            .overrides
1323            .insert("parent/lodash".into(), "4.17.21".into());
1324        let ws_overrides = BTreeMap::from([("parent/lodash".to_string(), "catalog:".to_string())]);
1325        let catalogs = BTreeMap::from([(
1326            "default".to_string(),
1327            BTreeMap::from([("lodash".to_string(), "4.17.21".to_string())]),
1328        )]);
1329
1330        assert_eq!(
1331            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1332            DriftStatus::Fresh,
1333        );
1334    }
1335
1336    #[test]
1337    fn catalog_overrides_resolve_slash_targets_with_comparators() {
1338        let overrides = BTreeMap::from([
1339            ("parent/lodash@>=4.0.0".to_string(), "catalog:".to_string()),
1340            (
1341                "parent/@scope/pkg@>1.0.0".to_string(),
1342                "catalog:".to_string(),
1343            ),
1344            ("parent@^1>123numeric".to_string(), "catalog:".to_string()),
1345        ]);
1346        let catalogs = BTreeMap::from([(
1347            "default".to_string(),
1348            BTreeMap::from([
1349                ("lodash".to_string(), "4.17.21".to_string()),
1350                ("@scope/pkg".to_string(), "2.0.0".to_string()),
1351                ("123numeric".to_string(), "1.0.0".to_string()),
1352            ]),
1353        )]);
1354
1355        assert_eq!(
1356            resolve_catalog_refs_in_overrides(&overrides, &catalogs),
1357            BTreeMap::from([
1358                ("parent/lodash@>=4.0.0".to_string(), "4.17.21".to_string(),),
1359                ("parent/@scope/pkg@>1.0.0".to_string(), "2.0.0".to_string(),),
1360                ("parent@^1>123numeric".to_string(), "1.0.0".to_string()),
1361            ])
1362        );
1363    }
1364
1365    #[test]
1366    fn stale_when_override_catalog_ref_diverges_from_lockfile() {
1367        // If the catalog moves to a new version, the resolved override
1368        // no longer matches the lockfile — drift must fire, not silently
1369        // accept.
1370        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1371        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1372        graph.overrides.insert("lodash".into(), "4.17.21".into());
1373        let mut ws_overrides = BTreeMap::new();
1374        ws_overrides.insert("lodash".into(), "catalog:".into());
1375        let mut catalogs = BTreeMap::new();
1376        let mut default_cat = BTreeMap::new();
1377        default_cat.insert("lodash".into(), "4.17.22".into());
1378        catalogs.insert("default".into(), default_cat);
1379        match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
1380            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
1381            other => panic!("expected stale, got {other:?}"),
1382        }
1383    }
1384
1385    #[test]
1386    fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
1387        // pnpm rewrites the importer `specifier:` to the post-override
1388        // value when a bare-name override applies, so a pnpm-generated
1389        // lockfile records `specifier: 4.17.21` even though
1390        // `package.json` still reads `^4.17.0`. Without override-aware
1391        // drift, every frozen install against a pnpm lockfile with
1392        // overrides reads stale (discussion #174).
1393        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1394        let mut importers = BTreeMap::new();
1395        importers.insert(
1396            ".".to_string(),
1397            vec![DirectDep {
1398                name: "lodash".into(),
1399                dep_path: "lodash@4.17.21".into(),
1400                dep_type: DepType::Production,
1401                specifier: Some("4.17.21".into()),
1402            }],
1403        );
1404        let mut graph = LockfileGraph {
1405            importers,
1406            ..Default::default()
1407        };
1408        graph.overrides.insert("lodash".into(), "4.17.21".into());
1409        let mut ws_overrides = BTreeMap::new();
1410        ws_overrides.insert("lodash".into(), "4.17.21".into());
1411        assert_eq!(
1412            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1413            DriftStatus::Fresh,
1414        );
1415    }
1416
1417    #[test]
1418    fn fresh_when_version_keyed_override_rewrites_importer_spec() {
1419        // Discussion #352: an override keyed by name+range
1420        // (`plist@<3.0.5` → `>=3.0.5`) rewrites the importer specifier
1421        // the same way bare-name overrides do. The drift check has to
1422        // parse the key and compare-by-rule, not by raw map lookup,
1423        // otherwise pnpm-written lockfiles read stale on every frozen
1424        // install when version-conditional overrides are in play.
1425        let manifest = make_manifest(&[("plist", "^3.0.4")]);
1426        let mut importers = BTreeMap::new();
1427        importers.insert(
1428            ".".to_string(),
1429            vec![DirectDep {
1430                name: "plist".into(),
1431                dep_path: "plist@3.0.6".into(),
1432                dep_type: DepType::Production,
1433                specifier: Some(">=3.0.5".into()),
1434            }],
1435        );
1436        let mut graph = LockfileGraph {
1437            importers,
1438            ..Default::default()
1439        };
1440        graph
1441            .overrides
1442            .insert("plist@<3.0.5".into(), ">=3.0.5".into());
1443        let mut ws_overrides = BTreeMap::new();
1444        ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
1445        assert_eq!(
1446            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1447            DriftStatus::Fresh,
1448        );
1449    }
1450
1451    #[test]
1452    fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
1453        // Same drift-shaped bug as overrides: the resolver unions
1454        // `ignoredOptionalDependencies` from package.json and
1455        // pnpm-workspace.yaml, so the lockfile's
1456        // `ignored_optional_dependencies` carries the union, and the
1457        // drift check has to see the same union or the next
1458        // `--frozen-lockfile` run fails with "manifest removes".
1459        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1460        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1461        graph
1462            .ignored_optional_dependencies
1463            .insert("fsevents".to_string());
1464        let ws_ignored = vec!["fsevents".to_string()];
1465        assert_eq!(
1466            graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
1467            DriftStatus::Fresh,
1468        );
1469    }
1470
1471    #[test]
1472    fn fresh_when_optional_dep_was_recorded_as_skipped() {
1473        // Regression: a platform-skipped optional dep would otherwise
1474        // loop forever as "manifest adds X". When the previous
1475        // resolve recorded it under skipped_optional_dependencies with
1476        // a matching specifier, drift must report Fresh.
1477        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1478        manifest
1479            .optional_dependencies
1480            .insert("fsevents".into(), "^2.3.0".into());
1481        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1482        let mut inner = BTreeMap::new();
1483        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1484        graph
1485            .skipped_optional_dependencies
1486            .insert(".".to_string(), inner);
1487        assert_eq!(
1488            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1489            DriftStatus::Fresh
1490        );
1491    }
1492
1493    #[test]
1494    fn stale_when_new_optional_dep_was_never_seen() {
1495        // Cursor Bugbot regression: a brand-new optional dep that the
1496        // previous resolve never saw must trigger drift, otherwise it
1497        // would silently never get installed. Distinct from a
1498        // platform-skipped optional, which has an entry in
1499        // `skipped_optional_dependencies`.
1500        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1501        manifest
1502            .optional_dependencies
1503            .insert("fsevents".into(), "^2.3.0".into());
1504        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1505        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1506            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1507            DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
1508        }
1509    }
1510
1511    #[test]
1512    fn stale_when_skipped_optional_dep_specifier_changes() {
1513        // The user bumped the range on a previously-skipped optional;
1514        // the recorded specifier no longer matches the manifest, so we
1515        // need to re-resolve.
1516        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1517        manifest
1518            .optional_dependencies
1519            .insert("fsevents".into(), "^2.4.0".into());
1520        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1521        let mut inner = BTreeMap::new();
1522        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1523        graph
1524            .skipped_optional_dependencies
1525            .insert(".".to_string(), inner);
1526        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1527            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1528            DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
1529        }
1530    }
1531
1532    #[test]
1533    fn stale_when_skipped_optional_is_promoted_to_required() {
1534        // Cursor Bugbot regression: if the user moves a previously-
1535        // skipped optional into `dependencies` (same specifier), the
1536        // skipped-list exemption must NOT fire — the dep is now
1537        // required and the lockfile genuinely doesn't include it.
1538        let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1539        // Note: fsevents lives in `dependencies`, not
1540        // `optional_dependencies`, even though the lockfile recorded
1541        // it under skipped optionals from a previous resolve.
1542        manifest.optional_dependencies.clear();
1543        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1544        let mut inner = BTreeMap::new();
1545        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1546        graph
1547            .skipped_optional_dependencies
1548            .insert(".".to_string(), inner);
1549        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1550            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1551            DriftStatus::Fresh => {
1552                panic!("expected Stale: skipped-optional exemption must not apply to required deps")
1553            }
1554        }
1555    }
1556
1557    #[test]
1558    fn stale_when_optional_dep_specifier_changes_in_lockfile() {
1559        // Spec changes on optionals that *are* present must still
1560        // drift, so the resolver re-runs when the user bumps a range.
1561        let mut manifest = make_manifest(&[]);
1562        manifest
1563            .optional_dependencies
1564            .insert("fsevents".into(), "^2.4.0".into());
1565        let mut graph = make_graph(&[]);
1566        graph.importers.get_mut(".").unwrap().push(DirectDep {
1567            name: "fsevents".into(),
1568            dep_path: "fsevents@2.3.0".into(),
1569            dep_type: DepType::Optional,
1570            specifier: Some("^2.3.0".into()),
1571        });
1572        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1573            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1574            DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
1575        }
1576    }
1577
1578    #[test]
1579    fn fresh_for_empty_manifest_and_lockfile() {
1580        let manifest = make_manifest(&[]);
1581        let graph = make_graph(&[]);
1582        assert_eq!(
1583            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1584            DriftStatus::Fresh
1585        );
1586    }
1587
1588    #[test]
1589    fn workspace_drift_detects_change_in_non_root_importer() {
1590        // Build a graph with two importers: root and packages/app.
1591        let root_dep = DirectDep {
1592            name: "lodash".into(),
1593            dep_path: "lodash@4.17.21".into(),
1594            dep_type: DepType::Production,
1595            specifier: Some("^4.17.0".into()),
1596        };
1597        let app_dep = DirectDep {
1598            name: "express".into(),
1599            dep_path: "express@4.18.0".into(),
1600            dep_type: DepType::Production,
1601            specifier: Some("^4.18.0".into()),
1602        };
1603        let mut importers = BTreeMap::new();
1604        importers.insert(".".to_string(), vec![root_dep]);
1605        importers.insert("packages/app".to_string(), vec![app_dep]);
1606        let graph = LockfileGraph {
1607            importers,
1608            packages: BTreeMap::new(),
1609            ..Default::default()
1610        };
1611
1612        let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
1613        // App manifest changed express to ^5.0.0 — should be detected as stale.
1614        let app_manifest = make_manifest(&[("express", "^5.0.0")]);
1615
1616        let workspace_manifests = vec![
1617            (".".to_string(), root_manifest.clone()),
1618            ("packages/app".to_string(), app_manifest),
1619        ];
1620        match graph.check_drift_workspace(
1621            &workspace_manifests,
1622            &BTreeMap::new(),
1623            &[],
1624            &BTreeMap::new(),
1625            true,
1626        ) {
1627            DriftStatus::Stale { reason } => {
1628                assert!(reason.contains("packages/app"));
1629                assert!(reason.contains("express"));
1630            }
1631            DriftStatus::Fresh => panic!("expected Stale"),
1632        }
1633
1634        // Single-importer check_drift on root only would say Fresh.
1635        assert_eq!(
1636            graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1637            DriftStatus::Fresh
1638        );
1639    }
1640
1641    #[test]
1642    fn filter_deps_prunes_dev_only_subtree() {
1643        // Graph: prod-root (foo) + dev-root (jest) with transitive chains.
1644        // After filtering out Dev, jest + its transitives should be pruned,
1645        // foo + its transitives should remain.
1646        let mut importers = BTreeMap::new();
1647        importers.insert(
1648            ".".to_string(),
1649            vec![
1650                DirectDep {
1651                    name: "foo".into(),
1652                    dep_path: "foo@1.0.0".into(),
1653                    dep_type: DepType::Production,
1654                    specifier: Some("^1.0.0".into()),
1655                },
1656                DirectDep {
1657                    name: "jest".into(),
1658                    dep_path: "jest@29.0.0".into(),
1659                    dep_type: DepType::Dev,
1660                    specifier: Some("^29.0.0".into()),
1661                },
1662            ],
1663        );
1664
1665        let mut packages = BTreeMap::new();
1666        let mut foo_deps = BTreeMap::new();
1667        foo_deps.insert("bar".to_string(), "2.0.0".to_string());
1668        packages.insert(
1669            "foo@1.0.0".to_string(),
1670            LockedPackage {
1671                name: "foo".into(),
1672                version: "1.0.0".into(),
1673                integrity: None,
1674                dependencies: foo_deps,
1675                dep_path: "foo@1.0.0".into(),
1676                ..Default::default()
1677            },
1678        );
1679        packages.insert(
1680            "bar@2.0.0".to_string(),
1681            LockedPackage {
1682                name: "bar".into(),
1683                version: "2.0.0".into(),
1684                integrity: None,
1685                dependencies: BTreeMap::new(),
1686                dep_path: "bar@2.0.0".into(),
1687                ..Default::default()
1688            },
1689        );
1690        let mut jest_deps = BTreeMap::new();
1691        jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
1692        packages.insert(
1693            "jest@29.0.0".to_string(),
1694            LockedPackage {
1695                name: "jest".into(),
1696                version: "29.0.0".into(),
1697                integrity: None,
1698                dependencies: jest_deps,
1699                dep_path: "jest@29.0.0".into(),
1700                ..Default::default()
1701            },
1702        );
1703        packages.insert(
1704            "jest-core@29.0.0".to_string(),
1705            LockedPackage {
1706                name: "jest-core".into(),
1707                version: "29.0.0".into(),
1708                integrity: None,
1709                dependencies: BTreeMap::new(),
1710                dep_path: "jest-core@29.0.0".into(),
1711                ..Default::default()
1712            },
1713        );
1714
1715        let graph = LockfileGraph {
1716            importers,
1717            packages,
1718            ..Default::default()
1719        };
1720
1721        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1722
1723        // Direct deps: only foo, jest dropped
1724        let roots = prod.root_deps();
1725        assert_eq!(roots.len(), 1);
1726        assert_eq!(roots[0].name, "foo");
1727
1728        // Reachable packages: foo + bar (transitive), NOT jest or jest-core
1729        assert!(prod.packages.contains_key("foo@1.0.0"));
1730        assert!(prod.packages.contains_key("bar@2.0.0"));
1731        assert!(!prod.packages.contains_key("jest@29.0.0"));
1732        assert!(!prod.packages.contains_key("jest-core@29.0.0"));
1733    }
1734
1735    // Regression for #50 feedback: `filter_deps` is a structural
1736    // operation and must preserve the source graph's `settings:`
1737    // metadata. A filtered graph that's handed to the lockfile writer
1738    // (as `aube prune` does today) would otherwise reset
1739    // `autoInstallPeers` to its default and silently flip the user's
1740    // choice on the next install.
1741    #[test]
1742    fn filter_deps_preserves_lockfile_settings() {
1743        let graph = LockfileGraph {
1744            importers: BTreeMap::new(),
1745            packages: BTreeMap::new(),
1746            settings: LockfileSettings {
1747                auto_install_peers: false,
1748                exclude_links_from_lockfile: true,
1749                lockfile_include_tarball_url: false,
1750            },
1751            ..Default::default()
1752        };
1753        let filtered = graph.filter_deps(|_| true);
1754        assert!(!filtered.settings.auto_install_peers);
1755        assert!(filtered.settings.exclude_links_from_lockfile);
1756    }
1757
1758    #[test]
1759    fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
1760        // Graph: prod foo → shared, dev jest → shared
1761        // Filtering out Dev should still keep `shared` because foo → shared
1762        // keeps it reachable.
1763        let mut importers = BTreeMap::new();
1764        importers.insert(
1765            ".".to_string(),
1766            vec![
1767                DirectDep {
1768                    name: "foo".into(),
1769                    dep_path: "foo@1.0.0".into(),
1770                    dep_type: DepType::Production,
1771                    specifier: Some("^1.0.0".into()),
1772                },
1773                DirectDep {
1774                    name: "jest".into(),
1775                    dep_path: "jest@29.0.0".into(),
1776                    dep_type: DepType::Dev,
1777                    specifier: Some("^29.0.0".into()),
1778                },
1779            ],
1780        );
1781
1782        let mut packages = BTreeMap::new();
1783        for (name, ver, deps) in [
1784            ("foo", "1.0.0", vec![("shared", "1.0.0")]),
1785            ("jest", "29.0.0", vec![("shared", "1.0.0")]),
1786            ("shared", "1.0.0", vec![]),
1787        ] {
1788            let mut dep_map = BTreeMap::new();
1789            for (n, v) in deps {
1790                dep_map.insert(n.to_string(), v.to_string());
1791            }
1792            packages.insert(
1793                format!("{name}@{ver}"),
1794                LockedPackage {
1795                    name: name.into(),
1796                    version: ver.into(),
1797                    integrity: None,
1798                    dependencies: dep_map,
1799                    dep_path: format!("{name}@{ver}"),
1800                    ..Default::default()
1801                },
1802            );
1803        }
1804
1805        let graph = LockfileGraph {
1806            importers,
1807            packages,
1808            ..Default::default()
1809        };
1810        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1811
1812        assert!(prod.packages.contains_key("foo@1.0.0"));
1813        assert!(prod.packages.contains_key("shared@1.0.0"));
1814        assert!(!prod.packages.contains_key("jest@29.0.0"));
1815    }
1816
1817    #[test]
1818    fn subset_to_importer_returns_none_for_missing_importer() {
1819        let graph = LockfileGraph {
1820            importers: BTreeMap::new(),
1821            packages: BTreeMap::new(),
1822            ..Default::default()
1823        };
1824        assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
1825    }
1826
1827    #[test]
1828    fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
1829        // Workspace graph with two importers that own independent
1830        // subtrees: packages/lib pulls is-odd → is-number, packages/app
1831        // pulls express. Subsetting to packages/lib must yield a graph
1832        // rooted at `.` containing only is-odd + is-number, with
1833        // express pruned. Matches what `aube deploy --filter @test/lib`
1834        // should write into the target.
1835        let mut importers = BTreeMap::new();
1836        importers.insert(".".to_string(), vec![]);
1837        importers.insert(
1838            "packages/lib".to_string(),
1839            vec![DirectDep {
1840                name: "is-odd".into(),
1841                dep_path: "is-odd@3.0.1".into(),
1842                dep_type: DepType::Production,
1843                specifier: Some("^3.0.1".into()),
1844            }],
1845        );
1846        importers.insert(
1847            "packages/app".to_string(),
1848            vec![DirectDep {
1849                name: "express".into(),
1850                dep_path: "express@4.18.0".into(),
1851                dep_type: DepType::Production,
1852                specifier: Some("^4.18.0".into()),
1853            }],
1854        );
1855
1856        let mut packages = BTreeMap::new();
1857        let mut is_odd_deps = BTreeMap::new();
1858        is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
1859        packages.insert(
1860            "is-odd@3.0.1".to_string(),
1861            LockedPackage {
1862                name: "is-odd".into(),
1863                version: "3.0.1".into(),
1864                dependencies: is_odd_deps,
1865                dep_path: "is-odd@3.0.1".into(),
1866                ..Default::default()
1867            },
1868        );
1869        packages.insert(
1870            "is-number@6.0.0".to_string(),
1871            LockedPackage {
1872                name: "is-number".into(),
1873                version: "6.0.0".into(),
1874                dep_path: "is-number@6.0.0".into(),
1875                ..Default::default()
1876            },
1877        );
1878        packages.insert(
1879            "express@4.18.0".to_string(),
1880            LockedPackage {
1881                name: "express".into(),
1882                version: "4.18.0".into(),
1883                dep_path: "express@4.18.0".into(),
1884                ..Default::default()
1885            },
1886        );
1887
1888        let graph = LockfileGraph {
1889            importers,
1890            packages,
1891            ..Default::default()
1892        };
1893        let subset = graph
1894            .subset_to_importer("packages/lib", |_| true)
1895            .expect("packages/lib importer present");
1896
1897        assert_eq!(subset.importers.len(), 1);
1898        let roots = subset.root_deps();
1899        assert_eq!(roots.len(), 1);
1900        assert_eq!(roots[0].name, "is-odd");
1901
1902        assert!(subset.packages.contains_key("is-odd@3.0.1"));
1903        assert!(subset.packages.contains_key("is-number@6.0.0"));
1904        assert!(!subset.packages.contains_key("express@4.18.0"));
1905    }
1906
1907    #[test]
1908    fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
1909        // packages/lib has both prod (is-odd) and dev (jest) deps.
1910        // `aube deploy --prod` should pass `|d| d.dep_type != Dev` as
1911        // the keep filter; the resulting subset retains only is-odd
1912        // so drift against the target's dev-stripped manifest stays
1913        // clean.
1914        let mut importers = BTreeMap::new();
1915        importers.insert(
1916            "packages/lib".to_string(),
1917            vec![
1918                DirectDep {
1919                    name: "is-odd".into(),
1920                    dep_path: "is-odd@3.0.1".into(),
1921                    dep_type: DepType::Production,
1922                    specifier: Some("^3.0.1".into()),
1923                },
1924                DirectDep {
1925                    name: "jest".into(),
1926                    dep_path: "jest@29.0.0".into(),
1927                    dep_type: DepType::Dev,
1928                    specifier: Some("^29.0.0".into()),
1929                },
1930            ],
1931        );
1932        let mut packages = BTreeMap::new();
1933        packages.insert(
1934            "is-odd@3.0.1".to_string(),
1935            LockedPackage {
1936                name: "is-odd".into(),
1937                version: "3.0.1".into(),
1938                dep_path: "is-odd@3.0.1".into(),
1939                ..Default::default()
1940            },
1941        );
1942        packages.insert(
1943            "jest@29.0.0".to_string(),
1944            LockedPackage {
1945                name: "jest".into(),
1946                version: "29.0.0".into(),
1947                dep_path: "jest@29.0.0".into(),
1948                ..Default::default()
1949            },
1950        );
1951        let graph = LockfileGraph {
1952            importers,
1953            packages,
1954            ..Default::default()
1955        };
1956
1957        let prod = graph
1958            .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
1959            .expect("importer present");
1960        let roots = prod.root_deps();
1961        assert_eq!(roots.len(), 1);
1962        assert_eq!(roots[0].name, "is-odd");
1963        assert!(prod.packages.contains_key("is-odd@3.0.1"));
1964        assert!(!prod.packages.contains_key("jest@29.0.0"));
1965    }
1966
1967    #[test]
1968    fn subset_to_importer_preserves_graph_settings() {
1969        // Structural pruning, not a resolution-mode reset: a deploy
1970        // into a target that uses the source workspace's settings
1971        // header (autoInstallPeers / lockfileIncludeTarballUrl)
1972        // should write them through unchanged so a frozen install in
1973        // the target sees the same resolution-mode state.
1974        let mut importers = BTreeMap::new();
1975        importers.insert("packages/lib".to_string(), vec![]);
1976        let graph = LockfileGraph {
1977            importers,
1978            packages: BTreeMap::new(),
1979            settings: LockfileSettings {
1980                auto_install_peers: false,
1981                exclude_links_from_lockfile: true,
1982                lockfile_include_tarball_url: true,
1983            },
1984            ..Default::default()
1985        };
1986        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1987        assert!(!subset.settings.auto_install_peers);
1988        assert!(subset.settings.exclude_links_from_lockfile);
1989        assert!(subset.settings.lockfile_include_tarball_url);
1990    }
1991
1992    #[test]
1993    fn subset_to_importer_rekeys_skipped_optionals_to_root() {
1994        // `skipped_optional_dependencies` is per-importer. After
1995        // subsetting, only the retained importer's entry should
1996        // survive — rekeyed to `.` so a frozen install in the target
1997        // (which has exactly one importer) doesn't see ghost entries.
1998        let mut importers = BTreeMap::new();
1999        importers.insert("packages/lib".to_string(), vec![]);
2000        importers.insert("packages/app".to_string(), vec![]);
2001        let mut skipped = BTreeMap::new();
2002        let mut lib_skip = BTreeMap::new();
2003        lib_skip.insert("fsevents".to_string(), "^2".to_string());
2004        skipped.insert("packages/lib".to_string(), lib_skip);
2005        let mut app_skip = BTreeMap::new();
2006        app_skip.insert("ghost".to_string(), "*".to_string());
2007        skipped.insert("packages/app".to_string(), app_skip);
2008        let graph = LockfileGraph {
2009            importers,
2010            packages: BTreeMap::new(),
2011            skipped_optional_dependencies: skipped,
2012            ..Default::default()
2013        };
2014        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
2015        assert_eq!(subset.skipped_optional_dependencies.len(), 1);
2016        let root = subset.skipped_optional_dependencies.get(".").unwrap();
2017        assert!(root.contains_key("fsevents"));
2018        assert!(!root.contains_key("ghost"));
2019    }
2020
2021    #[test]
2022    fn workspace_drift_fresh_when_all_importers_match() {
2023        let root_dep = DirectDep {
2024            name: "lodash".into(),
2025            dep_path: "lodash@4.17.21".into(),
2026            dep_type: DepType::Production,
2027            specifier: Some("^4.17.0".into()),
2028        };
2029        let app_dep = DirectDep {
2030            name: "express".into(),
2031            dep_path: "express@4.18.0".into(),
2032            dep_type: DepType::Production,
2033            specifier: Some("^4.18.0".into()),
2034        };
2035        let mut importers = BTreeMap::new();
2036        importers.insert(".".to_string(), vec![root_dep]);
2037        importers.insert("packages/app".to_string(), vec![app_dep]);
2038        let graph = LockfileGraph {
2039            importers,
2040            packages: BTreeMap::new(),
2041            ..Default::default()
2042        };
2043
2044        let workspace_manifests = vec![
2045            (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
2046            (
2047                "packages/app".to_string(),
2048                make_manifest(&[("express", "^4.18.0")]),
2049            ),
2050        ];
2051        assert_eq!(
2052            graph.check_drift_workspace(
2053                &workspace_manifests,
2054                &BTreeMap::new(),
2055                &[],
2056                &BTreeMap::new(),
2057                true,
2058            ),
2059            DriftStatus::Fresh
2060        );
2061    }
2062
2063    #[allow(clippy::type_complexity)]
2064    fn mk_catalogs(
2065        entries: &[(&str, &[(&str, &str, &str)])],
2066    ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
2067        let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
2068        for (cat, pkgs) in entries {
2069            let mut inner = BTreeMap::new();
2070            for (pkg, spec, ver) in *pkgs {
2071                inner.insert(
2072                    (*pkg).to_string(),
2073                    CatalogEntry {
2074                        specifier: (*spec).to_string(),
2075                        version: (*ver).to_string(),
2076                    },
2077                );
2078            }
2079            out.insert((*cat).to_string(), inner);
2080        }
2081        out
2082    }
2083
2084    fn mk_workspace_catalogs(
2085        entries: &[(&str, &[(&str, &str)])],
2086    ) -> BTreeMap<String, BTreeMap<String, String>> {
2087        entries
2088            .iter()
2089            .map(|(cat, pkgs)| {
2090                (
2091                    (*cat).to_string(),
2092                    pkgs.iter()
2093                        .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
2094                        .collect(),
2095                )
2096            })
2097            .collect()
2098    }
2099
2100    #[test]
2101    fn catalog_drift_fresh_when_specifiers_match() {
2102        let graph = LockfileGraph {
2103            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2104            ..Default::default()
2105        };
2106        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
2107        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2108    }
2109
2110    #[test]
2111    fn catalog_drift_stale_on_changed_specifier() {
2112        let graph = LockfileGraph {
2113            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2114            ..Default::default()
2115        };
2116        let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
2117        match graph.check_catalogs_drift(&ws) {
2118            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2119            other => panic!("expected stale, got {other:?}"),
2120        }
2121    }
2122
2123    #[test]
2124    fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
2125        // pnpm only writes referenced entries — an unreferenced
2126        // workspace entry is not drift. The "newly used" transition
2127        // is caught by the importer-level drift check.
2128        let graph = LockfileGraph::default();
2129        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
2130        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2131    }
2132
2133    #[test]
2134    fn catalog_drift_stale_on_removed_workspace_entry() {
2135        let graph = LockfileGraph {
2136            catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
2137            ..Default::default()
2138        };
2139        let ws = mk_workspace_catalogs(&[]);
2140        assert!(matches!(
2141            graph.check_catalogs_drift(&ws),
2142            DriftStatus::Stale { .. }
2143        ));
2144    }
2145}