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,
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    /// Names listed in the root manifest's `pnpm.ignoredOptionalDependencies`.
61    /// The resolver drops entries in this set from every `optionalDependencies`
62    /// map before enqueueing, matching pnpm's read-package hook. Round-tripped
63    /// through pnpm-lock.yaml's top-level `ignoredOptionalDependencies:` list
64    /// so drift detection can notice when the user edits the field.
65    pub ignored_optional_dependencies: BTreeSet<String>,
66    /// Per-package publish timestamps, keyed by canonical `name@version`
67    /// (no peer suffix). Round-trips through pnpm-lock.yaml's top-level
68    /// `time:` block so `--resolution-mode=time-based` can compute a
69    /// `publishedBy` cutoff from packages already in the lockfile
70    /// without re-fetching packuments.
71    pub times: BTreeMap<String, String>,
72    /// Optional dependencies the resolver intentionally skipped on the
73    /// platform that wrote this lockfile (either filtered by
74    /// `os`/`cpu`/`libc`, or named in
75    /// `pnpm.ignoredOptionalDependencies`). Keyed by importer path,
76    /// inner map is name → specifier captured from `package.json` at
77    /// resolve time.
78    ///
79    /// Drift detection uses this to distinguish "user just added a new
80    /// optional dep" (which is real drift) from "this optional was
81    /// already considered and consciously dropped on this platform"
82    /// (which is *not* drift). Without it, every `--frozen-lockfile`
83    /// install on a platform that skipped a fixture would hard-fail.
84    pub skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
85    /// Resolved catalog entries, mirroring pnpm v9's top-level
86    /// `catalogs:` block. Outer key is the catalog name (`default` for
87    /// the unnamed `catalog:` field in `pnpm-workspace.yaml`); inner key
88    /// is the package name. Each entry pairs the original specifier
89    /// from the workspace catalog with the version the resolver chose
90    /// for it. Round-tripped through the lockfile so drift detection
91    /// can fire when a catalog spec changes without re-resolving.
92    pub catalogs: BTreeMap<String, BTreeMap<String, CatalogEntry>>,
93    /// bun's top-level `configVersion` — a second format counter bun
94    /// added alongside `lockfileVersion` to track its own config-
95    /// schema changes. Only the bun parser/writer ever touches this;
96    /// other formats leave it `None`. Round-tripping the parsed
97    /// value keeps the writer from silently downgrading the field
98    /// (e.g. from `2` back to `1`) when bun bumps it in a future
99    /// release.
100    pub bun_config_version: Option<u32>,
101    /// Top-level `patchedDependencies:` block mirrored by bun 1.1+ and
102    /// pnpm 9+. Key: selector (`lodash@4.17.21`), value: relative patch
103    /// file path (`patches/lodash@4.17.21.patch`). Round-tripped
104    /// verbatim so a parse/write cycle doesn't silently drop user
105    /// patches from the lockfile.
106    pub patched_dependencies: BTreeMap<String, String>,
107    /// Top-level `trustedDependencies:` block (bun) — a package-name
108    /// allowlist for lifecycle script execution. Preserved so
109    /// re-emitting a bun.lock doesn't strip the allowlist and cause
110    /// subsequent installs to skip scripts the user explicitly
111    /// approved.
112    ///
113    /// Kept as a `Vec` (not a set) so bun's original order round-trips
114    /// byte-identically; bun emits the list in insertion order. The
115    /// parser is responsible for deduping if the source lockfile
116    /// carried a duplicate.
117    pub trusted_dependencies: Vec<String>,
118    /// Top-level lockfile fields that aren't explicitly modeled on
119    /// `LockfileGraph`. Populated by per-format parsers on best-effort
120    /// basis so the writer can re-emit blocks a future lockfile
121    /// version might add (or ones we haven't promoted to typed fields
122    /// yet) without silently stripping them on round-trip. Each
123    /// parser/writer is responsible for emitting values in its
124    /// format's native serialization.
125    pub extra_fields: BTreeMap<String, serde_json::Value>,
126    /// Per-workspace-importer extras keyed by importer path (`""` for
127    /// root in bun, `"."` for others). Stores anything in the
128    /// workspace entry the typed model doesn't capture so a parse/
129    /// write cycle doesn't drop fields the user (or bun) wrote there.
130    pub workspace_extra_fields: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
131}
132
133/// One entry in a lockfile catalog: the workspace-declared range and the
134/// resolved version. Mirrors pnpm v9's `catalogs:` block exactly.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct CatalogEntry {
137    pub specifier: String,
138    pub version: String,
139}
140
141/// Per-graph settings that mirror pnpm v9's `settings:` header.
142/// Extend as more knobs become round-trip-aware.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct LockfileSettings {
145    /// pnpm's `auto-install-peers` — when false the resolver leaves
146    /// unmet peers alone (just warns) instead of dragging them in.
147    pub auto_install_peers: bool,
148    /// pnpm's `exclude-links-from-lockfile` — not yet honored by aube
149    /// but round-tripped for lockfile compatibility.
150    pub exclude_links_from_lockfile: bool,
151    /// pnpm's `lockfile-include-tarball-url` — when true the writer
152    /// emits the full registry tarball URL in each package's
153    /// `resolution.tarball:` field alongside `integrity:`. Makes the
154    /// lockfile self-contained so air-gapped installs don't need to
155    /// derive the URL from `.npmrc`. Round-tripped through the
156    /// `settings:` header so it survives parse/write cycles without
157    /// re-reading `.npmrc`.
158    pub lockfile_include_tarball_url: bool,
159}
160
161impl Default for LockfileSettings {
162    fn default() -> Self {
163        Self {
164            auto_install_peers: true,
165            exclude_links_from_lockfile: false,
166            lockfile_include_tarball_url: false,
167        }
168    }
169}
170
171/// A direct dependency of a workspace importer.
172#[derive(Debug, Clone)]
173pub struct DirectDep {
174    pub name: String,
175    /// The dep_path key in the lockfile (e.g., "is-odd@3.0.1")
176    pub dep_path: String,
177    pub dep_type: DepType,
178    /// The specifier as written in package.json at the time the lockfile was
179    /// generated (e.g., `"^4.17.0"`). Used by drift detection to compare against
180    /// the current manifest. Only populated by formats that record it
181    /// (pnpm-lock.yaml v9). `None` for npm/yarn/bun lockfiles.
182    pub specifier: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum DepType {
187    Production,
188    Dev,
189    Optional,
190}
191
192/// Render a `DepType` as the matching `package.json` field name
193/// (`dependencies` / `devDependencies` / `optionalDependencies`).
194/// Single source of truth so drift diagnostics, install summaries,
195/// the `outdated` / `why` / `deprecations` renderers, and the
196/// `outdated --json` shape all agree on the spelling.
197pub fn dep_type_label(dt: DepType) -> &'static str {
198    match dt {
199        DepType::Production => "dependencies",
200        DepType::Dev => "devDependencies",
201        DepType::Optional => "optionalDependencies",
202    }
203}
204
205/// A single resolved package in the lockfile.
206///
207/// The `dependencies` map keys are dep names and values are the dependency's
208/// dep_path *tail* — i.e. the string that follows `<name>@`. For a plain
209/// package this is just the version (`"4.17.21"`); for a package with its
210/// own peer context it includes the suffix (`"18.2.0(prop-types@15.8.1)"`).
211/// Combining the key with its value reproduces the full dep_path (which is
212/// also the key in `LockfileGraph.packages`).
213#[derive(Debug, Clone, Default)]
214pub struct LockedPackage {
215    /// Package name (e.g., "lodash")
216    pub name: String,
217    /// Exact resolved version (e.g., "4.17.21")
218    pub version: String,
219    /// Integrity hash (e.g., "sha512-...")
220    pub integrity: Option<String>,
221    /// Dependencies of this package (name -> dep_path tail, see struct docs)
222    pub dependencies: BTreeMap<String, String>,
223    /// Optional dependency edges for this package. Active optional edges are
224    /// also mirrored in `dependencies` so graph walks and the linker continue
225    /// to see them; this separate map lets platform filtering prune optional
226    /// edges without touching regular dependencies.
227    pub optional_dependencies: BTreeMap<String, String>,
228    /// Peer dependency ranges as *declared* by the package (from its
229    /// package.json / packument). These are the constraints; the resolved
230    /// versions live in `dependencies` after the peer-context pass runs.
231    pub peer_dependencies: BTreeMap<String, String>,
232    /// `peerDependenciesMeta` entries, keyed by peer name.
233    pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
234    /// The dep_path key used in the lockfile. For packages with resolved
235    /// peer contexts this includes the suffix, e.g.
236    /// `"styled-components@6.1.0(react@18.2.0)"`.
237    pub dep_path: String,
238    /// Set for non-registry packages (those installed via `file:` or
239    /// `link:` specifiers). `None` for the common case of a package
240    /// resolved from an npm registry, where `integrity` is the full
241    /// record of where the bits came from.
242    pub local_source: Option<LocalSource>,
243    /// `os` / `cpu` / `libc` arrays from the package's manifest. Used
244    /// by the resolver to filter optional deps that can't run on the
245    /// current (or user-overridden) platform. Empty arrays mean no
246    /// constraint.
247    pub os: PlatformList,
248    pub cpu: PlatformList,
249    pub libc: PlatformList,
250    /// Names declared in the package's own `bundledDependencies`. These
251    /// ship inside the parent tarball's `node_modules/`, so the resolver
252    /// neither fetches nor recurses into them, and the linker avoids
253    /// creating sibling symlinks that would shadow the bundled tree.
254    /// An empty Vec means "no bundled deps"; `None` is kept as a
255    /// distinct value only inside the resolver and collapsed to empty
256    /// here because the lockfile round-trip doesn't need to preserve
257    /// the "unset" vs "empty list" distinction.
258    pub bundled_dependencies: Vec<String>,
259    /// Full registry tarball URL for registry-sourced packages. Only
260    /// populated when `LockfileSettings::lockfile_include_tarball_url`
261    /// is active on this graph; otherwise `None` and the lockfile
262    /// writer derives the URL at fetch time from the configured
263    /// registry. `local_source`-backed packages (file:, link:, git:,
264    /// remote tarball) already carry their own URL via `LocalSource`
265    /// and don't populate this field.
266    pub tarball_url: Option<String>,
267    /// pnpm `resolution.gitHosted` for registry-keyed packages. Remote
268    /// tarball sources carry the same flag on `RemoteTarballSource`,
269    /// but registry entries keep `local_source: None`, so this field
270    /// preserves third-party pnpm lockfiles that mark registry-shaped
271    /// tarballs as hosted git.
272    pub registry_git_hosted: bool,
273    /// For npm-alias deps (`"h3-v2": "npm:h3@2.0.1-rc.20"`): the real
274    /// package name on the registry (`"h3"`). `None` means the entry
275    /// is not aliased and `name` already holds the registry name.
276    ///
277    /// Install semantics when `Some(real)`:
278    /// - `name` is the *alias* — that's the folder under `node_modules/`,
279    ///   the symlink name for transitive deps, and the key every package
280    ///   that declares this dep refers to.
281    /// - `alias_of` is the real package name used for tarball URL lookup,
282    ///   store index keying, and packument fetches.
283    /// - `version` is the real resolved version.
284    ///
285    /// `registry_name()` returns the right name for registry IO; every
286    /// call site that talks to the registry or the CAS uses that helper.
287    pub alias_of: Option<String>,
288    /// Yarn berry's `checksum:` field, preserved verbatim when parsing a
289    /// yarn 2+ lockfile (e.g. `"10c0/<blake2b-hex>"`). The format is
290    /// yarn-specific — it uses a yarn-chosen hash family prefixed with
291    /// the `cacheKey` that produced it — and doesn't share a hash
292    /// algorithm with `integrity` (sha-512). When re-emitting a yarn
293    /// berry lockfile we write this field back as-is; packages that
294    /// didn't come through a berry parse (e.g. freshly-resolved entries
295    /// in a new install) leave this `None` and the writer omits the
296    /// `checksum:` field, which berry tolerates at the default
297    /// `checksumBehavior: throw` when the cache is fresh.
298    pub yarn_checksum: Option<String>,
299    /// `engines:` from the package's manifest, round-tripped through
300    /// the lockfile so pnpm-style writers can emit the same flow-form
301    /// `engines: {node: '>=8'}` line pnpm writes. Empty map means
302    /// "no engines declared" — the writer skips the field entirely.
303    pub engines: BTreeMap<String, String>,
304    /// `bin:` map from the package's manifest, normalized to
305    /// `name → path`. An empty map means "no bins declared".
306    ///
307    /// pnpm-style writers derive `hasBin: true` from
308    /// `!bin.is_empty()` (they don't preserve the names/paths); bun's
309    /// format emits the full map on the package's meta block. Keeping
310    /// the map here lets both writers render byte-identical output
311    /// without an extra tarball-level re-parse.
312    pub bin: BTreeMap<String, String>,
313    /// Dependency ranges as declared in this package's own
314    /// `package.json` — keyed by dep name, values are the raw
315    /// specifiers (`"^4.1.0"`, `"~1.1.4"`, `"workspace:*"`, …).
316    ///
317    /// Distinct from [`Self::dependencies`], which stores the
318    /// *resolved* dep_path tail (`"4.3.0"`). npm / yarn / bun
319    /// lockfiles preserve the declared ranges on every nested
320    /// package entry — rewriting them to the resolved pins is the
321    /// biggest source of round-trip churn against those formats. This
322    /// map lets writers emit the declared range when available and
323    /// fall back to the resolved pin otherwise (e.g. when the source
324    /// lockfile was pnpm, whose `snapshots:` only carries pins).
325    ///
326    /// Empty means "unknown" — writers should fall back to pins.
327    /// Covers production *and* optional dependencies in one map since
328    /// a package can't declare the same name twice across those
329    /// sections.
330    pub declared_dependencies: BTreeMap<String, String>,
331    /// Package's `license` field, collapsed to the simple string
332    /// form. Round-tripped so npm's lockfile keeps its per-entry
333    /// `"license": "MIT"` line; pnpm / yarn / bun don't record
334    /// licenses and leave this `None` on parse.
335    pub license: Option<String>,
336    /// Package's funding URL, extracted from whatever shape the
337    /// manifest's `funding:` field took (string / object / array).
338    /// Round-tripped so npm's lockfile keeps its per-entry
339    /// `"funding": {"url": "…"}` block.
340    pub funding_url: Option<String>,
341    /// pnpm `snapshots:` `optional: true` flag, marking a package
342    /// reachable only through optional edges (typically platform-
343    /// specific binaries like `@reflink/reflink-darwin-arm64`). pnpm
344    /// uses this on the next install to decide whether the entry
345    /// should be skipped on a non-matching platform; dropping it on
346    /// round-trip would let pnpm treat the package as required.
347    /// Always `false` outside the pnpm parse/write path.
348    pub optional: bool,
349    /// pnpm `snapshots:` `transitivePeerDependencies:` list — peer
350    /// names that bubble up transitively through this package. pnpm
351    /// reads it during hoisting and as a resolver staleness signal
352    /// (`resolveDependencies.ts`'s non-zero-length check); a missing
353    /// list looks like a graph change and triggers needless re-
354    /// resolution on the next pnpm install. Empty outside the pnpm
355    /// parse/write path. Fresh resolves leave this empty too — pnpm
356    /// recomputes it from the graph during `resolvePeers` when needed.
357    pub transitive_peer_dependencies: Vec<String>,
358    /// Per-package-meta extras preserved verbatim from the source
359    /// lockfile. Captures fields the typed model doesn't yet cover
360    /// (`deprecated`, `hasInstallScript`, bun's `optionalPeers`, and
361    /// anything a future lockfile bump adds) so a parse/write cycle
362    /// doesn't drop them. Each format's writer re-emits what makes
363    /// sense there — bun inlines the extras back on the package-entry
364    /// meta object, pnpm / yarn / npm currently ignore them.
365    pub extra_meta: BTreeMap<String, serde_json::Value>,
366}
367
368impl LockedPackage {
369    /// The package name to use for registry / store operations — the real
370    /// name behind an npm-alias when aliased, otherwise just `name`. Used
371    /// at every site that derives a tarball URL, a packument URL, or an
372    /// aube-store cache key so aliased entries hit the actual package
373    /// instead of the alias-qualified name.
374    pub fn registry_name(&self) -> &str {
375        self.alias_of.as_deref().unwrap_or(&self.name)
376    }
377
378    /// Canonical `"name@version"` key used as a handle in patches,
379    /// approve-builds prompts, lockfile canonical maps, and display
380    /// paths. Not the dep-path — that includes peer-context suffixes.
381    pub fn spec_key(&self) -> String {
382        format!("{}@{}", self.name, self.version)
383    }
384}
385
386/// Metadata about a single declared peer dependency. Matches the shape of
387/// `peerDependenciesMeta` in package.json.
388#[derive(Debug, Clone, Default, PartialEq, Eq)]
389pub struct PeerDepMeta {
390    /// When true, an unmet peer is silently allowed rather than warned about.
391    pub optional: bool,
392}
393
394impl LockfileGraph {
395    /// Get all direct dependencies of the root project.
396    pub fn root_deps(&self) -> &[DirectDep] {
397        self.importers.get(".").map(|v| v.as_slice()).unwrap_or(&[])
398    }
399
400    /// Get a package by its dep_path key.
401    pub fn get_package(&self, dep_path: &str) -> Option<&LockedPackage> {
402        self.packages.get(dep_path)
403    }
404
405    /// BFS the transitive closure of `roots` through `self.packages`,
406    /// returning every reachable dep_path (roots included). Missing
407    /// roots are skipped silently — a root without a matching package
408    /// is treated as a leaf, which matches what `filter_deps` /
409    /// `subset_to_importer` need when a retained importer points at a
410    /// package that was never fully installed (e.g. optional deps
411    /// filtered out on this platform).
412    ///
413    /// `LockedPackage.dependencies` maps `child_name → dep_path tail`,
414    /// so each child's full key reconstructs as `{child_name}@{tail}`.
415    fn transitive_closure<'a>(
416        &self,
417        roots: impl IntoIterator<Item = &'a str>,
418    ) -> std::collections::HashSet<String> {
419        let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
420        let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
421        for root in roots {
422            if reachable.insert(root.to_string()) {
423                queue.push_back(root.to_string());
424            }
425        }
426        while let Some(dep_path) = queue.pop_front() {
427            let Some(pkg) = self.packages.get(&dep_path) else {
428                continue;
429            };
430            for (child_name, child_version) in &pkg.dependencies {
431                let child_key = format!("{child_name}@{child_version}");
432                if reachable.insert(child_key.clone()) {
433                    queue.push_back(child_key);
434                }
435            }
436        }
437        reachable
438    }
439
440    /// Clone only the `packages` entries whose keys are in `reachable`.
441    /// Paired with `transitive_closure` to produce the pruned
442    /// `LockfileGraph.packages` for `filter_deps` / `subset_to_importer`.
443    fn packages_restricted_to(
444        &self,
445        reachable: &std::collections::HashSet<String>,
446    ) -> BTreeMap<String, LockedPackage> {
447        self.packages
448            .iter()
449            .filter(|(dep_path, _)| reachable.contains(*dep_path))
450            .map(|(k, v)| (k.clone(), v.clone()))
451            .collect()
452    }
453
454    /// Produce a new `LockfileGraph` containing only the direct deps that match
455    /// `keep` and the transitive deps reachable from them.
456    ///
457    /// Used by `install --prod` to drop `DepType::Dev` roots and everything
458    /// only reachable through them, and by `install --no-optional` for optional
459    /// deps. The filter runs over every importer's direct-dep list, so workspace
460    /// projects behave correctly.
461    ///
462    /// Packages that are reachable from a retained root through a transitive
463    /// chain are kept even if a pruned dev dep also happened to depend on them —
464    /// the check is "is this package reachable from any retained root?", not
465    /// "was this package introduced by a retained root?".
466    pub fn filter_deps<F>(&self, keep: F) -> LockfileGraph
467    where
468        F: Fn(&DirectDep) -> bool,
469    {
470        // Filter each importer's DirectDep list.
471        let importers: BTreeMap<String, Vec<DirectDep>> = self
472            .importers
473            .iter()
474            .map(|(path, deps)| {
475                let filtered: Vec<DirectDep> = deps.iter().filter(|d| keep(d)).cloned().collect();
476                (path.clone(), filtered)
477            })
478            .collect();
479
480        // BFS from every retained root across every importer.
481        let reachable = self.transitive_closure(
482            importers
483                .values()
484                .flat_map(|deps| deps.iter().map(|d| d.dep_path.as_str())),
485        );
486        let packages = self.packages_restricted_to(&reachable);
487
488        LockfileGraph {
489            importers,
490            packages,
491            // Preserve the source graph's settings — filter is a
492            // structural operation, not a resolution-mode reset.
493            // Writing the filtered graph (e.g. from `aube prune`) must
494            // emit the same `settings:` header the user chose.
495            settings: self.settings.clone(),
496            // Overrides are part of the user's resolution intent and
497            // should survive structural filters like `aube prune`.
498            overrides: self.overrides.clone(),
499            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
500            // Times follow the same round-trip invariant as settings:
501            // filter doesn't change what versions are locked, so the
502            // per-package publish timestamps carry through unchanged.
503            times: self.times.clone(),
504            skipped_optional_dependencies: self.skipped_optional_dependencies.clone(),
505            catalogs: self.catalogs.clone(),
506            bun_config_version: self.bun_config_version,
507            patched_dependencies: self.patched_dependencies.clone(),
508            trusted_dependencies: self.trusted_dependencies.clone(),
509            extra_fields: self.extra_fields.clone(),
510            workspace_extra_fields: self.workspace_extra_fields.clone(),
511        }
512    }
513
514    /// Produce a new `LockfileGraph` rooted at the importer at
515    /// `importer_path`, with its transitive closure preserved and every
516    /// other importer dropped. The retained importer is remapped to
517    /// `"."` because the consumer installs the result as a standalone
518    /// project.
519    ///
520    /// Used by `aube deploy`: reading the source workspace lockfile
521    /// and subsetting it to the deployed package lets a frozen install
522    /// in the target reproduce the workspace's exact versions without
523    /// re-resolving against the registry. `keep` filters the importer's
524    /// direct deps the same way `filter_deps` does, so `--prod` /
525    /// `--dev` / `--no-optional` deploys drop the matching roots.
526    ///
527    /// Returns `None` if `importer_path` is not present in
528    /// `self.importers`. Graph-wide metadata (`settings`, `overrides`,
529    /// `times`, `catalogs`, `ignored_optional_dependencies`) is copied
530    /// verbatim — structural pruning, not a resolution-mode reset.
531    /// Callers targeting a non-workspace install may want to clear
532    /// workspace-scope fields that would otherwise trigger drift
533    /// detection against a rewritten target manifest.
534    pub fn subset_to_importer<F>(&self, importer_path: &str, keep: F) -> Option<LockfileGraph>
535    where
536        F: Fn(&DirectDep) -> bool,
537    {
538        let src_deps = self.importers.get(importer_path)?;
539        let kept: Vec<DirectDep> = src_deps.iter().filter(|d| keep(d)).cloned().collect();
540
541        // BFS the transitive closure from retained roots, scoped to
542        // just this importer's kept direct deps.
543        let reachable = self.transitive_closure(kept.iter().map(|d| d.dep_path.as_str()));
544        let packages = self.packages_restricted_to(&reachable);
545
546        // Per-importer metadata: keep only the retained importer's
547        // entry, rekeyed to `.`. The source workspace's other
548        // importers are meaningless in a target that has exactly one.
549        let mut skipped_optional_dependencies = BTreeMap::new();
550        if let Some(skipped) = self.skipped_optional_dependencies.get(importer_path) {
551            skipped_optional_dependencies.insert(".".to_string(), skipped.clone());
552        }
553
554        let mut importers = BTreeMap::new();
555        importers.insert(".".to_string(), kept);
556
557        Some(LockfileGraph {
558            importers,
559            packages,
560            settings: self.settings.clone(),
561            overrides: self.overrides.clone(),
562            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
563            times: self.times.clone(),
564            skipped_optional_dependencies,
565            catalogs: self.catalogs.clone(),
566            bun_config_version: self.bun_config_version,
567            patched_dependencies: self.patched_dependencies.clone(),
568            trusted_dependencies: self.trusted_dependencies.clone(),
569            extra_fields: self.extra_fields.clone(),
570            workspace_extra_fields: self.workspace_extra_fields.clone(),
571        })
572    }
573
574    /// Overlay per-package metadata fields from `prior` onto `self`
575    /// for every `(name, version)` that survives in both graphs.
576    /// Carries forward only fields the abbreviated packument (npm
577    /// corgi) doesn't ship — `license`, `funding_url`, and the
578    /// bun-format `configVersion` — so a fresh re-resolve against
579    /// the same spec set doesn't lose them.
580    ///
581    /// Keyed by canonical `name@version`, so a peer-context rewrite
582    /// between the old and new graph still lines up. `self`'s own
583    /// values win when set (fresh registry data is authoritative);
584    /// `prior`'s fill in only the `None` / empty slots. Safe to call
585    /// on any pair of graphs — parsing the old lockfile is the
586    /// caller's concern.
587    pub fn overlay_metadata_from(&mut self, prior: &LockfileGraph) {
588        // Build a canonical `name@version → prior pkg` lookup once so
589        // repeated peer-context variants in `self.packages` all hit
590        // the same prior entry.
591        let prior_index = build_canonical_map(prior);
592        for pkg in self.packages.values_mut() {
593            let key = pkg.spec_key();
594            let Some(prior_pkg) = prior_index.get(&key) else {
595                continue;
596            };
597            if pkg.license.is_none() && prior_pkg.license.is_some() {
598                pkg.license = prior_pkg.license.clone();
599            }
600            if pkg.funding_url.is_none() && prior_pkg.funding_url.is_some() {
601                pkg.funding_url = prior_pkg.funding_url.clone();
602            }
603            // Per-entry extras (`deprecated`, `optionalPeers`,
604            // format-specific fields bun/npm/yarn wrote into the
605            // meta block) can't be recovered from a fresh resolve,
606            // so carry them forward when the newer graph doesn't
607            // already carry its own. `self`-side keys always win.
608            for (k, v) in &prior_pkg.extra_meta {
609                pkg.extra_meta.entry(k.clone()).or_insert_with(|| v.clone());
610            }
611        }
612        if self.bun_config_version.is_none() {
613            self.bun_config_version = prior.bun_config_version;
614        }
615        if self.patched_dependencies.is_empty() {
616            self.patched_dependencies = prior.patched_dependencies.clone();
617        }
618        if self.trusted_dependencies.is_empty() {
619            self.trusted_dependencies = prior.trusted_dependencies.clone();
620        }
621        if self.extra_fields.is_empty() {
622            self.extra_fields = prior.extra_fields.clone();
623        }
624        if self.workspace_extra_fields.is_empty() {
625            self.workspace_extra_fields = prior.workspace_extra_fields.clone();
626        }
627    }
628}