Skip to main content

aube_lockfile/
lib.rs

1pub mod bun;
2pub mod dep_path_filename;
3pub mod graph_hash;
4pub mod merge;
5pub mod npm;
6mod override_match;
7pub mod pnpm;
8pub mod yarn;
9
10pub use merge::{MergeReport, merge_branch_lockfiles};
11
12use smallvec::SmallVec;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15
16/// Most npm packages declare zero or one entry in `os`, `cpu`,
17/// `libc`. Two inline `SmallVec` slots cover empty on construction
18/// (zero heap alloc) and one-entry push (still zero heap) for ~99%
19/// of lockfile entries.
20pub type PlatformList = SmallVec<[String; 2]>;
21
22/// Represents a resolved dependency graph from any lockfile format.
23#[derive(Debug, Clone, Default)]
24pub struct LockfileGraph {
25    /// Direct dependencies of the root project (and workspace packages).
26    /// Key: importer path (e.g., "." for root), Value: list of (name, version) pairs.
27    pub importers: BTreeMap<String, Vec<DirectDep>>,
28    /// All resolved packages.
29    pub packages: BTreeMap<String, LockedPackage>,
30    /// Per-graph settings that round-trip through the lockfile header
31    /// (pnpm v9's `settings:` block). Don't affect graph structure;
32    /// stamped into the YAML when writing and read back when parsing,
33    /// so subsequent installs see the same resolution-mode state.
34    pub settings: LockfileSettings,
35    /// Dependency overrides recorded in pnpm-lock.yaml's top-level
36    /// `overrides:` block. Map of raw selector key → version specifier
37    /// (or `npm:` alias). Keys are the user's verbatim selector
38    /// strings — bare name, `foo>bar`, `foo@<2`, `**/foo`, or any
39    /// combination. Round-tripped so subsequent installs can detect
40    /// override drift on a string-compare of the key+value without
41    /// re-running the resolver. The resolver parses these into
42    /// `override_rule::OverrideRule`s at the start of each resolve
43    /// pass.
44    pub overrides: BTreeMap<String, String>,
45    /// Names listed in the root manifest's `pnpm.ignoredOptionalDependencies`.
46    /// The resolver drops entries in this set from every `optionalDependencies`
47    /// map before enqueueing, matching pnpm's read-package hook. Round-tripped
48    /// through pnpm-lock.yaml's top-level `ignoredOptionalDependencies:` list
49    /// so drift detection can notice when the user edits the field.
50    pub ignored_optional_dependencies: BTreeSet<String>,
51    /// Per-package publish timestamps, keyed by canonical `name@version`
52    /// (no peer suffix). Round-trips through pnpm-lock.yaml's top-level
53    /// `time:` block so `--resolution-mode=time-based` can compute a
54    /// `publishedBy` cutoff from packages already in the lockfile
55    /// without re-fetching packuments.
56    pub times: BTreeMap<String, String>,
57    /// Optional dependencies the resolver intentionally skipped on the
58    /// platform that wrote this lockfile (either filtered by
59    /// `os`/`cpu`/`libc`, or named in
60    /// `pnpm.ignoredOptionalDependencies`). Keyed by importer path,
61    /// inner map is name → specifier captured from `package.json` at
62    /// resolve time.
63    ///
64    /// Drift detection uses this to distinguish "user just added a new
65    /// optional dep" (which is real drift) from "this optional was
66    /// already considered and consciously dropped on this platform"
67    /// (which is *not* drift). Without it, every `--frozen-lockfile`
68    /// install on a platform that skipped a fixture would hard-fail.
69    pub skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
70    /// Resolved catalog entries, mirroring pnpm v9's top-level
71    /// `catalogs:` block. Outer key is the catalog name (`default` for
72    /// the unnamed `catalog:` field in `pnpm-workspace.yaml`); inner key
73    /// is the package name. Each entry pairs the original specifier
74    /// from the workspace catalog with the version the resolver chose
75    /// for it. Round-tripped through the lockfile so drift detection
76    /// can fire when a catalog spec changes without re-resolving.
77    pub catalogs: BTreeMap<String, BTreeMap<String, CatalogEntry>>,
78    /// bun's top-level `configVersion` — a second format counter bun
79    /// added alongside `lockfileVersion` to track its own config-
80    /// schema changes. Only the bun parser/writer ever touches this;
81    /// other formats leave it `None`. Round-tripping the parsed
82    /// value keeps the writer from silently downgrading the field
83    /// (e.g. from `2` back to `1`) when bun bumps it in a future
84    /// release.
85    pub bun_config_version: Option<u32>,
86    /// Top-level `patchedDependencies:` block mirrored by bun 1.1+ and
87    /// pnpm 9+. Key: selector (`lodash@4.17.21`), value: relative patch
88    /// file path (`patches/lodash@4.17.21.patch`). Round-tripped
89    /// verbatim so a parse/write cycle doesn't silently drop user
90    /// patches from the lockfile.
91    pub patched_dependencies: BTreeMap<String, String>,
92    /// Top-level `trustedDependencies:` block (bun) — a package-name
93    /// allowlist for lifecycle script execution. Preserved so
94    /// re-emitting a bun.lock doesn't strip the allowlist and cause
95    /// subsequent installs to skip scripts the user explicitly
96    /// approved.
97    ///
98    /// Kept as a `Vec` (not a set) so bun's original order round-trips
99    /// byte-identically; bun emits the list in insertion order. The
100    /// parser is responsible for deduping if the source lockfile
101    /// carried a duplicate.
102    pub trusted_dependencies: Vec<String>,
103    /// Top-level lockfile fields that aren't explicitly modeled on
104    /// `LockfileGraph`. Populated by per-format parsers on best-effort
105    /// basis so the writer can re-emit blocks a future lockfile
106    /// version might add (or ones we haven't promoted to typed fields
107    /// yet) without silently stripping them on round-trip. Each
108    /// parser/writer is responsible for emitting values in its
109    /// format's native serialization.
110    pub extra_fields: BTreeMap<String, serde_json::Value>,
111    /// Per-workspace-importer extras keyed by importer path (`""` for
112    /// root in bun, `"."` for others). Stores anything in the
113    /// workspace entry the typed model doesn't capture so a parse/
114    /// write cycle doesn't drop fields the user (or bun) wrote there.
115    pub workspace_extra_fields: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
116}
117
118/// One entry in a lockfile catalog: the workspace-declared range and the
119/// resolved version. Mirrors pnpm v9's `catalogs:` block exactly.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct CatalogEntry {
122    pub specifier: String,
123    pub version: String,
124}
125
126/// Per-graph settings that mirror pnpm v9's `settings:` header.
127/// Extend as more knobs become round-trip-aware.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct LockfileSettings {
130    /// pnpm's `auto-install-peers` — when false the resolver leaves
131    /// unmet peers alone (just warns) instead of dragging them in.
132    pub auto_install_peers: bool,
133    /// pnpm's `exclude-links-from-lockfile` — not yet honored by aube
134    /// but round-tripped for lockfile compatibility.
135    pub exclude_links_from_lockfile: bool,
136    /// pnpm's `lockfile-include-tarball-url` — when true the writer
137    /// emits the full registry tarball URL in each package's
138    /// `resolution.tarball:` field alongside `integrity:`. Makes the
139    /// lockfile self-contained so air-gapped installs don't need to
140    /// derive the URL from `.npmrc`. Round-tripped through the
141    /// `settings:` header so it survives parse/write cycles without
142    /// re-reading `.npmrc`.
143    pub lockfile_include_tarball_url: bool,
144}
145
146impl Default for LockfileSettings {
147    fn default() -> Self {
148        Self {
149            auto_install_peers: true,
150            exclude_links_from_lockfile: false,
151            lockfile_include_tarball_url: false,
152        }
153    }
154}
155
156/// A direct dependency of a workspace importer.
157#[derive(Debug, Clone)]
158pub struct DirectDep {
159    pub name: String,
160    /// The dep_path key in the lockfile (e.g., "is-odd@3.0.1")
161    pub dep_path: String,
162    pub dep_type: DepType,
163    /// The specifier as written in package.json at the time the lockfile was
164    /// generated (e.g., `"^4.17.0"`). Used by drift detection to compare against
165    /// the current manifest. Only populated by formats that record it
166    /// (pnpm-lock.yaml v9). `None` for npm/yarn/bun lockfiles.
167    pub specifier: Option<String>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum DepType {
172    Production,
173    Dev,
174    Optional,
175}
176
177/// Non-registry source for a locked package.
178///
179/// When a package comes from a local path (via `file:` or `link:` in
180/// `package.json`) it doesn't have a tarball URL or integrity hash, so we
181/// record the source separately and let the linker materialize it
182/// on-the-fly.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum LocalSource {
185    /// `file:<dir>` — a directory on disk whose contents should be
186    /// hardlink-copied into the virtual store like a normal package.
187    /// Path is stored relative to the project root.
188    Directory(PathBuf),
189    /// `file:<tarball>` — a `.tgz` on disk, extracted into the virtual
190    /// store the same way we extract registry tarballs.
191    Tarball(PathBuf),
192    /// `link:<dir>` — a plain symlink into `node_modules/<name>`, never
193    /// materialized into the virtual store. Transitive deps are the
194    /// target's responsibility.
195    Link(PathBuf),
196    /// `git+https://`, `git+ssh://`, `github:user/repo`, etc. — a
197    /// remote git repo. Cloned at fetch time and imported like a
198    /// `file:` directory. `url` is the normalized clone URL (what
199    /// gets passed to `git clone`). `committish` is the user-written
200    /// ref after `#` (branch, tag, or commit; `None` means HEAD).
201    /// `resolved` is the 40-char commit SHA that `git ls-remote`
202    /// pinned the ref to — the lockfile records this so repeat
203    /// installs reproduce bit-for-bit.
204    Git(GitSource),
205    /// `https://example.com/pkg.tgz` — a remote tarball URL. Fetched
206    /// once at resolve time so the resolver can read the enclosed
207    /// `package.json` for version + transitive deps and pin the
208    /// sha512 integrity. `integrity` stays empty on freshly-parsed
209    /// specifiers and is filled in by the resolver after download.
210    RemoteTarball(RemoteTarballSource),
211}
212
213/// A remote tarball dependency spec. See [`LocalSource::RemoteTarball`].
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct RemoteTarballSource {
216    pub url: String,
217    pub integrity: String,
218}
219
220/// A git dependency spec. See [`LocalSource::Git`].
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct GitSource {
223    pub url: String,
224    pub committish: Option<String>,
225    pub resolved: String,
226    /// pnpm `&path:/sub/dir` selector — when set, only this
227    /// subdirectory of the cloned repo is treated as the package
228    /// root. Stored without leading slash so dep_path hashes are
229    /// stable regardless of whether the user wrote `path:/x` or
230    /// `path:x`.
231    pub subpath: Option<String>,
232}
233
234impl LocalSource {
235    /// The original path (relative to the project root) the user wrote
236    /// in `package.json`. `None` for non-path sources like git.
237    pub fn path(&self) -> Option<&Path> {
238        match self {
239            LocalSource::Directory(p) | LocalSource::Tarball(p) | LocalSource::Link(p) => Some(p),
240            LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
241        }
242    }
243
244    /// The protocol kind (`"file"` / `"link"` / `"git"` / `"url"`).
245    pub fn kind_str(&self) -> &'static str {
246        match self {
247            LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
248            LocalSource::Link(_) => "link",
249            LocalSource::Git(_) => "git",
250            LocalSource::RemoteTarball(_) => "url",
251        }
252    }
253
254    /// The path as a POSIX-style string with forward-slash separators.
255    /// `Path::display()` and `to_string_lossy()` honor the host's
256    /// separator (backslash on Windows), which would make `dep_path`
257    /// hashes and lockfile `specifier:` strings non-portable: the
258    /// same `file:./some/dir` would render as `some\dir` on Windows
259    /// and `some/dir` on Unix, producing two different hashes for
260    /// the same logical target. Always rendering with `/` keeps
261    /// lockfiles cross-platform identical.
262    pub fn path_posix(&self) -> String {
263        self.path()
264            .map(|p| p.to_string_lossy().replace('\\', "/"))
265            .unwrap_or_default()
266    }
267
268    /// Canonical specifier string as pnpm writes it in the `packages:`
269    /// and `snapshots:` keys (post-`<name>@` part). For `file:` /
270    /// `link:` this is `file:./vendor/foo` / `link:../sibling`. For
271    /// `git`, pnpm uses the resolved form `<url>#<commit>` (no
272    /// `git+` prefix) because the lockfile pins to the exact commit
273    /// regardless of what the user wrote. Always emits POSIX
274    /// separators so the resulting lockfile is portable.
275    pub fn specifier(&self) -> String {
276        match self {
277            LocalSource::Git(g) => match &g.subpath {
278                Some(sub) => format!("{}#{}&path:/{}", g.url, g.resolved, sub),
279                None => format!("{}#{}", g.url, g.resolved),
280            },
281            LocalSource::RemoteTarball(t) => t.url.clone(),
282            _ => format!("{}:{}", self.kind_str(), self.path_posix()),
283        }
284    }
285
286    /// Internal FS-safe dep_path used as the key in
287    /// `LockfileGraph.packages` and as the `.aube/` subdir name.
288    ///
289    /// Distinct paths must map to distinct keys (otherwise the
290    /// linker would silently mix files between two local packages),
291    /// and the result must be a single filesystem component — no
292    /// `/`, `\`, `:`, or `..`. Ad-hoc character substitution trips
293    /// over cases like `../vendor` vs `__/vendor` or `a.b` vs `a_b`
294    /// collapsing to the same string, so we hash the raw path bytes
295    /// and suffix the first 16 hex chars (64 bits — more than enough
296    /// to avoid collisions inside a single project).
297    ///
298    /// The hash input is the POSIX-form path string so a checked-in
299    /// lockfile resolves to the same key regardless of which
300    /// platform ran `aube install`.
301    pub fn dep_path(&self, name: &str) -> String {
302        use sha2::{Digest, Sha256};
303        let mut hasher = Sha256::new();
304        match self {
305            LocalSource::Git(g) => {
306                hasher.update(g.url.as_bytes());
307                hasher.update(b"#");
308                hasher.update(g.resolved.as_bytes());
309                if let Some(sub) = &g.subpath {
310                    hasher.update(b"&path:/");
311                    hasher.update(sub.as_bytes());
312                }
313            }
314            LocalSource::RemoteTarball(t) => {
315                hasher.update(t.url.as_bytes());
316            }
317            _ => hasher.update(self.path_posix().as_bytes()),
318        }
319        let digest = hasher.finalize();
320        let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
321        format!("{name}@{}+{short}", self.kind_str())
322    }
323
324    /// Classify a user-written `file:` / `link:` specifier against the
325    /// project root. Returns `None` if `spec` isn't a local specifier.
326    /// Resolves the target path relative to `project_root`; a `file:`
327    /// target that resolves to a `.tgz` / `.tar.gz` on disk is treated
328    /// as a tarball, anything else as a directory.
329    pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
330        // Check git first so URLs like `https://host/user/repo.git`
331        // aren't swallowed by the broader bare-http tarball check
332        // below.
333        if let Some((url, committish, subpath)) = parse_git_spec(spec) {
334            // `resolved` is filled in by the resolver after running
335            // `git ls-remote`. A lockfile round-trip that never
336            // re-resolves will leave this empty, which is the sentinel
337            // the resolver checks for before calling ls-remote.
338            return Some(LocalSource::Git(GitSource {
339                url,
340                committish,
341                resolved: String::new(),
342                subpath,
343            }));
344        }
345        // Any remaining bare `http(s)://` URL is a remote tarball.
346        // npm semantics treat *all* non-git HTTP URLs in a dependency
347        // value as tarball URLs, so services that serve tarballs from
348        // URLs without a `.tgz` extension (pkg.pr.new, GitHub
349        // codeload, etc.) classify correctly here.
350        if Self::looks_like_remote_tarball_url(spec) {
351            return Some(LocalSource::RemoteTarball(RemoteTarballSource {
352                url: spec.to_string(),
353                integrity: String::new(),
354            }));
355        }
356        let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
357            ("file", r)
358        } else if let Some(r) = spec.strip_prefix("link:") {
359            ("link", r)
360        } else {
361            return None;
362        };
363        let rel = PathBuf::from(rest);
364        let abs = project_root.join(&rel);
365        if kind == "link" {
366            return Some(LocalSource::Link(rel));
367        }
368        if abs.is_file() && Self::path_looks_like_tarball(&rel) {
369            return Some(LocalSource::Tarball(rel));
370        }
371        Some(LocalSource::Directory(rel))
372    }
373
374    /// Whether a specifier looks like a direct HTTP(S) URL that should
375    /// be fetched as a tarball. Per npm semantics, *any* `http://` or
376    /// `https://` URL in a dependency value is a tarball URL — services
377    /// like pkg.pr.new, GitHub codeload, and private registries with
378    /// auth-token query strings serve tarballs from URLs that don't
379    /// carry a `.tgz` extension. Git URLs must already have been
380    /// ruled out by the caller (see [`parse_git_spec`]) so a
381    /// `.git`-suffixed URL doesn't get misclassified here.
382    pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
383        spec.starts_with("https://") || spec.starts_with("http://")
384    }
385
386    pub fn path_looks_like_tarball(path: &Path) -> bool {
387        let name = match path.file_name().and_then(|n| n.to_str()) {
388            Some(n) => n,
389            None => return false,
390        };
391        let lower = name.to_ascii_lowercase();
392        lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
393    }
394}
395
396/// Parse a git dependency specifier into `(clone_url, committish)`.
397///
398/// Recognized forms:
399/// - `git+https://host/user/repo.git[#ref]`
400/// - `git+ssh://git@host/user/repo.git[#ref]`
401/// - `git://host/user/repo.git[#ref]`
402/// - `https://host/user/repo.git[#ref]` (only when ending in `.git`)
403/// - `user@host:path[.git][#ref]` (scp-form, only for github.com / gitlab.com /
404///   bitbucket.org — matches pnpm 11 behavior, where unknown SCP hosts are
405///   treated as local paths) → `ssh://user@host/path[.git]`
406/// - `github:user/repo[#ref]` → `https://github.com/user/repo.git`
407/// - `gitlab:user/repo[#ref]` → `https://gitlab.com/user/repo.git`
408/// - `bitbucket:user/repo[#ref]` → `https://bitbucket.org/user/repo.git`
409///
410/// Returns `None` for any specifier that doesn't look like a git URL,
411/// so the caller can fall through to other protocol parsers.
412pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>, Option<String>)> {
413    let (body, committish, subpath) = match spec.find('#') {
414        Some(idx) => {
415            let (c, s) = parse_git_fragment(&spec[idx + 1..]);
416            (&spec[..idx], c, s)
417        }
418        None => (spec, None, None),
419    };
420    let is_bare_transport = body.starts_with("https://")
421        || body.starts_with("http://")
422        || body.starts_with("ssh://")
423        || body.starts_with("file://");
424    let url = if let Some(rest) = body.strip_prefix("git+") {
425        // `git+` explicitly tags the URL as git, so the `.git`
426        // suffix is optional (GitHub/GitLab accept both forms).
427        rest.to_string()
428    } else if body.starts_with("git://") {
429        body.to_string()
430    } else if let Some(scp) = parse_scp_url(body) {
431        scp
432    } else if let Some(path) = body.strip_prefix("github:") {
433        format!("https://github.com/{path}.git")
434    } else if let Some(path) = body.strip_prefix("gitlab:") {
435        format!("https://gitlab.com/{path}.git")
436    } else if let Some(path) = body.strip_prefix("bitbucket:") {
437        format!("https://bitbucket.org/{path}.git")
438    } else if is_bare_transport && body.ends_with(".git") {
439        body.to_string()
440    } else if is_bare_transport
441        && committish
442            .as_deref()
443            .is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
444    {
445        // Lockfile round-trip form: `specifier()` writes the stored
446        // URL verbatim plus `#<sha>`. URLs that dropped the `git+`
447        // prefix (and happen to lack `.git`) are disambiguated from
448        // plain tarball URLs by the 40-hex committish suffix.
449        body.to_string()
450    } else {
451        return None;
452    };
453    Some((url, committish, subpath))
454}
455
456fn parse_scp_url(body: &str) -> Option<String> {
457    if body.contains("://") {
458        return None;
459    }
460    let colon = body.find(':')?;
461    let before = &body[..colon];
462    let path = &body[colon + 1..];
463    if before.is_empty() || path.is_empty() {
464        return None;
465    }
466    if path.starts_with('/') {
467        return None;
468    }
469    let at = before.find('@')?;
470    let user = &before[..at];
471    let host = &before[at + 1..];
472    if user.is_empty() || host.is_empty() || host.contains('/') || host.contains('@') {
473        return None;
474    }
475    // pnpm 11 only resolves SCP-form as hosted Git for the three known
476    // providers; other hosts (e.g. `git@example.com:foo/bar.git`) are
477    // treated as local paths, and `host:path` without a user errors.
478    if !matches!(host, "github.com" | "gitlab.com" | "bitbucket.org") {
479        return None;
480    }
481    Some(format!("ssh://{user}@{host}/{path}"))
482}
483
484/// Normalize git URL fragments used by npm-compatible lockfiles.
485///
486/// Plain git accepts `#<ref>`, while npm and Yarn Berry also write
487/// key/value fragments such as `#commit=<sha>` for pinned git deps.
488/// Downstream code passes this value directly to `git ls-remote` and
489/// `git checkout`, so strip the selector key here and keep only the
490/// actual ref name or SHA.
491pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
492    parse_git_fragment(fragment).0
493}
494
495/// Parse a git URL fragment into `(committish, subpath)`. Handles the
496/// pnpm/hosted-git-info form `<ref>&path:/sub/dir` (the `path:` key
497/// uses a colon, not `=`, by historical convention) as well as the
498/// `key=value` form npm/Yarn Berry write. Unknown selectors are
499/// ignored. Subpath is returned without leading slash so the caller
500/// can join it with a clone dir without tripping the absolute-path
501/// branch of `Path::join`.
502pub(crate) fn parse_git_fragment(fragment: &str) -> (Option<String>, Option<String>) {
503    if fragment.is_empty() {
504        return (None, None);
505    }
506
507    let mut fallback: Option<&str> = None;
508    let mut preferred: Option<&str> = None;
509    let mut subpath: Option<String> = None;
510    for part in fragment.split('&') {
511        if part.is_empty() {
512            continue;
513        }
514        // Try `key=value` first; fall back to `key:value` only for
515        // the small set of selectors we actually handle below. A tag
516        // name with a colon (e.g. `release:2026-01`) is left alone —
517        // and `semver:^1.0.0` stays as a literal ref so `ls-remote`
518        // surfaces an explicit error rather than silently HEAD-ing.
519        let split = part.split_once('=').or_else(|| {
520            part.split_once(':')
521                .filter(|(k, _)| matches!(*k, "commit" | "tag" | "head" | "branch" | "path"))
522        });
523        let (key, value) = split.unwrap_or(("", part));
524        if value.is_empty() {
525            continue;
526        }
527        match key {
528            "commit" => {
529                preferred.get_or_insert(value);
530            }
531            "tag" | "head" | "branch" => {
532                fallback.get_or_insert(value);
533            }
534            "path" => {
535                // Strip leading slashes (pnpm writes `path:/sub`) and
536                // reject any `..` / `.` component. Without this, a
537                // crafted spec like `&path:/../../etc` would let the
538                // resolver and installer escape the clone dir and
539                // import an arbitrary host directory into the store.
540                if subpath.is_some() {
541                    // First-wins, matching the other selectors above.
542                    continue;
543                }
544                let trimmed = value.trim_start_matches('/');
545                if trimmed.is_empty() {
546                    continue;
547                }
548                if trimmed
549                    .split('/')
550                    .any(|c| c.is_empty() || c == "." || c == "..")
551                {
552                    continue;
553                }
554                subpath = Some(trimmed.to_string());
555            }
556            "" => {
557                fallback.get_or_insert(value);
558            }
559            _ => {}
560        }
561    }
562
563    (preferred.or(fallback).map(ToString::to_string), subpath)
564}
565
566/// A single resolved package in the lockfile.
567///
568/// The `dependencies` map keys are dep names and values are the dependency's
569/// dep_path *tail* — i.e. the string that follows `<name>@`. For a plain
570/// package this is just the version (`"4.17.21"`); for a package with its
571/// own peer context it includes the suffix (`"18.2.0(prop-types@15.8.1)"`).
572/// Combining the key with its value reproduces the full dep_path (which is
573/// also the key in `LockfileGraph.packages`).
574#[derive(Debug, Clone, Default)]
575pub struct LockedPackage {
576    /// Package name (e.g., "lodash")
577    pub name: String,
578    /// Exact resolved version (e.g., "4.17.21")
579    pub version: String,
580    /// Integrity hash (e.g., "sha512-...")
581    pub integrity: Option<String>,
582    /// Dependencies of this package (name -> dep_path tail, see struct docs)
583    pub dependencies: BTreeMap<String, String>,
584    /// Optional dependency edges for this package. Active optional edges are
585    /// also mirrored in `dependencies` so graph walks and the linker continue
586    /// to see them; this separate map lets platform filtering prune optional
587    /// edges without touching regular dependencies.
588    pub optional_dependencies: BTreeMap<String, String>,
589    /// Peer dependency ranges as *declared* by the package (from its
590    /// package.json / packument). These are the constraints; the resolved
591    /// versions live in `dependencies` after the peer-context pass runs.
592    pub peer_dependencies: BTreeMap<String, String>,
593    /// `peerDependenciesMeta` entries, keyed by peer name.
594    pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
595    /// The dep_path key used in the lockfile. For packages with resolved
596    /// peer contexts this includes the suffix, e.g.
597    /// `"styled-components@6.1.0(react@18.2.0)"`.
598    pub dep_path: String,
599    /// Set for non-registry packages (those installed via `file:` or
600    /// `link:` specifiers). `None` for the common case of a package
601    /// resolved from an npm registry, where `integrity` is the full
602    /// record of where the bits came from.
603    pub local_source: Option<LocalSource>,
604    /// `os` / `cpu` / `libc` arrays from the package's manifest. Used
605    /// by the resolver to filter optional deps that can't run on the
606    /// current (or user-overridden) platform. Empty arrays mean no
607    /// constraint.
608    pub os: PlatformList,
609    pub cpu: PlatformList,
610    pub libc: PlatformList,
611    /// Names declared in the package's own `bundledDependencies`. These
612    /// ship inside the parent tarball's `node_modules/`, so the resolver
613    /// neither fetches nor recurses into them, and the linker avoids
614    /// creating sibling symlinks that would shadow the bundled tree.
615    /// An empty Vec means "no bundled deps"; `None` is kept as a
616    /// distinct value only inside the resolver and collapsed to empty
617    /// here because the lockfile round-trip doesn't need to preserve
618    /// the "unset" vs "empty list" distinction.
619    pub bundled_dependencies: Vec<String>,
620    /// Full registry tarball URL for registry-sourced packages. Only
621    /// populated when `LockfileSettings::lockfile_include_tarball_url`
622    /// is active on this graph; otherwise `None` and the lockfile
623    /// writer derives the URL at fetch time from the configured
624    /// registry. `local_source`-backed packages (file:, link:, git:,
625    /// remote tarball) already carry their own URL via `LocalSource`
626    /// and don't populate this field.
627    pub tarball_url: Option<String>,
628    /// For npm-alias deps (`"h3-v2": "npm:h3@2.0.1-rc.20"`): the real
629    /// package name on the registry (`"h3"`). `None` means the entry
630    /// is not aliased and `name` already holds the registry name.
631    ///
632    /// Install semantics when `Some(real)`:
633    /// - `name` is the *alias* — that's the folder under `node_modules/`,
634    ///   the symlink name for transitive deps, and the key every package
635    ///   that declares this dep refers to.
636    /// - `alias_of` is the real package name used for tarball URL lookup,
637    ///   store index keying, and packument fetches.
638    /// - `version` is the real resolved version.
639    ///
640    /// `registry_name()` returns the right name for registry IO; every
641    /// call site that talks to the registry or the CAS uses that helper.
642    pub alias_of: Option<String>,
643    /// Yarn berry's `checksum:` field, preserved verbatim when parsing a
644    /// yarn 2+ lockfile (e.g. `"10c0/<blake2b-hex>"`). The format is
645    /// yarn-specific — it uses a yarn-chosen hash family prefixed with
646    /// the `cacheKey` that produced it — and doesn't share a hash
647    /// algorithm with `integrity` (sha-512). When re-emitting a yarn
648    /// berry lockfile we write this field back as-is; packages that
649    /// didn't come through a berry parse (e.g. freshly-resolved entries
650    /// in a new install) leave this `None` and the writer omits the
651    /// `checksum:` field, which berry tolerates at the default
652    /// `checksumBehavior: throw` when the cache is fresh.
653    pub yarn_checksum: Option<String>,
654    /// `engines:` from the package's manifest, round-tripped through
655    /// the lockfile so pnpm-style writers can emit the same flow-form
656    /// `engines: {node: '>=8'}` line pnpm writes. Empty map means
657    /// "no engines declared" — the writer skips the field entirely.
658    pub engines: BTreeMap<String, String>,
659    /// `bin:` map from the package's manifest, normalized to
660    /// `name → path`. An empty map means "no bins declared".
661    ///
662    /// pnpm-style writers derive `hasBin: true` from
663    /// `!bin.is_empty()` (they don't preserve the names/paths); bun's
664    /// format emits the full map on the package's meta block. Keeping
665    /// the map here lets both writers render byte-identical output
666    /// without an extra tarball-level re-parse.
667    pub bin: BTreeMap<String, String>,
668    /// Dependency ranges as declared in this package's own
669    /// `package.json` — keyed by dep name, values are the raw
670    /// specifiers (`"^4.1.0"`, `"~1.1.4"`, `"workspace:*"`, …).
671    ///
672    /// Distinct from [`Self::dependencies`], which stores the
673    /// *resolved* dep_path tail (`"4.3.0"`). npm / yarn / bun
674    /// lockfiles preserve the declared ranges on every nested
675    /// package entry — rewriting them to the resolved pins is the
676    /// biggest source of round-trip churn against those formats. This
677    /// map lets writers emit the declared range when available and
678    /// fall back to the resolved pin otherwise (e.g. when the source
679    /// lockfile was pnpm, whose `snapshots:` only carries pins).
680    ///
681    /// Empty means "unknown" — writers should fall back to pins.
682    /// Covers production *and* optional dependencies in one map since
683    /// a package can't declare the same name twice across those
684    /// sections.
685    pub declared_dependencies: BTreeMap<String, String>,
686    /// Package's `license` field, collapsed to the simple string
687    /// form. Round-tripped so npm's lockfile keeps its per-entry
688    /// `"license": "MIT"` line; pnpm / yarn / bun don't record
689    /// licenses and leave this `None` on parse.
690    pub license: Option<String>,
691    /// Package's funding URL, extracted from whatever shape the
692    /// manifest's `funding:` field took (string / object / array).
693    /// Round-tripped so npm's lockfile keeps its per-entry
694    /// `"funding": {"url": "…"}` block.
695    pub funding_url: Option<String>,
696    /// pnpm `snapshots:` `optional: true` flag, marking a package
697    /// reachable only through optional edges (typically platform-
698    /// specific binaries like `@reflink/reflink-darwin-arm64`). pnpm
699    /// uses this on the next install to decide whether the entry
700    /// should be skipped on a non-matching platform; dropping it on
701    /// round-trip would let pnpm treat the package as required.
702    /// Always `false` outside the pnpm parse/write path.
703    pub optional: bool,
704    /// pnpm `snapshots:` `transitivePeerDependencies:` list — peer
705    /// names that bubble up transitively through this package. pnpm
706    /// reads it during hoisting and as a resolver staleness signal
707    /// (`resolveDependencies.ts`'s non-zero-length check); a missing
708    /// list looks like a graph change and triggers needless re-
709    /// resolution on the next pnpm install. Empty outside the pnpm
710    /// parse/write path. Fresh resolves leave this empty too — pnpm
711    /// recomputes it from the graph during `resolvePeers` when needed.
712    pub transitive_peer_dependencies: Vec<String>,
713    /// Per-package-meta extras preserved verbatim from the source
714    /// lockfile. Captures fields the typed model doesn't yet cover
715    /// (`deprecated`, `hasInstallScript`, bun's `optionalPeers`, and
716    /// anything a future lockfile bump adds) so a parse/write cycle
717    /// doesn't drop them. Each format's writer re-emits what makes
718    /// sense there — bun inlines the extras back on the package-entry
719    /// meta object, pnpm / yarn / npm currently ignore them.
720    pub extra_meta: BTreeMap<String, serde_json::Value>,
721}
722
723impl LockedPackage {
724    /// The package name to use for registry / store operations — the real
725    /// name behind an npm-alias when aliased, otherwise just `name`. Used
726    /// at every site that derives a tarball URL, a packument URL, or an
727    /// aube-store cache key so aliased entries hit the actual package
728    /// instead of the alias-qualified name.
729    pub fn registry_name(&self) -> &str {
730        self.alias_of.as_deref().unwrap_or(&self.name)
731    }
732
733    /// Canonical `"name@version"` key used as a handle in patches,
734    /// approve-builds prompts, lockfile canonical maps, and display
735    /// paths. Not the dep-path — that includes peer-context suffixes.
736    pub fn spec_key(&self) -> String {
737        format!("{}@{}", self.name, self.version)
738    }
739}
740
741/// Metadata about a single declared peer dependency. Matches the shape of
742/// `peerDependenciesMeta` in package.json.
743#[derive(Debug, Clone, Default, PartialEq, Eq)]
744pub struct PeerDepMeta {
745    /// When true, an unmet peer is silently allowed rather than warned about.
746    pub optional: bool,
747}
748
749/// Which source lockfile format was parsed.
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
751pub enum LockfileKind {
752    /// `aube-lock.yaml` — aube's default lockfile when no existing
753    /// lockfile is present. Same on-disk format as pnpm v9 for now
754    /// (we piggyback on pnpm::read/write).
755    Aube,
756    /// `pnpm-lock.yaml` — pnpm v9 format. If this is the existing
757    /// project lockfile, aube reads and writes it in place.
758    Pnpm,
759    Npm,
760    /// `yarn.lock` v1 (classic yarn). Line-based text format with
761    /// 2-space indented fields.
762    Yarn,
763    /// `yarn.lock` v2+ (yarn berry). YAML format with `__metadata:`
764    /// header, `resolution:` / `checksum:` fields, and
765    /// `languageName` / `linkType`. Same filename as `Yarn`; detection
766    /// peeks at the content for the `__metadata:` marker to pick
767    /// between the two.
768    YarnBerry,
769    NpmShrinkwrap,
770    Bun,
771}
772
773impl LockfileKind {
774    pub fn filename(self) -> &'static str {
775        match self {
776            LockfileKind::Aube => "aube-lock.yaml",
777            LockfileKind::Pnpm => "pnpm-lock.yaml",
778            LockfileKind::Npm => "package-lock.json",
779            LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
780            LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
781            LockfileKind::Bun => "bun.lock",
782        }
783    }
784}
785
786impl LockfileGraph {
787    /// Get all direct dependencies of the root project.
788    pub fn root_deps(&self) -> &[DirectDep] {
789        self.importers.get(".").map(|v| v.as_slice()).unwrap_or(&[])
790    }
791
792    /// Get a package by its dep_path key.
793    pub fn get_package(&self, dep_path: &str) -> Option<&LockedPackage> {
794        self.packages.get(dep_path)
795    }
796
797    /// BFS the transitive closure of `roots` through `self.packages`,
798    /// returning every reachable dep_path (roots included). Missing
799    /// roots are skipped silently — a root without a matching package
800    /// is treated as a leaf, which matches what `filter_deps` /
801    /// `subset_to_importer` need when a retained importer points at a
802    /// package that was never fully installed (e.g. optional deps
803    /// filtered out on this platform).
804    ///
805    /// `LockedPackage.dependencies` maps `child_name → dep_path tail`,
806    /// so each child's full key reconstructs as `{child_name}@{tail}`.
807    fn transitive_closure<'a>(
808        &self,
809        roots: impl IntoIterator<Item = &'a str>,
810    ) -> std::collections::HashSet<String> {
811        let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
812        let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
813        for root in roots {
814            if reachable.insert(root.to_string()) {
815                queue.push_back(root.to_string());
816            }
817        }
818        while let Some(dep_path) = queue.pop_front() {
819            let Some(pkg) = self.packages.get(&dep_path) else {
820                continue;
821            };
822            for (child_name, child_version) in &pkg.dependencies {
823                let child_key = format!("{child_name}@{child_version}");
824                if reachable.insert(child_key.clone()) {
825                    queue.push_back(child_key);
826                }
827            }
828        }
829        reachable
830    }
831
832    /// Clone only the `packages` entries whose keys are in `reachable`.
833    /// Paired with `transitive_closure` to produce the pruned
834    /// `LockfileGraph.packages` for `filter_deps` / `subset_to_importer`.
835    fn packages_restricted_to(
836        &self,
837        reachable: &std::collections::HashSet<String>,
838    ) -> BTreeMap<String, LockedPackage> {
839        self.packages
840            .iter()
841            .filter(|(dep_path, _)| reachable.contains(*dep_path))
842            .map(|(k, v)| (k.clone(), v.clone()))
843            .collect()
844    }
845
846    /// Produce a new `LockfileGraph` containing only the direct deps that match
847    /// `keep` and the transitive deps reachable from them.
848    ///
849    /// Used by `install --prod` to drop `DepType::Dev` roots and everything
850    /// only reachable through them, and by `install --no-optional` for optional
851    /// deps. The filter runs over every importer's direct-dep list, so workspace
852    /// projects behave correctly.
853    ///
854    /// Packages that are reachable from a retained root through a transitive
855    /// chain are kept even if a pruned dev dep also happened to depend on them —
856    /// the check is "is this package reachable from any retained root?", not
857    /// "was this package introduced by a retained root?".
858    pub fn filter_deps<F>(&self, keep: F) -> LockfileGraph
859    where
860        F: Fn(&DirectDep) -> bool,
861    {
862        // Filter each importer's DirectDep list.
863        let importers: BTreeMap<String, Vec<DirectDep>> = self
864            .importers
865            .iter()
866            .map(|(path, deps)| {
867                let filtered: Vec<DirectDep> = deps.iter().filter(|d| keep(d)).cloned().collect();
868                (path.clone(), filtered)
869            })
870            .collect();
871
872        // BFS from every retained root across every importer.
873        let reachable = self.transitive_closure(
874            importers
875                .values()
876                .flat_map(|deps| deps.iter().map(|d| d.dep_path.as_str())),
877        );
878        let packages = self.packages_restricted_to(&reachable);
879
880        LockfileGraph {
881            importers,
882            packages,
883            // Preserve the source graph's settings — filter is a
884            // structural operation, not a resolution-mode reset.
885            // Writing the filtered graph (e.g. from `aube prune`) must
886            // emit the same `settings:` header the user chose.
887            settings: self.settings.clone(),
888            // Overrides are part of the user's resolution intent and
889            // should survive structural filters like `aube prune`.
890            overrides: self.overrides.clone(),
891            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
892            // Times follow the same round-trip invariant as settings:
893            // filter doesn't change what versions are locked, so the
894            // per-package publish timestamps carry through unchanged.
895            times: self.times.clone(),
896            skipped_optional_dependencies: self.skipped_optional_dependencies.clone(),
897            catalogs: self.catalogs.clone(),
898            bun_config_version: self.bun_config_version,
899            patched_dependencies: self.patched_dependencies.clone(),
900            trusted_dependencies: self.trusted_dependencies.clone(),
901            extra_fields: self.extra_fields.clone(),
902            workspace_extra_fields: self.workspace_extra_fields.clone(),
903        }
904    }
905
906    /// Produce a new `LockfileGraph` rooted at the importer at
907    /// `importer_path`, with its transitive closure preserved and every
908    /// other importer dropped. The retained importer is remapped to
909    /// `"."` because the consumer installs the result as a standalone
910    /// project.
911    ///
912    /// Used by `aube deploy`: reading the source workspace lockfile
913    /// and subsetting it to the deployed package lets a frozen install
914    /// in the target reproduce the workspace's exact versions without
915    /// re-resolving against the registry. `keep` filters the importer's
916    /// direct deps the same way `filter_deps` does, so `--prod` /
917    /// `--dev` / `--no-optional` deploys drop the matching roots.
918    ///
919    /// Returns `None` if `importer_path` is not present in
920    /// `self.importers`. Graph-wide metadata (`settings`, `overrides`,
921    /// `times`, `catalogs`, `ignored_optional_dependencies`) is copied
922    /// verbatim — structural pruning, not a resolution-mode reset.
923    /// Callers targeting a non-workspace install may want to clear
924    /// workspace-scope fields that would otherwise trigger drift
925    /// detection against a rewritten target manifest.
926    pub fn subset_to_importer<F>(&self, importer_path: &str, keep: F) -> Option<LockfileGraph>
927    where
928        F: Fn(&DirectDep) -> bool,
929    {
930        let src_deps = self.importers.get(importer_path)?;
931        let kept: Vec<DirectDep> = src_deps.iter().filter(|d| keep(d)).cloned().collect();
932
933        // BFS the transitive closure from retained roots, scoped to
934        // just this importer's kept direct deps.
935        let reachable = self.transitive_closure(kept.iter().map(|d| d.dep_path.as_str()));
936        let packages = self.packages_restricted_to(&reachable);
937
938        // Per-importer metadata: keep only the retained importer's
939        // entry, rekeyed to `.`. The source workspace's other
940        // importers are meaningless in a target that has exactly one.
941        let mut skipped_optional_dependencies = BTreeMap::new();
942        if let Some(skipped) = self.skipped_optional_dependencies.get(importer_path) {
943            skipped_optional_dependencies.insert(".".to_string(), skipped.clone());
944        }
945
946        let mut importers = BTreeMap::new();
947        importers.insert(".".to_string(), kept);
948
949        Some(LockfileGraph {
950            importers,
951            packages,
952            settings: self.settings.clone(),
953            overrides: self.overrides.clone(),
954            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
955            times: self.times.clone(),
956            skipped_optional_dependencies,
957            catalogs: self.catalogs.clone(),
958            bun_config_version: self.bun_config_version,
959            patched_dependencies: self.patched_dependencies.clone(),
960            trusted_dependencies: self.trusted_dependencies.clone(),
961            extra_fields: self.extra_fields.clone(),
962            workspace_extra_fields: self.workspace_extra_fields.clone(),
963        })
964    }
965
966    /// Overlay per-package metadata fields from `prior` onto `self`
967    /// for every `(name, version)` that survives in both graphs.
968    /// Carries forward only fields the abbreviated packument (npm
969    /// corgi) doesn't ship — `license`, `funding_url`, and the
970    /// bun-format `configVersion` — so a fresh re-resolve against
971    /// the same spec set doesn't lose them.
972    ///
973    /// Keyed by canonical `name@version`, so a peer-context rewrite
974    /// between the old and new graph still lines up. `self`'s own
975    /// values win when set (fresh registry data is authoritative);
976    /// `prior`'s fill in only the `None` / empty slots. Safe to call
977    /// on any pair of graphs — parsing the old lockfile is the
978    /// caller's concern.
979    pub fn overlay_metadata_from(&mut self, prior: &LockfileGraph) {
980        // Build a canonical `name@version → prior pkg` lookup once so
981        // repeated peer-context variants in `self.packages` all hit
982        // the same prior entry.
983        let prior_index = build_canonical_map(prior);
984        for pkg in self.packages.values_mut() {
985            let key = pkg.spec_key();
986            let Some(prior_pkg) = prior_index.get(&key) else {
987                continue;
988            };
989            if pkg.license.is_none() && prior_pkg.license.is_some() {
990                pkg.license = prior_pkg.license.clone();
991            }
992            if pkg.funding_url.is_none() && prior_pkg.funding_url.is_some() {
993                pkg.funding_url = prior_pkg.funding_url.clone();
994            }
995            // Per-entry extras (`deprecated`, `optionalPeers`,
996            // format-specific fields bun/npm/yarn wrote into the
997            // meta block) can't be recovered from a fresh resolve,
998            // so carry them forward when the newer graph doesn't
999            // already carry its own. `self`-side keys always win.
1000            for (k, v) in &prior_pkg.extra_meta {
1001                pkg.extra_meta.entry(k.clone()).or_insert_with(|| v.clone());
1002            }
1003        }
1004        if self.bun_config_version.is_none() {
1005            self.bun_config_version = prior.bun_config_version;
1006        }
1007        if self.patched_dependencies.is_empty() {
1008            self.patched_dependencies = prior.patched_dependencies.clone();
1009        }
1010        if self.trusted_dependencies.is_empty() {
1011            self.trusted_dependencies = prior.trusted_dependencies.clone();
1012        }
1013        if self.extra_fields.is_empty() {
1014            self.extra_fields = prior.extra_fields.clone();
1015        }
1016        if self.workspace_extra_fields.is_empty() {
1017            self.workspace_extra_fields = prior.workspace_extra_fields.clone();
1018        }
1019    }
1020
1021    /// Compare this lockfile's root importer against a single manifest.
1022    ///
1023    /// Mirrors pnpm's `prefer-frozen-lockfile` check: a lockfile is "fresh" iff
1024    /// every direct dep specifier in `package.json` exactly matches the specifier
1025    /// recorded in the lockfile (string compare, not semver). Used to decide
1026    /// whether to skip resolution and trust the lockfile (`Fresh`) or fall back
1027    /// to a full re-resolve (`Stale { reason }`).
1028    ///
1029    /// For workspace projects, use [`check_drift_workspace`] instead — this
1030    /// method only inspects the root importer.
1031    ///
1032    /// `workspace_overrides` is the `overrides:` block from
1033    /// `pnpm-workspace.yaml` (pnpm v10 moved overrides there). Pass an
1034    /// empty map when the project has no workspace-yaml overrides. Keys
1035    /// are merged on top of `manifest.overrides_map()` before the drift
1036    /// comparison, matching the resolver's effective-override set —
1037    /// otherwise a lockfile written with a workspace override
1038    /// immediately looks stale on the next `--frozen-lockfile` run.
1039    ///
1040    /// `workspace_ignored_optional` is the same idea for
1041    /// `pnpm-workspace.yaml`'s `ignoredOptionalDependencies` block:
1042    /// the resolver unions it with the manifest's list, so the drift
1043    /// check has to see the same union or a freshly-written lockfile
1044    /// immediately reads as stale.
1045    ///
1046    /// `workspace_catalogs` is the `catalog:` / `catalogs:` block from
1047    /// `pnpm-workspace.yaml`. pnpm resolves `catalog:` references in
1048    /// override values against this map before writing the lockfile
1049    /// and before comparing on re-install, so both sides of the drift
1050    /// check have to see the catalog-resolved form — otherwise a
1051    /// `"lodash": "catalog:"` override reads as stale against a
1052    /// lockfile that recorded the resolved `"lodash": "4.17.21"`.
1053    ///
1054    /// Lockfile formats that don't record specifiers (npm, yarn, bun) always
1055    /// return `Fresh` since we have no way to detect drift without re-resolving.
1056    ///
1057    /// [`check_drift_workspace`]: Self::check_drift_workspace
1058    pub fn check_drift(
1059        &self,
1060        manifest: &aube_manifest::PackageJson,
1061        workspace_overrides: &BTreeMap<String, String>,
1062        workspace_ignored_optional: &[String],
1063        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1064    ) -> DriftStatus {
1065        let effective = resolve_catalog_refs_in_overrides(
1066            &merge_manifest_and_workspace_overrides(manifest, workspace_overrides),
1067            workspace_catalogs,
1068        );
1069        let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
1070        if let Some(reason) = overrides_drift_reason(&locked, &effective) {
1071            return DriftStatus::Stale { reason };
1072        }
1073        let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
1074        effective_ignored.extend(workspace_ignored_optional.iter().cloned());
1075        if let Some(reason) =
1076            ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored)
1077        {
1078            return DriftStatus::Stale { reason };
1079        }
1080        self.check_drift_for_importer(".", manifest, &effective)
1081    }
1082
1083    /// Workspace-aware drift check.
1084    ///
1085    /// Each entry in `manifests` is `(importer_path, manifest)` — for example
1086    /// `(".", root_manifest), ("packages/app", app_manifest), ...`. Every
1087    /// importer is checked against its own manifest; the first stale importer
1088    /// determines the result.
1089    ///
1090    /// See [`check_drift`] for the `workspace_overrides` contract.
1091    ///
1092    /// [`check_drift`]: Self::check_drift
1093    pub fn check_drift_workspace(
1094        &self,
1095        manifests: &[(String, aube_manifest::PackageJson)],
1096        workspace_overrides: &BTreeMap<String, String>,
1097        workspace_ignored_optional: &[String],
1098        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1099    ) -> DriftStatus {
1100        // Override drift is checked once at the workspace level, against
1101        // the root manifest. Workspace-package manifests may declare
1102        // their own `overrides` blocks but pnpm only honors the root's,
1103        // so we mirror that here.
1104        let effective_overrides = match manifests.iter().find(|(p, _)| p == ".") {
1105            Some((_, root_manifest)) => {
1106                let effective = resolve_catalog_refs_in_overrides(
1107                    &merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides),
1108                    workspace_catalogs,
1109                );
1110                let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
1111                if let Some(reason) = overrides_drift_reason(&locked, &effective) {
1112                    return DriftStatus::Stale { reason };
1113                }
1114                let mut effective_ignored = root_manifest.pnpm_ignored_optional_dependencies();
1115                effective_ignored.extend(workspace_ignored_optional.iter().cloned());
1116                if let Some(reason) = ignored_optional_drift_reason(
1117                    &self.ignored_optional_dependencies,
1118                    &effective_ignored,
1119                ) {
1120                    return DriftStatus::Stale { reason };
1121                }
1122                effective
1123            }
1124            None => BTreeMap::new(),
1125        };
1126        for (importer_path, manifest) in manifests {
1127            match self.check_drift_for_importer(importer_path, manifest, &effective_overrides) {
1128                DriftStatus::Fresh => continue,
1129                stale => return stale,
1130            }
1131        }
1132        DriftStatus::Fresh
1133    }
1134
1135    /// Compare this lockfile's catalog snapshot against the current
1136    /// `pnpm-workspace.yaml` catalogs.
1137    ///
1138    /// pnpm only writes catalog entries that at least one importer
1139    /// references — unused entries are absent from the lockfile. So
1140    /// "missing from lockfile" doesn't mean "added by the user", it
1141    /// means "declared but unreferenced", which is not drift. The
1142    /// transition from unused → used is caught by the importer-level
1143    /// drift check, since a fresh `catalog:` reference shows up as a
1144    /// new dep in some `package.json`.
1145    ///
1146    /// We fire on two cases only:
1147    /// - the spec changed for an entry the lockfile already records
1148    ///   (the entry is in use, and re-resolution must rerun);
1149    /// - the workspace removed an entry that the lockfile records
1150    ///   (the importer using `catalog:` now points at nothing).
1151    ///
1152    /// Resolved versions are deliberately not part of the comparison —
1153    /// the version is an *output* of resolution, so a stale lockfile
1154    /// version is what re-resolution is supposed to fix. Drift only
1155    /// fires on user intent (the specifier).
1156    pub fn check_catalogs_drift(
1157        &self,
1158        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1159    ) -> DriftStatus {
1160        for (cat_name, cat) in workspace_catalogs {
1161            let Some(locked) = self.catalogs.get(cat_name) else {
1162                continue;
1163            };
1164            for (pkg, spec) in cat {
1165                if let Some(entry) = locked.get(pkg)
1166                    && entry.specifier != *spec
1167                {
1168                    return DriftStatus::Stale {
1169                        reason: format!(
1170                            "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
1171                            entry.specifier
1172                        ),
1173                    };
1174                }
1175            }
1176        }
1177        for (cat_name, cat) in &self.catalogs {
1178            let workspace_cat = workspace_catalogs.get(cat_name);
1179            for pkg in cat.keys() {
1180                if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
1181                    return DriftStatus::Stale {
1182                        reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
1183                    };
1184                }
1185            }
1186        }
1187        DriftStatus::Fresh
1188    }
1189
1190    /// Compare a single importer's `DirectDep` list against the corresponding
1191    /// `package.json`. Used by both [`check_drift`] and [`check_drift_workspace`].
1192    ///
1193    /// [`check_drift`]: Self::check_drift
1194    /// [`check_drift_workspace`]: Self::check_drift_workspace
1195    fn check_drift_for_importer(
1196        &self,
1197        importer_path: &str,
1198        manifest: &aube_manifest::PackageJson,
1199        effective_overrides: &BTreeMap<String, String>,
1200    ) -> DriftStatus {
1201        let label = if importer_path == "." {
1202            String::new()
1203        } else {
1204            format!("{importer_path}: ")
1205        };
1206
1207        let importer_deps: &[DirectDep] = self
1208            .importers
1209            .get(importer_path)
1210            .map(|v| v.as_slice())
1211            .unwrap_or(&[]);
1212
1213        // Skip the check entirely if no DirectDep has a specifier (non-pnpm format).
1214        if importer_deps.iter().all(|d| d.specifier.is_none()) {
1215            return DriftStatus::Fresh;
1216        }
1217        let lockfile_specs: BTreeMap<&str, &str> = importer_deps
1218            .iter()
1219            .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
1220            .collect();
1221
1222        let override_rules = override_match::compile(effective_overrides);
1223
1224        // Optionals the previous resolve recorded as intentionally
1225        // skipped on this importer's platform — keyed by name, value
1226        // is the specifier captured at that time. Distinct from
1227        // `ignored_optional_dependencies`, which is the user's static
1228        // ignore list; this map captures *runtime* platform skips.
1229        let skipped_optionals: BTreeMap<&str, &str> = self
1230            .skipped_optional_dependencies
1231            .get(importer_path)
1232            .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
1233            .unwrap_or_default();
1234
1235        // Iterate prod / dev / optional with a flag so the
1236        // skipped-optional exemption only applies to deps that came
1237        // from `optional_dependencies`. Without the flag, moving a
1238        // previously-skipped optional into `dependencies` with the same
1239        // specifier would silently report Fresh and the dep would
1240        // never install as a required dep.
1241        //
1242        // Optionals named in `ignored_optional_dependencies` are
1243        // dropped from the manifest-side scan: the resolver never
1244        // enqueues them, so the lockfile importer never has them
1245        // either, and the loop would otherwise report drift on every
1246        // install. (Their *spec* is still verified separately by the
1247        // round-tripped `ignored_optional_dependencies` block below.)
1248        let ignored = &self.ignored_optional_dependencies;
1249        let manifest_deps = manifest
1250            .dependencies
1251            .iter()
1252            .map(|(k, v)| (k, v, false))
1253            .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
1254            .chain(
1255                manifest
1256                    .optional_dependencies
1257                    .iter()
1258                    .filter(|(name, _)| !ignored.contains(name.as_str()))
1259                    .map(|(k, v)| (k, v, true)),
1260            );
1261
1262        for (name, spec, is_optional) in manifest_deps {
1263            match lockfile_specs.get(name.as_str()) {
1264                None => {
1265                    // A *missing* optional dep is only "fresh" if the
1266                    // previous resolve recorded it as intentionally
1267                    // skipped (platform mismatch or
1268                    // `pnpm.ignoredOptionalDependencies`) AND the
1269                    // recorded specifier still matches what's in the
1270                    // manifest. A genuinely *new* optional that the
1271                    // resolver has never seen is real drift — without
1272                    // that branch, adding `fsevents` to a fresh manifest
1273                    // would silently never get installed.
1274                    if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
1275                        if *locked_spec == spec {
1276                            continue;
1277                        }
1278                        return DriftStatus::Stale {
1279                            reason: format!(
1280                                "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
1281                            ),
1282                        };
1283                    }
1284                    return DriftStatus::Stale {
1285                        reason: format!("{label}manifest adds {name}@{spec}"),
1286                    };
1287                }
1288                Some(locked_spec) if *locked_spec != spec => {
1289                    // pnpm rewrites the importer specifier to the
1290                    // override-applied value when an override fires on
1291                    // a direct dep, so a pnpm-generated lockfile shows
1292                    // `specifier: ">=3.0.5"` even though `package.json`
1293                    // still reads `^3.0.4`. Accept that as fresh when
1294                    // an override for this name (bare or version-keyed)
1295                    // resolves to the lockfile's recorded spec —
1296                    // otherwise any pnpm-written lockfile with
1297                    // overrides reads stale on every frozen install.
1298                    if let Some(override_spec) =
1299                        override_match::apply(&override_rules, name.as_str(), spec)
1300                        && override_spec == *locked_spec
1301                    {
1302                        continue;
1303                    }
1304                    return DriftStatus::Stale {
1305                        reason: format!(
1306                            "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
1307                        ),
1308                    };
1309                }
1310                Some(_) => {}
1311            }
1312        }
1313
1314        // Anything in the lockfile but missing from the manifest is stale
1315        // — UNLESS it was auto-hoisted as a peer by the resolver. pnpm-style
1316        // `auto-install-peers=true` puts peers into the importer's
1317        // `dependencies` without the user having written them in
1318        // `package.json`, so we have to recognize those as derived state
1319        // rather than user intent.
1320        //
1321        // Critically, we identify an auto-hoisted entry by matching its
1322        // *recorded specifier* against peer ranges declared in the graph,
1323        // not just by name. A name-only check would silently exempt a
1324        // user-pinned `react` that the user later removed (if any package
1325        // anywhere in the graph peer-declares react, the name match would
1326        // fire and we'd report Fresh forever — defeating the drift check).
1327        //
1328        // The rule: a lockfile entry whose (name, specifier) pair exactly
1329        // matches some package's declared (peer_name, peer_range) is
1330        // auto-hoisted. If the user had pinned react with a different
1331        // specifier string and then removed it, the (name, specifier)
1332        // pair no longer matches any peer range, and drift correctly
1333        // fires so the resolver re-runs and rewrites the lockfile.
1334        let manifest_names: std::collections::HashSet<&str> = manifest
1335            .dependencies
1336            .keys()
1337            .chain(manifest.dev_dependencies.keys())
1338            .chain(
1339                manifest
1340                    .optional_dependencies
1341                    .keys()
1342                    .filter(|name| !ignored.contains(name.as_str())),
1343            )
1344            .map(|s| s.as_str())
1345            .collect();
1346        let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
1347            .packages
1348            .values()
1349            .flat_map(|p| {
1350                p.peer_dependencies
1351                    .iter()
1352                    .map(|(name, range)| (name.as_str(), range.as_str()))
1353            })
1354            .collect();
1355        for (locked_name, locked_spec) in &lockfile_specs {
1356            if manifest_names.contains(locked_name) {
1357                continue;
1358            }
1359            if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
1360                continue;
1361            }
1362            return DriftStatus::Stale {
1363                reason: format!("{label}manifest removed {locked_name}"),
1364            };
1365        }
1366
1367        DriftStatus::Fresh
1368    }
1369}
1370
1371/// Merge `pnpm-workspace.yaml` overrides on top of the manifest's
1372/// `overrides_map()`. Workspace entries win on key conflict, matching
1373/// pnpm v10's behavior where the workspace yaml is the canonical
1374/// home for overrides. Callers pass this into `overrides_drift_reason`
1375/// so the drift check sees the same effective map the resolver used.
1376fn merge_manifest_and_workspace_overrides(
1377    manifest: &aube_manifest::PackageJson,
1378    workspace_overrides: &BTreeMap<String, String>,
1379) -> BTreeMap<String, String> {
1380    let mut out = manifest.overrides_map();
1381    for (k, v) in workspace_overrides {
1382        out.insert(k.clone(), v.clone());
1383    }
1384    out
1385}
1386
1387/// Rewrite `catalog:` / `catalog:<name>` override values to the catalog's
1388/// resolved range. pnpm writes resolved override values into the lockfile
1389/// and compares against the resolved form on re-install, so both sides
1390/// of the drift check have to see the catalog-substituted map — otherwise
1391/// a `"lodash": "catalog:"` workspace-yaml override reads as stale against
1392/// a lockfile that recorded `"lodash": "4.17.21"`. Unresolvable references
1393/// (missing catalog or missing entry) pass through untouched; the caller
1394/// would have errored at resolve time if this ever reached a real install,
1395/// so a drift-mismatch here is fine.
1396fn resolve_catalog_refs_in_overrides(
1397    overrides: &BTreeMap<String, String>,
1398    workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1399) -> BTreeMap<String, String> {
1400    overrides
1401        .iter()
1402        .map(|(k, v)| {
1403            let resolved = v
1404                .strip_prefix("catalog:")
1405                .map(|tail| if tail.is_empty() { "default" } else { tail })
1406                .and_then(|cat_name| workspace_catalogs.get(cat_name))
1407                .and_then(|cat| cat.get(override_key_package_name(k)))
1408                .cloned()
1409                .unwrap_or_else(|| v.clone());
1410            (k.clone(), resolved)
1411        })
1412        .collect()
1413}
1414
1415/// Extract the package name from an override selector key so the catalog
1416/// can be looked up by pkg name. Handles bare (`lodash`), scoped
1417/// (`@babel/core`), ranged (`lodash@<5`), ancestor-chained
1418/// (`parent>lodash`), and combinations. Unparseable keys return the
1419/// input unchanged; the catalog lookup will then miss and leave the
1420/// value as-is.
1421fn override_key_package_name(key: &str) -> &str {
1422    let last = key.rsplit('>').next().unwrap_or(key);
1423    if let Some(after_scope) = last.strip_prefix('@') {
1424        match after_scope.find('@') {
1425            Some(idx) => &last[..idx + 1],
1426            None => last,
1427        }
1428    } else {
1429        match last.find('@') {
1430            Some(idx) => &last[..idx],
1431            None => last,
1432        }
1433    }
1434}
1435
1436/// Compare two override maps and return a human-readable reason
1437/// describing the first difference, or `None` if they're identical.
1438/// Drift messages cite the offending key by name so users can act on
1439/// them — `(lockfile: N entries, manifest: M entries)` is useless
1440/// when N == M but a value changed.
1441fn overrides_drift_reason(
1442    lockfile: &BTreeMap<String, String>,
1443    manifest: &BTreeMap<String, String>,
1444) -> Option<String> {
1445    for (k, v) in manifest {
1446        match lockfile.get(k) {
1447            None => return Some(format!("overrides: manifest adds {k}@{v}")),
1448            Some(locked) if locked != v => {
1449                return Some(format!("overrides: {k} changed ({locked} → {v})"));
1450            }
1451            Some(_) => {}
1452        }
1453    }
1454    for k in lockfile.keys() {
1455        if !manifest.contains_key(k) {
1456            return Some(format!("overrides: manifest removes {k}"));
1457        }
1458    }
1459    None
1460}
1461
1462/// Compare two `ignoredOptionalDependencies` sets and return a drift
1463/// reason string for the first difference, or `None` if identical.
1464fn ignored_optional_drift_reason(
1465    lockfile: &BTreeSet<String>,
1466    manifest: &BTreeSet<String>,
1467) -> Option<String> {
1468    for name in manifest {
1469        if !lockfile.contains(name) {
1470            return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
1471        }
1472    }
1473    for name in lockfile {
1474        if !manifest.contains(name) {
1475            return Some(format!(
1476                "ignoredOptionalDependencies: manifest removes {name}"
1477            ));
1478        }
1479    }
1480    None
1481}
1482
1483/// Result of comparing a lockfile against a manifest.
1484#[derive(Debug, Clone, PartialEq, Eq)]
1485pub enum DriftStatus {
1486    /// The lockfile is in sync with the manifest. Safe to use without re-resolving.
1487    Fresh,
1488    /// The lockfile is out of date. The reason describes the first mismatch found.
1489    Stale { reason: String },
1490}
1491
1492/// Atomic lockfile write. Tempfile in the same dir, fsync, rename
1493/// over the target. Every format writer goes through this so a
1494/// crash or Ctrl+C mid-write cannot leave a truncated lockfile on
1495/// disk. Rename is atomic on POSIX, on Windows MoveFileEx gives
1496/// the same guarantee post Win10. Caller passes the serialized
1497/// bytes already formatted, this just handles the IO layer.
1498pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
1499    aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
1500}
1501
1502/// Write a lockfile to the given project directory using aube's default
1503/// filename (`aube-lock.yaml`, or `aube-lock.<branch>.yaml` when branch
1504/// lockfiles are enabled).
1505pub fn write_lockfile(
1506    project_dir: &Path,
1507    graph: &LockfileGraph,
1508    manifest: &aube_manifest::PackageJson,
1509) -> Result<(), Error> {
1510    write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
1511    Ok(())
1512}
1513
1514/// Write a lockfile using the existing project lockfile kind, or
1515/// Collapse peer-context variants from `graph` into a single map keyed
1516/// by `"name@version"`, pointing at the first-seen package. Several
1517/// writers (npm, yarn, …) share this shape: one canonical entry per
1518/// `(name, version)` pair regardless of how many peer suffixes the
1519/// full graph emits.
1520pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
1521    let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
1522    for pkg in graph.packages.values() {
1523        canonical.entry(pkg.spec_key()).or_insert(pkg);
1524    }
1525    canonical
1526}
1527
1528/// `aube-lock.yaml` when the project does not have one yet.
1529///
1530/// This is the default write path for commands that mutate the active
1531/// project graph (`install`, `add`, `remove`, `update`, `dedupe`, ...).
1532pub fn write_lockfile_preserving_existing(
1533    project_dir: &Path,
1534    graph: &LockfileGraph,
1535    manifest: &aube_manifest::PackageJson,
1536) -> Result<PathBuf, Error> {
1537    let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
1538    write_lockfile_as(project_dir, graph, manifest, kind)
1539}
1540
1541/// Write `graph` in the requested lockfile format into `project_dir`.
1542///
1543/// Returns the path that was actually written (useful for logging
1544/// since `Aube` may resolve to a branch-specific filename). Callers
1545/// that want to preserve whatever format was already on disk should
1546/// pair this with [`detect_existing_lockfile_kind`].
1547///
1548/// All supported formats: `Aube`, `Pnpm`, `Npm`, `NpmShrinkwrap`,
1549/// `Yarn`, and `Bun`. This preserves the lockfile kind that already
1550/// exists in the project; callers should pass `Aube` only when no
1551/// lockfile exists yet. See each writer module's doc comment for
1552/// per-format lossy areas (peer contexts, `resolved` URLs, etc.).
1553pub fn write_lockfile_as(
1554    project_dir: &Path,
1555    graph: &LockfileGraph,
1556    manifest: &aube_manifest::PackageJson,
1557    kind: LockfileKind,
1558) -> Result<PathBuf, Error> {
1559    let filename = match kind {
1560        LockfileKind::Aube => aube_lock_filename(project_dir),
1561        LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
1562        other => other.filename().to_string(),
1563    };
1564    let path = project_dir.join(&filename);
1565    match kind {
1566        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
1567        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
1568        LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
1569        LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
1570        LockfileKind::Bun => bun::write(&path, graph, manifest)?,
1571    }
1572    Ok(path)
1573}
1574
1575/// Return the [`LockfileKind`] of the lockfile already on disk in
1576/// `project_dir`, if any. Follows the same precedence as
1577/// [`parse_lockfile_with_kind`] (aube > pnpm > bun > yarn >
1578/// npm-shrinkwrap > npm). Used by install to preserve a project's
1579/// existing lockfile format when rewriting after a re-resolve — a
1580/// user with only `pnpm-lock.yaml`, `package-lock.json`, or another
1581/// supported lockfile gets that file written back, not a surprise
1582/// `aube-lock.yaml` alongside it.
1583pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
1584    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
1585        if path.exists() {
1586            return Some(refine_yarn_kind(&path, kind));
1587        }
1588    }
1589    None
1590}
1591
1592/// Resolve the canonical lockfile filename for `project_dir` (aube's own).
1593///
1594/// Returns `aube-lock.<branch>.yaml` when `gitBranchLockfile: true` is
1595/// set in `pnpm-workspace.yaml` (or `aube-workspace.yaml`) and the
1596/// project is inside a git checkout with a current branch. Forward
1597/// slashes in the branch name are encoded as `!`, matching pnpm. Falls
1598/// back to plain `aube-lock.yaml` in every other case.
1599///
1600/// Memoized per `project_dir` for the lifetime of the process: a
1601/// single install resolves this 3–5 times (lockfile_candidates,
1602/// write_lockfile, debug log, state read/write), and
1603/// `check_needs_install` runs on every `aube run`/`aube exec` via
1604/// `ensure_installed`. Without caching, every command would pay for a
1605/// YAML parse + a `git branch --show-current` subprocess just to
1606/// recompute a value that can't change mid-process.
1607pub fn aube_lock_filename(project_dir: &Path) -> String {
1608    use std::sync::{Mutex, OnceLock};
1609    static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
1610    let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
1611    if let Ok(map) = cache.lock()
1612        && let Some(hit) = map.get(project_dir)
1613    {
1614        return hit.clone();
1615    }
1616    let resolved = if !git_branch_lockfile_enabled(project_dir) {
1617        "aube-lock.yaml".to_string()
1618    } else {
1619        match current_git_branch(project_dir) {
1620            Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
1621            None => "aube-lock.yaml".to_string(),
1622        }
1623    };
1624    if let Ok(mut map) = cache.lock() {
1625        map.insert(project_dir.to_path_buf(), resolved.clone());
1626    }
1627    resolved
1628}
1629
1630/// Resolve the pnpm lockfile filename for `project_dir`.
1631///
1632/// Mirrors [`aube_lock_filename`] for branch lockfiles, but keeps the
1633/// pnpm filename prefix so projects with an existing `pnpm-lock.yaml`
1634/// keep writing to pnpm's file.
1635pub fn pnpm_lock_filename(project_dir: &Path) -> String {
1636    let aube_name = aube_lock_filename(project_dir);
1637    // `aube_lock_filename` always returns "aube-lock.<rest>", so strip_prefix
1638    // always succeeds. The fallback is purely defensive.
1639    aube_name
1640        .strip_prefix("aube-lock.")
1641        .map(|rest| format!("pnpm-lock.{rest}"))
1642        .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
1643}
1644
1645fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
1646    // Goes through the build-time-generated typed accessor in
1647    // `aube_settings::resolved` so the alias list is driven off
1648    // `settings.toml` — no hand-maintained typed field. This path
1649    // reads only `pnpm-workspace.yaml`; `.npmrc` values are out of
1650    // scope here because aube-lockfile doesn't want a dependency on
1651    // aube-registry just to load npmrc (and the historical behavior
1652    // never read `.npmrc` either).
1653    let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
1654        return false;
1655    };
1656    let npmrc: Vec<(String, String)> = Vec::new();
1657    let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
1658    aube_settings::resolved::git_branch_lockfile(&ctx)
1659}
1660
1661pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
1662    let out = std::process::Command::new("git")
1663        .args(["-C"])
1664        .arg(project_dir)
1665        .args(["branch", "--show-current"])
1666        .output()
1667        .ok()?;
1668    if !out.status.success() {
1669        return None;
1670    }
1671    let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
1672    if branch.is_empty() {
1673        None
1674    } else {
1675        Some(branch)
1676    }
1677}
1678
1679/// Detect and parse the lockfile in the given project directory.
1680///
1681/// Priority: `aube-lock.yaml` → `pnpm-lock.yaml` → `bun.lock` →
1682/// `yarn.lock` → `npm-shrinkwrap.json` → `package-lock.json`.
1683/// (Shrinkwrap takes priority over package-lock.json when both exist, matching npm's behavior.)
1684///
1685/// `manifest` is needed to classify direct vs transitive deps when
1686/// reading yarn.lock (which has no notion of that distinction).
1687pub fn parse_lockfile(
1688    project_dir: &Path,
1689    manifest: &aube_manifest::PackageJson,
1690) -> Result<LockfileGraph, Error> {
1691    let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
1692    Ok(graph)
1693}
1694
1695/// Like [`parse_lockfile`] but also returns which format was read.
1696pub fn parse_lockfile_with_kind(
1697    project_dir: &Path,
1698    manifest: &aube_manifest::PackageJson,
1699) -> Result<(LockfileGraph, LockfileKind), Error> {
1700    reject_bun_binary(project_dir)?;
1701    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
1702        if !path.exists() {
1703            continue;
1704        }
1705        let kind = refine_yarn_kind(&path, kind);
1706        let graph = parse_one(&path, kind, manifest)?;
1707        return Ok((graph, kind));
1708    }
1709    Err(Error::NotFound(project_dir.to_path_buf()))
1710}
1711
1712/// Variant of [`parse_lockfile_with_kind`] used by `aube import`.
1713///
1714/// Skips `aube-lock.yaml` — if the project already has one, there's
1715/// nothing to import. `pnpm-lock.yaml` *is* included because the whole
1716/// point of `aube import` is to convert a foreign lockfile (including
1717/// pnpm's) into `aube-lock.yaml`.
1718pub fn parse_for_import(
1719    project_dir: &Path,
1720    manifest: &aube_manifest::PackageJson,
1721) -> Result<(LockfileGraph, LockfileKind), Error> {
1722    reject_bun_binary(project_dir)?;
1723    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ false) {
1724        if !path.exists() {
1725            continue;
1726        }
1727        let kind = refine_yarn_kind(&path, kind);
1728        let graph = parse_one(&path, kind, manifest)?;
1729        return Ok((graph, kind));
1730    }
1731    Err(Error::NotFound(project_dir.to_path_buf()))
1732}
1733
1734/// If only `bun.lockb` is present (without a text `bun.lock`), surface an
1735/// actionable error instead of silently falling through to another format.
1736fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
1737    let lockb = project_dir.join("bun.lockb");
1738    let text = project_dir.join("bun.lock");
1739    if lockb.exists() && !text.exists() {
1740        return Err(Error::parse(
1741            &lockb,
1742            "bun.lockb (binary format) is not supported — run `bun install --save-text-lockfile` to generate a bun.lock text file first, or upgrade to bun 1.2+ where text is the default",
1743        ));
1744    }
1745    Ok(())
1746}
1747
1748fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
1749    let mut out = Vec::new();
1750    if include_aube {
1751        // Prefer the branch-specific lockfile (if `gitBranchLockfile` is on
1752        // and we resolve a branch); fall through to plain `aube-lock.yaml`
1753        // so a freshly-enabled branch still picks up the base lockfile.
1754        let branch_name = aube_lock_filename(project_dir);
1755        if branch_name != "aube-lock.yaml" {
1756            out.push((project_dir.join(&branch_name), LockfileKind::Aube));
1757        }
1758        out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
1759    }
1760    // Preserve pnpm lockfiles in place. Branch-specific
1761    // `pnpm-lock.<branch>.yaml` mirrors the aube branch lockfile naming
1762    // logic, so a project that already uses pnpm branch lockfiles keeps
1763    // writing through that file.
1764    let pnpm_branch = {
1765        let mut s = aube_lock_filename(project_dir);
1766        if let Some(rest) = s.strip_prefix("aube-lock.") {
1767            s = format!("pnpm-lock.{rest}");
1768        }
1769        s
1770    };
1771    if pnpm_branch != "pnpm-lock.yaml" {
1772        out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
1773    }
1774    out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
1775    out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
1776    out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
1777    out.push((
1778        project_dir.join("npm-shrinkwrap.json"),
1779        LockfileKind::NpmShrinkwrap,
1780    ));
1781    out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
1782    out
1783}
1784
1785fn parse_one(
1786    path: &Path,
1787    kind: LockfileKind,
1788    manifest: &aube_manifest::PackageJson,
1789) -> Result<LockfileGraph, Error> {
1790    match kind {
1791        // `aube-lock.yaml` uses the same on-disk format as pnpm v9 for
1792        // now — same parser, same writer — so we piggyback on the pnpm
1793        // module. Keeping the variant distinct lets detection/import
1794        // treat the two differently even though the bytes are the same.
1795        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
1796        // yarn.rs::parse peeks the file for `__metadata:` and
1797        // dispatches between classic (v1) and berry (v2+) internally,
1798        // so we can hand both kinds to the same entry point. The
1799        // caller keeps the kind label it resolved from
1800        // `refine_yarn_kind` for downstream write-back.
1801        LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
1802        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
1803        LockfileKind::Bun => bun::parse(path),
1804    }
1805}
1806
1807/// Replace `LockfileKind::Yarn` with `LockfileKind::YarnBerry` when
1808/// the yarn.lock at `path` is actually a yarn 2+ lockfile. Other
1809/// kinds pass through unchanged.
1810///
1811/// `lockfile_candidates` only knows filenames, not content, so the
1812/// yarn entry is always tagged `Yarn`. Callers that need the precise
1813/// variant (install write-back, import conversions, drift logging)
1814/// funnel through this helper after confirming the candidate exists.
1815fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
1816    if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
1817        LockfileKind::YarnBerry
1818    } else {
1819        kind
1820    }
1821}
1822
1823#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1824pub enum Error {
1825    #[error("no lockfile found in {0}")]
1826    NotFound(std::path::PathBuf),
1827    #[error("unsupported lockfile format: {0}")]
1828    UnsupportedFormat(String),
1829    #[error("failed to read lockfile {0}: {1}")]
1830    Io(std::path::PathBuf, std::io::Error),
1831    /// Structural/serialization lockfile errors that have no source
1832    /// location — shape checks (`must be a mapping`), version guards
1833    /// (`lockfileVersion N unsupported`), and `yaml_serde::to_string`
1834    /// failures during write.
1835    #[error("failed to parse lockfile {0}: {1}")]
1836    Parse(std::path::PathBuf, String),
1837    /// Deserialization failure with a byte offset into the source
1838    /// content, so miette's `fancy` handler can draw a pointer at the
1839    /// offending byte of the lockfile. Reuses `aube_manifest`'s
1840    /// `ParseError` — identical shape, identical rendering — via the
1841    /// same `ParseDiag` pattern `aube-workspace` uses.
1842    #[error(transparent)]
1843    #[diagnostic(transparent)]
1844    ParseDiag(Box<aube_manifest::ParseError>),
1845}
1846
1847/// Read a lockfile from disk, mapping I/O errors to `Error::Io`.
1848pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
1849    std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
1850}
1851
1852/// Parse a JSON lockfile document, attaching a miette source span on
1853/// failure so the fancy handler can point at the offending byte.
1854pub fn parse_json<T: serde::de::DeserializeOwned>(
1855    path: &std::path::Path,
1856    content: String,
1857) -> Result<T, Error> {
1858    let mut buf = content.clone().into_bytes();
1859    match simd_json::serde::from_slice(&mut buf) {
1860        Ok(v) => Ok(v),
1861        Err(_) => match serde_json::from_str(&content) {
1862            Ok(v) => Ok(v),
1863            Err(e) => Err(Error::parse_json_err(path, content, &e)),
1864        },
1865    }
1866}
1867
1868impl Error {
1869    pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
1870        Error::Parse(path.to_path_buf(), msg.into())
1871    }
1872
1873    pub fn parse_json_err(
1874        path: &std::path::Path,
1875        content: String,
1876        err: &serde_json::Error,
1877    ) -> Self {
1878        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
1879            path, content, err,
1880        )))
1881    }
1882
1883    pub fn parse_yaml_err(
1884        path: &std::path::Path,
1885        content: String,
1886        err: &yaml_serde::Error,
1887    ) -> Self {
1888        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
1889            path, content, err,
1890        )))
1891    }
1892}
1893
1894#[cfg(test)]
1895mod parse_diag_tests {
1896    use super::*;
1897    use std::path::Path;
1898
1899    /// Trailing `,` in an otherwise fine JSON lockfile — confirm the
1900    /// helper attaches a `NamedSource` pointed at the lockfile path and
1901    /// the span stays in bounds so miette can render a pointer.
1902    #[test]
1903    fn parse_json_attaches_span_for_bad_input() {
1904        let path = Path::new("package-lock.json");
1905        let content = r#"{"name":"x","#.to_string();
1906        let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
1907        else {
1908            panic!("parse_json must produce ParseDiag on malformed input");
1909        };
1910        let offset: usize = pe.span.offset();
1911        let len: usize = pe.span.len();
1912        assert!(offset + len <= content.len());
1913        assert_eq!(pe.path, path);
1914    }
1915
1916    /// Same story for YAML — yaml_serde reports a `Location` with a
1917    /// byte index directly, so no line/col conversion is exercised
1918    /// here. Both production sites (`pnpm.rs`, `yarn.rs`) call
1919    /// `Error::parse_yaml_err` directly (one iterates multiple YAML
1920    /// documents, the other has only borrowed content), so that's the
1921    /// entry point this test locks down.
1922    #[test]
1923    fn parse_yaml_err_attaches_span_for_bad_input() {
1924        let path = Path::new("yarn.lock");
1925        let content = "packages:\n\t- pkg\n".to_string();
1926        let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
1927            .expect_err("tab-indented YAML must fail");
1928        let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
1929            panic!("parse_yaml_err must produce ParseDiag");
1930        };
1931        let offset: usize = pe.span.offset();
1932        let len: usize = pe.span.len();
1933        assert!(offset + len <= content.len());
1934        assert_eq!(pe.path, path);
1935    }
1936}
1937
1938#[cfg(test)]
1939mod looks_like_remote_tarball_url_tests {
1940    use super::*;
1941
1942    #[test]
1943    fn matches_https_tgz() {
1944        assert!(LocalSource::looks_like_remote_tarball_url(
1945            "https://example.com/pkg-1.0.0.tgz"
1946        ));
1947    }
1948
1949    #[test]
1950    fn matches_http_tar_gz() {
1951        assert!(LocalSource::looks_like_remote_tarball_url(
1952            "http://example.com/pkg-1.0.0.tar.gz"
1953        ));
1954    }
1955
1956    #[test]
1957    fn strips_fragment_before_suffix_check() {
1958        assert!(LocalSource::looks_like_remote_tarball_url(
1959            "https://example.com/pkg-1.0.0.tgz#sha512-abc"
1960        ));
1961    }
1962
1963    #[test]
1964    fn strips_query_string_before_suffix_check() {
1965        // Auth-token URLs from private registries (JFrog, Nexus,
1966        // CodeArtifact, …) routinely trail `?token=…` after the
1967        // filename. Must still classify as a tarball URL.
1968        assert!(LocalSource::looks_like_remote_tarball_url(
1969            "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
1970        ));
1971        assert!(LocalSource::looks_like_remote_tarball_url(
1972            "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
1973        ));
1974    }
1975
1976    #[test]
1977    fn matches_bare_http_url_without_tarball_suffix() {
1978        // pkg.pr.new serves tarballs from URLs without a `.tgz`
1979        // extension; npm treats all non-git http(s) URLs as tarball
1980        // URLs, so these must classify as remote tarballs.
1981        assert!(LocalSource::looks_like_remote_tarball_url(
1982            "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
1983        ));
1984        assert!(LocalSource::looks_like_remote_tarball_url(
1985            "https://codeload.github.com/user/repo/tar.gz/main"
1986        ));
1987    }
1988
1989    #[test]
1990    fn rejects_non_http_schemes() {
1991        assert!(!LocalSource::looks_like_remote_tarball_url(
1992            "ftp://example.com/pkg.tgz"
1993        ));
1994        assert!(!LocalSource::looks_like_remote_tarball_url(
1995            "git://example.com/repo.git"
1996        ));
1997    }
1998
1999    #[test]
2000    fn parse_classifies_bare_http_url_as_remote_tarball() {
2001        use std::path::Path;
2002        let parsed = LocalSource::parse(
2003            "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
2004            Path::new(""),
2005        );
2006        assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
2007    }
2008
2009    #[test]
2010    fn parse_prefers_git_over_tarball_for_dot_git_url() {
2011        use std::path::Path;
2012        let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
2013        assert!(matches!(parsed, Some(LocalSource::Git(_))));
2014    }
2015}
2016
2017#[cfg(test)]
2018mod filename_tests {
2019    use super::*;
2020
2021    #[test]
2022    fn defaults_to_plain_lockfile_when_setting_absent() {
2023        let dir = tempfile::tempdir().unwrap();
2024        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2025        assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
2026    }
2027
2028    #[test]
2029    fn defaults_to_plain_lockfile_when_setting_explicit_false() {
2030        let dir = tempfile::tempdir().unwrap();
2031        std::fs::write(
2032            dir.path().join("pnpm-workspace.yaml"),
2033            "gitBranchLockfile: false\n",
2034        )
2035        .unwrap();
2036        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2037    }
2038
2039    #[test]
2040    fn uses_branch_filename_when_enabled_inside_git_repo() {
2041        let dir = tempfile::tempdir().unwrap();
2042        std::fs::write(
2043            dir.path().join("pnpm-workspace.yaml"),
2044            "gitBranchLockfile: true\n",
2045        )
2046        .unwrap();
2047        // git init + checkout a branch with a `/` so we exercise the
2048        // pnpm-style `!` encoding.
2049        let run = |args: &[&str]| {
2050            std::process::Command::new("git")
2051                .args(["-C"])
2052                .arg(dir.path())
2053                .args(args)
2054                .output()
2055                .unwrap()
2056        };
2057        if run(&["init", "-q"]).status.success() {
2058            run(&["checkout", "-q", "-b", "feature/x"]);
2059            assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
2060            assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
2061        }
2062    }
2063}
2064
2065#[cfg(test)]
2066mod git_spec_tests {
2067    use super::*;
2068
2069    #[test]
2070    fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
2071        // Initial parse: `git+https://…/repo` (no `.git`).
2072        let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
2073        assert_eq!(url, "https://host/user/repo");
2074        assert_eq!(committish, None);
2075        assert_eq!(subpath, None);
2076
2077        // After resolving, the serializer writes `<url>#<sha>` into
2078        // the lockfile's importer `version:` field.
2079        let sha = "abcdef0123456789abcdef0123456789abcdef01";
2080        let source = LocalSource::Git(GitSource {
2081            url: url.clone(),
2082            committish: None,
2083            resolved: sha.to_string(),
2084            subpath: None,
2085        });
2086        let lockfile_version = source.specifier();
2087        assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
2088
2089        // Re-parse must recognize the bare URL because the 40-hex
2090        // committish suffix unambiguously tags it as git.
2091        let (round_url, round_committish, round_subpath) =
2092            parse_git_spec(&lockfile_version).unwrap();
2093        assert_eq!(round_url, "https://host/user/repo");
2094        assert_eq!(round_committish.as_deref(), Some(sha));
2095        assert_eq!(round_subpath, None);
2096    }
2097
2098    #[test]
2099    fn bare_https_without_dot_git_and_no_committish_is_not_git() {
2100        // A plain `https://…` URL with no `.git` and no SHA could be
2101        // anything (including a tarball); don't claim it.
2102        assert!(parse_git_spec("https://example.com/pkg").is_none());
2103    }
2104
2105    #[test]
2106    fn github_shorthand_expands_and_roundtrips() {
2107        let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
2108        assert_eq!(url, "https://github.com/user/repo.git");
2109    }
2110
2111    #[test]
2112    fn scp_form_recognized() {
2113        let (url, committish, _) =
2114            parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
2115        assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2116        assert!(committish.is_none());
2117    }
2118
2119    #[test]
2120    fn scp_form_with_ref_recognized() {
2121        let (url, committish, _) =
2122            parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
2123        assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2124        assert_eq!(committish.as_deref(), Some("0.1.5"));
2125    }
2126
2127    #[test]
2128    fn scp_form_bitbucket_recognized() {
2129        let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
2130        assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
2131    }
2132
2133    #[test]
2134    fn scp_form_unknown_host_rejected() {
2135        // pnpm 11 treats `user@unknown-host:path` as a local path, not Git.
2136        assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
2137        assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
2138    }
2139
2140    #[test]
2141    fn scp_form_without_user_rejected() {
2142        // pnpm 11 errors on bare `host:path` as unsupported.
2143        assert!(parse_git_spec("github.com:user/repo.git").is_none());
2144    }
2145
2146    #[test]
2147    fn commit_selector_fragment_normalizes_to_sha() {
2148        let sha = "abcdef0123456789abcdef0123456789abcdef01";
2149        let (url, committish, _) =
2150            parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
2151        assert_eq!(url, "https://host/user/repo.git");
2152        assert_eq!(committish.as_deref(), Some(sha));
2153    }
2154
2155    #[test]
2156    fn named_selector_fragment_normalizes_to_ref() {
2157        let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
2158        assert_eq!(url, "https://host/user/repo");
2159        assert_eq!(committish.as_deref(), Some("v1.2.3"));
2160    }
2161
2162    #[test]
2163    fn pnpm_path_subpath_extracted_from_fragment() {
2164        // pnpm syntax: `<url>#<ref>&path:/<subdir>` selects a
2165        // subdirectory of the cloned repo as the package root.
2166        let (url, committish, subpath) =
2167            parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
2168        assert_eq!(url, "https://github.com/org/dep.git");
2169        assert_eq!(committish.as_deref(), Some("v0.1.4"));
2170        assert_eq!(subpath.as_deref(), Some("packages/special"));
2171    }
2172
2173    #[test]
2174    fn path_subpath_roundtrips_via_specifier() {
2175        let sha = "abcdef0123456789abcdef0123456789abcdef01";
2176        let source = LocalSource::Git(GitSource {
2177            url: "https://github.com/org/dep.git".to_string(),
2178            committish: None,
2179            resolved: sha.to_string(),
2180            subpath: Some("packages/special".to_string()),
2181        });
2182        let spec = source.specifier();
2183        assert_eq!(
2184            spec,
2185            format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
2186        );
2187        let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
2188        assert_eq!(url, "https://github.com/org/dep.git");
2189        assert_eq!(committish.as_deref(), Some(sha));
2190        assert_eq!(subpath.as_deref(), Some("packages/special"));
2191    }
2192
2193    #[test]
2194    fn path_traversal_components_in_subpath_are_rejected() {
2195        // `..` and `.` components would let a crafted spec escape the
2196        // clone dir at install time. The parser drops them so the
2197        // resolver/installer never see a traversal-laden subpath.
2198        let cases = [
2199            "github:org/dep#main&path:/../../etc",
2200            "github:org/dep#main&path:/packages/../../../etc",
2201            "github:org/dep#main&path:/./packages/foo",
2202            "github:org/dep#main&path:/packages//foo",
2203        ];
2204        for spec in cases {
2205            let (_, _, subpath) = parse_git_spec(spec).unwrap();
2206            assert_eq!(subpath, None, "spec should drop subpath: {spec}");
2207        }
2208    }
2209
2210    #[test]
2211    fn dep_path_distinguishes_subpaths_under_same_commit() {
2212        // Two packages from the same repo+commit but different
2213        // subdirs must hash to distinct dep_paths so the linker
2214        // doesn't collapse them.
2215        let sha = "abcdef0123456789abcdef0123456789abcdef01";
2216        let a = LocalSource::Git(GitSource {
2217            url: "https://example.com/r.git".to_string(),
2218            committish: None,
2219            resolved: sha.to_string(),
2220            subpath: Some("packages/a".to_string()),
2221        });
2222        let b = LocalSource::Git(GitSource {
2223            url: "https://example.com/r.git".to_string(),
2224            committish: None,
2225            resolved: sha.to_string(),
2226            subpath: Some("packages/b".to_string()),
2227        });
2228        assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
2229    }
2230}
2231
2232#[cfg(test)]
2233mod drift_tests {
2234    use super::*;
2235    use aube_manifest::PackageJson;
2236    use std::collections::BTreeMap;
2237
2238    fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
2239        let mut m = PackageJson {
2240            name: Some("test".into()),
2241            version: Some("1.0.0".into()),
2242            dependencies: BTreeMap::new(),
2243            dev_dependencies: BTreeMap::new(),
2244            peer_dependencies: BTreeMap::new(),
2245            optional_dependencies: BTreeMap::new(),
2246            update_config: None,
2247            scripts: BTreeMap::new(),
2248            engines: BTreeMap::new(),
2249            workspaces: None,
2250            bundled_dependencies: None,
2251            extra: BTreeMap::new(),
2252        };
2253        for (name, spec) in deps {
2254            m.dependencies.insert((*name).into(), (*spec).into());
2255        }
2256        m
2257    }
2258
2259    fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
2260        // (name, specifier, dep_path)
2261        let direct: Vec<DirectDep> = deps
2262            .iter()
2263            .map(|(name, spec, dep_path)| DirectDep {
2264                name: (*name).into(),
2265                dep_path: (*dep_path).into(),
2266                dep_type: DepType::Production,
2267                specifier: Some((*spec).into()),
2268            })
2269            .collect();
2270        let mut importers = BTreeMap::new();
2271        importers.insert(".".to_string(), direct);
2272        LockfileGraph {
2273            importers,
2274            packages: BTreeMap::new(),
2275            ..Default::default()
2276        }
2277    }
2278
2279    #[test]
2280    fn fresh_when_specifiers_match() {
2281        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2282        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2283        assert_eq!(
2284            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2285            DriftStatus::Fresh
2286        );
2287    }
2288
2289    #[test]
2290    fn stale_when_specifier_changes() {
2291        let manifest = make_manifest(&[("lodash", "^4.18.0")]);
2292        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2293        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2294            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2295            DriftStatus::Fresh => panic!("expected Stale"),
2296        }
2297    }
2298
2299    #[test]
2300    fn stale_when_manifest_adds_dep() {
2301        let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
2302        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2303        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2304            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2305            DriftStatus::Fresh => panic!("expected Stale"),
2306        }
2307    }
2308
2309    #[test]
2310    fn stale_when_manifest_removes_dep() {
2311        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2312        let graph = make_graph(&[
2313            ("lodash", "^4.17.0", "lodash@4.17.21"),
2314            ("express", "^4.18.0", "express@4.18.0"),
2315        ]);
2316        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2317            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2318            DriftStatus::Fresh => panic!("expected Stale"),
2319        }
2320    }
2321
2322    // Regression guard for #42: the drift check must recognize
2323    // auto-hoisted peers as derived state, not as "manifest removed X".
2324    // Without this, every project that has any peer dep would trigger
2325    // a full re-resolve on every install, defeating lockfile caching.
2326    #[test]
2327    fn fresh_when_lockfile_has_auto_hoisted_peer() {
2328        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2329        let mut graph = make_graph(&[
2330            (
2331                "use-sync-external-store",
2332                "1.2.0",
2333                "use-sync-external-store@1.2.0",
2334            ),
2335            // Hoisted peer — in the lockfile importers but not in the
2336            // user's package.json.
2337            ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
2338        ]);
2339        // The declaring package must list react as a peer for the
2340        // drift check to recognize the hoist. We add that here.
2341        let mut declaring_pkg = LockedPackage {
2342            name: "use-sync-external-store".into(),
2343            version: "1.2.0".into(),
2344            dep_path: "use-sync-external-store@1.2.0".into(),
2345            ..Default::default()
2346        };
2347        declaring_pkg
2348            .peer_dependencies
2349            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2350        graph
2351            .packages
2352            .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
2353
2354        assert_eq!(
2355            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2356            DriftStatus::Fresh
2357        );
2358    }
2359
2360    // Regression: when a user explicitly pinned a dep that also happens
2361    // to share its name with a peer declaration elsewhere in the graph,
2362    // removing that pin from package.json must still be flagged as
2363    // stale — otherwise the old pinned version gets locked forever.
2364    // The check must key on (name, specifier), not name alone.
2365    #[test]
2366    fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
2367        // Manifest after the user removed react entirely. Only
2368        // use-sync-external-store remains.
2369        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2370
2371        // Lockfile still has the user's old `react: 17.0.2` pin alongside
2372        // use-sync-external-store. Pre-removal state.
2373        let mut graph = make_graph(&[
2374            (
2375                "use-sync-external-store",
2376                "1.2.0",
2377                "use-sync-external-store@1.2.0",
2378            ),
2379            ("react", "17.0.2", "react@17.0.2"),
2380        ]);
2381        // Add the peer declaration on the consumer package. This is
2382        // the case that previously defeated the name-only check:
2383        // react's specifier "17.0.2" doesn't match the declared peer
2384        // range, so the hoist recognizer must reject it.
2385        let mut consumer = LockedPackage {
2386            name: "use-sync-external-store".into(),
2387            version: "1.2.0".into(),
2388            dep_path: "use-sync-external-store@1.2.0".into(),
2389            ..Default::default()
2390        };
2391        consumer
2392            .peer_dependencies
2393            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2394        graph
2395            .packages
2396            .insert("use-sync-external-store@1.2.0".into(), consumer);
2397
2398        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2399            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2400            DriftStatus::Fresh => panic!(
2401                "drift check should flag a removed user-pinned dep as stale, \
2402                 even when its name matches a peer declaration"
2403            ),
2404        }
2405    }
2406
2407    // But if the lockfile has a user-removed dep that ISN'T declared as a
2408    // peer anywhere, we still need to flag it as stale.
2409    #[test]
2410    fn stale_when_lockfile_has_removed_non_peer_dep() {
2411        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2412        let graph = make_graph(&[
2413            ("lodash", "^4.17.0", "lodash@4.17.21"),
2414            ("chalk", "^5.0.0", "chalk@5.0.0"),
2415        ]);
2416        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2417            DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
2418            DriftStatus::Fresh => panic!("expected Stale"),
2419        }
2420    }
2421
2422    #[test]
2423    fn fresh_when_no_specifiers_recorded() {
2424        // Non-pnpm formats (npm/yarn/bun) don't store specifiers, so we can't
2425        // detect drift — we treat them as fresh and let the resolver decide.
2426        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2427        let graph = LockfileGraph {
2428            importers: {
2429                let mut m = BTreeMap::new();
2430                m.insert(
2431                    ".".to_string(),
2432                    vec![DirectDep {
2433                        name: "lodash".into(),
2434                        dep_path: "lodash@4.17.21".into(),
2435                        dep_type: DepType::Production,
2436                        specifier: None,
2437                    }],
2438                );
2439                m
2440            },
2441            packages: BTreeMap::new(),
2442            ..Default::default()
2443        };
2444        assert_eq!(
2445            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2446            DriftStatus::Fresh
2447        );
2448    }
2449
2450    #[test]
2451    fn stale_when_manifest_adds_override() {
2452        // Lockfile recorded no overrides; manifest now has one. Drift
2453        // must fire so the next install re-runs the resolver and bakes
2454        // the override into the graph.
2455        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2456        manifest
2457            .extra
2458            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2459        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2460        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2461            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
2462            DriftStatus::Fresh => panic!("expected Stale"),
2463        }
2464    }
2465
2466    #[test]
2467    fn stale_drift_message_names_changed_override_key() {
2468        // Both sides have one entry, but the value differs. The reason
2469        // should name the key — the previous "lockfile: 1 entries,
2470        // manifest: 1 entries" message looked like nothing changed.
2471        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2472        manifest
2473            .extra
2474            .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
2475        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2476        graph.overrides.insert("lodash".into(), "4.17.21".into());
2477        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2478            DriftStatus::Stale { reason } => {
2479                assert!(reason.contains("lodash"), "expected key in: {reason}");
2480                assert!(
2481                    reason.contains("4.17.21"),
2482                    "expected old value in: {reason}"
2483                );
2484                assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
2485            }
2486            DriftStatus::Fresh => panic!("expected Stale"),
2487        }
2488    }
2489
2490    #[test]
2491    fn stale_when_manifest_removes_override() {
2492        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2493        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2494        graph.overrides.insert("lodash".into(), "4.17.21".into());
2495        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2496            DriftStatus::Stale { reason } => {
2497                assert!(reason.contains("removes"));
2498                assert!(reason.contains("lodash"));
2499            }
2500            DriftStatus::Fresh => panic!("expected Stale"),
2501        }
2502    }
2503
2504    #[test]
2505    fn fresh_when_overrides_match() {
2506        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2507        manifest
2508            .extra
2509            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2510        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2511        graph.overrides.insert("lodash".into(), "4.17.21".into());
2512        assert_eq!(
2513            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2514            DriftStatus::Fresh
2515        );
2516    }
2517
2518    #[test]
2519    fn fresh_when_workspace_yaml_overrides_match_lockfile() {
2520        // pnpm v10 moved `overrides` to pnpm-workspace.yaml. When the
2521        // resolver wrote them into `self.overrides`, the drift check
2522        // must see the same map — otherwise the second install run
2523        // rejects the lockfile as stale with "manifest removes ..."
2524        // (reported in discussion #174).
2525        let manifest = make_manifest(&[("semver", "^7.5.0")]);
2526        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2527        graph.overrides.insert("semver".into(), "7.7.1".into());
2528        let mut ws_overrides = BTreeMap::new();
2529        ws_overrides.insert("semver".into(), "7.7.1".into());
2530        assert_eq!(
2531            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2532            DriftStatus::Fresh,
2533        );
2534    }
2535
2536    #[test]
2537    fn workspace_yaml_overrides_win_over_package_json() {
2538        // When both pnpm-workspace.yaml and package.json declare an
2539        // override for the same key, the workspace yaml wins — pnpm
2540        // v10's precedence. The drift check must apply the merged
2541        // effective map.
2542        let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
2543        manifest
2544            .extra
2545            .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
2546        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2547        graph.overrides.insert("semver".into(), "7.7.1".into());
2548        let mut ws_overrides = BTreeMap::new();
2549        ws_overrides.insert("semver".into(), "7.7.1".into());
2550        assert_eq!(
2551            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2552            DriftStatus::Fresh,
2553        );
2554    }
2555
2556    #[test]
2557    fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
2558        // pnpm-workspace.yaml: `overrides: { lodash: "catalog:" }` with
2559        // `catalog: { lodash: 4.17.21 }`. pnpm writes the lockfile with
2560        // the resolved override value (`lodash: 4.17.21`), so a frozen
2561        // install comparing the raw `catalog:` string against the
2562        // resolved form would always read stale (discussion #174).
2563        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2564        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2565        graph.overrides.insert("lodash".into(), "4.17.21".into());
2566        let mut ws_overrides = BTreeMap::new();
2567        ws_overrides.insert("lodash".into(), "catalog:".into());
2568        let mut catalogs = BTreeMap::new();
2569        let mut default_cat = BTreeMap::new();
2570        default_cat.insert("lodash".into(), "4.17.21".into());
2571        catalogs.insert("default".into(), default_cat);
2572        assert_eq!(
2573            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
2574            DriftStatus::Fresh,
2575        );
2576    }
2577
2578    #[test]
2579    fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
2580        // Named catalog variant: `overrides: { lodash: "catalog:evens" }`
2581        // resolves against `catalogs.evens.lodash`.
2582        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2583        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2584        graph.overrides.insert("lodash".into(), "4.17.21".into());
2585        let mut ws_overrides = BTreeMap::new();
2586        ws_overrides.insert("lodash".into(), "catalog:evens".into());
2587        let mut catalogs = BTreeMap::new();
2588        let mut evens = BTreeMap::new();
2589        evens.insert("lodash".into(), "4.17.21".into());
2590        catalogs.insert("evens".into(), evens);
2591        assert_eq!(
2592            graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
2593            DriftStatus::Fresh,
2594        );
2595    }
2596
2597    #[test]
2598    fn stale_when_override_catalog_ref_diverges_from_lockfile() {
2599        // If the catalog moves to a new version, the resolved override
2600        // no longer matches the lockfile — drift must fire, not silently
2601        // accept.
2602        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2603        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2604        graph.overrides.insert("lodash".into(), "4.17.21".into());
2605        let mut ws_overrides = BTreeMap::new();
2606        ws_overrides.insert("lodash".into(), "catalog:".into());
2607        let mut catalogs = BTreeMap::new();
2608        let mut default_cat = BTreeMap::new();
2609        default_cat.insert("lodash".into(), "4.17.22".into());
2610        catalogs.insert("default".into(), default_cat);
2611        match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
2612            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2613            other => panic!("expected stale, got {other:?}"),
2614        }
2615    }
2616
2617    #[test]
2618    fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
2619        // pnpm rewrites the importer `specifier:` to the post-override
2620        // value when a bare-name override applies, so a pnpm-generated
2621        // lockfile records `specifier: 4.17.21` even though
2622        // `package.json` still reads `^4.17.0`. Without override-aware
2623        // drift, every frozen install against a pnpm lockfile with
2624        // overrides reads stale (discussion #174).
2625        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2626        let mut importers = BTreeMap::new();
2627        importers.insert(
2628            ".".to_string(),
2629            vec![DirectDep {
2630                name: "lodash".into(),
2631                dep_path: "lodash@4.17.21".into(),
2632                dep_type: DepType::Production,
2633                specifier: Some("4.17.21".into()),
2634            }],
2635        );
2636        let mut graph = LockfileGraph {
2637            importers,
2638            ..Default::default()
2639        };
2640        graph.overrides.insert("lodash".into(), "4.17.21".into());
2641        let mut ws_overrides = BTreeMap::new();
2642        ws_overrides.insert("lodash".into(), "4.17.21".into());
2643        assert_eq!(
2644            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2645            DriftStatus::Fresh,
2646        );
2647    }
2648
2649    #[test]
2650    fn fresh_when_version_keyed_override_rewrites_importer_spec() {
2651        // Discussion #352: an override keyed by name+range
2652        // (`plist@<3.0.5` → `>=3.0.5`) rewrites the importer specifier
2653        // the same way bare-name overrides do. The drift check has to
2654        // parse the key and compare-by-rule, not by raw map lookup,
2655        // otherwise pnpm-written lockfiles read stale on every frozen
2656        // install when version-conditional overrides are in play.
2657        let manifest = make_manifest(&[("plist", "^3.0.4")]);
2658        let mut importers = BTreeMap::new();
2659        importers.insert(
2660            ".".to_string(),
2661            vec![DirectDep {
2662                name: "plist".into(),
2663                dep_path: "plist@3.0.6".into(),
2664                dep_type: DepType::Production,
2665                specifier: Some(">=3.0.5".into()),
2666            }],
2667        );
2668        let mut graph = LockfileGraph {
2669            importers,
2670            ..Default::default()
2671        };
2672        graph
2673            .overrides
2674            .insert("plist@<3.0.5".into(), ">=3.0.5".into());
2675        let mut ws_overrides = BTreeMap::new();
2676        ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
2677        assert_eq!(
2678            graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2679            DriftStatus::Fresh,
2680        );
2681    }
2682
2683    #[test]
2684    fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
2685        // Same drift-shaped bug as overrides: the resolver unions
2686        // `ignoredOptionalDependencies` from package.json and
2687        // pnpm-workspace.yaml, so the lockfile's
2688        // `ignored_optional_dependencies` carries the union, and the
2689        // drift check has to see the same union or the next
2690        // `--frozen-lockfile` run fails with "manifest removes".
2691        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2692        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2693        graph
2694            .ignored_optional_dependencies
2695            .insert("fsevents".to_string());
2696        let ws_ignored = vec!["fsevents".to_string()];
2697        assert_eq!(
2698            graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
2699            DriftStatus::Fresh,
2700        );
2701    }
2702
2703    #[test]
2704    fn fresh_when_optional_dep_was_recorded_as_skipped() {
2705        // Regression: a platform-skipped optional dep would otherwise
2706        // loop forever as "manifest adds X". When the previous
2707        // resolve recorded it under skipped_optional_dependencies with
2708        // a matching specifier, drift must report Fresh.
2709        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2710        manifest
2711            .optional_dependencies
2712            .insert("fsevents".into(), "^2.3.0".into());
2713        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2714        let mut inner = BTreeMap::new();
2715        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
2716        graph
2717            .skipped_optional_dependencies
2718            .insert(".".to_string(), inner);
2719        assert_eq!(
2720            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2721            DriftStatus::Fresh
2722        );
2723    }
2724
2725    #[test]
2726    fn stale_when_new_optional_dep_was_never_seen() {
2727        // Cursor Bugbot regression: a brand-new optional dep that the
2728        // previous resolve never saw must trigger drift, otherwise it
2729        // would silently never get installed. Distinct from a
2730        // platform-skipped optional, which has an entry in
2731        // `skipped_optional_dependencies`.
2732        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2733        manifest
2734            .optional_dependencies
2735            .insert("fsevents".into(), "^2.3.0".into());
2736        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2737        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2738            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2739            DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
2740        }
2741    }
2742
2743    #[test]
2744    fn stale_when_skipped_optional_dep_specifier_changes() {
2745        // The user bumped the range on a previously-skipped optional;
2746        // the recorded specifier no longer matches the manifest, so we
2747        // need to re-resolve.
2748        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2749        manifest
2750            .optional_dependencies
2751            .insert("fsevents".into(), "^2.4.0".into());
2752        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2753        let mut inner = BTreeMap::new();
2754        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
2755        graph
2756            .skipped_optional_dependencies
2757            .insert(".".to_string(), inner);
2758        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2759            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2760            DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
2761        }
2762    }
2763
2764    #[test]
2765    fn stale_when_skipped_optional_is_promoted_to_required() {
2766        // Cursor Bugbot regression: if the user moves a previously-
2767        // skipped optional into `dependencies` (same specifier), the
2768        // skipped-list exemption must NOT fire — the dep is now
2769        // required and the lockfile genuinely doesn't include it.
2770        let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
2771        // Note: fsevents lives in `dependencies`, not
2772        // `optional_dependencies`, even though the lockfile recorded
2773        // it under skipped optionals from a previous resolve.
2774        manifest.optional_dependencies.clear();
2775        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2776        let mut inner = BTreeMap::new();
2777        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
2778        graph
2779            .skipped_optional_dependencies
2780            .insert(".".to_string(), inner);
2781        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2782            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2783            DriftStatus::Fresh => {
2784                panic!("expected Stale: skipped-optional exemption must not apply to required deps")
2785            }
2786        }
2787    }
2788
2789    #[test]
2790    fn stale_when_optional_dep_specifier_changes_in_lockfile() {
2791        // Spec changes on optionals that *are* present must still
2792        // drift, so the resolver re-runs when the user bumps a range.
2793        let mut manifest = make_manifest(&[]);
2794        manifest
2795            .optional_dependencies
2796            .insert("fsevents".into(), "^2.4.0".into());
2797        let mut graph = make_graph(&[]);
2798        graph.importers.get_mut(".").unwrap().push(DirectDep {
2799            name: "fsevents".into(),
2800            dep_path: "fsevents@2.3.0".into(),
2801            dep_type: DepType::Optional,
2802            specifier: Some("^2.3.0".into()),
2803        });
2804        match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2805            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2806            DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
2807        }
2808    }
2809
2810    #[test]
2811    fn fresh_for_empty_manifest_and_lockfile() {
2812        let manifest = make_manifest(&[]);
2813        let graph = make_graph(&[]);
2814        assert_eq!(
2815            graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2816            DriftStatus::Fresh
2817        );
2818    }
2819
2820    #[test]
2821    fn workspace_drift_detects_change_in_non_root_importer() {
2822        // Build a graph with two importers: root and packages/app.
2823        let root_dep = DirectDep {
2824            name: "lodash".into(),
2825            dep_path: "lodash@4.17.21".into(),
2826            dep_type: DepType::Production,
2827            specifier: Some("^4.17.0".into()),
2828        };
2829        let app_dep = DirectDep {
2830            name: "express".into(),
2831            dep_path: "express@4.18.0".into(),
2832            dep_type: DepType::Production,
2833            specifier: Some("^4.18.0".into()),
2834        };
2835        let mut importers = BTreeMap::new();
2836        importers.insert(".".to_string(), vec![root_dep]);
2837        importers.insert("packages/app".to_string(), vec![app_dep]);
2838        let graph = LockfileGraph {
2839            importers,
2840            packages: BTreeMap::new(),
2841            ..Default::default()
2842        };
2843
2844        let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
2845        // App manifest changed express to ^5.0.0 — should be detected as stale.
2846        let app_manifest = make_manifest(&[("express", "^5.0.0")]);
2847
2848        let workspace_manifests = vec![
2849            (".".to_string(), root_manifest.clone()),
2850            ("packages/app".to_string(), app_manifest),
2851        ];
2852        match graph.check_drift_workspace(
2853            &workspace_manifests,
2854            &BTreeMap::new(),
2855            &[],
2856            &BTreeMap::new(),
2857        ) {
2858            DriftStatus::Stale { reason } => {
2859                assert!(reason.contains("packages/app"));
2860                assert!(reason.contains("express"));
2861            }
2862            DriftStatus::Fresh => panic!("expected Stale"),
2863        }
2864
2865        // Single-importer check_drift on root only would say Fresh.
2866        assert_eq!(
2867            graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2868            DriftStatus::Fresh
2869        );
2870    }
2871
2872    #[test]
2873    fn filter_deps_prunes_dev_only_subtree() {
2874        // Graph: prod-root (foo) + dev-root (jest) with transitive chains.
2875        // After filtering out Dev, jest + its transitives should be pruned,
2876        // foo + its transitives should remain.
2877        let mut importers = BTreeMap::new();
2878        importers.insert(
2879            ".".to_string(),
2880            vec![
2881                DirectDep {
2882                    name: "foo".into(),
2883                    dep_path: "foo@1.0.0".into(),
2884                    dep_type: DepType::Production,
2885                    specifier: Some("^1.0.0".into()),
2886                },
2887                DirectDep {
2888                    name: "jest".into(),
2889                    dep_path: "jest@29.0.0".into(),
2890                    dep_type: DepType::Dev,
2891                    specifier: Some("^29.0.0".into()),
2892                },
2893            ],
2894        );
2895
2896        let mut packages = BTreeMap::new();
2897        let mut foo_deps = BTreeMap::new();
2898        foo_deps.insert("bar".to_string(), "2.0.0".to_string());
2899        packages.insert(
2900            "foo@1.0.0".to_string(),
2901            LockedPackage {
2902                name: "foo".into(),
2903                version: "1.0.0".into(),
2904                integrity: None,
2905                dependencies: foo_deps,
2906                dep_path: "foo@1.0.0".into(),
2907                ..Default::default()
2908            },
2909        );
2910        packages.insert(
2911            "bar@2.0.0".to_string(),
2912            LockedPackage {
2913                name: "bar".into(),
2914                version: "2.0.0".into(),
2915                integrity: None,
2916                dependencies: BTreeMap::new(),
2917                dep_path: "bar@2.0.0".into(),
2918                ..Default::default()
2919            },
2920        );
2921        let mut jest_deps = BTreeMap::new();
2922        jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
2923        packages.insert(
2924            "jest@29.0.0".to_string(),
2925            LockedPackage {
2926                name: "jest".into(),
2927                version: "29.0.0".into(),
2928                integrity: None,
2929                dependencies: jest_deps,
2930                dep_path: "jest@29.0.0".into(),
2931                ..Default::default()
2932            },
2933        );
2934        packages.insert(
2935            "jest-core@29.0.0".to_string(),
2936            LockedPackage {
2937                name: "jest-core".into(),
2938                version: "29.0.0".into(),
2939                integrity: None,
2940                dependencies: BTreeMap::new(),
2941                dep_path: "jest-core@29.0.0".into(),
2942                ..Default::default()
2943            },
2944        );
2945
2946        let graph = LockfileGraph {
2947            importers,
2948            packages,
2949            ..Default::default()
2950        };
2951
2952        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
2953
2954        // Direct deps: only foo, jest dropped
2955        let roots = prod.root_deps();
2956        assert_eq!(roots.len(), 1);
2957        assert_eq!(roots[0].name, "foo");
2958
2959        // Reachable packages: foo + bar (transitive), NOT jest or jest-core
2960        assert!(prod.packages.contains_key("foo@1.0.0"));
2961        assert!(prod.packages.contains_key("bar@2.0.0"));
2962        assert!(!prod.packages.contains_key("jest@29.0.0"));
2963        assert!(!prod.packages.contains_key("jest-core@29.0.0"));
2964    }
2965
2966    // Regression for #50 feedback: `filter_deps` is a structural
2967    // operation and must preserve the source graph's `settings:`
2968    // metadata. A filtered graph that's handed to the lockfile writer
2969    // (as `aube prune` does today) would otherwise reset
2970    // `autoInstallPeers` to its default and silently flip the user's
2971    // choice on the next install.
2972    #[test]
2973    fn filter_deps_preserves_lockfile_settings() {
2974        let graph = LockfileGraph {
2975            importers: BTreeMap::new(),
2976            packages: BTreeMap::new(),
2977            settings: LockfileSettings {
2978                auto_install_peers: false,
2979                exclude_links_from_lockfile: true,
2980                lockfile_include_tarball_url: false,
2981            },
2982            ..Default::default()
2983        };
2984        let filtered = graph.filter_deps(|_| true);
2985        assert!(!filtered.settings.auto_install_peers);
2986        assert!(filtered.settings.exclude_links_from_lockfile);
2987    }
2988
2989    #[test]
2990    fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
2991        // Graph: prod foo → shared, dev jest → shared
2992        // Filtering out Dev should still keep `shared` because foo → shared
2993        // keeps it reachable.
2994        let mut importers = BTreeMap::new();
2995        importers.insert(
2996            ".".to_string(),
2997            vec![
2998                DirectDep {
2999                    name: "foo".into(),
3000                    dep_path: "foo@1.0.0".into(),
3001                    dep_type: DepType::Production,
3002                    specifier: Some("^1.0.0".into()),
3003                },
3004                DirectDep {
3005                    name: "jest".into(),
3006                    dep_path: "jest@29.0.0".into(),
3007                    dep_type: DepType::Dev,
3008                    specifier: Some("^29.0.0".into()),
3009                },
3010            ],
3011        );
3012
3013        let mut packages = BTreeMap::new();
3014        for (name, ver, deps) in [
3015            ("foo", "1.0.0", vec![("shared", "1.0.0")]),
3016            ("jest", "29.0.0", vec![("shared", "1.0.0")]),
3017            ("shared", "1.0.0", vec![]),
3018        ] {
3019            let mut dep_map = BTreeMap::new();
3020            for (n, v) in deps {
3021                dep_map.insert(n.to_string(), v.to_string());
3022            }
3023            packages.insert(
3024                format!("{name}@{ver}"),
3025                LockedPackage {
3026                    name: name.into(),
3027                    version: ver.into(),
3028                    integrity: None,
3029                    dependencies: dep_map,
3030                    dep_path: format!("{name}@{ver}"),
3031                    ..Default::default()
3032                },
3033            );
3034        }
3035
3036        let graph = LockfileGraph {
3037            importers,
3038            packages,
3039            ..Default::default()
3040        };
3041        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3042
3043        assert!(prod.packages.contains_key("foo@1.0.0"));
3044        assert!(prod.packages.contains_key("shared@1.0.0"));
3045        assert!(!prod.packages.contains_key("jest@29.0.0"));
3046    }
3047
3048    #[test]
3049    fn subset_to_importer_returns_none_for_missing_importer() {
3050        let graph = LockfileGraph {
3051            importers: BTreeMap::new(),
3052            packages: BTreeMap::new(),
3053            ..Default::default()
3054        };
3055        assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
3056    }
3057
3058    #[test]
3059    fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
3060        // Workspace graph with two importers that own independent
3061        // subtrees: packages/lib pulls is-odd → is-number, packages/app
3062        // pulls express. Subsetting to packages/lib must yield a graph
3063        // rooted at `.` containing only is-odd + is-number, with
3064        // express pruned. Matches what `aube deploy --filter @test/lib`
3065        // should write into the target.
3066        let mut importers = BTreeMap::new();
3067        importers.insert(".".to_string(), vec![]);
3068        importers.insert(
3069            "packages/lib".to_string(),
3070            vec![DirectDep {
3071                name: "is-odd".into(),
3072                dep_path: "is-odd@3.0.1".into(),
3073                dep_type: DepType::Production,
3074                specifier: Some("^3.0.1".into()),
3075            }],
3076        );
3077        importers.insert(
3078            "packages/app".to_string(),
3079            vec![DirectDep {
3080                name: "express".into(),
3081                dep_path: "express@4.18.0".into(),
3082                dep_type: DepType::Production,
3083                specifier: Some("^4.18.0".into()),
3084            }],
3085        );
3086
3087        let mut packages = BTreeMap::new();
3088        let mut is_odd_deps = BTreeMap::new();
3089        is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
3090        packages.insert(
3091            "is-odd@3.0.1".to_string(),
3092            LockedPackage {
3093                name: "is-odd".into(),
3094                version: "3.0.1".into(),
3095                dependencies: is_odd_deps,
3096                dep_path: "is-odd@3.0.1".into(),
3097                ..Default::default()
3098            },
3099        );
3100        packages.insert(
3101            "is-number@6.0.0".to_string(),
3102            LockedPackage {
3103                name: "is-number".into(),
3104                version: "6.0.0".into(),
3105                dep_path: "is-number@6.0.0".into(),
3106                ..Default::default()
3107            },
3108        );
3109        packages.insert(
3110            "express@4.18.0".to_string(),
3111            LockedPackage {
3112                name: "express".into(),
3113                version: "4.18.0".into(),
3114                dep_path: "express@4.18.0".into(),
3115                ..Default::default()
3116            },
3117        );
3118
3119        let graph = LockfileGraph {
3120            importers,
3121            packages,
3122            ..Default::default()
3123        };
3124        let subset = graph
3125            .subset_to_importer("packages/lib", |_| true)
3126            .expect("packages/lib importer present");
3127
3128        assert_eq!(subset.importers.len(), 1);
3129        let roots = subset.root_deps();
3130        assert_eq!(roots.len(), 1);
3131        assert_eq!(roots[0].name, "is-odd");
3132
3133        assert!(subset.packages.contains_key("is-odd@3.0.1"));
3134        assert!(subset.packages.contains_key("is-number@6.0.0"));
3135        assert!(!subset.packages.contains_key("express@4.18.0"));
3136    }
3137
3138    #[test]
3139    fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
3140        // packages/lib has both prod (is-odd) and dev (jest) deps.
3141        // `aube deploy --prod` should pass `|d| d.dep_type != Dev` as
3142        // the keep filter; the resulting subset retains only is-odd
3143        // so drift against the target's dev-stripped manifest stays
3144        // clean.
3145        let mut importers = BTreeMap::new();
3146        importers.insert(
3147            "packages/lib".to_string(),
3148            vec![
3149                DirectDep {
3150                    name: "is-odd".into(),
3151                    dep_path: "is-odd@3.0.1".into(),
3152                    dep_type: DepType::Production,
3153                    specifier: Some("^3.0.1".into()),
3154                },
3155                DirectDep {
3156                    name: "jest".into(),
3157                    dep_path: "jest@29.0.0".into(),
3158                    dep_type: DepType::Dev,
3159                    specifier: Some("^29.0.0".into()),
3160                },
3161            ],
3162        );
3163        let mut packages = BTreeMap::new();
3164        packages.insert(
3165            "is-odd@3.0.1".to_string(),
3166            LockedPackage {
3167                name: "is-odd".into(),
3168                version: "3.0.1".into(),
3169                dep_path: "is-odd@3.0.1".into(),
3170                ..Default::default()
3171            },
3172        );
3173        packages.insert(
3174            "jest@29.0.0".to_string(),
3175            LockedPackage {
3176                name: "jest".into(),
3177                version: "29.0.0".into(),
3178                dep_path: "jest@29.0.0".into(),
3179                ..Default::default()
3180            },
3181        );
3182        let graph = LockfileGraph {
3183            importers,
3184            packages,
3185            ..Default::default()
3186        };
3187
3188        let prod = graph
3189            .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
3190            .expect("importer present");
3191        let roots = prod.root_deps();
3192        assert_eq!(roots.len(), 1);
3193        assert_eq!(roots[0].name, "is-odd");
3194        assert!(prod.packages.contains_key("is-odd@3.0.1"));
3195        assert!(!prod.packages.contains_key("jest@29.0.0"));
3196    }
3197
3198    #[test]
3199    fn subset_to_importer_preserves_graph_settings() {
3200        // Structural pruning, not a resolution-mode reset: a deploy
3201        // into a target that uses the source workspace's settings
3202        // header (autoInstallPeers / lockfileIncludeTarballUrl)
3203        // should write them through unchanged so a frozen install in
3204        // the target sees the same resolution-mode state.
3205        let mut importers = BTreeMap::new();
3206        importers.insert("packages/lib".to_string(), vec![]);
3207        let graph = LockfileGraph {
3208            importers,
3209            packages: BTreeMap::new(),
3210            settings: LockfileSettings {
3211                auto_install_peers: false,
3212                exclude_links_from_lockfile: true,
3213                lockfile_include_tarball_url: true,
3214            },
3215            ..Default::default()
3216        };
3217        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3218        assert!(!subset.settings.auto_install_peers);
3219        assert!(subset.settings.exclude_links_from_lockfile);
3220        assert!(subset.settings.lockfile_include_tarball_url);
3221    }
3222
3223    #[test]
3224    fn subset_to_importer_rekeys_skipped_optionals_to_root() {
3225        // `skipped_optional_dependencies` is per-importer. After
3226        // subsetting, only the retained importer's entry should
3227        // survive — rekeyed to `.` so a frozen install in the target
3228        // (which has exactly one importer) doesn't see ghost entries.
3229        let mut importers = BTreeMap::new();
3230        importers.insert("packages/lib".to_string(), vec![]);
3231        importers.insert("packages/app".to_string(), vec![]);
3232        let mut skipped = BTreeMap::new();
3233        let mut lib_skip = BTreeMap::new();
3234        lib_skip.insert("fsevents".to_string(), "^2".to_string());
3235        skipped.insert("packages/lib".to_string(), lib_skip);
3236        let mut app_skip = BTreeMap::new();
3237        app_skip.insert("ghost".to_string(), "*".to_string());
3238        skipped.insert("packages/app".to_string(), app_skip);
3239        let graph = LockfileGraph {
3240            importers,
3241            packages: BTreeMap::new(),
3242            skipped_optional_dependencies: skipped,
3243            ..Default::default()
3244        };
3245        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3246        assert_eq!(subset.skipped_optional_dependencies.len(), 1);
3247        let root = subset.skipped_optional_dependencies.get(".").unwrap();
3248        assert!(root.contains_key("fsevents"));
3249        assert!(!root.contains_key("ghost"));
3250    }
3251
3252    #[test]
3253    fn workspace_drift_fresh_when_all_importers_match() {
3254        let root_dep = DirectDep {
3255            name: "lodash".into(),
3256            dep_path: "lodash@4.17.21".into(),
3257            dep_type: DepType::Production,
3258            specifier: Some("^4.17.0".into()),
3259        };
3260        let app_dep = DirectDep {
3261            name: "express".into(),
3262            dep_path: "express@4.18.0".into(),
3263            dep_type: DepType::Production,
3264            specifier: Some("^4.18.0".into()),
3265        };
3266        let mut importers = BTreeMap::new();
3267        importers.insert(".".to_string(), vec![root_dep]);
3268        importers.insert("packages/app".to_string(), vec![app_dep]);
3269        let graph = LockfileGraph {
3270            importers,
3271            packages: BTreeMap::new(),
3272            ..Default::default()
3273        };
3274
3275        let workspace_manifests = vec![
3276            (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
3277            (
3278                "packages/app".to_string(),
3279                make_manifest(&[("express", "^4.18.0")]),
3280            ),
3281        ];
3282        assert_eq!(
3283            graph.check_drift_workspace(
3284                &workspace_manifests,
3285                &BTreeMap::new(),
3286                &[],
3287                &BTreeMap::new()
3288            ),
3289            DriftStatus::Fresh
3290        );
3291    }
3292
3293    #[allow(clippy::type_complexity)]
3294    fn mk_catalogs(
3295        entries: &[(&str, &[(&str, &str, &str)])],
3296    ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
3297        let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
3298        for (cat, pkgs) in entries {
3299            let mut inner = BTreeMap::new();
3300            for (pkg, spec, ver) in *pkgs {
3301                inner.insert(
3302                    (*pkg).to_string(),
3303                    CatalogEntry {
3304                        specifier: (*spec).to_string(),
3305                        version: (*ver).to_string(),
3306                    },
3307                );
3308            }
3309            out.insert((*cat).to_string(), inner);
3310        }
3311        out
3312    }
3313
3314    fn mk_workspace_catalogs(
3315        entries: &[(&str, &[(&str, &str)])],
3316    ) -> BTreeMap<String, BTreeMap<String, String>> {
3317        entries
3318            .iter()
3319            .map(|(cat, pkgs)| {
3320                (
3321                    (*cat).to_string(),
3322                    pkgs.iter()
3323                        .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
3324                        .collect(),
3325                )
3326            })
3327            .collect()
3328    }
3329
3330    #[test]
3331    fn catalog_drift_fresh_when_specifiers_match() {
3332        let graph = LockfileGraph {
3333            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3334            ..Default::default()
3335        };
3336        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
3337        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3338    }
3339
3340    #[test]
3341    fn catalog_drift_stale_on_changed_specifier() {
3342        let graph = LockfileGraph {
3343            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3344            ..Default::default()
3345        };
3346        let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
3347        match graph.check_catalogs_drift(&ws) {
3348            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
3349            other => panic!("expected stale, got {other:?}"),
3350        }
3351    }
3352
3353    #[test]
3354    fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
3355        // pnpm only writes referenced entries — an unreferenced
3356        // workspace entry is not drift. The "newly used" transition
3357        // is caught by the importer-level drift check.
3358        let graph = LockfileGraph::default();
3359        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
3360        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3361    }
3362
3363    #[test]
3364    fn catalog_drift_stale_on_removed_workspace_entry() {
3365        let graph = LockfileGraph {
3366            catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
3367            ..Default::default()
3368        };
3369        let ws = mk_workspace_catalogs(&[]);
3370        assert!(matches!(
3371            graph.check_catalogs_drift(&ws),
3372            DriftStatus::Stale { .. }
3373        ));
3374    }
3375}