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