aube_lockfile/lib.rs
1pub mod bun;
2pub mod dep_path_filename;
3mod drift;
4pub mod graph_hash;
5mod io;
6pub mod merge;
7pub mod npm;
8mod override_match;
9pub mod pnpm;
10mod source;
11pub mod yarn;
12
13pub use drift::DriftStatus;
14pub use io::{
15 Error, LockfileKind, ParseOptions, active_lockfile_has_conflict_markers, aube_lock_filename,
16 build_canonical_map, detect_existing_lockfile_kind, parse_for_import, parse_json,
17 parse_lockfile, parse_lockfile_with_kind, parse_lockfile_with_kind_and_options,
18 pnpm_lock_filename, read_lockfile, write_lockfile, write_lockfile_as,
19 write_lockfile_preserving_existing,
20};
21pub(crate) use io::{atomic_write_lockfile, current_git_branch};
22pub use merge::{MergeReport, merge_branch_lockfiles};
23pub(crate) use source::normalize_git_fragment;
24pub use source::{
25 GitSource, HostedGit, HostedGitHost, LocalSource, RemoteTarballSource, git_commits_match,
26 parse_git_spec, parse_hosted_git, resolve_dep_edge, shared_local_dep_path,
27};
28
29pub(crate) const EXTRA_PRESERVE_TARBALL_URL: &str = "__aube_preserve_tarball_url";
30
31use smallvec::SmallVec;
32use std::collections::{BTreeMap, BTreeSet};
33
34/// Most npm packages declare zero or one entry in `os`, `cpu`,
35/// `libc`. Two inline `SmallVec` slots cover empty on construction
36/// (zero heap alloc) and one-entry push (still zero heap) for ~99%
37/// of lockfile entries.
38pub type PlatformList = SmallVec<[String; 2]>;
39
40/// Represents a resolved dependency graph from any lockfile format.
41#[derive(Debug, Clone, Default)]
42pub struct LockfileGraph {
43 /// Direct dependencies of the root project (and workspace packages).
44 /// Key: importer path (e.g., "." for root), Value: list of (name, version) pairs.
45 pub importers: BTreeMap<String, Vec<DirectDep>>,
46 /// All resolved packages.
47 pub packages: BTreeMap<String, LockedPackage>,
48 /// Per-graph settings that round-trip through the lockfile header
49 /// (pnpm v9's `settings:` block). Don't affect graph structure;
50 /// stamped into the YAML when writing and read back when parsing,
51 /// so subsequent installs see the same resolution-mode state.
52 pub settings: LockfileSettings,
53 /// Dependency overrides recorded in pnpm-lock.yaml's top-level
54 /// `overrides:` block. Map of raw selector key → version specifier
55 /// (or `npm:` alias). Keys are the user's verbatim selector
56 /// strings — bare name, `foo>bar`, `foo@<2`, `**/foo`, or any
57 /// combination. Round-tripped so subsequent installs can detect
58 /// override drift on a string-compare of the key+value without
59 /// re-running the resolver. The resolver parses these into
60 /// `override_rule::OverrideRule`s at the start of each resolve
61 /// pass.
62 pub overrides: BTreeMap<String, String>,
63 /// pnpm's top-level `packageExtensionsChecksum:` — a `sha256-`
64 /// prefixed `object-hash` of the effective `packageExtensions`
65 /// config. Lets pnpm detect that the extensions changed (and the
66 /// graph must be re-resolved) without re-reading every manifest.
67 /// `None` when there are no package extensions (pnpm omits the
68 /// field). Only the pnpm reader/writer touches this; other formats
69 /// leave it `None`. Computed via
70 /// [`pnpm::package_extensions_checksum`].
71 pub package_extensions_checksum: Option<String>,
72 /// pnpm's top-level `pnpmfileChecksum:` — a `sha256-` prefixed hash
73 /// of the local pnpmfile contents (CRLF-normalized). Lets pnpm
74 /// detect that a `.pnpmfile.cjs`/`.mjs` hook changed without
75 /// re-running it. `None` when no local pnpmfile participates (pnpm
76 /// omits the field). pnpm-only, like `package_extensions_checksum`.
77 /// Computed via [`pnpm::pnpmfile_checksum`].
78 pub pnpmfile_checksum: Option<String>,
79 /// Names listed in the root manifest's `pnpm.ignoredOptionalDependencies`.
80 /// The resolver drops entries in this set from every `optionalDependencies`
81 /// map before enqueueing, matching pnpm's read-package hook. Round-tripped
82 /// through pnpm-lock.yaml's top-level `ignoredOptionalDependencies:` list
83 /// so drift detection can notice when the user edits the field.
84 pub ignored_optional_dependencies: BTreeSet<String>,
85 /// Per-package publish timestamps, keyed by canonical `name@version`
86 /// (no peer suffix). Round-trips through pnpm-lock.yaml's top-level
87 /// `time:` block so `--resolution-mode=time-based` can compute a
88 /// `publishedBy` cutoff from packages already in the lockfile
89 /// without re-fetching packuments.
90 pub times: BTreeMap<String, String>,
91 /// Optional dependencies the resolver intentionally skipped on the
92 /// platform that wrote this lockfile (either filtered by
93 /// `os`/`cpu`/`libc`, or named in
94 /// `pnpm.ignoredOptionalDependencies`). Keyed by importer path,
95 /// inner map is name → specifier captured from `package.json` at
96 /// resolve time.
97 ///
98 /// Drift detection uses this to distinguish "user just added a new
99 /// optional dep" (which is real drift) from "this optional was
100 /// already considered and consciously dropped on this platform"
101 /// (which is *not* drift). Without it, every `--frozen-lockfile`
102 /// install on a platform that skipped a fixture would hard-fail.
103 pub skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
104 /// Resolved catalog entries, mirroring pnpm v9's top-level
105 /// `catalogs:` block. Outer key is the catalog name (`default` for
106 /// the unnamed `catalog:` field in `pnpm-workspace.yaml`); inner key
107 /// is the package name. Each entry pairs the original specifier
108 /// from the workspace catalog with the version the resolver chose
109 /// for it. Round-tripped through the lockfile so drift detection
110 /// can fire when a catalog spec changes without re-resolving.
111 pub catalogs: BTreeMap<String, BTreeMap<String, CatalogEntry>>,
112 /// bun's top-level `configVersion` — a second format counter bun
113 /// added alongside `lockfileVersion` to track its own config-
114 /// schema changes. Only the bun parser/writer ever touches this;
115 /// other formats leave it `None`. Round-tripping the parsed
116 /// value keeps the writer from silently downgrading the field
117 /// (e.g. from `2` back to `1`) when bun bumps it in a future
118 /// release.
119 pub bun_config_version: Option<u32>,
120 /// Top-level `patchedDependencies:` block mirrored by bun 1.1+ and
121 /// pnpm 9+. Key: selector (`lodash@4.17.21`); value is the relative
122 /// patch path for bun/fresh resolution or pnpm's patch-content hash
123 /// when parsed from a pnpm lockfile. The pnpm writer resolves paths
124 /// to hashes before serializing.
125 pub patched_dependencies: BTreeMap<String, String>,
126 /// Top-level `trustedDependencies:` block (bun) — a package-name
127 /// allowlist for lifecycle script execution. Preserved so
128 /// re-emitting a bun.lock doesn't strip the allowlist and cause
129 /// subsequent installs to skip scripts the user explicitly
130 /// approved.
131 ///
132 /// Kept as a `Vec` (not a set) so bun's original order round-trips
133 /// byte-identically; bun emits the list in insertion order. The
134 /// parser is responsible for deduping if the source lockfile
135 /// carried a duplicate.
136 pub trusted_dependencies: Vec<String>,
137 /// Pinned runtimes (pnpm 10.14+ `devEngines.runtime` recording),
138 /// keyed by runtime name (`node`). pnpm models a pinned runtime as
139 /// a synthetic importer dep whose specifier/version carry a
140 /// `runtime:` prefix plus a `packages:` entry keyed
141 /// `<name>@runtime:<version>` holding a `variations` resolution
142 /// with one downloadable artifact per platform. aube lifts that
143 /// encoding into this typed map on parse and re-emits the pnpm
144 /// shape on write (aube-lock.yaml and pnpm-lock.yaml share the
145 /// writer). Foreign formats (npm/yarn/bun) have no runtime shape:
146 /// their parsers leave this empty and their writers skip it.
147 pub runtimes: BTreeMap<String, RuntimePin>,
148 /// Top-level lockfile fields that aren't explicitly modeled on
149 /// `LockfileGraph`. Populated by per-format parsers on best-effort
150 /// basis so the writer can re-emit blocks a future lockfile
151 /// version might add (or ones we haven't promoted to typed fields
152 /// yet) without silently stripping them on round-trip. Each
153 /// parser/writer is responsible for emitting values in its
154 /// format's native serialization.
155 pub extra_fields: BTreeMap<String, serde_json::Value>,
156 /// Per-workspace-importer extras keyed by importer path (`""` for
157 /// root in bun, `"."` for others). Stores anything in the
158 /// workspace entry the typed model doesn't capture so a parse/
159 /// write cycle doesn't drop fields the user (or bun) wrote there.
160 pub workspace_extra_fields: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
161}
162
163/// One entry in a lockfile catalog: the workspace-declared range and the
164/// resolved version. Mirrors pnpm v9's `catalogs:` block exactly.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct CatalogEntry {
167 pub specifier: String,
168 pub version: String,
169}
170
171/// A pinned runtime (Node.js) recorded in the lockfile. Mirrors pnpm
172/// 10.14+'s `devEngines.runtime` encoding: the manifest's requested
173/// range plus the exact resolved version, and one downloadable
174/// artifact per supported platform so any machine reading the
175/// lockfile can fetch the same release without re-resolving.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub struct RuntimePin {
178 /// The requested range from `devEngines.runtime.version`, without
179 /// the `runtime:` prefix pnpm adds in the importer entry
180 /// (`"^24.4.0"`).
181 pub specifier: String,
182 /// Exact resolved version (`"24.4.1"`).
183 pub version: String,
184 /// Whether the importer entry sits under `devDependencies`
185 /// (devEngines-sourced pins do; pnpm only emits this form today).
186 pub dev: bool,
187 /// `hasBin` flag on the packages entry — always true for real
188 /// runtime pins; round-tripped for byte fidelity.
189 pub has_bin: bool,
190 /// Per-platform artifacts from the `variations` resolution.
191 pub variants: Vec<RuntimeVariant>,
192}
193
194impl RuntimePin {
195 /// The variant whose target list matches `(os, cpu, libc)`. `libc`
196 /// follows pnpm's convention: `Some("musl")` matches only
197 /// musl-tagged targets; `None` matches targets without a libc tag.
198 pub fn variant_for(&self, os: &str, cpu: &str, libc: Option<&str>) -> Option<&RuntimeVariant> {
199 self.variants.iter().find(|v| {
200 v.targets
201 .iter()
202 .any(|t| t.os == os && t.cpu == cpu && t.libc.as_deref() == libc)
203 })
204 }
205}
206
207/// One platform-specific artifact inside a runtime pin's `variations`
208/// resolution. Field set mirrors pnpm's `BinaryResolution` +
209/// `PlatformAssetResolution` pair.
210#[derive(Debug, Clone, Default, PartialEq, Eq)]
211pub struct RuntimeVariant {
212 /// Platforms this artifact serves (usually exactly one).
213 pub targets: Vec<RuntimeTarget>,
214 /// `"tarball"` or `"zip"`.
215 pub archive: String,
216 /// Download URL for the artifact.
217 pub url: String,
218 /// SRI integrity (`sha256-<base64>` — Node publishes SHA-256
219 /// checksums for release artifacts).
220 pub integrity: String,
221 /// Executable map. pnpm writes either a bare string (`bin/node`,
222 /// meaning the `node` bin) or a `name → path` map; both parse into
223 /// this struct and the original shape round-trips via
224 /// [`Self::bin_is_bare_string`].
225 pub bin: BTreeMap<String, String>,
226 /// True when the source lockfile wrote `bin:` as a bare string;
227 /// preserved so a parse/write cycle stays byte-identical.
228 pub bin_is_bare_string: bool,
229 /// Top-level directory to strip when extracting (pnpm sets this on
230 /// zip archives, whose entries are rooted at
231 /// `node-v<V>-win-<arch>/`).
232 pub prefix: Option<String>,
233}
234
235/// One `(os, cpu, libc)` triple a runtime variant targets. Values use
236/// Node's `process.platform` / `process.arch` vocabulary (`win32`,
237/// `darwin`, `linux`; `x64`, `arm64`), with `libc: Some("musl")` only
238/// on musl builds.
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
240pub struct RuntimeTarget {
241 pub os: String,
242 pub cpu: String,
243 pub libc: Option<String>,
244}
245
246/// Per-graph settings that mirror pnpm v9's `settings:` header.
247/// Extend as more knobs become round-trip-aware.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct LockfileSettings {
250 /// pnpm's `auto-install-peers` — when false the resolver leaves
251 /// unmet peers alone (just warns) instead of dragging them in.
252 pub auto_install_peers: bool,
253 /// pnpm's `exclude-links-from-lockfile` — not yet honored by aube
254 /// but round-tripped for lockfile compatibility.
255 pub exclude_links_from_lockfile: bool,
256 /// pnpm's `lockfile-include-tarball-url` — when true the writer
257 /// emits the full registry tarball URL in each package's
258 /// `resolution.tarball:` field alongside `integrity:`. Makes the
259 /// lockfile self-contained so air-gapped installs don't need to
260 /// derive the URL from `.npmrc`. Round-tripped through the
261 /// `settings:` header so it survives parse/write cycles without
262 /// re-reading `.npmrc`.
263 pub lockfile_include_tarball_url: bool,
264}
265
266impl Default for LockfileSettings {
267 fn default() -> Self {
268 Self {
269 auto_install_peers: true,
270 exclude_links_from_lockfile: false,
271 lockfile_include_tarball_url: false,
272 }
273 }
274}
275
276/// A direct dependency of a workspace importer.
277#[derive(Debug, Clone)]
278pub struct DirectDep {
279 pub name: String,
280 /// The dep_path key in the lockfile (e.g., "is-odd@3.0.1")
281 pub dep_path: String,
282 pub dep_type: DepType,
283 /// The specifier as written in package.json at the time the lockfile was
284 /// generated (e.g., `"^4.17.0"`). Used by drift detection to compare against
285 /// the current manifest. Populated by formats that record it
286 /// (pnpm importers and npm root/workspace package entries).
287 pub specifier: Option<String>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum DepType {
292 Production,
293 Dev,
294 Optional,
295}
296
297/// Render a `DepType` as the matching `package.json` field name
298/// (`dependencies` / `devDependencies` / `optionalDependencies`).
299/// Single source of truth so drift diagnostics, install summaries,
300/// the `outdated` / `why` / `deprecations` renderers, and the
301/// `outdated --json` shape all agree on the spelling.
302pub fn dep_type_label(dt: DepType) -> &'static str {
303 match dt {
304 DepType::Production => "dependencies",
305 DepType::Dev => "devDependencies",
306 DepType::Optional => "optionalDependencies",
307 }
308}
309
310/// A single resolved package in the lockfile.
311///
312/// The `dependencies` map keys are dep names and values are the dependency's
313/// dep_path *tail* — i.e. the string that follows `<name>@`. For a plain
314/// package this is just the version (`"4.17.21"`); for a package with its
315/// own peer context it includes the suffix (`"18.2.0(prop-types@15.8.1)"`).
316/// Combining the key with its value reproduces the full dep_path (which is
317/// also the key in `LockfileGraph.packages`).
318#[derive(Debug, Clone, Default)]
319pub struct LockedPackage {
320 /// Package name (e.g., "lodash")
321 pub name: String,
322 /// Exact resolved version (e.g., "4.17.21")
323 pub version: String,
324 /// Integrity hash (e.g., "sha512-...")
325 pub integrity: Option<String>,
326 /// Dependencies of this package (name -> dep_path tail, see struct docs)
327 pub dependencies: BTreeMap<String, String>,
328 /// Optional dependency edges for this package. Active optional edges are
329 /// also mirrored in `dependencies` so graph walks and the linker continue
330 /// to see them; this separate map lets platform filtering prune optional
331 /// edges without touching regular dependencies.
332 pub optional_dependencies: BTreeMap<String, String>,
333 /// Peer dependency ranges as *declared* by the package (from its
334 /// package.json / packument). These are the constraints; the resolved
335 /// versions live in `dependencies` after the peer-context pass runs.
336 pub peer_dependencies: BTreeMap<String, String>,
337 /// `peerDependenciesMeta` entries, keyed by peer name.
338 pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
339 /// The dep_path key used in the lockfile. For packages with resolved
340 /// peer contexts this includes the suffix, e.g.
341 /// `"styled-components@6.1.0(react@18.2.0)"`.
342 pub dep_path: String,
343 /// Set for non-registry packages (those installed via `file:` or
344 /// `link:` specifiers). `None` for the common case of a package
345 /// resolved from an npm registry, where `integrity` is the full
346 /// record of where the bits came from.
347 pub local_source: Option<LocalSource>,
348 /// `os` / `cpu` / `libc` arrays from the package's manifest. Used
349 /// by the resolver to filter optional deps that can't run on the
350 /// current (or user-overridden) platform. Empty arrays mean no
351 /// constraint.
352 pub os: PlatformList,
353 pub cpu: PlatformList,
354 pub libc: PlatformList,
355 /// Names declared in the package's own `bundledDependencies`. These
356 /// ship inside the parent tarball's `node_modules/`, so the resolver
357 /// neither fetches nor recurses into them, and the linker avoids
358 /// creating sibling symlinks that would shadow the bundled tree.
359 /// An empty Vec means "no bundled deps"; `None` is kept as a
360 /// distinct value only inside the resolver and collapsed to empty
361 /// here because the lockfile round-trip doesn't need to preserve
362 /// the "unset" vs "empty list" distinction.
363 pub bundled_dependencies: Vec<String>,
364 /// Full registry tarball URL for registry-sourced packages. Only
365 /// populated when `LockfileSettings::lockfile_include_tarball_url`
366 /// is active on this graph; otherwise `None` and the lockfile
367 /// writer derives the URL at fetch time from the configured
368 /// registry. `local_source`-backed packages (file:, link:, git:,
369 /// remote tarball) already carry their own URL via `LocalSource`
370 /// and don't populate this field.
371 pub tarball_url: Option<String>,
372 /// pnpm `resolution.gitHosted` for registry-keyed packages. Remote
373 /// tarball sources carry the same flag on `RemoteTarballSource`,
374 /// but registry entries keep `local_source: None`, so this field
375 /// preserves third-party pnpm lockfiles that mark registry-shaped
376 /// tarballs as hosted git.
377 pub registry_git_hosted: bool,
378 /// For npm-alias deps (`"h3-v2": "npm:h3@2.0.1-rc.20"`): the real
379 /// package name on the registry (`"h3"`). `None` means the entry
380 /// is not aliased and `name` already holds the registry name.
381 ///
382 /// Install semantics when `Some(real)`:
383 /// - `name` is the *alias* — that's the folder under `node_modules/`,
384 /// the symlink name for transitive deps, and the key every package
385 /// that declares this dep refers to.
386 /// - `alias_of` is the real package name used for tarball URL lookup,
387 /// store index keying, and packument fetches.
388 /// - `version` is the real resolved version.
389 ///
390 /// `registry_name()` returns the right name for registry IO; every
391 /// call site that talks to the registry or the CAS uses that helper.
392 pub alias_of: Option<String>,
393 /// Yarn berry's `checksum:` field, preserved verbatim when parsing a
394 /// yarn 2+ lockfile (e.g. `"10c0/<blake2b-hex>"`). The format is
395 /// yarn-specific — it uses a yarn-chosen hash family prefixed with
396 /// the `cacheKey` that produced it — and doesn't share a hash
397 /// algorithm with `integrity` (sha-512). When re-emitting a yarn
398 /// berry lockfile we write this field back as-is; packages that
399 /// didn't come through a berry parse (e.g. freshly-resolved entries
400 /// in a new install) leave this `None` and the writer omits the
401 /// `checksum:` field, which berry tolerates at the default
402 /// `checksumBehavior: throw` when the cache is fresh.
403 pub yarn_checksum: Option<String>,
404 /// `engines:` from the package's manifest, round-tripped through
405 /// the lockfile so pnpm-style writers can emit the same flow-form
406 /// `engines: {node: '>=8'}` line pnpm writes. Empty map means
407 /// "no engines declared" — the writer skips the field entirely.
408 pub engines: BTreeMap<String, String>,
409 /// `bin:` map from the package's manifest, normalized to
410 /// `name → path`. An empty map means "no bins declared".
411 ///
412 /// pnpm-style writers derive `hasBin: true` from
413 /// `!bin.is_empty()` (they don't preserve the names/paths); bun's
414 /// format emits the full map on the package's meta block. Keeping
415 /// the map here lets both writers render byte-identical output
416 /// without an extra tarball-level re-parse.
417 pub bin: BTreeMap<String, String>,
418 /// Dependency ranges as declared in this package's own
419 /// `package.json` — keyed by dep name, values are the raw
420 /// specifiers (`"^4.1.0"`, `"~1.1.4"`, `"workspace:*"`, …).
421 ///
422 /// Distinct from [`Self::dependencies`], which stores the
423 /// *resolved* dep_path tail (`"4.3.0"`). npm / yarn / bun
424 /// lockfiles preserve the declared ranges on every nested
425 /// package entry — rewriting them to the resolved pins is the
426 /// biggest source of round-trip churn against those formats. This
427 /// map lets writers emit the declared range when available and
428 /// fall back to the resolved pin otherwise (e.g. when the source
429 /// lockfile was pnpm, whose `snapshots:` only carries pins).
430 ///
431 /// Empty means "unknown" — writers should fall back to pins.
432 /// Covers production *and* optional dependencies in one map since
433 /// a package can't declare the same name twice across those
434 /// sections.
435 pub declared_dependencies: BTreeMap<String, String>,
436 /// Package's `license` field, collapsed to the simple string
437 /// form. Round-tripped so npm's lockfile keeps its per-entry
438 /// `"license": "MIT"` line; pnpm / yarn / bun don't record
439 /// licenses and leave this `None` on parse.
440 pub license: Option<String>,
441 /// Package's funding URL, extracted from whatever shape the
442 /// manifest's `funding:` field took (string / object / array).
443 /// Round-tripped so npm's lockfile keeps its per-entry
444 /// `"funding": {"url": "…"}` block.
445 pub funding_url: Option<String>,
446 /// pnpm `snapshots:` `optional: true` flag, marking a package
447 /// reachable only through optional edges (typically platform-
448 /// specific binaries like `@reflink/reflink-darwin-arm64`). pnpm
449 /// uses this on the next install to decide whether the entry
450 /// should be skipped on a non-matching platform; dropping it on
451 /// round-trip would let pnpm treat the package as required.
452 /// Always `false` outside the pnpm parse/write path.
453 pub optional: bool,
454 /// pnpm `snapshots:` `transitivePeerDependencies:` list — peer
455 /// names that bubble up transitively through this package. pnpm
456 /// reads it during hoisting and as a resolver staleness signal
457 /// (`resolveDependencies.ts`'s non-zero-length check); a missing
458 /// list looks like a graph change and triggers needless re-
459 /// resolution on the next pnpm install. Empty outside the pnpm
460 /// parse/write path. Fresh resolves leave this empty too — pnpm
461 /// recomputes it from the graph during `resolvePeers` when needed.
462 pub transitive_peer_dependencies: Vec<String>,
463 /// Per-package-meta extras preserved verbatim from the source
464 /// lockfile. Captures fields the typed model doesn't yet cover
465 /// (`deprecated`, `hasInstallScript`, bun's `optionalPeers`, and
466 /// anything a future lockfile bump adds) so a parse/write cycle
467 /// doesn't drop them. Each format's writer re-emits what makes
468 /// sense there — bun inlines the extras back on the package-entry
469 /// meta object, pnpm / yarn / npm currently ignore them.
470 pub extra_meta: BTreeMap<String, serde_json::Value>,
471}
472
473impl LockedPackage {
474 /// The package name to use for registry / store operations — the real
475 /// name behind an npm-alias when aliased, otherwise just `name`. Used
476 /// at every site that derives a tarball URL, a packument URL, or an
477 /// aube-store cache key so aliased entries hit the actual package
478 /// instead of the alias-qualified name.
479 pub fn registry_name(&self) -> &str {
480 self.alias_of.as_deref().unwrap_or(&self.name)
481 }
482
483 /// Canonical `"name@version"` key used as a handle in patches,
484 /// approve-builds prompts, lockfile canonical maps, and display
485 /// paths. Not the dep-path — that includes peer-context suffixes.
486 pub fn spec_key(&self) -> String {
487 format!("{}@{}", self.name, self.version)
488 }
489
490 /// Resolve a patch entry for this package from a `name@version`-keyed
491 /// map, returning the matched key alongside the value.
492 ///
493 /// Tries the alias-qualified [`Self::spec_key`] first, then falls
494 /// back to the registry identity (`registry_name()@version`).
495 /// `patchedDependencies` are declared against the *real* package
496 /// name, so an npm-aliased entry (`"odd-alias": "npm:is-odd@3.0.1"`)
497 /// carries `name = "odd-alias"` while the patch is keyed
498 /// `is-odd@3.0.1`; the fallback is what lets the patch reach the
499 /// aliased install. Mirrors pnpm, which resolves patches on the
500 /// resolved manifest name and keeps no separate alias node.
501 pub fn lookup_patch<'a, V>(&self, map: &'a BTreeMap<String, V>) -> Option<(String, &'a V)> {
502 let spec_key = self.spec_key();
503 if let Some(value) = map.get(&spec_key) {
504 return Some((spec_key, value));
505 }
506 let registry_key = format!("{}@{}", self.registry_name(), self.version);
507 if registry_key != spec_key
508 && let Some(value) = map.get(®istry_key)
509 {
510 return Some((registry_key, value));
511 }
512 None
513 }
514
515 /// Exact approval key for non-registry package sources.
516 ///
517 /// Name-wide build approvals are only trustworthy for packages
518 /// fetched from a registry. Source-backed entries need to be
519 /// approved by their source identity as pnpm records it in
520 /// lockfile keys / `allowBuilds` placeholders.
521 pub fn source_approval_key(&self) -> Option<String> {
522 self.local_source
523 .as_ref()
524 .map(|source| format!("{}@{}", self.registry_name(), source.specifier()))
525 }
526
527 /// Repository-wide approval key for a Git-backed package source.
528 ///
529 /// Unlike [`Self::source_approval_key`], this deliberately omits the
530 /// resolved commit. It is only used for an explicit `allowBuilds`
531 /// `git+<repository>` rule, never for package-name approval.
532 pub fn git_repository_approval_key(&self) -> Option<String> {
533 let LocalSource::Git(git) = self.local_source.as_ref()? else {
534 return None;
535 };
536 Some(format!(
537 "{}@git+{}",
538 self.registry_name(),
539 git.url.strip_prefix("git+").unwrap_or(&git.url)
540 ))
541 }
542
543 /// Declared peer ranges with pnpm's meta-only peers folded in as `*`.
544 ///
545 /// pnpm records a `peerDependencies: { x: '*' }` entry for every
546 /// `peerDependenciesMeta` key a package ships without an explicit
547 /// range (debug's optional `supports-color`, typescript-eslint's
548 /// optional `typescript`, …). This returns `peer_dependencies` with
549 /// those meta-only keys added as `*` — both what the pnpm writer emits
550 /// in `packages:` and the "declared peers" set the transitive-peer
551 /// pass subtracts resolved deps from. Centralizing the rule keeps the
552 /// writer and the resolver's transitive-peer pass from drifting.
553 pub fn peer_dependencies_with_meta_defaults(&self) -> BTreeMap<String, String> {
554 let mut deps = self.peer_dependencies.clone();
555 for name in self.peer_dependencies_meta.keys() {
556 deps.entry(name.clone()).or_insert_with(|| "*".to_string());
557 }
558 deps
559 }
560}
561
562#[cfg(test)]
563mod locked_package_tests {
564 use super::*;
565 use std::path::PathBuf;
566
567 fn pkg() -> LockedPackage {
568 LockedPackage {
569 name: "pkg".to_string(),
570 version: "1.0.0".to_string(),
571 integrity: Some("sha512-abc".to_string()),
572 dependencies: BTreeMap::new(),
573 optional_dependencies: BTreeMap::new(),
574 peer_dependencies: BTreeMap::new(),
575 peer_dependencies_meta: BTreeMap::new(),
576 dep_path: "pkg@1.0.0".to_string(),
577 local_source: None,
578 os: PlatformList::default(),
579 cpu: PlatformList::default(),
580 libc: PlatformList::default(),
581 bundled_dependencies: Vec::new(),
582 tarball_url: None,
583 registry_git_hosted: false,
584 alias_of: None,
585 yarn_checksum: None,
586 engines: BTreeMap::new(),
587 bin: BTreeMap::new(),
588 declared_dependencies: BTreeMap::new(),
589 license: None,
590 funding_url: None,
591 optional: false,
592 transitive_peer_dependencies: Vec::new(),
593 extra_meta: BTreeMap::new(),
594 }
595 }
596
597 #[test]
598 fn source_approval_key_ignores_registry_git_hosted_packages() {
599 let mut pkg = pkg();
600 pkg.registry_git_hosted = true;
601
602 assert_eq!(pkg.source_approval_key(), None);
603 }
604
605 #[test]
606 fn lookup_patch_matches_plain_spec_key() {
607 let pkg = pkg();
608 let map = BTreeMap::from([("pkg@1.0.0".to_string(), "patch".to_string())]);
609 assert_eq!(
610 pkg.lookup_patch(&map),
611 Some(("pkg@1.0.0".to_string(), &"patch".to_string()))
612 );
613 }
614
615 #[test]
616 fn lookup_patch_falls_back_to_registry_name_for_alias() {
617 // `"odd-alias": "npm:is-odd@3.0.1"` records name = alias,
618 // alias_of = registry name. The patch is declared against the
619 // registry identity, so the aliased entry must resolve it via
620 // the fallback (this is the discussion #1082 bug).
621 let mut pkg = pkg();
622 pkg.name = "odd-alias".to_string();
623 pkg.version = "3.0.1".to_string();
624 pkg.alias_of = Some("is-odd".to_string());
625
626 let map = BTreeMap::from([("is-odd@3.0.1".to_string(), "patch".to_string())]);
627 assert_eq!(
628 pkg.lookup_patch(&map),
629 Some(("is-odd@3.0.1".to_string(), &"patch".to_string()))
630 );
631 }
632
633 #[test]
634 fn lookup_patch_prefers_alias_qualified_key_over_registry_key() {
635 // A patch declared against the alias identity wins over one
636 // declared against the registry identity — spec_key is tried
637 // first.
638 let mut pkg = pkg();
639 pkg.name = "odd-alias".to_string();
640 pkg.version = "3.0.1".to_string();
641 pkg.alias_of = Some("is-odd".to_string());
642
643 let map = BTreeMap::from([
644 ("odd-alias@3.0.1".to_string(), "alias-patch".to_string()),
645 ("is-odd@3.0.1".to_string(), "registry-patch".to_string()),
646 ]);
647 assert_eq!(
648 pkg.lookup_patch(&map),
649 Some(("odd-alias@3.0.1".to_string(), &"alias-patch".to_string()))
650 );
651 }
652
653 #[test]
654 fn lookup_patch_returns_none_when_unpatched() {
655 let pkg = pkg();
656 let map = BTreeMap::from([("other@2.0.0".to_string(), "patch".to_string())]);
657 assert_eq!(pkg.lookup_patch(&map), None);
658 }
659
660 #[test]
661 fn source_approval_key_uses_source_spec_for_local_sources() {
662 let mut pkg = pkg();
663 pkg.dep_path = "pkg@file+abc(peer@1.0.0)".to_string();
664 pkg.local_source = Some(LocalSource::Directory(PathBuf::from("vendor/pkg")));
665
666 assert_eq!(
667 pkg.source_approval_key(),
668 Some("pkg@file:vendor/pkg".to_string())
669 );
670 }
671
672 #[test]
673 fn source_approval_key_uses_raw_remote_tarball_url() {
674 let mut pkg = pkg();
675 pkg.dep_path = "pkg@url+abc123".to_string();
676 pkg.local_source = Some(LocalSource::RemoteTarball(RemoteTarballSource {
677 url: "https://example.com/pkg.tgz".to_string(),
678 integrity: "sha512-tarball".to_string(),
679 git_hosted: false,
680 }));
681
682 assert_eq!(
683 pkg.source_approval_key(),
684 Some("pkg@https://example.com/pkg.tgz".to_string())
685 );
686 }
687
688 #[test]
689 fn git_repository_approval_key_omits_resolved_commit() {
690 let mut pkg = pkg();
691 pkg.local_source = Some(LocalSource::Git(GitSource {
692 url: "https://github.com/acme/pkg.git".to_string(),
693 committish: Some("main".to_string()),
694 resolved: "0123456789012345678901234567890123456789".to_string(),
695 integrity: None,
696 subpath: None,
697 }));
698
699 assert_eq!(
700 pkg.git_repository_approval_key(),
701 Some("pkg@git+https://github.com/acme/pkg.git".to_string())
702 );
703 }
704
705 #[test]
706 fn git_repository_approval_key_normalizes_an_existing_git_prefix() {
707 let mut pkg = pkg();
708 pkg.local_source = Some(LocalSource::Git(GitSource {
709 url: "git+ssh://git@github.com/acme/pkg.git".to_string(),
710 committish: None,
711 resolved: "0123456789012345678901234567890123456789".to_string(),
712 integrity: None,
713 subpath: None,
714 }));
715
716 assert_eq!(
717 pkg.git_repository_approval_key(),
718 Some("pkg@git+ssh://git@github.com/acme/pkg.git".to_string())
719 );
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
731impl LockfileGraph {
732 /// Get all direct dependencies of the root project.
733 pub fn root_deps(&self) -> &[DirectDep] {
734 self.importers.get(".").map(|v| v.as_slice()).unwrap_or(&[])
735 }
736
737 /// Get a package by its dep_path key.
738 pub fn get_package(&self, dep_path: &str) -> Option<&LockedPackage> {
739 self.packages.get(dep_path)
740 }
741
742 /// BFS the transitive closure of `roots` through `self.packages`,
743 /// returning every reachable dep_path (roots included). Missing
744 /// roots are skipped silently — a root without a matching package
745 /// is treated as a leaf, which matches what `filter_deps` /
746 /// `subset_to_importer` need when a retained importer points at a
747 /// package that was never fully installed (e.g. optional deps
748 /// filtered out on this platform).
749 ///
750 /// `LockedPackage.dependencies` maps `child_name → dep_path tail`,
751 /// so each child's full key reconstructs as `{child_name}@{tail}`.
752 fn transitive_closure<'a>(
753 &self,
754 roots: impl IntoIterator<Item = &'a str>,
755 ) -> std::collections::HashSet<String> {
756 let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
757 let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
758 for root in roots {
759 if reachable.insert(root.to_string()) {
760 queue.push_back(root.to_string());
761 }
762 }
763 while let Some(dep_path) = queue.pop_front() {
764 let Some(pkg) = self.packages.get(&dep_path) else {
765 continue;
766 };
767 for (child_name, child_version) in &pkg.dependencies {
768 let child_key = format!("{child_name}@{child_version}");
769 if reachable.insert(child_key.clone()) {
770 queue.push_back(child_key);
771 }
772 }
773 }
774 reachable
775 }
776
777 /// Clone only the `packages` entries whose keys are in `reachable`.
778 /// Paired with `transitive_closure` to produce the pruned
779 /// `LockfileGraph.packages` for `filter_deps` / `subset_to_importer`.
780 fn packages_restricted_to(
781 &self,
782 reachable: &std::collections::HashSet<String>,
783 ) -> BTreeMap<String, LockedPackage> {
784 self.packages
785 .iter()
786 .filter(|(dep_path, _)| reachable.contains(*dep_path))
787 .map(|(k, v)| (k.clone(), v.clone()))
788 .collect()
789 }
790
791 /// Produce a new `LockfileGraph` containing only the direct deps that match
792 /// `keep` and the transitive deps reachable from them.
793 ///
794 /// Used by `install --prod` to drop `DepType::Dev` roots and everything
795 /// only reachable through them, and by `install --no-optional` for optional
796 /// deps. The filter runs over every importer's direct-dep list, so workspace
797 /// projects behave correctly.
798 ///
799 /// Packages that are reachable from a retained root through a transitive
800 /// chain are kept even if a pruned dev dep also happened to depend on them —
801 /// the check is "is this package reachable from any retained root?", not
802 /// "was this package introduced by a retained root?".
803 pub fn filter_deps<F>(&self, keep: F) -> LockfileGraph
804 where
805 F: Fn(&DirectDep) -> bool,
806 {
807 // Filter each importer's DirectDep list.
808 let importers: BTreeMap<String, Vec<DirectDep>> = self
809 .importers
810 .iter()
811 .map(|(path, deps)| {
812 let filtered: Vec<DirectDep> = deps.iter().filter(|d| keep(d)).cloned().collect();
813 (path.clone(), filtered)
814 })
815 .collect();
816
817 // BFS from every retained root across every importer.
818 let reachable = self.transitive_closure(
819 importers
820 .values()
821 .flat_map(|deps| deps.iter().map(|d| d.dep_path.as_str())),
822 );
823 let packages = self.packages_restricted_to(&reachable);
824
825 LockfileGraph {
826 importers,
827 packages,
828 // Preserve the source graph's settings — filter is a
829 // structural operation, not a resolution-mode reset.
830 // Writing the filtered graph (e.g. from `aube prune`) must
831 // emit the same `settings:` header the user chose.
832 settings: self.settings.clone(),
833 // Overrides are part of the user's resolution intent and
834 // should survive structural filters like `aube prune`.
835 overrides: self.overrides.clone(),
836 // Config checksums describe the inputs that produced the
837 // graph, not its shape — a structural filter must carry
838 // them through unchanged.
839 package_extensions_checksum: self.package_extensions_checksum.clone(),
840 pnpmfile_checksum: self.pnpmfile_checksum.clone(),
841 ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
842 // Times follow the same round-trip invariant as settings:
843 // filter doesn't change what versions are locked, so the
844 // per-package publish timestamps carry through unchanged.
845 times: self.times.clone(),
846 skipped_optional_dependencies: self.skipped_optional_dependencies.clone(),
847 catalogs: self.catalogs.clone(),
848 bun_config_version: self.bun_config_version,
849 patched_dependencies: self.patched_dependencies.clone(),
850 trusted_dependencies: self.trusted_dependencies.clone(),
851 // Runtime pins are graph-wide resolution intent, same as
852 // overrides/catalogs — structural filters carry them.
853 runtimes: self.runtimes.clone(),
854 extra_fields: self.extra_fields.clone(),
855 workspace_extra_fields: self.workspace_extra_fields.clone(),
856 }
857 }
858
859 /// Produce a new `LockfileGraph` rooted at the importer at
860 /// `importer_path`, with its transitive closure preserved and every
861 /// other importer dropped. The retained importer is remapped to
862 /// `"."` because the consumer installs the result as a standalone
863 /// project.
864 ///
865 /// Used by `aube deploy`: reading the source workspace lockfile
866 /// and subsetting it to the deployed package lets a frozen install
867 /// in the target reproduce the workspace's exact versions without
868 /// re-resolving against the registry. `keep` filters the importer's
869 /// direct deps the same way `filter_deps` does, so `--prod` /
870 /// `--dev` / `--no-optional` deploys drop the matching roots.
871 ///
872 /// Returns `None` if `importer_path` is not present in
873 /// `self.importers`. Graph-wide metadata (`settings`, `overrides`,
874 /// `times`, `catalogs`, `ignored_optional_dependencies`) is copied
875 /// verbatim — structural pruning, not a resolution-mode reset.
876 /// Callers targeting a non-workspace install may want to clear
877 /// workspace-scope fields that would otherwise trigger drift
878 /// detection against a rewritten target manifest.
879 pub fn subset_to_importer<F>(&self, importer_path: &str, keep: F) -> Option<LockfileGraph>
880 where
881 F: Fn(&DirectDep) -> bool,
882 {
883 let src_deps = self.importers.get(importer_path)?;
884 let kept: Vec<DirectDep> = src_deps.iter().filter(|d| keep(d)).cloned().collect();
885
886 // BFS the transitive closure from retained roots, scoped to
887 // just this importer's kept direct deps.
888 let reachable = self.transitive_closure(kept.iter().map(|d| d.dep_path.as_str()));
889 let packages = self.packages_restricted_to(&reachable);
890
891 // Per-importer metadata: keep only the retained importer's
892 // entry, rekeyed to `.`. The source workspace's other
893 // importers are meaningless in a target that has exactly one.
894 let mut skipped_optional_dependencies = BTreeMap::new();
895 if let Some(skipped) = self.skipped_optional_dependencies.get(importer_path) {
896 skipped_optional_dependencies.insert(".".to_string(), skipped.clone());
897 }
898
899 let mut importers = BTreeMap::new();
900 importers.insert(".".to_string(), kept);
901
902 Some(LockfileGraph {
903 importers,
904 packages,
905 settings: self.settings.clone(),
906 overrides: self.overrides.clone(),
907 // The deployed subset inherits the source workspace's
908 // config checksums: the same `packageExtensions`/pnpmfile
909 // governed the resolution being subsetted.
910 package_extensions_checksum: self.package_extensions_checksum.clone(),
911 pnpmfile_checksum: self.pnpmfile_checksum.clone(),
912 ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
913 times: self.times.clone(),
914 skipped_optional_dependencies,
915 catalogs: self.catalogs.clone(),
916 bun_config_version: self.bun_config_version,
917 patched_dependencies: self.patched_dependencies.clone(),
918 trusted_dependencies: self.trusted_dependencies.clone(),
919 runtimes: self.runtimes.clone(),
920 extra_fields: self.extra_fields.clone(),
921 workspace_extra_fields: self.workspace_extra_fields.clone(),
922 })
923 }
924
925 /// Overlay per-package metadata fields from `prior` onto `self`
926 /// for every `(name, version)` that survives in both graphs.
927 /// Carries forward only fields the abbreviated packument (npm
928 /// corgi) doesn't ship — `license`, `funding_url`, and the
929 /// bun-format `configVersion` — so a fresh re-resolve against
930 /// the same spec set doesn't lose them.
931 ///
932 /// Keyed by canonical `name@version`, so a peer-context rewrite
933 /// between the old and new graph still lines up. `self`'s own
934 /// values win when set (fresh registry data is authoritative);
935 /// `prior`'s fill in only the `None` / empty slots. Safe to call
936 /// on any pair of graphs — parsing the old lockfile is the
937 /// caller's concern.
938 pub fn overlay_metadata_from(&mut self, prior: &LockfileGraph) {
939 // Build a canonical `name@version → prior pkg` lookup once so
940 // repeated peer-context variants in `self.packages` all hit
941 // the same prior entry.
942 let prior_index = build_canonical_map(prior);
943 for pkg in self.packages.values_mut() {
944 let key = pkg.spec_key();
945 let Some(prior_pkg) = prior_index.get(&key) else {
946 continue;
947 };
948 if pkg.license.is_none() && prior_pkg.license.is_some() {
949 pkg.license = prior_pkg.license.clone();
950 }
951 if pkg.funding_url.is_none() && prior_pkg.funding_url.is_some() {
952 pkg.funding_url = prior_pkg.funding_url.clone();
953 }
954 // Per-entry extras (`deprecated`, `optionalPeers`,
955 // format-specific fields bun/npm/yarn wrote into the
956 // meta block) can't be recovered from a fresh resolve,
957 // so carry them forward when the newer graph doesn't
958 // already carry its own. `self`-side keys always win.
959 for (k, v) in &prior_pkg.extra_meta {
960 pkg.extra_meta.entry(k.clone()).or_insert_with(|| v.clone());
961 }
962 }
963 if self.bun_config_version.is_none() {
964 self.bun_config_version = prior.bun_config_version;
965 }
966 if self.patched_dependencies.is_empty() {
967 self.patched_dependencies = prior.patched_dependencies.clone();
968 }
969 if self.trusted_dependencies.is_empty() {
970 self.trusted_dependencies = prior.trusted_dependencies.clone();
971 }
972 // Runtime pins can't be recovered from a fresh package resolve
973 // (they come from devEngines resolution, a separate pass), so a
974 // re-resolved graph that hasn't re-pinned yet inherits the
975 // prior pin. The install driver overwrites it when the
976 // devEngines range drifted.
977 if self.runtimes.is_empty() {
978 self.runtimes = prior.runtimes.clone();
979 }
980 if self.extra_fields.is_empty() {
981 self.extra_fields = prior.extra_fields.clone();
982 }
983 if self.workspace_extra_fields.is_empty() {
984 self.workspace_extra_fields = prior.workspace_extra_fields.clone();
985 }
986 }
987}