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 ) -> DriftStatus {
1271 // Override drift is checked once at the workspace level, against
1272 // the root manifest. Workspace-package manifests may declare
1273 // their own `overrides` blocks but pnpm only honors the root's,
1274 // so we mirror that here.
1275 let effective_overrides = match manifests.iter().find(|(p, _)| p == ".") {
1276 Some((_, root_manifest)) => {
1277 let effective = resolve_catalog_refs_in_overrides(
1278 &merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides),
1279 workspace_catalogs,
1280 );
1281 let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs);
1282 if let Some(reason) = overrides_drift_reason(&locked, &effective) {
1283 return DriftStatus::Stale { reason };
1284 }
1285 let mut effective_ignored = root_manifest.pnpm_ignored_optional_dependencies();
1286 effective_ignored.extend(workspace_ignored_optional.iter().cloned());
1287 if let Some(reason) = ignored_optional_drift_reason(
1288 &self.ignored_optional_dependencies,
1289 &effective_ignored,
1290 ) {
1291 return DriftStatus::Stale { reason };
1292 }
1293 effective
1294 }
1295 None => BTreeMap::new(),
1296 };
1297 for (importer_path, manifest) in manifests {
1298 match self.check_drift_for_importer(importer_path, manifest, &effective_overrides) {
1299 DriftStatus::Fresh => continue,
1300 stale => return stale,
1301 }
1302 }
1303 DriftStatus::Fresh
1304 }
1305
1306 /// Compare this lockfile's catalog snapshot against the current
1307 /// `pnpm-workspace.yaml` catalogs.
1308 ///
1309 /// pnpm only writes catalog entries that at least one importer
1310 /// references — unused entries are absent from the lockfile. So
1311 /// "missing from lockfile" doesn't mean "added by the user", it
1312 /// means "declared but unreferenced", which is not drift. The
1313 /// transition from unused → used is caught by the importer-level
1314 /// drift check, since a fresh `catalog:` reference shows up as a
1315 /// new dep in some `package.json`.
1316 ///
1317 /// We fire on two cases only:
1318 /// - the spec changed for an entry the lockfile already records
1319 /// (the entry is in use, and re-resolution must rerun);
1320 /// - the workspace removed an entry that the lockfile records
1321 /// (the importer using `catalog:` now points at nothing).
1322 ///
1323 /// Resolved versions are deliberately not part of the comparison —
1324 /// the version is an *output* of resolution, so a stale lockfile
1325 /// version is what re-resolution is supposed to fix. Drift only
1326 /// fires on user intent (the specifier).
1327 pub fn check_catalogs_drift(
1328 &self,
1329 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1330 ) -> DriftStatus {
1331 for (cat_name, cat) in workspace_catalogs {
1332 let Some(locked) = self.catalogs.get(cat_name) else {
1333 continue;
1334 };
1335 for (pkg, spec) in cat {
1336 if let Some(entry) = locked.get(pkg)
1337 && entry.specifier != *spec
1338 {
1339 return DriftStatus::Stale {
1340 reason: format!(
1341 "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
1342 entry.specifier
1343 ),
1344 };
1345 }
1346 }
1347 }
1348 for (cat_name, cat) in &self.catalogs {
1349 let workspace_cat = workspace_catalogs.get(cat_name);
1350 for pkg in cat.keys() {
1351 if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
1352 return DriftStatus::Stale {
1353 reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
1354 };
1355 }
1356 }
1357 }
1358 DriftStatus::Fresh
1359 }
1360
1361 /// Compare a single importer's `DirectDep` list against the corresponding
1362 /// `package.json`. Used by both [`check_drift`] and [`check_drift_workspace`].
1363 ///
1364 /// [`check_drift`]: Self::check_drift
1365 /// [`check_drift_workspace`]: Self::check_drift_workspace
1366 fn check_drift_for_importer(
1367 &self,
1368 importer_path: &str,
1369 manifest: &aube_manifest::PackageJson,
1370 effective_overrides: &BTreeMap<String, String>,
1371 ) -> DriftStatus {
1372 let label = if importer_path == "." {
1373 String::new()
1374 } else {
1375 format!("{importer_path}: ")
1376 };
1377
1378 let importer_deps: &[DirectDep] = self
1379 .importers
1380 .get(importer_path)
1381 .map(|v| v.as_slice())
1382 .unwrap_or(&[]);
1383
1384 // Skip the check entirely if no DirectDep has a specifier (non-pnpm format).
1385 if importer_deps.iter().all(|d| d.specifier.is_none()) {
1386 return DriftStatus::Fresh;
1387 }
1388 let lockfile_specs: BTreeMap<&str, &str> = importer_deps
1389 .iter()
1390 .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
1391 .collect();
1392
1393 let override_rules = override_match::compile(effective_overrides);
1394
1395 // Optionals the previous resolve recorded as intentionally
1396 // skipped on this importer's platform — keyed by name, value
1397 // is the specifier captured at that time. Distinct from
1398 // `ignored_optional_dependencies`, which is the user's static
1399 // ignore list; this map captures *runtime* platform skips.
1400 let skipped_optionals: BTreeMap<&str, &str> = self
1401 .skipped_optional_dependencies
1402 .get(importer_path)
1403 .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
1404 .unwrap_or_default();
1405
1406 // Iterate prod / dev / optional with a flag so the
1407 // skipped-optional exemption only applies to deps that came
1408 // from `optional_dependencies`. Without the flag, moving a
1409 // previously-skipped optional into `dependencies` with the same
1410 // specifier would silently report Fresh and the dep would
1411 // never install as a required dep.
1412 //
1413 // Optionals named in `ignored_optional_dependencies` are
1414 // dropped from the manifest-side scan: the resolver never
1415 // enqueues them, so the lockfile importer never has them
1416 // either, and the loop would otherwise report drift on every
1417 // install. (Their *spec* is still verified separately by the
1418 // round-tripped `ignored_optional_dependencies` block below.)
1419 let ignored = &self.ignored_optional_dependencies;
1420 let manifest_deps = manifest
1421 .dependencies
1422 .iter()
1423 .map(|(k, v)| (k, v, false))
1424 .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
1425 .chain(
1426 manifest
1427 .optional_dependencies
1428 .iter()
1429 .filter(|(name, _)| !ignored.contains(name.as_str()))
1430 .map(|(k, v)| (k, v, true)),
1431 );
1432
1433 for (name, spec, is_optional) in manifest_deps {
1434 match lockfile_specs.get(name.as_str()) {
1435 None => {
1436 // A *missing* optional dep is only "fresh" if the
1437 // previous resolve recorded it as intentionally
1438 // skipped (platform mismatch or
1439 // `pnpm.ignoredOptionalDependencies`) AND the
1440 // recorded specifier still matches what's in the
1441 // manifest. A genuinely *new* optional that the
1442 // resolver has never seen is real drift — without
1443 // that branch, adding `fsevents` to a fresh manifest
1444 // would silently never get installed.
1445 if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
1446 if *locked_spec == spec {
1447 continue;
1448 }
1449 return DriftStatus::Stale {
1450 reason: format!(
1451 "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
1452 ),
1453 };
1454 }
1455 return DriftStatus::Stale {
1456 reason: format!("{label}manifest adds {name}@{spec}"),
1457 };
1458 }
1459 Some(locked_spec) if *locked_spec != spec => {
1460 // pnpm rewrites the importer specifier to the
1461 // override-applied value when an override fires on
1462 // a direct dep, so a pnpm-generated lockfile shows
1463 // `specifier: ">=3.0.5"` even though `package.json`
1464 // still reads `^3.0.4`. Accept that as fresh when
1465 // an override for this name (bare or version-keyed)
1466 // resolves to the lockfile's recorded spec —
1467 // otherwise any pnpm-written lockfile with
1468 // overrides reads stale on every frozen install.
1469 if let Some(override_spec) =
1470 override_match::apply(&override_rules, name.as_str(), spec)
1471 && override_spec == *locked_spec
1472 {
1473 continue;
1474 }
1475 return DriftStatus::Stale {
1476 reason: format!(
1477 "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
1478 ),
1479 };
1480 }
1481 Some(_) => {}
1482 }
1483 }
1484
1485 // Anything in the lockfile but missing from the manifest is stale
1486 // — UNLESS it was auto-hoisted as a peer by the resolver. pnpm-style
1487 // `auto-install-peers=true` puts peers into the importer's
1488 // `dependencies` without the user having written them in
1489 // `package.json`, so we have to recognize those as derived state
1490 // rather than user intent.
1491 //
1492 // Critically, we identify an auto-hoisted entry by matching its
1493 // *recorded specifier* against peer ranges declared in the graph,
1494 // not just by name. A name-only check would silently exempt a
1495 // user-pinned `react` that the user later removed (if any package
1496 // anywhere in the graph peer-declares react, the name match would
1497 // fire and we'd report Fresh forever — defeating the drift check).
1498 //
1499 // The rule: a lockfile entry whose (name, specifier) pair exactly
1500 // matches some package's declared (peer_name, peer_range) is
1501 // auto-hoisted. If the user had pinned react with a different
1502 // specifier string and then removed it, the (name, specifier)
1503 // pair no longer matches any peer range, and drift correctly
1504 // fires so the resolver re-runs and rewrites the lockfile.
1505 let manifest_names: std::collections::HashSet<&str> = manifest
1506 .dependencies
1507 .keys()
1508 .chain(manifest.dev_dependencies.keys())
1509 .chain(
1510 manifest
1511 .optional_dependencies
1512 .keys()
1513 .filter(|name| !ignored.contains(name.as_str())),
1514 )
1515 .map(|s| s.as_str())
1516 .collect();
1517 let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
1518 .packages
1519 .values()
1520 .flat_map(|p| {
1521 p.peer_dependencies
1522 .iter()
1523 .map(|(name, range)| (name.as_str(), range.as_str()))
1524 })
1525 .collect();
1526 for (locked_name, locked_spec) in &lockfile_specs {
1527 if manifest_names.contains(locked_name) {
1528 continue;
1529 }
1530 if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
1531 continue;
1532 }
1533 return DriftStatus::Stale {
1534 reason: format!("{label}manifest removed {locked_name}"),
1535 };
1536 }
1537
1538 DriftStatus::Fresh
1539 }
1540}
1541
1542/// Merge `pnpm-workspace.yaml` overrides on top of the manifest's
1543/// `overrides_map()`. Workspace entries win on key conflict, matching
1544/// pnpm v10's behavior where the workspace yaml is the canonical
1545/// home for overrides. Callers pass this into `overrides_drift_reason`
1546/// so the drift check sees the same effective map the resolver used.
1547fn merge_manifest_and_workspace_overrides(
1548 manifest: &aube_manifest::PackageJson,
1549 workspace_overrides: &BTreeMap<String, String>,
1550) -> BTreeMap<String, String> {
1551 let mut out = manifest.overrides_map();
1552 for (k, v) in workspace_overrides {
1553 out.insert(k.clone(), v.clone());
1554 }
1555 out
1556}
1557
1558/// Rewrite `catalog:` / `catalog:<name>` override values to the catalog's
1559/// resolved range. pnpm writes resolved override values into the lockfile
1560/// and compares against the resolved form on re-install, so both sides
1561/// of the drift check have to see the catalog-substituted map — otherwise
1562/// a `"lodash": "catalog:"` workspace-yaml override reads as stale against
1563/// a lockfile that recorded `"lodash": "4.17.21"`. Unresolvable references
1564/// (missing catalog or missing entry) pass through untouched; the caller
1565/// would have errored at resolve time if this ever reached a real install,
1566/// so a drift-mismatch here is fine.
1567fn resolve_catalog_refs_in_overrides(
1568 overrides: &BTreeMap<String, String>,
1569 workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
1570) -> BTreeMap<String, String> {
1571 overrides
1572 .iter()
1573 .map(|(k, v)| {
1574 let resolved = v
1575 .strip_prefix("catalog:")
1576 .map(|tail| if tail.is_empty() { "default" } else { tail })
1577 .and_then(|cat_name| workspace_catalogs.get(cat_name))
1578 .and_then(|cat| cat.get(override_key_package_name(k)))
1579 .cloned()
1580 .unwrap_or_else(|| v.clone());
1581 (k.clone(), resolved)
1582 })
1583 .collect()
1584}
1585
1586/// Extract the package name from an override selector key so the catalog
1587/// can be looked up by pkg name. Handles bare (`lodash`), scoped
1588/// (`@babel/core`), ranged (`lodash@<5`), ancestor-chained
1589/// (`parent>lodash`), and combinations. Unparseable keys return the
1590/// input unchanged; the catalog lookup will then miss and leave the
1591/// value as-is.
1592fn override_key_package_name(key: &str) -> &str {
1593 let last = key.rsplit('>').next().unwrap_or(key);
1594 if let Some(after_scope) = last.strip_prefix('@') {
1595 match after_scope.find('@') {
1596 Some(idx) => &last[..idx + 1],
1597 None => last,
1598 }
1599 } else {
1600 match last.find('@') {
1601 Some(idx) => &last[..idx],
1602 None => last,
1603 }
1604 }
1605}
1606
1607/// Compare two override maps and return a human-readable reason
1608/// describing the first difference, or `None` if they're identical.
1609/// Drift messages cite the offending key by name so users can act on
1610/// them — `(lockfile: N entries, manifest: M entries)` is useless
1611/// when N == M but a value changed.
1612fn overrides_drift_reason(
1613 lockfile: &BTreeMap<String, String>,
1614 manifest: &BTreeMap<String, String>,
1615) -> Option<String> {
1616 for (k, v) in manifest {
1617 match lockfile.get(k) {
1618 None => return Some(format!("overrides: manifest adds {k}@{v}")),
1619 Some(locked) if locked != v => {
1620 return Some(format!("overrides: {k} changed ({locked} → {v})"));
1621 }
1622 Some(_) => {}
1623 }
1624 }
1625 for k in lockfile.keys() {
1626 if !manifest.contains_key(k) {
1627 return Some(format!("overrides: manifest removes {k}"));
1628 }
1629 }
1630 None
1631}
1632
1633/// Compare two `ignoredOptionalDependencies` sets and return a drift
1634/// reason string for the first difference, or `None` if identical.
1635fn ignored_optional_drift_reason(
1636 lockfile: &BTreeSet<String>,
1637 manifest: &BTreeSet<String>,
1638) -> Option<String> {
1639 for name in manifest {
1640 if !lockfile.contains(name) {
1641 return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
1642 }
1643 }
1644 for name in lockfile {
1645 if !manifest.contains(name) {
1646 return Some(format!(
1647 "ignoredOptionalDependencies: manifest removes {name}"
1648 ));
1649 }
1650 }
1651 None
1652}
1653
1654/// Result of comparing a lockfile against a manifest.
1655#[derive(Debug, Clone, PartialEq, Eq)]
1656pub enum DriftStatus {
1657 /// The lockfile is in sync with the manifest. Safe to use without re-resolving.
1658 Fresh,
1659 /// The lockfile is out of date. The reason describes the first mismatch found.
1660 Stale { reason: String },
1661}
1662
1663/// Atomic lockfile write. Tempfile in the same dir, fsync, rename
1664/// over the target. Every format writer goes through this so a
1665/// crash or Ctrl+C mid-write cannot leave a truncated lockfile on
1666/// disk. Rename is atomic on POSIX, on Windows MoveFileEx gives
1667/// the same guarantee post Win10. Caller passes the serialized
1668/// bytes already formatted, this just handles the IO layer.
1669pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
1670 aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
1671}
1672
1673/// Write a lockfile to the given project directory using aube's default
1674/// filename (`aube-lock.yaml`, or `aube-lock.<branch>.yaml` when branch
1675/// lockfiles are enabled).
1676pub fn write_lockfile(
1677 project_dir: &Path,
1678 graph: &LockfileGraph,
1679 manifest: &aube_manifest::PackageJson,
1680) -> Result<(), Error> {
1681 write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
1682 Ok(())
1683}
1684
1685/// Write a lockfile using the existing project lockfile kind, or
1686/// Collapse peer-context variants from `graph` into a single map keyed
1687/// by `"name@version"`, pointing at the first-seen package. Several
1688/// writers (npm, yarn, …) share this shape: one canonical entry per
1689/// `(name, version)` pair regardless of how many peer suffixes the
1690/// full graph emits.
1691pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
1692 let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
1693 for pkg in graph.packages.values() {
1694 canonical.entry(pkg.spec_key()).or_insert(pkg);
1695 }
1696 canonical
1697}
1698
1699/// `aube-lock.yaml` when the project does not have one yet.
1700///
1701/// This is the default write path for commands that mutate the active
1702/// project graph (`install`, `add`, `remove`, `update`, `dedupe`, ...).
1703pub fn write_lockfile_preserving_existing(
1704 project_dir: &Path,
1705 graph: &LockfileGraph,
1706 manifest: &aube_manifest::PackageJson,
1707) -> Result<PathBuf, Error> {
1708 let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
1709 write_lockfile_as(project_dir, graph, manifest, kind)
1710}
1711
1712/// Write `graph` in the requested lockfile format into `project_dir`.
1713///
1714/// Returns the path that was actually written (useful for logging
1715/// since `Aube` may resolve to a branch-specific filename). Callers
1716/// that want to preserve whatever format was already on disk should
1717/// pair this with [`detect_existing_lockfile_kind`].
1718///
1719/// All supported formats: `Aube`, `Pnpm`, `Npm`, `NpmShrinkwrap`,
1720/// `Yarn`, and `Bun`. This preserves the lockfile kind that already
1721/// exists in the project; callers should pass `Aube` only when no
1722/// lockfile exists yet. See each writer module's doc comment for
1723/// per-format lossy areas (peer contexts, `resolved` URLs, etc.).
1724pub fn write_lockfile_as(
1725 project_dir: &Path,
1726 graph: &LockfileGraph,
1727 manifest: &aube_manifest::PackageJson,
1728 kind: LockfileKind,
1729) -> Result<PathBuf, Error> {
1730 let filename = match kind {
1731 LockfileKind::Aube => aube_lock_filename(project_dir),
1732 LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
1733 other => other.filename().to_string(),
1734 };
1735 let path = project_dir.join(&filename);
1736 match kind {
1737 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
1738 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
1739 LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
1740 LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
1741 LockfileKind::Bun => bun::write(&path, graph, manifest)?,
1742 }
1743 Ok(path)
1744}
1745
1746/// Return the [`LockfileKind`] of the lockfile already on disk in
1747/// `project_dir`, if any. Follows the same precedence as
1748/// [`parse_lockfile_with_kind`] (aube > pnpm > bun > yarn >
1749/// npm-shrinkwrap > npm). Used by install to preserve a project's
1750/// existing lockfile format when rewriting after a re-resolve — a
1751/// user with only `pnpm-lock.yaml`, `package-lock.json`, or another
1752/// supported lockfile gets that file written back, not a surprise
1753/// `aube-lock.yaml` alongside it.
1754pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
1755 for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
1756 if path.exists() {
1757 return Some(refine_yarn_kind(&path, kind));
1758 }
1759 }
1760 None
1761}
1762
1763/// Resolve the canonical lockfile filename for `project_dir` (aube's own).
1764///
1765/// Returns `aube-lock.<branch>.yaml` when `gitBranchLockfile: true` is
1766/// set in `pnpm-workspace.yaml` (or `aube-workspace.yaml`) and the
1767/// project is inside a git checkout with a current branch. Forward
1768/// slashes in the branch name are encoded as `!`, matching pnpm. Falls
1769/// back to plain `aube-lock.yaml` in every other case.
1770///
1771/// Memoized per `project_dir` for the lifetime of the process: a
1772/// single install resolves this 3–5 times (lockfile_candidates,
1773/// write_lockfile, debug log, state read/write), and
1774/// `check_needs_install` runs on every `aube run`/`aube exec` via
1775/// `ensure_installed`. Without caching, every command would pay for a
1776/// YAML parse + a `git branch --show-current` subprocess just to
1777/// recompute a value that can't change mid-process.
1778pub fn aube_lock_filename(project_dir: &Path) -> String {
1779 use std::sync::{Mutex, OnceLock};
1780 static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
1781 let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
1782 if let Ok(map) = cache.lock()
1783 && let Some(hit) = map.get(project_dir)
1784 {
1785 return hit.clone();
1786 }
1787 let resolved = if !git_branch_lockfile_enabled(project_dir) {
1788 "aube-lock.yaml".to_string()
1789 } else {
1790 match current_git_branch(project_dir) {
1791 Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
1792 None => "aube-lock.yaml".to_string(),
1793 }
1794 };
1795 if let Ok(mut map) = cache.lock() {
1796 map.insert(project_dir.to_path_buf(), resolved.clone());
1797 }
1798 resolved
1799}
1800
1801/// Resolve the pnpm lockfile filename for `project_dir`.
1802///
1803/// Mirrors [`aube_lock_filename`] for branch lockfiles, but keeps the
1804/// pnpm filename prefix so projects with an existing `pnpm-lock.yaml`
1805/// keep writing to pnpm's file.
1806pub fn pnpm_lock_filename(project_dir: &Path) -> String {
1807 let aube_name = aube_lock_filename(project_dir);
1808 // `aube_lock_filename` always returns "aube-lock.<rest>", so strip_prefix
1809 // always succeeds. The fallback is purely defensive.
1810 aube_name
1811 .strip_prefix("aube-lock.")
1812 .map(|rest| format!("pnpm-lock.{rest}"))
1813 .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
1814}
1815
1816fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
1817 // Goes through the build-time-generated typed accessor in
1818 // `aube_settings::resolved` so the alias list is driven off
1819 // `settings.toml` — no hand-maintained typed field. This path
1820 // reads only `pnpm-workspace.yaml`; `.npmrc` values are out of
1821 // scope here because aube-lockfile doesn't want a dependency on
1822 // aube-registry just to load npmrc (and the historical behavior
1823 // never read `.npmrc` either).
1824 let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
1825 return false;
1826 };
1827 let npmrc: Vec<(String, String)> = Vec::new();
1828 let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
1829 aube_settings::resolved::git_branch_lockfile(&ctx)
1830}
1831
1832pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
1833 let out = std::process::Command::new("git")
1834 .args(["-C"])
1835 .arg(project_dir)
1836 .args(["branch", "--show-current"])
1837 .output()
1838 .ok()?;
1839 if !out.status.success() {
1840 return None;
1841 }
1842 let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
1843 if branch.is_empty() {
1844 None
1845 } else {
1846 Some(branch)
1847 }
1848}
1849
1850/// Detect and parse the lockfile in the given project directory.
1851///
1852/// Priority: `aube-lock.yaml` → `pnpm-lock.yaml` → `bun.lock` →
1853/// `yarn.lock` → `npm-shrinkwrap.json` → `package-lock.json`.
1854/// (Shrinkwrap takes priority over package-lock.json when both exist, matching npm's behavior.)
1855///
1856/// `manifest` is needed to classify direct vs transitive deps when
1857/// reading yarn.lock (which has no notion of that distinction).
1858pub fn parse_lockfile(
1859 project_dir: &Path,
1860 manifest: &aube_manifest::PackageJson,
1861) -> Result<LockfileGraph, Error> {
1862 let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
1863 Ok(graph)
1864}
1865
1866/// Like [`parse_lockfile`] but also returns which format was read.
1867pub fn parse_lockfile_with_kind(
1868 project_dir: &Path,
1869 manifest: &aube_manifest::PackageJson,
1870) -> Result<(LockfileGraph, LockfileKind), Error> {
1871 reject_bun_binary(project_dir)?;
1872 for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
1873 if !path.exists() {
1874 continue;
1875 }
1876 let kind = refine_yarn_kind(&path, kind);
1877 let graph = parse_one(&path, kind, manifest)?;
1878 return Ok((graph, kind));
1879 }
1880 Err(Error::NotFound(project_dir.to_path_buf()))
1881}
1882
1883/// Variant of [`parse_lockfile_with_kind`] used by `aube import`.
1884///
1885/// Skips `aube-lock.yaml` — if the project already has one, there's
1886/// nothing to import. `pnpm-lock.yaml` *is* included because the whole
1887/// point of `aube import` is to convert a foreign lockfile (including
1888/// pnpm's) into `aube-lock.yaml`.
1889pub fn parse_for_import(
1890 project_dir: &Path,
1891 manifest: &aube_manifest::PackageJson,
1892) -> Result<(LockfileGraph, LockfileKind), Error> {
1893 reject_bun_binary(project_dir)?;
1894 for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ false) {
1895 if !path.exists() {
1896 continue;
1897 }
1898 let kind = refine_yarn_kind(&path, kind);
1899 let graph = parse_one(&path, kind, manifest)?;
1900 return Ok((graph, kind));
1901 }
1902 Err(Error::NotFound(project_dir.to_path_buf()))
1903}
1904
1905/// If only `bun.lockb` is present (without a text `bun.lock`), surface an
1906/// actionable error instead of silently falling through to another format.
1907fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
1908 let lockb = project_dir.join("bun.lockb");
1909 let text = project_dir.join("bun.lock");
1910 if lockb.exists() && !text.exists() {
1911 return Err(Error::parse(
1912 &lockb,
1913 "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",
1914 ));
1915 }
1916 Ok(())
1917}
1918
1919fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
1920 let mut out = Vec::new();
1921 if include_aube {
1922 // Prefer the branch-specific lockfile (if `gitBranchLockfile` is on
1923 // and we resolve a branch); fall through to plain `aube-lock.yaml`
1924 // so a freshly-enabled branch still picks up the base lockfile.
1925 let branch_name = aube_lock_filename(project_dir);
1926 if branch_name != "aube-lock.yaml" {
1927 out.push((project_dir.join(&branch_name), LockfileKind::Aube));
1928 }
1929 out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
1930 }
1931 // Preserve pnpm lockfiles in place. Branch-specific
1932 // `pnpm-lock.<branch>.yaml` mirrors the aube branch lockfile naming
1933 // logic, so a project that already uses pnpm branch lockfiles keeps
1934 // writing through that file.
1935 let pnpm_branch = {
1936 let mut s = aube_lock_filename(project_dir);
1937 if let Some(rest) = s.strip_prefix("aube-lock.") {
1938 s = format!("pnpm-lock.{rest}");
1939 }
1940 s
1941 };
1942 if pnpm_branch != "pnpm-lock.yaml" {
1943 out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
1944 }
1945 out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
1946 out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
1947 out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
1948 out.push((
1949 project_dir.join("npm-shrinkwrap.json"),
1950 LockfileKind::NpmShrinkwrap,
1951 ));
1952 out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
1953 out
1954}
1955
1956fn parse_one(
1957 path: &Path,
1958 kind: LockfileKind,
1959 manifest: &aube_manifest::PackageJson,
1960) -> Result<LockfileGraph, Error> {
1961 match kind {
1962 // `aube-lock.yaml` uses the same on-disk format as pnpm v9 for
1963 // now — same parser, same writer — so we piggyback on the pnpm
1964 // module. Keeping the variant distinct lets detection/import
1965 // treat the two differently even though the bytes are the same.
1966 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
1967 // yarn.rs::parse peeks the file for `__metadata:` and
1968 // dispatches between classic (v1) and berry (v2+) internally,
1969 // so we can hand both kinds to the same entry point. The
1970 // caller keeps the kind label it resolved from
1971 // `refine_yarn_kind` for downstream write-back.
1972 LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
1973 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
1974 LockfileKind::Bun => bun::parse(path),
1975 }
1976}
1977
1978/// Replace `LockfileKind::Yarn` with `LockfileKind::YarnBerry` when
1979/// the yarn.lock at `path` is actually a yarn 2+ lockfile. Other
1980/// kinds pass through unchanged.
1981///
1982/// `lockfile_candidates` only knows filenames, not content, so the
1983/// yarn entry is always tagged `Yarn`. Callers that need the precise
1984/// variant (install write-back, import conversions, drift logging)
1985/// funnel through this helper after confirming the candidate exists.
1986fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
1987 if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
1988 LockfileKind::YarnBerry
1989 } else {
1990 kind
1991 }
1992}
1993
1994#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1995pub enum Error {
1996 #[error("no lockfile found in {0}")]
1997 #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
1998 NotFound(std::path::PathBuf),
1999 #[error("unsupported lockfile format: {0}")]
2000 #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
2001 UnsupportedFormat(String),
2002 #[error("failed to read lockfile {0}: {1}")]
2003 Io(std::path::PathBuf, std::io::Error),
2004 /// Structural/serialization lockfile errors that have no source
2005 /// location — shape checks (`must be a mapping`), version guards
2006 /// (`lockfileVersion N unsupported`), and `yaml_serde::to_string`
2007 /// failures during write.
2008 #[error("failed to parse lockfile {0}: {1}")]
2009 #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
2010 Parse(std::path::PathBuf, String),
2011 /// Deserialization failure with a byte offset into the source
2012 /// content, so miette's `fancy` handler can draw a pointer at the
2013 /// offending byte of the lockfile. Reuses `aube_manifest`'s
2014 /// `ParseError` — identical shape, identical rendering — via the
2015 /// same `ParseDiag` pattern `aube-workspace` uses.
2016 #[error(transparent)]
2017 #[diagnostic(transparent)]
2018 ParseDiag(Box<aube_manifest::ParseError>),
2019}
2020
2021/// Read a lockfile from disk, mapping I/O errors to `Error::Io`.
2022pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
2023 std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
2024}
2025
2026/// Parse a JSON lockfile document, attaching a miette source span on
2027/// failure so the fancy handler can point at the offending byte.
2028pub fn parse_json<T: serde::de::DeserializeOwned>(
2029 path: &std::path::Path,
2030 content: String,
2031) -> Result<T, Error> {
2032 // simd-json mutates the input buffer in place to unflatten
2033 // escape sequences. On parse failure that mutation has already
2034 // happened, so the diagnostic must run on the ORIGINAL bytes,
2035 // not the simd-json buffer. Keeping `content` and feeding a
2036 // clone to simd-json preserves both: zero-alloc happy path on
2037 // simd-json success (one clone, dropped immediately), correct
2038 // diagnostic on failure (uses untouched `content`).
2039 let mut buf = content.clone().into_bytes();
2040 match simd_json::serde::from_slice(&mut buf) {
2041 Ok(v) => Ok(v),
2042 Err(_) => match serde_json::from_str(&content) {
2043 Ok(v) => Ok(v),
2044 Err(e) => Err(Error::parse_json_err(path, content, &e)),
2045 },
2046 }
2047}
2048
2049impl Error {
2050 pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
2051 Error::Parse(path.to_path_buf(), msg.into())
2052 }
2053
2054 pub fn parse_json_err(
2055 path: &std::path::Path,
2056 content: String,
2057 err: &serde_json::Error,
2058 ) -> Self {
2059 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
2060 path, content, err,
2061 )))
2062 }
2063
2064 pub fn parse_yaml_err(
2065 path: &std::path::Path,
2066 content: String,
2067 err: &yaml_serde::Error,
2068 ) -> Self {
2069 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
2070 path, content, err,
2071 )))
2072 }
2073}
2074
2075#[cfg(test)]
2076mod parse_diag_tests {
2077 use super::*;
2078 use std::path::Path;
2079
2080 /// Trailing `,` in an otherwise fine JSON lockfile — confirm the
2081 /// helper attaches a `NamedSource` pointed at the lockfile path and
2082 /// the span stays in bounds so miette can render a pointer.
2083 #[test]
2084 fn parse_json_attaches_span_for_bad_input() {
2085 let path = Path::new("package-lock.json");
2086 let content = r#"{"name":"x","#.to_string();
2087 let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
2088 else {
2089 panic!("parse_json must produce ParseDiag on malformed input");
2090 };
2091 let offset: usize = pe.span.offset();
2092 let len: usize = pe.span.len();
2093 assert!(offset + len <= content.len());
2094 assert_eq!(pe.path, path);
2095 }
2096
2097 /// Same story for YAML — yaml_serde reports a `Location` with a
2098 /// byte index directly, so no line/col conversion is exercised
2099 /// here. Both production sites (`pnpm.rs`, `yarn.rs`) call
2100 /// `Error::parse_yaml_err` directly (one iterates multiple YAML
2101 /// documents, the other has only borrowed content), so that's the
2102 /// entry point this test locks down.
2103 #[test]
2104 fn parse_yaml_err_attaches_span_for_bad_input() {
2105 let path = Path::new("yarn.lock");
2106 let content = "packages:\n\t- pkg\n".to_string();
2107 let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
2108 .expect_err("tab-indented YAML must fail");
2109 let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
2110 panic!("parse_yaml_err must produce ParseDiag");
2111 };
2112 let offset: usize = pe.span.offset();
2113 let len: usize = pe.span.len();
2114 assert!(offset + len <= content.len());
2115 assert_eq!(pe.path, path);
2116 }
2117}
2118
2119#[cfg(test)]
2120mod looks_like_remote_tarball_url_tests {
2121 use super::*;
2122
2123 #[test]
2124 fn matches_https_tgz() {
2125 assert!(LocalSource::looks_like_remote_tarball_url(
2126 "https://example.com/pkg-1.0.0.tgz"
2127 ));
2128 }
2129
2130 #[test]
2131 fn matches_http_tar_gz() {
2132 assert!(LocalSource::looks_like_remote_tarball_url(
2133 "http://example.com/pkg-1.0.0.tar.gz"
2134 ));
2135 }
2136
2137 #[test]
2138 fn strips_fragment_before_suffix_check() {
2139 assert!(LocalSource::looks_like_remote_tarball_url(
2140 "https://example.com/pkg-1.0.0.tgz#sha512-abc"
2141 ));
2142 }
2143
2144 #[test]
2145 fn strips_query_string_before_suffix_check() {
2146 // Auth-token URLs from private registries (JFrog, Nexus,
2147 // CodeArtifact, …) routinely trail `?token=…` after the
2148 // filename. Must still classify as a tarball URL.
2149 assert!(LocalSource::looks_like_remote_tarball_url(
2150 "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
2151 ));
2152 assert!(LocalSource::looks_like_remote_tarball_url(
2153 "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
2154 ));
2155 }
2156
2157 #[test]
2158 fn matches_bare_http_url_without_tarball_suffix() {
2159 // pkg.pr.new serves tarballs from URLs without a `.tgz`
2160 // extension; npm treats all non-git http(s) URLs as tarball
2161 // URLs, so these must classify as remote tarballs.
2162 assert!(LocalSource::looks_like_remote_tarball_url(
2163 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
2164 ));
2165 assert!(LocalSource::looks_like_remote_tarball_url(
2166 "https://codeload.github.com/user/repo/tar.gz/main"
2167 ));
2168 }
2169
2170 #[test]
2171 fn rejects_non_http_schemes() {
2172 assert!(!LocalSource::looks_like_remote_tarball_url(
2173 "ftp://example.com/pkg.tgz"
2174 ));
2175 assert!(!LocalSource::looks_like_remote_tarball_url(
2176 "git://example.com/repo.git"
2177 ));
2178 }
2179
2180 #[test]
2181 fn parse_classifies_bare_http_url_as_remote_tarball() {
2182 use std::path::Path;
2183 let parsed = LocalSource::parse(
2184 "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
2185 Path::new(""),
2186 );
2187 assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
2188 }
2189
2190 #[test]
2191 fn parse_prefers_git_over_tarball_for_dot_git_url() {
2192 use std::path::Path;
2193 let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
2194 assert!(matches!(parsed, Some(LocalSource::Git(_))));
2195 }
2196}
2197
2198#[cfg(test)]
2199mod filename_tests {
2200 use super::*;
2201
2202 #[test]
2203 fn defaults_to_plain_lockfile_when_setting_absent() {
2204 let dir = tempfile::tempdir().unwrap();
2205 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2206 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
2207 }
2208
2209 #[test]
2210 fn defaults_to_plain_lockfile_when_setting_explicit_false() {
2211 let dir = tempfile::tempdir().unwrap();
2212 std::fs::write(
2213 dir.path().join("pnpm-workspace.yaml"),
2214 "gitBranchLockfile: false\n",
2215 )
2216 .unwrap();
2217 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
2218 }
2219
2220 #[test]
2221 fn uses_branch_filename_when_enabled_inside_git_repo() {
2222 let dir = tempfile::tempdir().unwrap();
2223 std::fs::write(
2224 dir.path().join("pnpm-workspace.yaml"),
2225 "gitBranchLockfile: true\n",
2226 )
2227 .unwrap();
2228 // git init + checkout a branch with a `/` so we exercise the
2229 // pnpm-style `!` encoding.
2230 let run = |args: &[&str]| {
2231 std::process::Command::new("git")
2232 .args(["-C"])
2233 .arg(dir.path())
2234 .args(args)
2235 .output()
2236 .unwrap()
2237 };
2238 if run(&["init", "-q"]).status.success() {
2239 run(&["checkout", "-q", "-b", "feature/x"]);
2240 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
2241 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
2242 }
2243 }
2244}
2245
2246#[cfg(test)]
2247mod git_spec_tests {
2248 use super::*;
2249
2250 #[test]
2251 fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
2252 // Initial parse: `git+https://…/repo` (no `.git`).
2253 let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
2254 assert_eq!(url, "https://host/user/repo");
2255 assert_eq!(committish, None);
2256 assert_eq!(subpath, None);
2257
2258 // After resolving, the serializer writes `<url>#<sha>` into
2259 // the lockfile's importer `version:` field.
2260 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2261 let source = LocalSource::Git(GitSource {
2262 url: url.clone(),
2263 committish: None,
2264 resolved: sha.to_string(),
2265 subpath: None,
2266 });
2267 let lockfile_version = source.specifier();
2268 assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
2269
2270 // Re-parse must recognize the bare URL because the 40-hex
2271 // committish suffix unambiguously tags it as git.
2272 let (round_url, round_committish, round_subpath) =
2273 parse_git_spec(&lockfile_version).unwrap();
2274 assert_eq!(round_url, "https://host/user/repo");
2275 assert_eq!(round_committish.as_deref(), Some(sha));
2276 assert_eq!(round_subpath, None);
2277 }
2278
2279 #[test]
2280 fn bare_https_without_dot_git_and_no_committish_is_not_git() {
2281 // A plain `https://…` URL with no `.git` and no SHA could be
2282 // anything (including a tarball); don't claim it.
2283 assert!(parse_git_spec("https://example.com/pkg").is_none());
2284 }
2285
2286 #[test]
2287 fn github_shorthand_expands_and_roundtrips() {
2288 let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
2289 assert_eq!(url, "https://github.com/user/repo.git");
2290 }
2291
2292 #[test]
2293 fn bare_user_repo_expands_to_github() {
2294 let (url, committish, subpath) = parse_git_spec("kevva/is-negative").unwrap();
2295 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2296 assert!(committish.is_none());
2297 assert!(subpath.is_none());
2298 }
2299
2300 #[test]
2301 fn bare_user_repo_with_committish_preserved() {
2302 let (url, committish, _) = parse_git_spec("kevva/is-negative#v1.0.0").unwrap();
2303 assert_eq!(url, "https://github.com/kevva/is-negative.git");
2304 assert_eq!(committish.as_deref(), Some("v1.0.0"));
2305 }
2306
2307 #[test]
2308 fn bare_scope_pkg_is_not_git_shorthand() {
2309 // npm-style `@scope/pkg` is a registry name, not a GitHub shorthand.
2310 assert!(parse_git_spec("@types/node").is_none());
2311 }
2312
2313 #[test]
2314 fn bare_relative_path_is_not_git_shorthand() {
2315 // Single-component relative paths split as owner=".", owner="..",
2316 // so owner-starts-with-`.` is the load-bearing guard here.
2317 assert!(parse_git_spec("./repo").is_none());
2318 assert!(parse_git_spec("../repo").is_none());
2319 // Multi-component relative paths additionally fail the
2320 // single-`/`-only guard.
2321 assert!(parse_git_spec("./local/path").is_none());
2322 assert!(parse_git_spec("../local/path").is_none());
2323 }
2324
2325 #[test]
2326 fn bare_path_with_extra_slashes_is_not_git_shorthand() {
2327 // Real GitHub shorthand is exactly `user/repo` — anything with a
2328 // second `/` is a path, not a shorthand.
2329 assert!(parse_git_spec("path/with/slashes/extra").is_none());
2330 }
2331
2332 #[test]
2333 fn bare_scp_form_unknown_host_is_not_github_shorthand() {
2334 // `user@host:repo.git` is scp form (handled or rejected above);
2335 // the bare-shorthand branch must not pick it up.
2336 assert!(parse_git_spec("user@host:repo.git").is_none());
2337 }
2338
2339 #[test]
2340 fn scp_form_recognized() {
2341 let (url, committish, _) =
2342 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
2343 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2344 assert!(committish.is_none());
2345 }
2346
2347 #[test]
2348 fn scp_form_with_ref_recognized() {
2349 let (url, committish, _) =
2350 parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
2351 assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
2352 assert_eq!(committish.as_deref(), Some("0.1.5"));
2353 }
2354
2355 #[test]
2356 fn scp_form_bitbucket_recognized() {
2357 let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
2358 assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
2359 }
2360
2361 #[test]
2362 fn scp_form_unknown_host_rejected() {
2363 // pnpm 11 treats `user@unknown-host:path` as a local path, not Git.
2364 assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
2365 assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
2366 }
2367
2368 #[test]
2369 fn scp_form_without_user_rejected() {
2370 // pnpm 11 errors on bare `host:path` as unsupported.
2371 assert!(parse_git_spec("github.com:user/repo.git").is_none());
2372 }
2373
2374 #[test]
2375 fn commit_selector_fragment_normalizes_to_sha() {
2376 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2377 let (url, committish, _) =
2378 parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
2379 assert_eq!(url, "https://host/user/repo.git");
2380 assert_eq!(committish.as_deref(), Some(sha));
2381 }
2382
2383 #[test]
2384 fn named_selector_fragment_normalizes_to_ref() {
2385 let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
2386 assert_eq!(url, "https://host/user/repo");
2387 assert_eq!(committish.as_deref(), Some("v1.2.3"));
2388 }
2389
2390 #[test]
2391 fn pnpm_path_subpath_extracted_from_fragment() {
2392 // pnpm syntax: `<url>#<ref>&path:/<subdir>` selects a
2393 // subdirectory of the cloned repo as the package root.
2394 let (url, committish, subpath) =
2395 parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
2396 assert_eq!(url, "https://github.com/org/dep.git");
2397 assert_eq!(committish.as_deref(), Some("v0.1.4"));
2398 assert_eq!(subpath.as_deref(), Some("packages/special"));
2399 }
2400
2401 #[test]
2402 fn path_subpath_roundtrips_via_specifier() {
2403 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2404 let source = LocalSource::Git(GitSource {
2405 url: "https://github.com/org/dep.git".to_string(),
2406 committish: None,
2407 resolved: sha.to_string(),
2408 subpath: Some("packages/special".to_string()),
2409 });
2410 let spec = source.specifier();
2411 assert_eq!(
2412 spec,
2413 format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
2414 );
2415 let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
2416 assert_eq!(url, "https://github.com/org/dep.git");
2417 assert_eq!(committish.as_deref(), Some(sha));
2418 assert_eq!(subpath.as_deref(), Some("packages/special"));
2419 }
2420
2421 #[test]
2422 fn parse_hosted_git_recognizes_canonical_forms() {
2423 // All these point at the same (github.com, owner, repo) tuple
2424 // and must map to the same HostedGit so the runtime fetch URL
2425 // doesn't depend on which scheme the lockfile happens to record.
2426 let canonical = HostedGit {
2427 host: HostedGitHost::GitHub,
2428 owner: "owner".to_string(),
2429 repo: "repo".to_string(),
2430 };
2431 for spec in [
2432 "https://github.com/owner/repo.git",
2433 "https://github.com/owner/repo",
2434 "http://github.com/owner/repo.git",
2435 "git+https://github.com/owner/repo.git",
2436 "git+https://github.com/owner/repo",
2437 "git://github.com/owner/repo.git",
2438 "ssh://git@github.com/owner/repo.git",
2439 "git+ssh://git@github.com/owner/repo.git",
2440 "git@github.com:owner/repo.git",
2441 ] {
2442 assert_eq!(
2443 parse_hosted_git(spec).as_ref(),
2444 Some(&canonical),
2445 "spec {spec} should map to canonical HostedGit",
2446 );
2447 }
2448 }
2449
2450 #[test]
2451 fn parse_hosted_git_returns_none_for_non_hosted() {
2452 // Self-hosted GitLab / Gitea / arbitrary hosts: no codeload
2453 // template, so the codeload fast path doesn't apply.
2454 for spec in [
2455 "https://example.com/owner/repo.git",
2456 "ssh://git@gitea.internal/owner/repo.git",
2457 "git+ssh://git@gitlab.example.com/group/sub/repo.git",
2458 "https://github.com/owner/repo/sub",
2459 "https://github.com/owner",
2460 ] {
2461 assert!(
2462 parse_hosted_git(spec).is_none(),
2463 "spec {spec} must not match a hosted provider",
2464 );
2465 }
2466 }
2467
2468 #[test]
2469 fn hosted_tarball_url_only_for_full_sha() {
2470 let g = HostedGit {
2471 host: HostedGitHost::GitHub,
2472 owner: "o".to_string(),
2473 repo: "r".to_string(),
2474 };
2475 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2476 assert_eq!(
2477 g.tarball_url(sha).as_deref(),
2478 Some("https://codeload.github.com/o/r/tar.gz/abcdef0123456789abcdef0123456789abcdef01"),
2479 );
2480 // Branch / tag / abbreviated SHA don't take the fast path —
2481 // codeload accepts them but the wrapper-dir name varies and
2482 // we can't verify a non-SHA committish post-extraction.
2483 assert!(g.tarball_url("main").is_none());
2484 assert!(g.tarball_url("v1.2.3").is_none());
2485 assert!(g.tarball_url("abcdef0").is_none());
2486 }
2487
2488 #[test]
2489 fn hosted_tarball_url_per_provider() {
2490 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2491 let gitlab = HostedGit {
2492 host: HostedGitHost::GitLab,
2493 owner: "g".to_string(),
2494 repo: "r".to_string(),
2495 }
2496 .tarball_url(sha)
2497 .unwrap();
2498 assert!(gitlab.starts_with("https://gitlab.com/g/r/-/archive/"));
2499 assert!(gitlab.ends_with("/r-abcdef0123456789abcdef0123456789abcdef01.tar.gz"));
2500 let bitbucket = HostedGit {
2501 host: HostedGitHost::Bitbucket,
2502 owner: "g".to_string(),
2503 repo: "r".to_string(),
2504 }
2505 .tarball_url(sha)
2506 .unwrap();
2507 assert_eq!(
2508 bitbucket,
2509 "https://bitbucket.org/g/r/get/abcdef0123456789abcdef0123456789abcdef01.tar.gz",
2510 );
2511 }
2512
2513 #[test]
2514 fn hosted_https_url_normalizes() {
2515 let g = parse_hosted_git("git+ssh://git@github.com/owner/repo.git").unwrap();
2516 assert_eq!(g.https_url(), "https://github.com/owner/repo.git");
2517 }
2518
2519 #[test]
2520 fn path_traversal_components_in_subpath_are_rejected() {
2521 // `..` and `.` components would let a crafted spec escape the
2522 // clone dir at install time. The parser drops them so the
2523 // resolver/installer never see a traversal-laden subpath.
2524 let cases = [
2525 "github:org/dep#main&path:/../../etc",
2526 "github:org/dep#main&path:/packages/../../../etc",
2527 "github:org/dep#main&path:/./packages/foo",
2528 "github:org/dep#main&path:/packages//foo",
2529 ];
2530 for spec in cases {
2531 let (_, _, subpath) = parse_git_spec(spec).unwrap();
2532 assert_eq!(subpath, None, "spec should drop subpath: {spec}");
2533 }
2534 }
2535
2536 #[test]
2537 fn dep_path_distinguishes_subpaths_under_same_commit() {
2538 // Two packages from the same repo+commit but different
2539 // subdirs must hash to distinct dep_paths so the linker
2540 // doesn't collapse them.
2541 let sha = "abcdef0123456789abcdef0123456789abcdef01";
2542 let a = LocalSource::Git(GitSource {
2543 url: "https://example.com/r.git".to_string(),
2544 committish: None,
2545 resolved: sha.to_string(),
2546 subpath: Some("packages/a".to_string()),
2547 });
2548 let b = LocalSource::Git(GitSource {
2549 url: "https://example.com/r.git".to_string(),
2550 committish: None,
2551 resolved: sha.to_string(),
2552 subpath: Some("packages/b".to_string()),
2553 });
2554 assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
2555 }
2556}
2557
2558#[cfg(test)]
2559mod drift_tests {
2560 use super::*;
2561 use aube_manifest::PackageJson;
2562 use std::collections::BTreeMap;
2563
2564 fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
2565 let mut m = PackageJson {
2566 name: Some("test".into()),
2567 version: Some("1.0.0".into()),
2568 dependencies: BTreeMap::new(),
2569 dev_dependencies: BTreeMap::new(),
2570 peer_dependencies: BTreeMap::new(),
2571 optional_dependencies: BTreeMap::new(),
2572 update_config: None,
2573 scripts: BTreeMap::new(),
2574 engines: BTreeMap::new(),
2575 workspaces: None,
2576 bundled_dependencies: None,
2577 extra: BTreeMap::new(),
2578 };
2579 for (name, spec) in deps {
2580 m.dependencies.insert((*name).into(), (*spec).into());
2581 }
2582 m
2583 }
2584
2585 fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
2586 // (name, specifier, dep_path)
2587 let direct: Vec<DirectDep> = deps
2588 .iter()
2589 .map(|(name, spec, dep_path)| DirectDep {
2590 name: (*name).into(),
2591 dep_path: (*dep_path).into(),
2592 dep_type: DepType::Production,
2593 specifier: Some((*spec).into()),
2594 })
2595 .collect();
2596 let mut importers = BTreeMap::new();
2597 importers.insert(".".to_string(), direct);
2598 LockfileGraph {
2599 importers,
2600 packages: BTreeMap::new(),
2601 ..Default::default()
2602 }
2603 }
2604
2605 #[test]
2606 fn fresh_when_specifiers_match() {
2607 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2608 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2609 assert_eq!(
2610 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2611 DriftStatus::Fresh
2612 );
2613 }
2614
2615 #[test]
2616 fn stale_when_specifier_changes() {
2617 let manifest = make_manifest(&[("lodash", "^4.18.0")]);
2618 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2619 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2620 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2621 DriftStatus::Fresh => panic!("expected Stale"),
2622 }
2623 }
2624
2625 #[test]
2626 fn stale_when_manifest_adds_dep() {
2627 let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
2628 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2629 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2630 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2631 DriftStatus::Fresh => panic!("expected Stale"),
2632 }
2633 }
2634
2635 #[test]
2636 fn stale_when_manifest_removes_dep() {
2637 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2638 let graph = make_graph(&[
2639 ("lodash", "^4.17.0", "lodash@4.17.21"),
2640 ("express", "^4.18.0", "express@4.18.0"),
2641 ]);
2642 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2643 DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2644 DriftStatus::Fresh => panic!("expected Stale"),
2645 }
2646 }
2647
2648 // Regression guard for #42: the drift check must recognize
2649 // auto-hoisted peers as derived state, not as "manifest removed X".
2650 // Without this, every project that has any peer dep would trigger
2651 // a full re-resolve on every install, defeating lockfile caching.
2652 #[test]
2653 fn fresh_when_lockfile_has_auto_hoisted_peer() {
2654 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2655 let mut graph = make_graph(&[
2656 (
2657 "use-sync-external-store",
2658 "1.2.0",
2659 "use-sync-external-store@1.2.0",
2660 ),
2661 // Hoisted peer — in the lockfile importers but not in the
2662 // user's package.json.
2663 ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
2664 ]);
2665 // The declaring package must list react as a peer for the
2666 // drift check to recognize the hoist. We add that here.
2667 let mut declaring_pkg = LockedPackage {
2668 name: "use-sync-external-store".into(),
2669 version: "1.2.0".into(),
2670 dep_path: "use-sync-external-store@1.2.0".into(),
2671 ..Default::default()
2672 };
2673 declaring_pkg
2674 .peer_dependencies
2675 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2676 graph
2677 .packages
2678 .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
2679
2680 assert_eq!(
2681 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2682 DriftStatus::Fresh
2683 );
2684 }
2685
2686 // Regression: when a user explicitly pinned a dep that also happens
2687 // to share its name with a peer declaration elsewhere in the graph,
2688 // removing that pin from package.json must still be flagged as
2689 // stale — otherwise the old pinned version gets locked forever.
2690 // The check must key on (name, specifier), not name alone.
2691 #[test]
2692 fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
2693 // Manifest after the user removed react entirely. Only
2694 // use-sync-external-store remains.
2695 let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2696
2697 // Lockfile still has the user's old `react: 17.0.2` pin alongside
2698 // use-sync-external-store. Pre-removal state.
2699 let mut graph = make_graph(&[
2700 (
2701 "use-sync-external-store",
2702 "1.2.0",
2703 "use-sync-external-store@1.2.0",
2704 ),
2705 ("react", "17.0.2", "react@17.0.2"),
2706 ]);
2707 // Add the peer declaration on the consumer package. This is
2708 // the case that previously defeated the name-only check:
2709 // react's specifier "17.0.2" doesn't match the declared peer
2710 // range, so the hoist recognizer must reject it.
2711 let mut consumer = LockedPackage {
2712 name: "use-sync-external-store".into(),
2713 version: "1.2.0".into(),
2714 dep_path: "use-sync-external-store@1.2.0".into(),
2715 ..Default::default()
2716 };
2717 consumer
2718 .peer_dependencies
2719 .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2720 graph
2721 .packages
2722 .insert("use-sync-external-store@1.2.0".into(), consumer);
2723
2724 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2725 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2726 DriftStatus::Fresh => panic!(
2727 "drift check should flag a removed user-pinned dep as stale, \
2728 even when its name matches a peer declaration"
2729 ),
2730 }
2731 }
2732
2733 // But if the lockfile has a user-removed dep that ISN'T declared as a
2734 // peer anywhere, we still need to flag it as stale.
2735 #[test]
2736 fn stale_when_lockfile_has_removed_non_peer_dep() {
2737 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2738 let graph = make_graph(&[
2739 ("lodash", "^4.17.0", "lodash@4.17.21"),
2740 ("chalk", "^5.0.0", "chalk@5.0.0"),
2741 ]);
2742 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2743 DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
2744 DriftStatus::Fresh => panic!("expected Stale"),
2745 }
2746 }
2747
2748 #[test]
2749 fn fresh_when_no_specifiers_recorded() {
2750 // Non-pnpm formats (npm/yarn/bun) don't store specifiers, so we can't
2751 // detect drift — we treat them as fresh and let the resolver decide.
2752 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2753 let graph = LockfileGraph {
2754 importers: {
2755 let mut m = BTreeMap::new();
2756 m.insert(
2757 ".".to_string(),
2758 vec![DirectDep {
2759 name: "lodash".into(),
2760 dep_path: "lodash@4.17.21".into(),
2761 dep_type: DepType::Production,
2762 specifier: None,
2763 }],
2764 );
2765 m
2766 },
2767 packages: BTreeMap::new(),
2768 ..Default::default()
2769 };
2770 assert_eq!(
2771 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2772 DriftStatus::Fresh
2773 );
2774 }
2775
2776 #[test]
2777 fn stale_when_manifest_adds_override() {
2778 // Lockfile recorded no overrides; manifest now has one. Drift
2779 // must fire so the next install re-runs the resolver and bakes
2780 // the override into the graph.
2781 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2782 manifest
2783 .extra
2784 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2785 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2786 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2787 DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
2788 DriftStatus::Fresh => panic!("expected Stale"),
2789 }
2790 }
2791
2792 #[test]
2793 fn stale_drift_message_names_changed_override_key() {
2794 // Both sides have one entry, but the value differs. The reason
2795 // should name the key — the previous "lockfile: 1 entries,
2796 // manifest: 1 entries" message looked like nothing changed.
2797 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2798 manifest
2799 .extra
2800 .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
2801 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2802 graph.overrides.insert("lodash".into(), "4.17.21".into());
2803 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2804 DriftStatus::Stale { reason } => {
2805 assert!(reason.contains("lodash"), "expected key in: {reason}");
2806 assert!(
2807 reason.contains("4.17.21"),
2808 "expected old value in: {reason}"
2809 );
2810 assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
2811 }
2812 DriftStatus::Fresh => panic!("expected Stale"),
2813 }
2814 }
2815
2816 #[test]
2817 fn stale_when_manifest_removes_override() {
2818 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2819 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2820 graph.overrides.insert("lodash".into(), "4.17.21".into());
2821 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
2822 DriftStatus::Stale { reason } => {
2823 assert!(reason.contains("removes"));
2824 assert!(reason.contains("lodash"));
2825 }
2826 DriftStatus::Fresh => panic!("expected Stale"),
2827 }
2828 }
2829
2830 #[test]
2831 fn fresh_when_overrides_match() {
2832 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2833 manifest
2834 .extra
2835 .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2836 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2837 graph.overrides.insert("lodash".into(), "4.17.21".into());
2838 assert_eq!(
2839 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
2840 DriftStatus::Fresh
2841 );
2842 }
2843
2844 #[test]
2845 fn fresh_when_workspace_yaml_overrides_match_lockfile() {
2846 // pnpm v10 moved `overrides` to pnpm-workspace.yaml. When the
2847 // resolver wrote them into `self.overrides`, the drift check
2848 // must see the same map — otherwise the second install run
2849 // rejects the lockfile as stale with "manifest removes ..."
2850 // (reported in discussion #174).
2851 let manifest = make_manifest(&[("semver", "^7.5.0")]);
2852 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2853 graph.overrides.insert("semver".into(), "7.7.1".into());
2854 let mut ws_overrides = BTreeMap::new();
2855 ws_overrides.insert("semver".into(), "7.7.1".into());
2856 assert_eq!(
2857 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2858 DriftStatus::Fresh,
2859 );
2860 }
2861
2862 #[test]
2863 fn workspace_yaml_overrides_win_over_package_json() {
2864 // When both pnpm-workspace.yaml and package.json declare an
2865 // override for the same key, the workspace yaml wins — pnpm
2866 // v10's precedence. The drift check must apply the merged
2867 // effective map.
2868 let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
2869 manifest
2870 .extra
2871 .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
2872 let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2873 graph.overrides.insert("semver".into(), "7.7.1".into());
2874 let mut ws_overrides = BTreeMap::new();
2875 ws_overrides.insert("semver".into(), "7.7.1".into());
2876 assert_eq!(
2877 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2878 DriftStatus::Fresh,
2879 );
2880 }
2881
2882 #[test]
2883 fn fresh_when_override_catalog_ref_matches_lockfile_resolved() {
2884 // pnpm-workspace.yaml: `overrides: { lodash: "catalog:" }` with
2885 // `catalog: { lodash: 4.17.21 }`. pnpm writes the lockfile with
2886 // the resolved override value (`lodash: 4.17.21`), so a frozen
2887 // install comparing the raw `catalog:` string against the
2888 // resolved form would always read stale (discussion #174).
2889 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2890 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2891 graph.overrides.insert("lodash".into(), "4.17.21".into());
2892 let mut ws_overrides = BTreeMap::new();
2893 ws_overrides.insert("lodash".into(), "catalog:".into());
2894 let mut catalogs = BTreeMap::new();
2895 let mut default_cat = BTreeMap::new();
2896 default_cat.insert("lodash".into(), "4.17.21".into());
2897 catalogs.insert("default".into(), default_cat);
2898 assert_eq!(
2899 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
2900 DriftStatus::Fresh,
2901 );
2902 }
2903
2904 #[test]
2905 fn fresh_when_override_named_catalog_ref_matches_lockfile_resolved() {
2906 // Named catalog variant: `overrides: { lodash: "catalog:evens" }`
2907 // resolves against `catalogs.evens.lodash`.
2908 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2909 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2910 graph.overrides.insert("lodash".into(), "4.17.21".into());
2911 let mut ws_overrides = BTreeMap::new();
2912 ws_overrides.insert("lodash".into(), "catalog:evens".into());
2913 let mut catalogs = BTreeMap::new();
2914 let mut evens = BTreeMap::new();
2915 evens.insert("lodash".into(), "4.17.21".into());
2916 catalogs.insert("evens".into(), evens);
2917 assert_eq!(
2918 graph.check_drift(&manifest, &ws_overrides, &[], &catalogs),
2919 DriftStatus::Fresh,
2920 );
2921 }
2922
2923 #[test]
2924 fn stale_when_override_catalog_ref_diverges_from_lockfile() {
2925 // If the catalog moves to a new version, the resolved override
2926 // no longer matches the lockfile — drift must fire, not silently
2927 // accept.
2928 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2929 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2930 graph.overrides.insert("lodash".into(), "4.17.21".into());
2931 let mut ws_overrides = BTreeMap::new();
2932 ws_overrides.insert("lodash".into(), "catalog:".into());
2933 let mut catalogs = BTreeMap::new();
2934 let mut default_cat = BTreeMap::new();
2935 default_cat.insert("lodash".into(), "4.17.22".into());
2936 catalogs.insert("default".into(), default_cat);
2937 match graph.check_drift(&manifest, &ws_overrides, &[], &catalogs) {
2938 DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2939 other => panic!("expected stale, got {other:?}"),
2940 }
2941 }
2942
2943 #[test]
2944 fn fresh_when_pnpm_wrote_override_rewritten_importer_spec() {
2945 // pnpm rewrites the importer `specifier:` to the post-override
2946 // value when a bare-name override applies, so a pnpm-generated
2947 // lockfile records `specifier: 4.17.21` even though
2948 // `package.json` still reads `^4.17.0`. Without override-aware
2949 // drift, every frozen install against a pnpm lockfile with
2950 // overrides reads stale (discussion #174).
2951 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2952 let mut importers = BTreeMap::new();
2953 importers.insert(
2954 ".".to_string(),
2955 vec![DirectDep {
2956 name: "lodash".into(),
2957 dep_path: "lodash@4.17.21".into(),
2958 dep_type: DepType::Production,
2959 specifier: Some("4.17.21".into()),
2960 }],
2961 );
2962 let mut graph = LockfileGraph {
2963 importers,
2964 ..Default::default()
2965 };
2966 graph.overrides.insert("lodash".into(), "4.17.21".into());
2967 let mut ws_overrides = BTreeMap::new();
2968 ws_overrides.insert("lodash".into(), "4.17.21".into());
2969 assert_eq!(
2970 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
2971 DriftStatus::Fresh,
2972 );
2973 }
2974
2975 #[test]
2976 fn fresh_when_version_keyed_override_rewrites_importer_spec() {
2977 // Discussion #352: an override keyed by name+range
2978 // (`plist@<3.0.5` → `>=3.0.5`) rewrites the importer specifier
2979 // the same way bare-name overrides do. The drift check has to
2980 // parse the key and compare-by-rule, not by raw map lookup,
2981 // otherwise pnpm-written lockfiles read stale on every frozen
2982 // install when version-conditional overrides are in play.
2983 let manifest = make_manifest(&[("plist", "^3.0.4")]);
2984 let mut importers = BTreeMap::new();
2985 importers.insert(
2986 ".".to_string(),
2987 vec![DirectDep {
2988 name: "plist".into(),
2989 dep_path: "plist@3.0.6".into(),
2990 dep_type: DepType::Production,
2991 specifier: Some(">=3.0.5".into()),
2992 }],
2993 );
2994 let mut graph = LockfileGraph {
2995 importers,
2996 ..Default::default()
2997 };
2998 graph
2999 .overrides
3000 .insert("plist@<3.0.5".into(), ">=3.0.5".into());
3001 let mut ws_overrides = BTreeMap::new();
3002 ws_overrides.insert("plist@<3.0.5".into(), ">=3.0.5".into());
3003 assert_eq!(
3004 graph.check_drift(&manifest, &ws_overrides, &[], &BTreeMap::new()),
3005 DriftStatus::Fresh,
3006 );
3007 }
3008
3009 #[test]
3010 fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
3011 // Same drift-shaped bug as overrides: the resolver unions
3012 // `ignoredOptionalDependencies` from package.json and
3013 // pnpm-workspace.yaml, so the lockfile's
3014 // `ignored_optional_dependencies` carries the union, and the
3015 // drift check has to see the same union or the next
3016 // `--frozen-lockfile` run fails with "manifest removes".
3017 let manifest = make_manifest(&[("lodash", "^4.17.0")]);
3018 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3019 graph
3020 .ignored_optional_dependencies
3021 .insert("fsevents".to_string());
3022 let ws_ignored = vec!["fsevents".to_string()];
3023 assert_eq!(
3024 graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored, &BTreeMap::new()),
3025 DriftStatus::Fresh,
3026 );
3027 }
3028
3029 #[test]
3030 fn fresh_when_optional_dep_was_recorded_as_skipped() {
3031 // Regression: a platform-skipped optional dep would otherwise
3032 // loop forever as "manifest adds X". When the previous
3033 // resolve recorded it under skipped_optional_dependencies with
3034 // a matching specifier, drift must report Fresh.
3035 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3036 manifest
3037 .optional_dependencies
3038 .insert("fsevents".into(), "^2.3.0".into());
3039 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3040 let mut inner = BTreeMap::new();
3041 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3042 graph
3043 .skipped_optional_dependencies
3044 .insert(".".to_string(), inner);
3045 assert_eq!(
3046 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3047 DriftStatus::Fresh
3048 );
3049 }
3050
3051 #[test]
3052 fn stale_when_new_optional_dep_was_never_seen() {
3053 // Cursor Bugbot regression: a brand-new optional dep that the
3054 // previous resolve never saw must trigger drift, otherwise it
3055 // would silently never get installed. Distinct from a
3056 // platform-skipped optional, which has an entry in
3057 // `skipped_optional_dependencies`.
3058 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3059 manifest
3060 .optional_dependencies
3061 .insert("fsevents".into(), "^2.3.0".into());
3062 let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3063 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3064 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3065 DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
3066 }
3067 }
3068
3069 #[test]
3070 fn stale_when_skipped_optional_dep_specifier_changes() {
3071 // The user bumped the range on a previously-skipped optional;
3072 // the recorded specifier no longer matches the manifest, so we
3073 // need to re-resolve.
3074 let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
3075 manifest
3076 .optional_dependencies
3077 .insert("fsevents".into(), "^2.4.0".into());
3078 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3079 let mut inner = BTreeMap::new();
3080 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3081 graph
3082 .skipped_optional_dependencies
3083 .insert(".".to_string(), inner);
3084 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3085 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3086 DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
3087 }
3088 }
3089
3090 #[test]
3091 fn stale_when_skipped_optional_is_promoted_to_required() {
3092 // Cursor Bugbot regression: if the user moves a previously-
3093 // skipped optional into `dependencies` (same specifier), the
3094 // skipped-list exemption must NOT fire — the dep is now
3095 // required and the lockfile genuinely doesn't include it.
3096 let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
3097 // Note: fsevents lives in `dependencies`, not
3098 // `optional_dependencies`, even though the lockfile recorded
3099 // it under skipped optionals from a previous resolve.
3100 manifest.optional_dependencies.clear();
3101 let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
3102 let mut inner = BTreeMap::new();
3103 inner.insert("fsevents".to_string(), "^2.3.0".to_string());
3104 graph
3105 .skipped_optional_dependencies
3106 .insert(".".to_string(), inner);
3107 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3108 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3109 DriftStatus::Fresh => {
3110 panic!("expected Stale: skipped-optional exemption must not apply to required deps")
3111 }
3112 }
3113 }
3114
3115 #[test]
3116 fn stale_when_optional_dep_specifier_changes_in_lockfile() {
3117 // Spec changes on optionals that *are* present must still
3118 // drift, so the resolver re-runs when the user bumps a range.
3119 let mut manifest = make_manifest(&[]);
3120 manifest
3121 .optional_dependencies
3122 .insert("fsevents".into(), "^2.4.0".into());
3123 let mut graph = make_graph(&[]);
3124 graph.importers.get_mut(".").unwrap().push(DirectDep {
3125 name: "fsevents".into(),
3126 dep_path: "fsevents@2.3.0".into(),
3127 dep_type: DepType::Optional,
3128 specifier: Some("^2.3.0".into()),
3129 });
3130 match graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()) {
3131 DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
3132 DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
3133 }
3134 }
3135
3136 #[test]
3137 fn fresh_for_empty_manifest_and_lockfile() {
3138 let manifest = make_manifest(&[]);
3139 let graph = make_graph(&[]);
3140 assert_eq!(
3141 graph.check_drift(&manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3142 DriftStatus::Fresh
3143 );
3144 }
3145
3146 #[test]
3147 fn workspace_drift_detects_change_in_non_root_importer() {
3148 // Build a graph with two importers: root and packages/app.
3149 let root_dep = DirectDep {
3150 name: "lodash".into(),
3151 dep_path: "lodash@4.17.21".into(),
3152 dep_type: DepType::Production,
3153 specifier: Some("^4.17.0".into()),
3154 };
3155 let app_dep = DirectDep {
3156 name: "express".into(),
3157 dep_path: "express@4.18.0".into(),
3158 dep_type: DepType::Production,
3159 specifier: Some("^4.18.0".into()),
3160 };
3161 let mut importers = BTreeMap::new();
3162 importers.insert(".".to_string(), vec![root_dep]);
3163 importers.insert("packages/app".to_string(), vec![app_dep]);
3164 let graph = LockfileGraph {
3165 importers,
3166 packages: BTreeMap::new(),
3167 ..Default::default()
3168 };
3169
3170 let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
3171 // App manifest changed express to ^5.0.0 — should be detected as stale.
3172 let app_manifest = make_manifest(&[("express", "^5.0.0")]);
3173
3174 let workspace_manifests = vec![
3175 (".".to_string(), root_manifest.clone()),
3176 ("packages/app".to_string(), app_manifest),
3177 ];
3178 match graph.check_drift_workspace(
3179 &workspace_manifests,
3180 &BTreeMap::new(),
3181 &[],
3182 &BTreeMap::new(),
3183 ) {
3184 DriftStatus::Stale { reason } => {
3185 assert!(reason.contains("packages/app"));
3186 assert!(reason.contains("express"));
3187 }
3188 DriftStatus::Fresh => panic!("expected Stale"),
3189 }
3190
3191 // Single-importer check_drift on root only would say Fresh.
3192 assert_eq!(
3193 graph.check_drift(&root_manifest, &BTreeMap::new(), &[], &BTreeMap::new()),
3194 DriftStatus::Fresh
3195 );
3196 }
3197
3198 #[test]
3199 fn filter_deps_prunes_dev_only_subtree() {
3200 // Graph: prod-root (foo) + dev-root (jest) with transitive chains.
3201 // After filtering out Dev, jest + its transitives should be pruned,
3202 // foo + its transitives should remain.
3203 let mut importers = BTreeMap::new();
3204 importers.insert(
3205 ".".to_string(),
3206 vec![
3207 DirectDep {
3208 name: "foo".into(),
3209 dep_path: "foo@1.0.0".into(),
3210 dep_type: DepType::Production,
3211 specifier: Some("^1.0.0".into()),
3212 },
3213 DirectDep {
3214 name: "jest".into(),
3215 dep_path: "jest@29.0.0".into(),
3216 dep_type: DepType::Dev,
3217 specifier: Some("^29.0.0".into()),
3218 },
3219 ],
3220 );
3221
3222 let mut packages = BTreeMap::new();
3223 let mut foo_deps = BTreeMap::new();
3224 foo_deps.insert("bar".to_string(), "2.0.0".to_string());
3225 packages.insert(
3226 "foo@1.0.0".to_string(),
3227 LockedPackage {
3228 name: "foo".into(),
3229 version: "1.0.0".into(),
3230 integrity: None,
3231 dependencies: foo_deps,
3232 dep_path: "foo@1.0.0".into(),
3233 ..Default::default()
3234 },
3235 );
3236 packages.insert(
3237 "bar@2.0.0".to_string(),
3238 LockedPackage {
3239 name: "bar".into(),
3240 version: "2.0.0".into(),
3241 integrity: None,
3242 dependencies: BTreeMap::new(),
3243 dep_path: "bar@2.0.0".into(),
3244 ..Default::default()
3245 },
3246 );
3247 let mut jest_deps = BTreeMap::new();
3248 jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
3249 packages.insert(
3250 "jest@29.0.0".to_string(),
3251 LockedPackage {
3252 name: "jest".into(),
3253 version: "29.0.0".into(),
3254 integrity: None,
3255 dependencies: jest_deps,
3256 dep_path: "jest@29.0.0".into(),
3257 ..Default::default()
3258 },
3259 );
3260 packages.insert(
3261 "jest-core@29.0.0".to_string(),
3262 LockedPackage {
3263 name: "jest-core".into(),
3264 version: "29.0.0".into(),
3265 integrity: None,
3266 dependencies: BTreeMap::new(),
3267 dep_path: "jest-core@29.0.0".into(),
3268 ..Default::default()
3269 },
3270 );
3271
3272 let graph = LockfileGraph {
3273 importers,
3274 packages,
3275 ..Default::default()
3276 };
3277
3278 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3279
3280 // Direct deps: only foo, jest dropped
3281 let roots = prod.root_deps();
3282 assert_eq!(roots.len(), 1);
3283 assert_eq!(roots[0].name, "foo");
3284
3285 // Reachable packages: foo + bar (transitive), NOT jest or jest-core
3286 assert!(prod.packages.contains_key("foo@1.0.0"));
3287 assert!(prod.packages.contains_key("bar@2.0.0"));
3288 assert!(!prod.packages.contains_key("jest@29.0.0"));
3289 assert!(!prod.packages.contains_key("jest-core@29.0.0"));
3290 }
3291
3292 // Regression for #50 feedback: `filter_deps` is a structural
3293 // operation and must preserve the source graph's `settings:`
3294 // metadata. A filtered graph that's handed to the lockfile writer
3295 // (as `aube prune` does today) would otherwise reset
3296 // `autoInstallPeers` to its default and silently flip the user's
3297 // choice on the next install.
3298 #[test]
3299 fn filter_deps_preserves_lockfile_settings() {
3300 let graph = LockfileGraph {
3301 importers: BTreeMap::new(),
3302 packages: BTreeMap::new(),
3303 settings: LockfileSettings {
3304 auto_install_peers: false,
3305 exclude_links_from_lockfile: true,
3306 lockfile_include_tarball_url: false,
3307 },
3308 ..Default::default()
3309 };
3310 let filtered = graph.filter_deps(|_| true);
3311 assert!(!filtered.settings.auto_install_peers);
3312 assert!(filtered.settings.exclude_links_from_lockfile);
3313 }
3314
3315 #[test]
3316 fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
3317 // Graph: prod foo → shared, dev jest → shared
3318 // Filtering out Dev should still keep `shared` because foo → shared
3319 // keeps it reachable.
3320 let mut importers = BTreeMap::new();
3321 importers.insert(
3322 ".".to_string(),
3323 vec![
3324 DirectDep {
3325 name: "foo".into(),
3326 dep_path: "foo@1.0.0".into(),
3327 dep_type: DepType::Production,
3328 specifier: Some("^1.0.0".into()),
3329 },
3330 DirectDep {
3331 name: "jest".into(),
3332 dep_path: "jest@29.0.0".into(),
3333 dep_type: DepType::Dev,
3334 specifier: Some("^29.0.0".into()),
3335 },
3336 ],
3337 );
3338
3339 let mut packages = BTreeMap::new();
3340 for (name, ver, deps) in [
3341 ("foo", "1.0.0", vec![("shared", "1.0.0")]),
3342 ("jest", "29.0.0", vec![("shared", "1.0.0")]),
3343 ("shared", "1.0.0", vec![]),
3344 ] {
3345 let mut dep_map = BTreeMap::new();
3346 for (n, v) in deps {
3347 dep_map.insert(n.to_string(), v.to_string());
3348 }
3349 packages.insert(
3350 format!("{name}@{ver}"),
3351 LockedPackage {
3352 name: name.into(),
3353 version: ver.into(),
3354 integrity: None,
3355 dependencies: dep_map,
3356 dep_path: format!("{name}@{ver}"),
3357 ..Default::default()
3358 },
3359 );
3360 }
3361
3362 let graph = LockfileGraph {
3363 importers,
3364 packages,
3365 ..Default::default()
3366 };
3367 let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
3368
3369 assert!(prod.packages.contains_key("foo@1.0.0"));
3370 assert!(prod.packages.contains_key("shared@1.0.0"));
3371 assert!(!prod.packages.contains_key("jest@29.0.0"));
3372 }
3373
3374 #[test]
3375 fn subset_to_importer_returns_none_for_missing_importer() {
3376 let graph = LockfileGraph {
3377 importers: BTreeMap::new(),
3378 packages: BTreeMap::new(),
3379 ..Default::default()
3380 };
3381 assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
3382 }
3383
3384 #[test]
3385 fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
3386 // Workspace graph with two importers that own independent
3387 // subtrees: packages/lib pulls is-odd → is-number, packages/app
3388 // pulls express. Subsetting to packages/lib must yield a graph
3389 // rooted at `.` containing only is-odd + is-number, with
3390 // express pruned. Matches what `aube deploy --filter @test/lib`
3391 // should write into the target.
3392 let mut importers = BTreeMap::new();
3393 importers.insert(".".to_string(), vec![]);
3394 importers.insert(
3395 "packages/lib".to_string(),
3396 vec![DirectDep {
3397 name: "is-odd".into(),
3398 dep_path: "is-odd@3.0.1".into(),
3399 dep_type: DepType::Production,
3400 specifier: Some("^3.0.1".into()),
3401 }],
3402 );
3403 importers.insert(
3404 "packages/app".to_string(),
3405 vec![DirectDep {
3406 name: "express".into(),
3407 dep_path: "express@4.18.0".into(),
3408 dep_type: DepType::Production,
3409 specifier: Some("^4.18.0".into()),
3410 }],
3411 );
3412
3413 let mut packages = BTreeMap::new();
3414 let mut is_odd_deps = BTreeMap::new();
3415 is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
3416 packages.insert(
3417 "is-odd@3.0.1".to_string(),
3418 LockedPackage {
3419 name: "is-odd".into(),
3420 version: "3.0.1".into(),
3421 dependencies: is_odd_deps,
3422 dep_path: "is-odd@3.0.1".into(),
3423 ..Default::default()
3424 },
3425 );
3426 packages.insert(
3427 "is-number@6.0.0".to_string(),
3428 LockedPackage {
3429 name: "is-number".into(),
3430 version: "6.0.0".into(),
3431 dep_path: "is-number@6.0.0".into(),
3432 ..Default::default()
3433 },
3434 );
3435 packages.insert(
3436 "express@4.18.0".to_string(),
3437 LockedPackage {
3438 name: "express".into(),
3439 version: "4.18.0".into(),
3440 dep_path: "express@4.18.0".into(),
3441 ..Default::default()
3442 },
3443 );
3444
3445 let graph = LockfileGraph {
3446 importers,
3447 packages,
3448 ..Default::default()
3449 };
3450 let subset = graph
3451 .subset_to_importer("packages/lib", |_| true)
3452 .expect("packages/lib importer present");
3453
3454 assert_eq!(subset.importers.len(), 1);
3455 let roots = subset.root_deps();
3456 assert_eq!(roots.len(), 1);
3457 assert_eq!(roots[0].name, "is-odd");
3458
3459 assert!(subset.packages.contains_key("is-odd@3.0.1"));
3460 assert!(subset.packages.contains_key("is-number@6.0.0"));
3461 assert!(!subset.packages.contains_key("express@4.18.0"));
3462 }
3463
3464 #[test]
3465 fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
3466 // packages/lib has both prod (is-odd) and dev (jest) deps.
3467 // `aube deploy --prod` should pass `|d| d.dep_type != Dev` as
3468 // the keep filter; the resulting subset retains only is-odd
3469 // so drift against the target's dev-stripped manifest stays
3470 // clean.
3471 let mut importers = BTreeMap::new();
3472 importers.insert(
3473 "packages/lib".to_string(),
3474 vec![
3475 DirectDep {
3476 name: "is-odd".into(),
3477 dep_path: "is-odd@3.0.1".into(),
3478 dep_type: DepType::Production,
3479 specifier: Some("^3.0.1".into()),
3480 },
3481 DirectDep {
3482 name: "jest".into(),
3483 dep_path: "jest@29.0.0".into(),
3484 dep_type: DepType::Dev,
3485 specifier: Some("^29.0.0".into()),
3486 },
3487 ],
3488 );
3489 let mut packages = BTreeMap::new();
3490 packages.insert(
3491 "is-odd@3.0.1".to_string(),
3492 LockedPackage {
3493 name: "is-odd".into(),
3494 version: "3.0.1".into(),
3495 dep_path: "is-odd@3.0.1".into(),
3496 ..Default::default()
3497 },
3498 );
3499 packages.insert(
3500 "jest@29.0.0".to_string(),
3501 LockedPackage {
3502 name: "jest".into(),
3503 version: "29.0.0".into(),
3504 dep_path: "jest@29.0.0".into(),
3505 ..Default::default()
3506 },
3507 );
3508 let graph = LockfileGraph {
3509 importers,
3510 packages,
3511 ..Default::default()
3512 };
3513
3514 let prod = graph
3515 .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
3516 .expect("importer present");
3517 let roots = prod.root_deps();
3518 assert_eq!(roots.len(), 1);
3519 assert_eq!(roots[0].name, "is-odd");
3520 assert!(prod.packages.contains_key("is-odd@3.0.1"));
3521 assert!(!prod.packages.contains_key("jest@29.0.0"));
3522 }
3523
3524 #[test]
3525 fn subset_to_importer_preserves_graph_settings() {
3526 // Structural pruning, not a resolution-mode reset: a deploy
3527 // into a target that uses the source workspace's settings
3528 // header (autoInstallPeers / lockfileIncludeTarballUrl)
3529 // should write them through unchanged so a frozen install in
3530 // the target sees the same resolution-mode state.
3531 let mut importers = BTreeMap::new();
3532 importers.insert("packages/lib".to_string(), vec![]);
3533 let graph = LockfileGraph {
3534 importers,
3535 packages: BTreeMap::new(),
3536 settings: LockfileSettings {
3537 auto_install_peers: false,
3538 exclude_links_from_lockfile: true,
3539 lockfile_include_tarball_url: true,
3540 },
3541 ..Default::default()
3542 };
3543 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3544 assert!(!subset.settings.auto_install_peers);
3545 assert!(subset.settings.exclude_links_from_lockfile);
3546 assert!(subset.settings.lockfile_include_tarball_url);
3547 }
3548
3549 #[test]
3550 fn subset_to_importer_rekeys_skipped_optionals_to_root() {
3551 // `skipped_optional_dependencies` is per-importer. After
3552 // subsetting, only the retained importer's entry should
3553 // survive — rekeyed to `.` so a frozen install in the target
3554 // (which has exactly one importer) doesn't see ghost entries.
3555 let mut importers = BTreeMap::new();
3556 importers.insert("packages/lib".to_string(), vec![]);
3557 importers.insert("packages/app".to_string(), vec![]);
3558 let mut skipped = BTreeMap::new();
3559 let mut lib_skip = BTreeMap::new();
3560 lib_skip.insert("fsevents".to_string(), "^2".to_string());
3561 skipped.insert("packages/lib".to_string(), lib_skip);
3562 let mut app_skip = BTreeMap::new();
3563 app_skip.insert("ghost".to_string(), "*".to_string());
3564 skipped.insert("packages/app".to_string(), app_skip);
3565 let graph = LockfileGraph {
3566 importers,
3567 packages: BTreeMap::new(),
3568 skipped_optional_dependencies: skipped,
3569 ..Default::default()
3570 };
3571 let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
3572 assert_eq!(subset.skipped_optional_dependencies.len(), 1);
3573 let root = subset.skipped_optional_dependencies.get(".").unwrap();
3574 assert!(root.contains_key("fsevents"));
3575 assert!(!root.contains_key("ghost"));
3576 }
3577
3578 #[test]
3579 fn workspace_drift_fresh_when_all_importers_match() {
3580 let root_dep = DirectDep {
3581 name: "lodash".into(),
3582 dep_path: "lodash@4.17.21".into(),
3583 dep_type: DepType::Production,
3584 specifier: Some("^4.17.0".into()),
3585 };
3586 let app_dep = DirectDep {
3587 name: "express".into(),
3588 dep_path: "express@4.18.0".into(),
3589 dep_type: DepType::Production,
3590 specifier: Some("^4.18.0".into()),
3591 };
3592 let mut importers = BTreeMap::new();
3593 importers.insert(".".to_string(), vec![root_dep]);
3594 importers.insert("packages/app".to_string(), vec![app_dep]);
3595 let graph = LockfileGraph {
3596 importers,
3597 packages: BTreeMap::new(),
3598 ..Default::default()
3599 };
3600
3601 let workspace_manifests = vec![
3602 (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
3603 (
3604 "packages/app".to_string(),
3605 make_manifest(&[("express", "^4.18.0")]),
3606 ),
3607 ];
3608 assert_eq!(
3609 graph.check_drift_workspace(
3610 &workspace_manifests,
3611 &BTreeMap::new(),
3612 &[],
3613 &BTreeMap::new()
3614 ),
3615 DriftStatus::Fresh
3616 );
3617 }
3618
3619 #[allow(clippy::type_complexity)]
3620 fn mk_catalogs(
3621 entries: &[(&str, &[(&str, &str, &str)])],
3622 ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
3623 let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
3624 for (cat, pkgs) in entries {
3625 let mut inner = BTreeMap::new();
3626 for (pkg, spec, ver) in *pkgs {
3627 inner.insert(
3628 (*pkg).to_string(),
3629 CatalogEntry {
3630 specifier: (*spec).to_string(),
3631 version: (*ver).to_string(),
3632 },
3633 );
3634 }
3635 out.insert((*cat).to_string(), inner);
3636 }
3637 out
3638 }
3639
3640 fn mk_workspace_catalogs(
3641 entries: &[(&str, &[(&str, &str)])],
3642 ) -> BTreeMap<String, BTreeMap<String, String>> {
3643 entries
3644 .iter()
3645 .map(|(cat, pkgs)| {
3646 (
3647 (*cat).to_string(),
3648 pkgs.iter()
3649 .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
3650 .collect(),
3651 )
3652 })
3653 .collect()
3654 }
3655
3656 #[test]
3657 fn catalog_drift_fresh_when_specifiers_match() {
3658 let graph = LockfileGraph {
3659 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3660 ..Default::default()
3661 };
3662 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
3663 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3664 }
3665
3666 #[test]
3667 fn catalog_drift_stale_on_changed_specifier() {
3668 let graph = LockfileGraph {
3669 catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
3670 ..Default::default()
3671 };
3672 let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
3673 match graph.check_catalogs_drift(&ws) {
3674 DriftStatus::Stale { reason } => assert!(reason.contains("react")),
3675 other => panic!("expected stale, got {other:?}"),
3676 }
3677 }
3678
3679 #[test]
3680 fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
3681 // pnpm only writes referenced entries — an unreferenced
3682 // workspace entry is not drift. The "newly used" transition
3683 // is caught by the importer-level drift check.
3684 let graph = LockfileGraph::default();
3685 let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
3686 assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
3687 }
3688
3689 #[test]
3690 fn catalog_drift_stale_on_removed_workspace_entry() {
3691 let graph = LockfileGraph {
3692 catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
3693 ..Default::default()
3694 };
3695 let ws = mk_workspace_catalogs(&[]);
3696 assert!(matches!(
3697 graph.check_catalogs_drift(&ws),
3698 DriftStatus::Stale { .. }
3699 ));
3700 }
3701}