Skip to main content

aube_lockfile/
lib.rs

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