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