Skip to main content

aube_lockfile/
lib.rs

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