Skip to main content

aube_lockfile/
drift.rs

1use crate::{
2    DepType, DirectDep, LocalSource, LockfileGraph, LockfileKind, dep_type_label, override_match,
3};
4use std::collections::{BTreeMap, BTreeSet};
5
6impl LockfileGraph {
7    /// Compare this lockfile's root importer against a single manifest.
8    ///
9    /// Mirrors pnpm's `prefer-frozen-lockfile` check: a lockfile is "fresh" iff
10    /// every direct dep specifier in `package.json` exactly matches the specifier
11    /// recorded in the lockfile (string compare, not semver). Used to decide
12    /// whether to skip resolution and trust the lockfile (`Fresh`) or fall back
13    /// to a full re-resolve (`Stale { reason }`).
14    ///
15    /// For workspace projects, use [`check_drift_workspace`] instead — this
16    /// method only inspects the root importer.
17    ///
18    /// `workspace_overrides` is the `overrides:` block from
19    /// `pnpm-workspace.yaml` (pnpm v10 moved overrides there). Pass an
20    /// empty map when the project has no workspace-yaml overrides. Keys
21    /// are merged on top of `manifest.overrides_map()` before the drift
22    /// comparison, matching the resolver's effective-override set —
23    /// otherwise a lockfile written with a workspace override
24    /// immediately looks stale on the next `--frozen-lockfile` run.
25    ///
26    /// `workspace_ignored_optional` is the same idea for
27    /// `pnpm-workspace.yaml`'s `ignoredOptionalDependencies` block:
28    /// the resolver unions it with the manifest's list, so the drift
29    /// check has to see the same union or a freshly-written lockfile
30    /// immediately reads as stale.
31    ///
32    /// `workspace_catalogs` is the `catalog:` / `catalogs:` block from
33    /// `pnpm-workspace.yaml`. pnpm resolves `catalog:` references in
34    /// override values against this map before writing the lockfile
35    /// and before comparing on re-install, so both sides of the drift
36    /// check have to see the catalog-resolved form — otherwise a
37    /// `"lodash": "catalog:"` override reads as stale against a
38    /// lockfile that recorded the resolved `"lodash": "4.17.21"`.
39    ///
40    /// Lockfile formats that don't record specifiers (npm, yarn, bun) always
41    /// return `Fresh` since we have no way to detect drift without re-resolving.
42    ///
43    /// [`check_drift_workspace`]: Self::check_drift_workspace
44    pub fn check_drift(
45        &self,
46        manifest: &aube_manifest::PackageJson,
47        workspace_overrides: &BTreeMap<String, String>,
48        workspace_ignored_optional: &[String],
49        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
50    ) -> DriftStatus {
51        self.check_drift_with_options(
52            manifest,
53            workspace_overrides,
54            workspace_ignored_optional,
55            workspace_catalogs,
56            true,
57        )
58    }
59
60    pub fn check_drift_for_kind(
61        &self,
62        manifest: &aube_manifest::PackageJson,
63        workspace_overrides: &BTreeMap<String, String>,
64        workspace_ignored_optional: &[String],
65        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
66        kind: LockfileKind,
67    ) -> DriftStatus {
68        self.check_drift_with_options(
69            manifest,
70            workspace_overrides,
71            workspace_ignored_optional,
72            workspace_catalogs,
73            kind_records_resolution_metadata(kind),
74        )
75    }
76
77    /// Workspace-aware drift check.
78    ///
79    /// Each entry in `manifests` is `(importer_path, manifest)` — for example
80    /// `(".", root_manifest), ("packages/app", app_manifest), ...`. Every
81    /// importer is checked against its own manifest; the first stale importer
82    /// determines the result.
83    ///
84    /// See [`check_drift`] for the `workspace_overrides` contract.
85    ///
86    /// [`check_drift`]: Self::check_drift
87    pub fn check_drift_workspace(
88        &self,
89        manifests: &[(String, aube_manifest::PackageJson)],
90        workspace_overrides: &BTreeMap<String, String>,
91        workspace_ignored_optional: &[String],
92        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
93        is_workspace_install: bool,
94    ) -> DriftStatus {
95        self.check_drift_workspace_with_options(
96            manifests,
97            workspace_overrides,
98            workspace_ignored_optional,
99            workspace_catalogs,
100            is_workspace_install,
101            true,
102        )
103    }
104
105    pub fn check_drift_workspace_for_kind(
106        &self,
107        manifests: &[(String, aube_manifest::PackageJson)],
108        workspace_overrides: &BTreeMap<String, String>,
109        workspace_ignored_optional: &[String],
110        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
111        is_workspace_install: bool,
112        kind: LockfileKind,
113    ) -> DriftStatus {
114        self.check_drift_workspace_with_options(
115            manifests,
116            workspace_overrides,
117            workspace_ignored_optional,
118            workspace_catalogs,
119            is_workspace_install,
120            kind_records_resolution_metadata(kind),
121        )
122    }
123
124    fn check_drift_with_options(
125        &self,
126        manifest: &aube_manifest::PackageJson,
127        workspace_overrides: &BTreeMap<String, String>,
128        workspace_ignored_optional: &[String],
129        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
130        check_resolution_metadata: bool,
131    ) -> DriftStatus {
132        let effective = resolve_catalog_refs_in_overrides(
133            &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
134            workspace_catalogs,
135        );
136        if check_resolution_metadata
137            && let Some(reason) = self.resolution_metadata_drift_reason(
138                manifest,
139                workspace_overrides,
140                workspace_ignored_optional,
141                workspace_catalogs,
142            )
143        {
144            return DriftStatus::Stale { reason };
145        }
146        self.check_drift_for_importer(".", manifest, &effective)
147    }
148
149    fn check_drift_workspace_with_options(
150        &self,
151        manifests: &[(String, aube_manifest::PackageJson)],
152        workspace_overrides: &BTreeMap<String, String>,
153        workspace_ignored_optional: &[String],
154        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
155        is_workspace_install: bool,
156        check_resolution_metadata: bool,
157    ) -> DriftStatus {
158        // Override drift is checked once at the workspace level, against
159        // the root manifest. Workspace-package manifests may declare
160        // their own `overrides` blocks but pnpm only honors the root's,
161        // so we mirror that here.
162        let effective_overrides = match manifests.iter().find(|(p, _)| p == ".") {
163            Some((_, root_manifest)) => {
164                let effective = resolve_catalog_refs_in_overrides(
165                    &merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides),
166                    workspace_catalogs,
167                );
168                if check_resolution_metadata
169                    && let Some(reason) = self.resolution_metadata_drift_reason(
170                        root_manifest,
171                        workspace_overrides,
172                        workspace_ignored_optional,
173                        workspace_catalogs,
174                    )
175                {
176                    return DriftStatus::Stale { reason };
177                }
178                effective
179            }
180            None => BTreeMap::new(),
181        };
182        let workspace_link_names: std::collections::HashSet<&str> = manifests
183            .iter()
184            .filter(|(path, _)| path != ".")
185            .filter_map(|(_, manifest)| manifest.name.as_deref())
186            .collect();
187        for (importer_path, manifest) in manifests {
188            match self.check_drift_for_importer_with_workspace_links(
189                importer_path,
190                manifest,
191                &effective_overrides,
192                &workspace_link_names,
193            ) {
194                DriftStatus::Fresh => continue,
195                stale => return stale,
196            }
197        }
198        // Stale-importer pass: in a workspace install, lockfile
199        // importer entries for workspace projects that no longer
200        // exist on disk must invalidate the lockfile. Without this
201        // guard, the warm-path short-circuit and drift check both
202        // report fresh and the next install carries the orphan
203        // importer/snapshot pair forward in the shared lockfile
204        // until a user explicitly runs `--no-frozen-lockfile`.
205        //
206        // Gated on the caller-supplied `is_workspace_install` flag
207        // (true when `pnpm-workspace.yaml` exists or `package.json`
208        // declares `workspaces`) — the manifests array can collapse
209        // to `[(".", root)]` even in a workspace install when the
210        // last sub-package is removed, so a manifest-shape check
211        // would miss the all-packages-gone case. The flag is also
212        // what tells us we're not in the npm `package-lock.json`
213        // path, where the parser synthesizes importer entries for
214        // every `file:` link and a manifest-shape gate would
215        // false-positive on legitimate single-package installs.
216        if is_workspace_install {
217            let current_importers: std::collections::HashSet<&str> =
218                manifests.iter().map(|(p, _)| p.as_str()).collect();
219            for importer_path in self.importers.keys() {
220                if !current_importers.contains(importer_path.as_str()) {
221                    return DriftStatus::Stale {
222                        reason: format!(
223                            "workspace importer {importer_path} is in the lockfile but not in the workspace"
224                        ),
225                    };
226                }
227            }
228        }
229        DriftStatus::Fresh
230    }
231
232    fn resolution_metadata_drift_reason(
233        &self,
234        manifest: &aube_manifest::PackageJson,
235        workspace_overrides: &BTreeMap<String, String>,
236        workspace_ignored_optional: &[String],
237        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
238    ) -> Option<String> {
239        let effective = resolve_catalog_refs_in_overrides(
240            &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
241            workspace_catalogs,
242        );
243        let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
244        overrides_drift_reason(&locked, &effective)
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| cat.get(override_key_package_name(k)))
622                .cloned()
623                .unwrap_or_else(|| v.clone());
624            (k.clone(), resolved)
625        })
626        .collect()
627}
628
629/// Extract the package name from an override selector key so the catalog
630/// can be looked up by pkg name. Handles bare (`lodash`), scoped
631/// (`@babel/core`), ranged (`lodash@<5`), ancestor-chained
632/// (`parent>lodash`), and combinations. Unparseable keys return the
633/// input unchanged; the catalog lookup will then miss and leave the
634/// value as-is.
635fn override_key_package_name(key: &str) -> &str {
636    let last = key.rsplit('>').next().unwrap_or(key);
637    if let Some(after_scope) = last.strip_prefix('@') {
638        match after_scope.find('@') {
639            Some(idx) => &last[..idx + 1],
640            None => last,
641        }
642    } else {
643        match last.find('@') {
644            Some(idx) => &last[..idx],
645            None => last,
646        }
647    }
648}
649
650/// Compare two override maps and return a human-readable reason
651/// describing the first difference, or `None` if they're identical.
652/// Drift messages cite the offending key by name so users can act on
653/// them — `(lockfile: N entries, manifest: M entries)` is useless
654/// when N == M but a value changed.
655fn overrides_drift_reason(
656    lockfile: &BTreeMap<String, String>,
657    manifest: &BTreeMap<String, String>,
658) -> Option<String> {
659    for (k, v) in manifest {
660        match lockfile.get(k) {
661            None => return Some(format!("overrides: manifest adds {k}@{v}")),
662            Some(locked) if locked != v => {
663                return Some(format!("overrides: {k} changed ({locked} → {v})"));
664            }
665            Some(_) => {}
666        }
667    }
668    for k in lockfile.keys() {
669        if !manifest.contains_key(k) {
670            return Some(format!("overrides: manifest removes {k}"));
671        }
672    }
673    None
674}
675
676/// Compare two `ignoredOptionalDependencies` sets and return a drift
677/// reason string for the first difference, or `None` if identical.
678fn ignored_optional_drift_reason(
679    lockfile: &BTreeSet<String>,
680    manifest: &BTreeSet<String>,
681) -> Option<String> {
682    for name in manifest {
683        if !lockfile.contains(name) {
684            return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
685        }
686    }
687    for name in lockfile {
688        if !manifest.contains(name) {
689            return Some(format!(
690                "ignoredOptionalDependencies: manifest removes {name}"
691            ));
692        }
693    }
694    None
695}
696
697/// Compare recorded runtime pins against the manifest's
698/// `devEngines.runtime` declarations.
699///
700/// Only an *existing* pin can drift here: the requested range changed,
701/// or the manifest dropped the devEngines entry the pin came from. The
702/// inverse case — devEngines present but no pin recorded yet — is
703/// deliberately not drift, because formats that can't record runtime
704/// pins (npm/yarn/bun) would read as permanently stale. The install
705/// driver adds the missing pin on formats that support it.
706fn runtime_drift_reason(
707    runtimes: &BTreeMap<String, crate::RuntimePin>,
708    manifest: &aube_manifest::PackageJson,
709) -> Option<String> {
710    for (name, pin) in runtimes {
711        let entry = manifest
712            .dev_engines
713            .as_ref()
714            .and_then(|d| d.runtime.iter().find(|r| r.name == *name));
715        match entry {
716            None => {
717                return Some(format!(
718                    "devEngines.runtime: manifest no longer pins {name} (lockfile records {})",
719                    pin.version
720                ));
721            }
722            // An entry that names the runtime but declares no
723            // `version` carries no concrete range — resolution treats
724            // it as "no requirement", so it can't contradict the pin.
725            // Flagging it would hard-fail frozen installs over a
726            // field that changes nothing.
727            Some(entry) => match entry.version.as_deref() {
728                None => {}
729                Some(range) if range != pin.specifier => {
730                    return Some(format!(
731                        "devEngines.runtime: {name} changed ({} → {range})",
732                        pin.specifier
733                    ));
734                }
735                Some(_) => {}
736            },
737        }
738    }
739    None
740}
741
742/// Result of comparing a lockfile against a manifest.
743#[derive(Debug, Clone, PartialEq, Eq)]
744pub enum DriftStatus {
745    /// The lockfile is in sync with the manifest. Safe to use without re-resolving.
746    Fresh,
747    /// The lockfile is out of date. The reason describes the first mismatch found.
748    Stale { reason: String },
749}
750
751fn kind_records_resolution_metadata(kind: LockfileKind) -> bool {
752    matches!(
753        kind,
754        LockfileKind::Aube | LockfileKind::Pnpm | LockfileKind::Bun
755    )
756}
757
758/// True for importer specifiers that point at a local on-disk source
759/// (`link:` / `file:` / `portal:` / `exec:`) rather than a registry range
760/// or workspace alias — the same set `LocalSource::parse` recognizes. The
761/// drift check uses this to recognize pnpmfile-`readPackage`-rewritten
762/// importer deps, where the hook turns a plain range into a local link
763/// (e.g. `"*"` → `link:../pkg/dist`).
764fn is_local_source_spec(spec: &str) -> bool {
765    spec.starts_with("link:")
766        || spec.starts_with("file:")
767        || spec.starts_with("portal:")
768        || spec.starts_with("exec:")
769}
770
771#[cfg(test)]
772mod drift_tests {
773    use super::*;
774    use crate::{CatalogEntry, LockedPackage, LockfileSettings};
775    use aube_manifest::PackageJson;
776    use std::collections::BTreeMap;
777    use std::path::PathBuf;
778
779    fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
780        let mut m = PackageJson {
781            name: Some("test".into()),
782            version: Some("1.0.0".into()),
783            dependencies: BTreeMap::new(),
784            dev_dependencies: BTreeMap::new(),
785            peer_dependencies: BTreeMap::new(),
786            optional_dependencies: BTreeMap::new(),
787            update_config: None,
788            scripts: BTreeMap::new(),
789            engines: BTreeMap::new(),
790            dev_engines: None,
791            workspaces: None,
792            bundled_dependencies: None,
793            extra: BTreeMap::new(),
794        };
795        for (name, spec) in deps {
796            m.dependencies.insert((*name).into(), (*spec).into());
797        }
798        m
799    }
800
801    fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
802        // (name, specifier, dep_path)
803        let direct: Vec<DirectDep> = deps
804            .iter()
805            .map(|(name, spec, dep_path)| DirectDep {
806                name: (*name).into(),
807                dep_path: (*dep_path).into(),
808                dep_type: DepType::Production,
809                specifier: Some((*spec).into()),
810            })
811            .collect();
812        let mut importers = BTreeMap::new();
813        importers.insert(".".to_string(), direct);
814        LockfileGraph {
815            importers,
816            packages: BTreeMap::new(),
817            ..Default::default()
818        }
819    }
820
821    #[test]
822    fn stale_when_dep_moves_between_sections() {
823        // Discussion #602: moving a dep between `dependencies` and
824        // `devDependencies` keeps the same specifier, so the spec-only
825        // checks reported Fresh and the warm path short-circuited
826        // without rewriting the lockfile.
827        let mut manifest = make_manifest(&[]);
828        manifest
829            .dev_dependencies
830            .insert("msw".into(), "catalog:".into());
831        let mut graph = make_graph(&[("msw", "catalog:", "msw@2.14.4")]);
832        graph
833            .importers
834            .get_mut(".")
835            .unwrap()
836            .iter_mut()
837            .for_each(|d| d.dep_type = DepType::Production);
838        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
839            DriftStatus::Stale { reason } => {
840                assert!(reason.contains("msw"), "reason: {reason}");
841                assert!(reason.contains("devDependencies"), "reason: {reason}");
842            }
843            DriftStatus::Fresh => panic!("expected Stale"),
844        }
845    }
846
847    #[test]
848    fn fresh_when_specifiers_match() {
849        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
850        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
851        assert_eq!(
852            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
853            DriftStatus::Fresh
854        );
855    }
856
857    #[test]
858    fn stale_when_specifier_changes() {
859        let manifest = make_manifest(&[("lodash", "^4.18.0")]);
860        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
861        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
862            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
863            DriftStatus::Fresh => panic!("expected Stale"),
864        }
865    }
866
867    #[test]
868    fn stale_when_manifest_adds_dep() {
869        let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
870        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
871        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
872            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
873            DriftStatus::Fresh => panic!("expected Stale"),
874        }
875    }
876
877    #[test]
878    fn stale_when_manifest_removes_dep() {
879        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
880        let graph = make_graph(&[
881            ("lodash", "^4.17.0", "lodash@4.17.21"),
882            ("express", "^4.18.0", "express@4.18.0"),
883        ]);
884        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
885            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
886            DriftStatus::Fresh => panic!("expected Stale"),
887        }
888    }
889
890    #[test]
891    fn fresh_when_pnpmfile_hook_rewrites_dep_to_link() {
892        // A pnpmfile `readPackage` hook rewrites `"@scope/api": "*"` to
893        // `link:../api/dist` (wiring a sibling's build output). pnpm and
894        // aube both record the *rewritten* spec in the importer, so the
895        // raw manifest (`*`) never matches the lockfile (`link:...`). With
896        // a `pnpmfileChecksum` recorded — i.e. the lockfile was produced
897        // by a hook-exporting pnpmfile — trust the local spec instead of
898        // re-resolving on every install. Mirrors pnpm, which re-runs the
899        // hook and reports "Already up to date".
900        let manifest = make_manifest(&[("@scope/api", "*")]);
901        let mut graph = make_graph(&[(
902            "@scope/api",
903            "link:../api/dist",
904            "@scope/api@link:../api/dist",
905        )]);
906        graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
907        assert_eq!(
908            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
909            DriftStatus::Fresh
910        );
911    }
912
913    #[test]
914    fn stale_when_link_importer_spec_has_no_pnpmfile_checksum() {
915        // Without a recorded pnpmfileChecksum there's no hook to attribute
916        // the link to, so a `link:` importer spec the manifest doesn't
917        // contain is genuine drift (e.g. the user hand-edited the lockfile
918        // or repointed a dep) and must re-resolve.
919        let manifest = make_manifest(&[("@scope/api", "*")]);
920        let graph = make_graph(&[(
921            "@scope/api",
922            "link:../api/dist",
923            "@scope/api@link:../api/dist",
924        )]);
925        assert!(matches!(
926            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
927            DriftStatus::Stale { .. }
928        ));
929    }
930
931    #[test]
932    fn stale_when_manifest_link_repointed_even_with_pnpmfile_checksum() {
933        // The hook exemption only covers a *non-local* manifest range the
934        // hook turned into a link. A user-authored `link:` that changed
935        // target stays a local spec on the manifest side, so the gate
936        // (`!is_local_source_spec(spec)`) keeps it detectable and forces a
937        // re-resolve rather than silently trusting a stale link.
938        let manifest = make_manifest(&[("@scope/api", "link:../api/old")]);
939        let mut graph = make_graph(&[(
940            "@scope/api",
941            "link:../api/new",
942            "@scope/api@link:../api/new",
943        )]);
944        graph.pnpmfile_checksum = Some("sha256-deadbeef".into());
945        assert!(matches!(
946            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
947            DriftStatus::Stale { .. }
948        ));
949    }
950
951    // Regression guard for #42: the drift check must recognize
952    // auto-hoisted peers as derived state, not as "manifest removed X".
953    // Without this, every project that has any peer dep would trigger
954    // a full re-resolve on every install, defeating lockfile caching.
955    #[test]
956    fn fresh_when_lockfile_has_auto_hoisted_peer() {
957        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
958        let mut graph = make_graph(&[
959            (
960                "use-sync-external-store",
961                "1.2.0",
962                "use-sync-external-store@1.2.0",
963            ),
964            // Hoisted peer — in the lockfile importers but not in the
965            // user's package.json.
966            ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
967        ]);
968        // The declaring package must list react as a peer for the
969        // drift check to recognize the hoist. We add that here.
970        let mut declaring_pkg = LockedPackage {
971            name: "use-sync-external-store".into(),
972            version: "1.2.0".into(),
973            dep_path: "use-sync-external-store@1.2.0".into(),
974            ..Default::default()
975        };
976        declaring_pkg
977            .peer_dependencies
978            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
979        graph
980            .packages
981            .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
982
983        assert_eq!(
984            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
985            DriftStatus::Fresh
986        );
987    }
988
989    // Regression: when a user explicitly pinned a dep that also happens
990    // to share its name with a peer declaration elsewhere in the graph,
991    // removing that pin from package.json must still be flagged as
992    // stale — otherwise the old pinned version gets locked forever.
993    // The check must key on (name, specifier), not name alone.
994    #[test]
995    fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
996        // Manifest after the user removed react entirely. Only
997        // use-sync-external-store remains.
998        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
999
1000        // Lockfile still has the user's old `react: 17.0.2` pin alongside
1001        // use-sync-external-store. Pre-removal state.
1002        let mut graph = make_graph(&[
1003            (
1004                "use-sync-external-store",
1005                "1.2.0",
1006                "use-sync-external-store@1.2.0",
1007            ),
1008            ("react", "17.0.2", "react@17.0.2"),
1009        ]);
1010        // Add the peer declaration on the consumer package. This is
1011        // the case that previously defeated the name-only check:
1012        // react's specifier "17.0.2" doesn't match the declared peer
1013        // range, so the hoist recognizer must reject it.
1014        let mut consumer = LockedPackage {
1015            name: "use-sync-external-store".into(),
1016            version: "1.2.0".into(),
1017            dep_path: "use-sync-external-store@1.2.0".into(),
1018            ..Default::default()
1019        };
1020        consumer
1021            .peer_dependencies
1022            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
1023        graph
1024            .packages
1025            .insert("use-sync-external-store@1.2.0".into(), consumer);
1026
1027        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1028            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
1029            DriftStatus::Fresh => panic!(
1030                "drift check should flag a removed user-pinned dep as stale, \
1031                 even when its name matches a peer declaration"
1032            ),
1033        }
1034    }
1035
1036    // But if the lockfile has a user-removed dep that ISN'T declared as a
1037    // peer anywhere, we still need to flag it as stale.
1038    #[test]
1039    fn stale_when_lockfile_has_removed_non_peer_dep() {
1040        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1041        let graph = make_graph(&[
1042            ("lodash", "^4.17.0", "lodash@4.17.21"),
1043            ("chalk", "^5.0.0", "chalk@5.0.0"),
1044        ]);
1045        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1046            DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
1047            DriftStatus::Fresh => panic!("expected Stale"),
1048        }
1049    }
1050
1051    #[test]
1052    fn workspace_drift_allows_root_links_for_workspace_packages() {
1053        let root_manifest = make_manifest(&[]);
1054        let mut app_manifest = make_manifest(&[]);
1055        app_manifest.name = Some("@scope/app".to_string());
1056
1057        let link = LocalSource::Link(PathBuf::from("packages/app"));
1058        let dep_path = link.dep_path("@scope/app");
1059        let mut graph = make_graph(&[("@scope/app", "*", &dep_path)]);
1060        graph.packages.insert(
1061            dep_path.clone(),
1062            LockedPackage {
1063                name: "@scope/app".to_string(),
1064                version: "1.0.0".to_string(),
1065                dep_path,
1066                local_source: Some(link),
1067                ..Default::default()
1068            },
1069        );
1070
1071        assert_eq!(
1072            graph.check_drift_workspace(
1073                &[
1074                    (".".to_string(), root_manifest),
1075                    ("packages/app".to_string(), app_manifest),
1076                ],
1077                &BTreeMap::new(),
1078                &[],
1079                &BTreeMap::new(),
1080                true,
1081            ),
1082            DriftStatus::Fresh
1083        );
1084    }
1085
1086    #[test]
1087    fn fresh_when_no_specifiers_recorded() {
1088        // Non-pnpm formats (npm/yarn/bun) don't store specifiers, so we can't
1089        // detect drift — we treat them as fresh and let the resolver decide.
1090        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1091        let graph = LockfileGraph {
1092            importers: {
1093                let mut m = BTreeMap::new();
1094                m.insert(
1095                    ".".to_string(),
1096                    vec![DirectDep {
1097                        name: "lodash".into(),
1098                        dep_path: "lodash@4.17.21".into(),
1099                        dep_type: DepType::Production,
1100                        specifier: None,
1101                    }],
1102                );
1103                m
1104            },
1105            packages: BTreeMap::new(),
1106            ..Default::default()
1107        };
1108        assert_eq!(
1109            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1110            DriftStatus::Fresh
1111        );
1112    }
1113
1114    #[test]
1115    fn stale_when_manifest_adds_override() {
1116        // Lockfile recorded no overrides; manifest now has one. Drift
1117        // must fire so the next install re-runs the resolver and bakes
1118        // the override into the graph.
1119        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1120        manifest
1121            .extra
1122            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1123        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1124        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1125            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1126            DriftStatus::Fresh => panic!("expected Stale"),
1127        }
1128    }
1129
1130    #[test]
1131    fn fresh_when_npm_lockfile_cannot_record_overrides() {
1132        // package-lock.json has no top-level override snapshot. Treating
1133        // that absence as drift makes aube re-resolve and rewrite npm's
1134        // lockfile graph even when the override is unrelated to the
1135        // existing packages.
1136        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1137        manifest
1138            .extra
1139            .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1140        let graph = LockfileGraph {
1141            importers: {
1142                let mut m = BTreeMap::new();
1143                m.insert(
1144                    ".".to_string(),
1145                    vec![DirectDep {
1146                        name: "lodash".into(),
1147                        dep_path: "lodash@4.17.21".into(),
1148                        dep_type: DepType::Production,
1149                        specifier: None,
1150                    }],
1151                );
1152                m
1153            },
1154            packages: BTreeMap::new(),
1155            ..Default::default()
1156        };
1157        assert_eq!(
1158            graph.check_drift_for_kind(
1159                &manifest,
1160                &BTreeMap::new(),
1161                &[],
1162                &BTreeMap::new(),
1163                LockfileKind::Npm,
1164            ),
1165            DriftStatus::Fresh
1166        );
1167    }
1168
1169    #[test]
1170    fn stale_when_bun_lockfile_can_record_overrides() {
1171        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1172        manifest
1173            .extra
1174            .insert("overrides".into(), serde_json::json!({"left-pad": "1.3.0"}));
1175        let graph = LockfileGraph {
1176            importers: {
1177                let mut m = BTreeMap::new();
1178                m.insert(
1179                    ".".to_string(),
1180                    vec![DirectDep {
1181                        name: "lodash".into(),
1182                        dep_path: "lodash@4.17.21".into(),
1183                        dep_type: DepType::Production,
1184                        specifier: None,
1185                    }],
1186                );
1187                m
1188            },
1189            packages: BTreeMap::new(),
1190            ..Default::default()
1191        };
1192        match graph.check_drift_for_kind(
1193            &manifest,
1194            &BTreeMap::new(),
1195            &[],
1196            &BTreeMap::new(),
1197            LockfileKind::Bun,
1198        ) {
1199            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
1200            DriftStatus::Fresh => panic!("expected Stale"),
1201        }
1202    }
1203
1204    #[test]
1205    fn stale_drift_message_names_changed_override_key() {
1206        // Both sides have one entry, but the value differs. The reason
1207        // should name the key — the previous "lockfile: 1 entries,
1208        // manifest: 1 entries" message looked like nothing changed.
1209        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1210        manifest
1211            .extra
1212            .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
1213        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1214        graph.overrides.insert("lodash".into(), "4.17.21".into());
1215        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1216            DriftStatus::Stale { reason } => {
1217                assert!(reason.contains("lodash"), "expected key in: {reason}");
1218                assert!(
1219                    reason.contains("4.17.21"),
1220                    "expected old value in: {reason}"
1221                );
1222                assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
1223            }
1224            DriftStatus::Fresh => panic!("expected Stale"),
1225        }
1226    }
1227
1228    #[test]
1229    fn stale_when_manifest_removes_override() {
1230        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1231        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1232        graph.overrides.insert("lodash".into(), "4.17.21".into());
1233        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1234            DriftStatus::Stale { reason } => {
1235                assert!(reason.contains("removes"));
1236                assert!(reason.contains("lodash"));
1237            }
1238            DriftStatus::Fresh => panic!("expected Stale"),
1239        }
1240    }
1241
1242    #[test]
1243    fn fresh_when_overrides_match() {
1244        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1245        manifest
1246            .extra
1247            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
1248        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1249        graph.overrides.insert("lodash".into(), "4.17.21".into());
1250        assert_eq!(
1251            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1252            DriftStatus::Fresh
1253        );
1254    }
1255
1256    #[test]
1257    fn fresh_when_workspace_yaml_overrides_match_lockfile() {
1258        // pnpm v10 moved `overrides` to pnpm-workspace.yaml. When the
1259        // resolver wrote them into `self.overrides`, the drift check
1260        // must see the same map — otherwise the second install run
1261        // rejects the lockfile as stale with "manifest removes ..."
1262        // (reported in discussion #174).
1263        let manifest = make_manifest(&[("semver", "^7.5.0")]);
1264        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1265        graph.overrides.insert("semver".into(), "7.7.1".into());
1266        let mut ws_overrides = BTreeMap::new();
1267        ws_overrides.insert("semver".into(), "7.7.1".into());
1268        assert_eq!(
1269            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1270            DriftStatus::Fresh,
1271        );
1272    }
1273
1274    #[test]
1275    fn workspace_yaml_overrides_win_over_package_json() {
1276        // When both pnpm-workspace.yaml and package.json declare an
1277        // override for the same key, the workspace yaml wins — pnpm
1278        // v10's precedence. The drift check must apply the merged
1279        // effective map.
1280        let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
1281        manifest
1282            .extra
1283            .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
1284        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
1285        graph.overrides.insert("semver".into(), "7.7.1".into());
1286        let mut ws_overrides = BTreeMap::new();
1287        ws_overrides.insert("semver".into(), "7.7.1".into());
1288        assert_eq!(
1289            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1290            DriftStatus::Fresh,
1291        );
1292    }
1293
1294    #[test]
1295    fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
1296        // pnpm-workspace.yaml: `overrides: { lodash: "catalog:" }` with
1297        // `catalog: { lodash: 4.17.21 }`. pnpm writes the lockfile with
1298        // the resolved override value (`lodash: 4.17.21`), so a frozen
1299        // install comparing the raw `catalog:` string against the
1300        // resolved form would always read stale (discussion #174).
1301        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1302        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1303        graph.overrides.insert("lodash".into(), "4.17.21".into());
1304        let mut ws_overrides = BTreeMap::new();
1305        ws_overrides.insert("lodash".into(), "catalog:".into());
1306        let mut catalogs = BTreeMap::new();
1307        let mut default_cat = BTreeMap::new();
1308        default_cat.insert("lodash".into(), "4.17.21".into());
1309        catalogs.insert("default".into(), default_cat);
1310        assert_eq!(
1311            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1312            DriftStatus::Fresh,
1313        );
1314    }
1315
1316    #[test]
1317    fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
1318        // Named catalog variant: `overrides: { lodash: "catalog:evens" }`
1319        // resolves against `catalogs.evens.lodash`.
1320        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1321        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1322        graph.overrides.insert("lodash".into(), "4.17.21".into());
1323        let mut ws_overrides = BTreeMap::new();
1324        ws_overrides.insert("lodash".into(), "catalog:evens".into());
1325        let mut catalogs = BTreeMap::new();
1326        let mut evens = BTreeMap::new();
1327        evens.insert("lodash".into(), "4.17.21".into());
1328        catalogs.insert("evens".into(), evens);
1329        assert_eq!(
1330            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
1331            DriftStatus::Fresh,
1332        );
1333    }
1334
1335    #[test]
1336    fn stale_when_override_catalog_ref_diverges_from_lockfile() {
1337        // If the catalog moves to a new version, the resolved override
1338        // no longer matches the lockfile — drift must fire, not silently
1339        // accept.
1340        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1341        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1342        graph.overrides.insert("lodash".into(), "4.17.21".into());
1343        let mut ws_overrides = BTreeMap::new();
1344        ws_overrides.insert("lodash".into(), "catalog:".into());
1345        let mut catalogs = BTreeMap::new();
1346        let mut default_cat = BTreeMap::new();
1347        default_cat.insert("lodash".into(), "4.17.22".into());
1348        catalogs.insert("default".into(), default_cat);
1349        match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
1350            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
1351            other => panic!("expected stale, got {other:?}"),
1352        }
1353    }
1354
1355    #[test]
1356    fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
1357        // pnpm rewrites the importer `specifier:` to the post-override
1358        // value when a bare-name override applies, so a pnpm-generated
1359        // lockfile records `specifier: 4.17.21` even though
1360        // `package.json` still reads `^4.17.0`. Without override-aware
1361        // drift, every frozen install against a pnpm lockfile with
1362        // overrides reads stale (discussion #174).
1363        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1364        let mut importers = BTreeMap::new();
1365        importers.insert(
1366            ".".to_string(),
1367            vec![DirectDep {
1368                name: "lodash".into(),
1369                dep_path: "lodash@4.17.21".into(),
1370                dep_type: DepType::Production,
1371                specifier: Some("4.17.21".into()),
1372            }],
1373        );
1374        let mut graph = LockfileGraph {
1375            importers,
1376            ..Default::default()
1377        };
1378        graph.overrides.insert("lodash".into(), "4.17.21".into());
1379        let mut ws_overrides = BTreeMap::new();
1380        ws_overrides.insert("lodash".into(), "4.17.21".into());
1381        assert_eq!(
1382            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1383            DriftStatus::Fresh,
1384        );
1385    }
1386
1387    #[test]
1388    fn fresh_when_version_keyed_override_rewrites_importer_spec() {
1389        // Discussion #352: an override keyed by name+range
1390        // (`plist@<3.0.5` → `>=3.0.5`) rewrites the importer specifier
1391        // the same way bare-name overrides do. The drift check has to
1392        // parse the key and compare-by-rule, not by raw map lookup,
1393        // otherwise pnpm-written lockfiles read stale on every frozen
1394        // install when version-conditional overrides are in play.
1395        let manifest = make_manifest(&[("plist", "^3.0.4")]);
1396        let mut importers = BTreeMap::new();
1397        importers.insert(
1398            ".".to_string(),
1399            vec![DirectDep {
1400                name: "plist".into(),
1401                dep_path: "plist@3.0.6".into(),
1402                dep_type: DepType::Production,
1403                specifier: Some(">=3.0.5".into()),
1404            }],
1405        );
1406        let mut graph = LockfileGraph {
1407            importers,
1408            ..Default::default()
1409        };
1410        graph
1411            .overrides
1412            .insert("plist@<3.0.5".into(), ">=3.0.5".into());
1413        let mut ws_overrides = BTreeMap::new();
1414        ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
1415        assert_eq!(
1416            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
1417            DriftStatus::Fresh,
1418        );
1419    }
1420
1421    #[test]
1422    fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
1423        // Same drift-shaped bug as overrides: the resolver unions
1424        // `ignoredOptionalDependencies` from package.json and
1425        // pnpm-workspace.yaml, so the lockfile's
1426        // `ignored_optional_dependencies` carries the union, and the
1427        // drift check has to see the same union or the next
1428        // `--frozen-lockfile` run fails with "manifest removes".
1429        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
1430        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1431        graph
1432            .ignored_optional_dependencies
1433            .insert("fsevents".to_string());
1434        let ws_ignored = vec!["fsevents".to_string()];
1435        assert_eq!(
1436            graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
1437            DriftStatus::Fresh,
1438        );
1439    }
1440
1441    #[test]
1442    fn fresh_when_optional_dep_was_recorded_as_skipped() {
1443        // Regression: a platform-skipped optional dep would otherwise
1444        // loop forever as "manifest adds X". When the previous
1445        // resolve recorded it under skipped_optional_dependencies with
1446        // a matching specifier, drift must report Fresh.
1447        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1448        manifest
1449            .optional_dependencies
1450            .insert("fsevents".into(), "^2.3.0".into());
1451        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1452        let mut inner = BTreeMap::new();
1453        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1454        graph
1455            .skipped_optional_dependencies
1456            .insert(".".to_string(), inner);
1457        assert_eq!(
1458            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1459            DriftStatus::Fresh
1460        );
1461    }
1462
1463    #[test]
1464    fn stale_when_new_optional_dep_was_never_seen() {
1465        // Cursor Bugbot regression: a brand-new optional dep that the
1466        // previous resolve never saw must trigger drift, otherwise it
1467        // would silently never get installed. Distinct from a
1468        // platform-skipped optional, which has an entry in
1469        // `skipped_optional_dependencies`.
1470        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1471        manifest
1472            .optional_dependencies
1473            .insert("fsevents".into(), "^2.3.0".into());
1474        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1475        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1476            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1477            DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
1478        }
1479    }
1480
1481    #[test]
1482    fn stale_when_skipped_optional_dep_specifier_changes() {
1483        // The user bumped the range on a previously-skipped optional;
1484        // the recorded specifier no longer matches the manifest, so we
1485        // need to re-resolve.
1486        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
1487        manifest
1488            .optional_dependencies
1489            .insert("fsevents".into(), "^2.4.0".into());
1490        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1491        let mut inner = BTreeMap::new();
1492        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1493        graph
1494            .skipped_optional_dependencies
1495            .insert(".".to_string(), inner);
1496        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1497            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1498            DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
1499        }
1500    }
1501
1502    #[test]
1503    fn stale_when_skipped_optional_is_promoted_to_required() {
1504        // Cursor Bugbot regression: if the user moves a previously-
1505        // skipped optional into `dependencies` (same specifier), the
1506        // skipped-list exemption must NOT fire — the dep is now
1507        // required and the lockfile genuinely doesn't include it.
1508        let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
1509        // Note: fsevents lives in `dependencies`, not
1510        // `optional_dependencies`, even though the lockfile recorded
1511        // it under skipped optionals from a previous resolve.
1512        manifest.optional_dependencies.clear();
1513        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
1514        let mut inner = BTreeMap::new();
1515        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
1516        graph
1517            .skipped_optional_dependencies
1518            .insert(".".to_string(), inner);
1519        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1520            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1521            DriftStatus::Fresh => {
1522                panic!("expected Stale: skipped-optional exemption must not apply to required deps")
1523            }
1524        }
1525    }
1526
1527    #[test]
1528    fn stale_when_optional_dep_specifier_changes_in_lockfile() {
1529        // Spec changes on optionals that *are* present must still
1530        // drift, so the resolver re-runs when the user bumps a range.
1531        let mut manifest = make_manifest(&[]);
1532        manifest
1533            .optional_dependencies
1534            .insert("fsevents".into(), "^2.4.0".into());
1535        let mut graph = make_graph(&[]);
1536        graph.importers.get_mut(".").unwrap().push(DirectDep {
1537            name: "fsevents".into(),
1538            dep_path: "fsevents@2.3.0".into(),
1539            dep_type: DepType::Optional,
1540            specifier: Some("^2.3.0".into()),
1541        });
1542        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
1543            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
1544            DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
1545        }
1546    }
1547
1548    #[test]
1549    fn fresh_for_empty_manifest_and_lockfile() {
1550        let manifest = make_manifest(&[]);
1551        let graph = make_graph(&[]);
1552        assert_eq!(
1553            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1554            DriftStatus::Fresh
1555        );
1556    }
1557
1558    #[test]
1559    fn workspace_drift_detects_change_in_non_root_importer() {
1560        // Build a graph with two importers: root and packages/app.
1561        let root_dep = DirectDep {
1562            name: "lodash".into(),
1563            dep_path: "lodash@4.17.21".into(),
1564            dep_type: DepType::Production,
1565            specifier: Some("^4.17.0".into()),
1566        };
1567        let app_dep = DirectDep {
1568            name: "express".into(),
1569            dep_path: "express@4.18.0".into(),
1570            dep_type: DepType::Production,
1571            specifier: Some("^4.18.0".into()),
1572        };
1573        let mut importers = BTreeMap::new();
1574        importers.insert(".".to_string(), vec![root_dep]);
1575        importers.insert("packages/app".to_string(), vec![app_dep]);
1576        let graph = LockfileGraph {
1577            importers,
1578            packages: BTreeMap::new(),
1579            ..Default::default()
1580        };
1581
1582        let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
1583        // App manifest changed express to ^5.0.0 — should be detected as stale.
1584        let app_manifest = make_manifest(&[("express", "^5.0.0")]);
1585
1586        let workspace_manifests = vec![
1587            (".".to_string(), root_manifest.clone()),
1588            ("packages/app".to_string(), app_manifest),
1589        ];
1590        match graph.check_drift_workspace(
1591            &workspace_manifests,
1592            &BTreeMap::new(),
1593            &[],
1594            &BTreeMap::new(),
1595            true,
1596        ) {
1597            DriftStatus::Stale { reason } => {
1598                assert!(reason.contains("packages/app"));
1599                assert!(reason.contains("express"));
1600            }
1601            DriftStatus::Fresh => panic!("expected Stale"),
1602        }
1603
1604        // Single-importer check_drift on root only would say Fresh.
1605        assert_eq!(
1606            graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
1607            DriftStatus::Fresh
1608        );
1609    }
1610
1611    #[test]
1612    fn filter_deps_prunes_dev_only_subtree() {
1613        // Graph: prod-root (foo) + dev-root (jest) with transitive chains.
1614        // After filtering out Dev, jest + its transitives should be pruned,
1615        // foo + its transitives should remain.
1616        let mut importers = BTreeMap::new();
1617        importers.insert(
1618            ".".to_string(),
1619            vec![
1620                DirectDep {
1621                    name: "foo".into(),
1622                    dep_path: "foo@1.0.0".into(),
1623                    dep_type: DepType::Production,
1624                    specifier: Some("^1.0.0".into()),
1625                },
1626                DirectDep {
1627                    name: "jest".into(),
1628                    dep_path: "jest@29.0.0".into(),
1629                    dep_type: DepType::Dev,
1630                    specifier: Some("^29.0.0".into()),
1631                },
1632            ],
1633        );
1634
1635        let mut packages = BTreeMap::new();
1636        let mut foo_deps = BTreeMap::new();
1637        foo_deps.insert("bar".to_string(), "2.0.0".to_string());
1638        packages.insert(
1639            "foo@1.0.0".to_string(),
1640            LockedPackage {
1641                name: "foo".into(),
1642                version: "1.0.0".into(),
1643                integrity: None,
1644                dependencies: foo_deps,
1645                dep_path: "foo@1.0.0".into(),
1646                ..Default::default()
1647            },
1648        );
1649        packages.insert(
1650            "bar@2.0.0".to_string(),
1651            LockedPackage {
1652                name: "bar".into(),
1653                version: "2.0.0".into(),
1654                integrity: None,
1655                dependencies: BTreeMap::new(),
1656                dep_path: "bar@2.0.0".into(),
1657                ..Default::default()
1658            },
1659        );
1660        let mut jest_deps = BTreeMap::new();
1661        jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
1662        packages.insert(
1663            "jest@29.0.0".to_string(),
1664            LockedPackage {
1665                name: "jest".into(),
1666                version: "29.0.0".into(),
1667                integrity: None,
1668                dependencies: jest_deps,
1669                dep_path: "jest@29.0.0".into(),
1670                ..Default::default()
1671            },
1672        );
1673        packages.insert(
1674            "jest-core@29.0.0".to_string(),
1675            LockedPackage {
1676                name: "jest-core".into(),
1677                version: "29.0.0".into(),
1678                integrity: None,
1679                dependencies: BTreeMap::new(),
1680                dep_path: "jest-core@29.0.0".into(),
1681                ..Default::default()
1682            },
1683        );
1684
1685        let graph = LockfileGraph {
1686            importers,
1687            packages,
1688            ..Default::default()
1689        };
1690
1691        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1692
1693        // Direct deps: only foo, jest dropped
1694        let roots = prod.root_deps();
1695        assert_eq!(roots.len(), 1);
1696        assert_eq!(roots[0].name, "foo");
1697
1698        // Reachable packages: foo + bar (transitive), NOT jest or jest-core
1699        assert!(prod.packages.contains_key("foo@1.0.0"));
1700        assert!(prod.packages.contains_key("bar@2.0.0"));
1701        assert!(!prod.packages.contains_key("jest@29.0.0"));
1702        assert!(!prod.packages.contains_key("jest-core@29.0.0"));
1703    }
1704
1705    // Regression for #50 feedback: `filter_deps` is a structural
1706    // operation and must preserve the source graph's `settings:`
1707    // metadata. A filtered graph that's handed to the lockfile writer
1708    // (as `aube prune` does today) would otherwise reset
1709    // `autoInstallPeers` to its default and silently flip the user's
1710    // choice on the next install.
1711    #[test]
1712    fn filter_deps_preserves_lockfile_settings() {
1713        let graph = LockfileGraph {
1714            importers: BTreeMap::new(),
1715            packages: BTreeMap::new(),
1716            settings: LockfileSettings {
1717                auto_install_peers: false,
1718                exclude_links_from_lockfile: true,
1719                lockfile_include_tarball_url: false,
1720            },
1721            ..Default::default()
1722        };
1723        let filtered = graph.filter_deps(|_| true);
1724        assert!(!filtered.settings.auto_install_peers);
1725        assert!(filtered.settings.exclude_links_from_lockfile);
1726    }
1727
1728    #[test]
1729    fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
1730        // Graph: prod foo → shared, dev jest → shared
1731        // Filtering out Dev should still keep `shared` because foo → shared
1732        // keeps it reachable.
1733        let mut importers = BTreeMap::new();
1734        importers.insert(
1735            ".".to_string(),
1736            vec![
1737                DirectDep {
1738                    name: "foo".into(),
1739                    dep_path: "foo@1.0.0".into(),
1740                    dep_type: DepType::Production,
1741                    specifier: Some("^1.0.0".into()),
1742                },
1743                DirectDep {
1744                    name: "jest".into(),
1745                    dep_path: "jest@29.0.0".into(),
1746                    dep_type: DepType::Dev,
1747                    specifier: Some("^29.0.0".into()),
1748                },
1749            ],
1750        );
1751
1752        let mut packages = BTreeMap::new();
1753        for (name, ver, deps) in [
1754            ("foo", "1.0.0", vec![("shared", "1.0.0")]),
1755            ("jest", "29.0.0", vec![("shared", "1.0.0")]),
1756            ("shared", "1.0.0", vec![]),
1757        ] {
1758            let mut dep_map = BTreeMap::new();
1759            for (n, v) in deps {
1760                dep_map.insert(n.to_string(), v.to_string());
1761            }
1762            packages.insert(
1763                format!("{name}@{ver}"),
1764                LockedPackage {
1765                    name: name.into(),
1766                    version: ver.into(),
1767                    integrity: None,
1768                    dependencies: dep_map,
1769                    dep_path: format!("{name}@{ver}"),
1770                    ..Default::default()
1771                },
1772            );
1773        }
1774
1775        let graph = LockfileGraph {
1776            importers,
1777            packages,
1778            ..Default::default()
1779        };
1780        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
1781
1782        assert!(prod.packages.contains_key("foo@1.0.0"));
1783        assert!(prod.packages.contains_key("shared@1.0.0"));
1784        assert!(!prod.packages.contains_key("jest@29.0.0"));
1785    }
1786
1787    #[test]
1788    fn subset_to_importer_returns_none_for_missing_importer() {
1789        let graph = LockfileGraph {
1790            importers: BTreeMap::new(),
1791            packages: BTreeMap::new(),
1792            ..Default::default()
1793        };
1794        assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
1795    }
1796
1797    #[test]
1798    fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
1799        // Workspace graph with two importers that own independent
1800        // subtrees: packages/lib pulls is-odd → is-number, packages/app
1801        // pulls express. Subsetting to packages/lib must yield a graph
1802        // rooted at `.` containing only is-odd + is-number, with
1803        // express pruned. Matches what `aube deploy --filter @test/lib`
1804        // should write into the target.
1805        let mut importers = BTreeMap::new();
1806        importers.insert(".".to_string(), vec![]);
1807        importers.insert(
1808            "packages/lib".to_string(),
1809            vec![DirectDep {
1810                name: "is-odd".into(),
1811                dep_path: "is-odd@3.0.1".into(),
1812                dep_type: DepType::Production,
1813                specifier: Some("^3.0.1".into()),
1814            }],
1815        );
1816        importers.insert(
1817            "packages/app".to_string(),
1818            vec![DirectDep {
1819                name: "express".into(),
1820                dep_path: "express@4.18.0".into(),
1821                dep_type: DepType::Production,
1822                specifier: Some("^4.18.0".into()),
1823            }],
1824        );
1825
1826        let mut packages = BTreeMap::new();
1827        let mut is_odd_deps = BTreeMap::new();
1828        is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
1829        packages.insert(
1830            "is-odd@3.0.1".to_string(),
1831            LockedPackage {
1832                name: "is-odd".into(),
1833                version: "3.0.1".into(),
1834                dependencies: is_odd_deps,
1835                dep_path: "is-odd@3.0.1".into(),
1836                ..Default::default()
1837            },
1838        );
1839        packages.insert(
1840            "is-number@6.0.0".to_string(),
1841            LockedPackage {
1842                name: "is-number".into(),
1843                version: "6.0.0".into(),
1844                dep_path: "is-number@6.0.0".into(),
1845                ..Default::default()
1846            },
1847        );
1848        packages.insert(
1849            "express@4.18.0".to_string(),
1850            LockedPackage {
1851                name: "express".into(),
1852                version: "4.18.0".into(),
1853                dep_path: "express@4.18.0".into(),
1854                ..Default::default()
1855            },
1856        );
1857
1858        let graph = LockfileGraph {
1859            importers,
1860            packages,
1861            ..Default::default()
1862        };
1863        let subset = graph
1864            .subset_to_importer("packages/lib", |_| true)
1865            .expect("packages/lib importer present");
1866
1867        assert_eq!(subset.importers.len(), 1);
1868        let roots = subset.root_deps();
1869        assert_eq!(roots.len(), 1);
1870        assert_eq!(roots[0].name, "is-odd");
1871
1872        assert!(subset.packages.contains_key("is-odd@3.0.1"));
1873        assert!(subset.packages.contains_key("is-number@6.0.0"));
1874        assert!(!subset.packages.contains_key("express@4.18.0"));
1875    }
1876
1877    #[test]
1878    fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
1879        // packages/lib has both prod (is-odd) and dev (jest) deps.
1880        // `aube deploy --prod` should pass `|d| d.dep_type != Dev` as
1881        // the keep filter; the resulting subset retains only is-odd
1882        // so drift against the target's dev-stripped manifest stays
1883        // clean.
1884        let mut importers = BTreeMap::new();
1885        importers.insert(
1886            "packages/lib".to_string(),
1887            vec![
1888                DirectDep {
1889                    name: "is-odd".into(),
1890                    dep_path: "is-odd@3.0.1".into(),
1891                    dep_type: DepType::Production,
1892                    specifier: Some("^3.0.1".into()),
1893                },
1894                DirectDep {
1895                    name: "jest".into(),
1896                    dep_path: "jest@29.0.0".into(),
1897                    dep_type: DepType::Dev,
1898                    specifier: Some("^29.0.0".into()),
1899                },
1900            ],
1901        );
1902        let mut packages = BTreeMap::new();
1903        packages.insert(
1904            "is-odd@3.0.1".to_string(),
1905            LockedPackage {
1906                name: "is-odd".into(),
1907                version: "3.0.1".into(),
1908                dep_path: "is-odd@3.0.1".into(),
1909                ..Default::default()
1910            },
1911        );
1912        packages.insert(
1913            "jest@29.0.0".to_string(),
1914            LockedPackage {
1915                name: "jest".into(),
1916                version: "29.0.0".into(),
1917                dep_path: "jest@29.0.0".into(),
1918                ..Default::default()
1919            },
1920        );
1921        let graph = LockfileGraph {
1922            importers,
1923            packages,
1924            ..Default::default()
1925        };
1926
1927        let prod = graph
1928            .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
1929            .expect("importer present");
1930        let roots = prod.root_deps();
1931        assert_eq!(roots.len(), 1);
1932        assert_eq!(roots[0].name, "is-odd");
1933        assert!(prod.packages.contains_key("is-odd@3.0.1"));
1934        assert!(!prod.packages.contains_key("jest@29.0.0"));
1935    }
1936
1937    #[test]
1938    fn subset_to_importer_preserves_graph_settings() {
1939        // Structural pruning, not a resolution-mode reset: a deploy
1940        // into a target that uses the source workspace's settings
1941        // header (autoInstallPeers / lockfileIncludeTarballUrl)
1942        // should write them through unchanged so a frozen install in
1943        // the target sees the same resolution-mode state.
1944        let mut importers = BTreeMap::new();
1945        importers.insert("packages/lib".to_string(), vec![]);
1946        let graph = LockfileGraph {
1947            importers,
1948            packages: BTreeMap::new(),
1949            settings: LockfileSettings {
1950                auto_install_peers: false,
1951                exclude_links_from_lockfile: true,
1952                lockfile_include_tarball_url: true,
1953            },
1954            ..Default::default()
1955        };
1956        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1957        assert!(!subset.settings.auto_install_peers);
1958        assert!(subset.settings.exclude_links_from_lockfile);
1959        assert!(subset.settings.lockfile_include_tarball_url);
1960    }
1961
1962    #[test]
1963    fn subset_to_importer_rekeys_skipped_optionals_to_root() {
1964        // `skipped_optional_dependencies` is per-importer. After
1965        // subsetting, only the retained importer's entry should
1966        // survive — rekeyed to `.` so a frozen install in the target
1967        // (which has exactly one importer) doesn't see ghost entries.
1968        let mut importers = BTreeMap::new();
1969        importers.insert("packages/lib".to_string(), vec![]);
1970        importers.insert("packages/app".to_string(), vec![]);
1971        let mut skipped = BTreeMap::new();
1972        let mut lib_skip = BTreeMap::new();
1973        lib_skip.insert("fsevents".to_string(), "^2".to_string());
1974        skipped.insert("packages/lib".to_string(), lib_skip);
1975        let mut app_skip = BTreeMap::new();
1976        app_skip.insert("ghost".to_string(), "*".to_string());
1977        skipped.insert("packages/app".to_string(), app_skip);
1978        let graph = LockfileGraph {
1979            importers,
1980            packages: BTreeMap::new(),
1981            skipped_optional_dependencies: skipped,
1982            ..Default::default()
1983        };
1984        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
1985        assert_eq!(subset.skipped_optional_dependencies.len(), 1);
1986        let root = subset.skipped_optional_dependencies.get(".").unwrap();
1987        assert!(root.contains_key("fsevents"));
1988        assert!(!root.contains_key("ghost"));
1989    }
1990
1991    #[test]
1992    fn workspace_drift_fresh_when_all_importers_match() {
1993        let root_dep = DirectDep {
1994            name: "lodash".into(),
1995            dep_path: "lodash@4.17.21".into(),
1996            dep_type: DepType::Production,
1997            specifier: Some("^4.17.0".into()),
1998        };
1999        let app_dep = DirectDep {
2000            name: "express".into(),
2001            dep_path: "express@4.18.0".into(),
2002            dep_type: DepType::Production,
2003            specifier: Some("^4.18.0".into()),
2004        };
2005        let mut importers = BTreeMap::new();
2006        importers.insert(".".to_string(), vec![root_dep]);
2007        importers.insert("packages/app".to_string(), vec![app_dep]);
2008        let graph = LockfileGraph {
2009            importers,
2010            packages: BTreeMap::new(),
2011            ..Default::default()
2012        };
2013
2014        let workspace_manifests = vec![
2015            (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
2016            (
2017                "packages/app".to_string(),
2018                make_manifest(&[("express", "^4.18.0")]),
2019            ),
2020        ];
2021        assert_eq!(
2022            graph.check_drift_workspace(
2023                &workspace_manifests,
2024                &BTreeMap::new(),
2025                &[],
2026                &BTreeMap::new(),
2027                true,
2028            ),
2029            DriftStatus::Fresh
2030        );
2031    }
2032
2033    #[allow(clippy::type_complexity)]
2034    fn mk_catalogs(
2035        entries: &[(&str, &[(&str, &str, &str)])],
2036    ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
2037        let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
2038        for (cat, pkgs) in entries {
2039            let mut inner = BTreeMap::new();
2040            for (pkg, spec, ver) in *pkgs {
2041                inner.insert(
2042                    (*pkg).to_string(),
2043                    CatalogEntry {
2044                        specifier: (*spec).to_string(),
2045                        version: (*ver).to_string(),
2046                    },
2047                );
2048            }
2049            out.insert((*cat).to_string(), inner);
2050        }
2051        out
2052    }
2053
2054    fn mk_workspace_catalogs(
2055        entries: &[(&str, &[(&str, &str)])],
2056    ) -> BTreeMap<String, BTreeMap<String, String>> {
2057        entries
2058            .iter()
2059            .map(|(cat, pkgs)| {
2060                (
2061                    (*cat).to_string(),
2062                    pkgs.iter()
2063                        .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
2064                        .collect(),
2065                )
2066            })
2067            .collect()
2068    }
2069
2070    #[test]
2071    fn catalog_drift_fresh_when_specifiers_match() {
2072        let graph = LockfileGraph {
2073            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2074            ..Default::default()
2075        };
2076        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
2077        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2078    }
2079
2080    #[test]
2081    fn catalog_drift_stale_on_changed_specifier() {
2082        let graph = LockfileGraph {
2083            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2084            ..Default::default()
2085        };
2086        let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
2087        match graph.check_catalogs_drift(&ws) {
2088            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2089            other => panic!("expected stale, got {other:?}"),
2090        }
2091    }
2092
2093    #[test]
2094    fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
2095        // pnpm only writes referenced entries — an unreferenced
2096        // workspace entry is not drift. The "newly used" transition
2097        // is caught by the importer-level drift check.
2098        let graph = LockfileGraph::default();
2099        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
2100        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2101    }
2102
2103    #[test]
2104    fn catalog_drift_stale_on_removed_workspace_entry() {
2105        let graph = LockfileGraph {
2106            catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
2107            ..Default::default()
2108        };
2109        let ws = mk_workspace_catalogs(&[]);
2110        assert!(matches!(
2111            graph.check_catalogs_drift(&ws),
2112            DriftStatus::Stale { .. }
2113        ));
2114    }
2115}