Skip to main content

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