use super::dep_path::{ alias_dep_path, parse_dep_path, peerless_alias_target, registry_name_from_qualified_version, rewrite_peer_suffix, rewrite_snapshot_alias_deps, version_to_dep_path, }; use super::raw::{ RawBinSpec, RawDepSpec, RawRuntimeVariant, local_source_from_resolution, parse_raw_lockfile, }; use crate::{ CatalogEntry, DepType, DirectDep, Error, LocalSource, LockedPackage, LockfileGraph, ParseOptions, PeerDepMeta, RuntimePin, RuntimeTarget, RuntimeVariant, git_commits_match, }; use aube_util::path::normalize_lexical; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; fn rebase_importer_local(local: LocalSource, importer_path: &str) -> LocalSource { fn rebase(path: PathBuf, importer_path: &str) -> PathBuf { if importer_path == "." { path } else { normalize_lexical(&Path::new(importer_path).join(path)) } } match local { LocalSource::Directory(path) => LocalSource::Directory(rebase(path, importer_path)), LocalSource::Tarball(path) => LocalSource::Tarball(rebase(path, importer_path)), LocalSource::Link(path) => LocalSource::Link(rebase(path, importer_path)), LocalSource::Portal(path) => LocalSource::Portal(rebase(path, importer_path)), LocalSource::Exec(path) => LocalSource::Exec(rebase(path, importer_path)), LocalSource::Git(_) | LocalSource::RemoteTarball(_) => local, } } /// Lowest `lockfileVersion` this reader understands. pnpm v9 replaced /// the top-level `dependencies:` map and `/name@version` package keys /// with `importers:` + `snapshots:`; every pass below assumes that /// shape, so older files must be rejected rather than parsed into an /// empty graph. pub(super) const MIN_LOCKFILE_VERSION: u64 = 9; /// Major component of a `lockfileVersion`. pnpm has written the value /// both as a quoted string (`'9.0'`, `'6.0'`) and as a bare YAML float /// (`5.4`), so both encodings are accepted. /// /// Every dot-separated component must be digits: reading only the /// leading component would accept `9.invalid` as version 9 and let a /// lockfile of unknown shape through the guard. Returns `None` for /// anything that isn't a well-formed version number, which the caller /// treats as unsupported. pub(super) fn lockfile_version_major(value: &yaml_serde::Value) -> Option { if let Some(s) = value.as_str() { let s = s.trim(); let mut components = s.split('.'); let major = components.next()?; if !components.all(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit())) { return None; } return major.parse().ok(); } if let Some(u) = value.as_u64() { return Some(u); } // A bare YAML float is numeric by construction, so truncating to // the major is safe here — `9.5` is a v9-family lockfile. let f = value.as_f64()?; (f.is_finite() && f >= 0.0).then_some(f.trunc() as u64) } /// Render a `lockfileVersion` back for the error message, preserving /// whichever encoding the lockfile used. fn render_lockfile_version(value: &yaml_serde::Value) -> String { if let Some(s) = value.as_str() { return s.to_string(); } if let Some(u) = value.as_u64() { return u.to_string(); } if let Some(i) = value.as_i64() { return i.to_string(); } match value.as_f64() { Some(f) => f.to_string(), None => "(unreadable)".to_string(), } } /// Parse a pnpm-lock.yaml file into a LockfileGraph. pub fn parse(path: &Path) -> Result { parse_with_options(path, ParseOptions::default()) } pub fn parse_with_options(path: &Path, options: ParseOptions) -> Result { let content = crate::read_lockfile(path)?; let raw = parse_raw_lockfile(&content) .map_err(|e| Error::parse_yaml_err(path, content.clone(), &e))?; // Version guard before any structural pass: a pre-v9 lockfile is // valid YAML that yields zero importers, which would otherwise // link nothing and exit 0. match lockfile_version_major(&raw.lockfile_version) { Some(major) if major >= MIN_LOCKFILE_VERSION => {} _ => { return Err(Error::UnsupportedPnpmLockfileVersion { path: path.to_path_buf(), version: render_lockfile_version(&raw.lockfile_version), }); } } // Layout guard for a lockfile whose declared version does not match // its body: `lockfileVersion: '9.0'` over a pre-v9 body passes the // version check, and its root deps are then dropped on the floor — // the graph comes back empty and the declared dependencies go // unlinked. // // Two independent signatures, because either can appear alone: a // root-level dependency block (the only shape a `link:`/`file:`-only // project has, since it has no `packages:` at all), and a // slash-prefixed package key (`/is-odd@3.0.1` in v6, // `/is-odd/3.0.1` in v5). A package name cannot begin with `/`, and // no v9+ document puts dependencies at the root, so neither shape is // one pnpm or aube writes at v9. let legacy_marker = [ ("dependencies", raw.dependencies.is_some()), ("devDependencies", raw.dev_dependencies.is_some()), ("optionalDependencies", raw.optional_dependencies.is_some()), ("specifiers", raw.specifiers.is_some()), ] .into_iter() .find_map(|(block, present)| present.then(|| format!("root-level `{block}:` block"))) .or_else(|| { raw.packages .keys() .find(|key| key.starts_with('/')) .map(|key| format!("package key `{key}`")) }); if let Some(marker) = legacy_marker { return Err(Error::PnpmLockfileLegacyLayout { path: path.to_path_buf(), version: render_lockfile_version(&raw.lockfile_version), marker, }); } // Parse importers (direct deps of each workspace package). // We track synthesized LockedPackages for local (`file:` / `link:`) // deps here so the main packages loop below doesn't try to process // them off the canonical lockfile key. let mut importers = BTreeMap::new(); let mut local_packages: BTreeMap = BTreeMap::new(); let mut local_importers: BTreeMap = BTreeMap::new(); let mut local_snapshot_keys: BTreeMap = BTreeMap::new(); let mut all_local_snapshot_keys: std::collections::HashSet = std::collections::HashSet::new(); let mut skipped_optional_dependencies: BTreeMap> = BTreeMap::new(); // pnpm v9 encodes npm-aliases implicitly: the importer key is // the alias (`express-fork`), `specifier:` carries `npm:@`, // and `version:` is `@`. There is no `aliasOf:` // field — that's an aube-specific writer extension. We record // each alias here and synthesize an alias-keyed LockedPackage // after the canonical packages loop, mirroring the shape the // resolver-fresh path emits so the linker stays single-shape. // Tuple: (alias_dep_path, real_dep_path, alias_name, real_name). let mut alias_remaps: Vec<(String, String, String, String)> = Vec::new(); let mut reserved_package_keys = raw .packages .iter() .filter(|(key, package)| package.alias_of.is_none() || key.contains("@npm:")) .map(|(key, _)| key.clone()) .collect::>(); // Reserve ambiguous keys before assigning aliases so traversal order cannot select a target. let importer_references = raw.importers.values().flat_map(|importer| { [ &importer.dependencies, &importer.dev_dependencies, &importer.optional_dependencies, ] .into_iter() .flatten() .flat_map(|dependencies| dependencies.iter()) .map(|(name, dependency)| (name, &dependency.version)) }); let snapshot_references = raw.snapshots.values().flat_map(|snapshot| { [&snapshot.dependencies, &snapshot.optional_dependencies] .into_iter() .flatten() .flat_map(|dependencies| dependencies.iter()) }); let mut alias_targets = BTreeMap::new(); for (name, reference) in importer_references.chain(snapshot_references) { let reference = reference.strip_prefix("npm:").unwrap_or(reference); let Some((real_name, version)) = parse_dep_path(reference.split('(').next().unwrap_or(reference)) else { continue; }; if real_name == *name { continue; } let key = version_to_dep_path(name, &version); if let Some(previous_target) = alias_targets.insert(key.clone(), real_name.clone()) && previous_target != real_name { reserved_package_keys.insert(key); } } // pnpm 10.14+ records `devEngines.runtime` pins as synthetic // importer deps whose specifier/version carry a `runtime:` prefix // (`node: {specifier: runtime:^24.4.0, version: runtime:24.4.1}`). // Those are not packages — route them into `graph.runtimes` // instead of `DirectDep`s so the resolver/linker never tries to // fetch `node` from the npm registry. Map: runtime name → // (specifier, exact version, came from devDependencies). First // importer to declare a runtime wins (pnpm only writes it on the // importer whose manifest declares devEngines — the root). let mut runtime_imports: BTreeMap = BTreeMap::new(); let mut record_runtime = |name: &str, info: &RawDepSpec, dep_type: DepType| -> bool { let Some(version) = info.version.strip_prefix("runtime:") else { return false; }; let specifier = info .specifier .strip_prefix("runtime:") .unwrap_or(&info.specifier); runtime_imports.entry(name.to_string()).or_insert_with(|| { ( specifier.to_string(), version.to_string(), matches!(dep_type, DepType::Dev), ) }); true }; let mut push_direct = |deps: &mut Vec, alias_remaps: &mut Vec<(String, String, String, String)>, importer_path: &str, name: &str, info: &RawDepSpec, dep_type: DepType| { // pnpm appends a `(peer@ver)` suffix to the importer // `version:` of URL- and git-based direct deps when the // resolved snapshot carries peer context, the same way it // does for semver versions. `LocalSource::parse` treats the // whole string as the URL, so a RemoteTarballSource built // from the raw value fetches `…/tar.gz/SHA(peer@ver)` and // 404s. Strip it here so the URL that reaches the fetcher // and the dep_path hash are both peer-context-free — // consistent with what `parse_dep_path` does for snapshot // keys downstream. let classify_version = info.version.split('(').next().unwrap_or(&info.version); if let Some(local) = LocalSource::parse(classify_version, Path::new("")) { // `Path::new("")` means tarball-vs-dir classification is // skipped; we default to Directory and rely on the // resolver's on-disk re-read for the authoritative source // type during a subsequent `aube install` (lockfile-only // path never materializes local deps anyway before the // fetch step re-classifies). // // Re-classify Directory → Tarball if the path looks // like a tarball filename, so `.tgz`/`.tar.gz` // targets round-trip correctly even when the file // isn't present at parse time. The filename // heuristic lives on `LocalSource` so this stays in // lockstep with `LocalSource::parse`. let local = match local { LocalSource::Directory(p) if LocalSource::path_looks_like_tarball(&p) => { LocalSource::Tarball(p) } // Importer `version:` for git deps is the canonical // `#` form pnpm writes. The parser // puts the `` into `committish`; since // this is a lockfile round-trip (not a raw user // spec), treat it as the pinned commit. LocalSource::Git(mut g) if g.resolved.is_empty() => { if let Some(c) = g.committish.take() { g.resolved = c; } LocalSource::Git(g) } other => other, }; let snapshot_key = format!("{name}@{}", local.specifier()); let should_rebase = importer_path != "." && (info.specifier == classify_version || info.specifier.starts_with("workspace:")); // A member's `link:` version is relative to the member, as pnpm // writes it (normalized, `link:./x` as `link:x`), which the // equality check above misses. That holds for a target an // override set too, so the version, not the specifier, settles // the target. Older aube wrote a member's own link root-relative: // a version equal to the specifier resolved from the member. let member_link_spec = (importer_path != "." && matches!(local, LocalSource::Link(_))) .then(|| LocalSource::parse(&info.specifier, Path::new(""))) .flatten() .filter(|spec| matches!(spec, LocalSource::Link(_))); let local = match member_link_spec { Some(spec) => { let own = rebase_importer_local(spec, importer_path); let root_relative = matches!( (&own, &local), (LocalSource::Link(own), LocalSource::Link(version)) if *own == normalize_lexical(version) ); if root_relative { own } else { rebase_importer_local(local, importer_path) } } None if should_rebase => rebase_importer_local(local, importer_path), None => local, }; let dep_path = local.dep_path(name); deps.push(DirectDep { name: name.to_string(), dep_path: dep_path.clone(), dep_type, specifier: Some(info.specifier.clone()), }); local_packages .entry(dep_path.clone()) .or_insert_with(|| LockedPackage { name: name.to_string(), version: "0.0.0".to_string(), integrity: None, dependencies: BTreeMap::new(), peer_dependencies: BTreeMap::new(), peer_dependencies_meta: BTreeMap::new(), dep_path: dep_path.clone(), local_source: Some(local), ..Default::default() }); if should_rebase { local_importers .entry(dep_path.clone()) .or_insert_with(|| importer_path.to_string()); } local_snapshot_keys .entry(dep_path) .or_insert_with(|| snapshot_key.clone()); all_local_snapshot_keys.insert(snapshot_key); } else { // Detect npm-aliased deps purely from the shape of // `version:`. pnpm encodes aliases as // `@(peers…)` regardless of how the // alias was declared: // - direct: `specifier: npm:beamcoder-prebuild@0.7.1` // - catalog: `specifier: 'catalog:'` (the alias lives // in `pnpm-workspace.yaml#catalog`) // The earlier `specifier.starts_with("npm:")` gate missed // the catalog flavor and silently dropped those deps. // Strip any peer suffix before parsing so `version: // 18.2.0(react@18.2.0)` (a regular dep with peers) does // not parse as `name="18.2.0(react"`. let reference = info.version.strip_prefix("npm:").unwrap_or(&info.version); let bare_version = reference.split('(').next().unwrap_or(reference); let dep_path = if let Some((real_name, resolved)) = parse_dep_path(bare_version) && real_name != name { let real_dep_path = reference.to_string(); let alias_dep_path = alias_dep_path(name, &real_dep_path, &resolved, &reserved_package_keys); alias_remaps.push(( alias_dep_path.clone(), real_dep_path, name.to_string(), real_name, )); alias_dep_path } else { version_to_dep_path(name, &info.version) }; deps.push(DirectDep { name: name.to_string(), dep_path, dep_type, specifier: Some(info.specifier.clone()), }); } }; for (importer_path, importer) in &raw.importers { // pnpm writes the workspace root as either `'.'` (most // common / current) or `''` (seen on v9 lockfiles in the // wild, e.g. npmx.dev). Both mean "the repo root" — we key // the graph on `.` everywhere downstream (linker, filters, // stats), so normalize at parse time and keep the rest of // the pipeline single-shape. let importer_path = if importer_path.is_empty() { "." } else { importer_path.as_str() }; // Guard against a malformed lockfile that writes both `''` // and `'.'` for root — `BTreeMap` iteration visits `''` // first, so the real `'.'` entry would otherwise silently // overwrite the normalized empty-key entry. pnpm never // emits this, but skipping the second visit is cheap and // makes the intent explicit. if importers.contains_key(importer_path) { continue; } let mut deps = Vec::new(); // One declaration produces one `DirectDep`. pnpm classifies a // name declared in several importer sections under the first // one it appears in, so the block order below is the // precedence and first wins. pnpm does not itself emit such a // lockfile, but a hand-edited or third-party-written one can, // and a duplicate here reads as section drift under // `--frozen-lockfile` and makes the linker create the root // `node_modules/` symlink twice. Keyed on the declared // name so two aliases of the same package stay distinct. let mut direct_seen: BTreeSet = BTreeSet::new(); if let Some(ref d) = importer.dependencies { for (name, info) in d { if record_runtime(name, info, DepType::Production) { continue; } if !direct_seen.insert(name.clone()) { continue; } push_direct( &mut deps, &mut alias_remaps, importer_path, name, info, DepType::Production, ); } } if let Some(ref d) = importer.dev_dependencies { for (name, info) in d { if record_runtime(name, info, DepType::Dev) { continue; } if !direct_seen.insert(name.clone()) { continue; } push_direct( &mut deps, &mut alias_remaps, importer_path, name, info, DepType::Dev, ); } } if let Some(ref d) = importer.optional_dependencies { for (name, info) in d { if record_runtime(name, info, DepType::Optional) { continue; } if !direct_seen.insert(name.clone()) { continue; } push_direct( &mut deps, &mut alias_remaps, importer_path, name, info, DepType::Optional, ); } } if let Some(ref d) = importer.skipped_optional_dependencies && !d.is_empty() { let mut map = BTreeMap::new(); for (name, info) in d { map.insert(name.clone(), info.specifier.clone()); } skipped_optional_dependencies.insert(importer_path.to_string(), map); } importers.insert(importer_path.to_string(), deps); } // pnpm v9 splits packages (canonical, keyed by `name@version`) from // snapshots (contextualized, keyed by the full dep_path with any // `(peer@ver)` suffix). The LockfileGraph needs one entry per snapshot // — the same canonical package can produce multiple snapshots when // different parts of the tree resolve its peers differently. // // If `snapshots:` is missing (older aube lockfiles where we wrote // everything into packages), fall back to iterating packages directly. let mut packages = BTreeMap::new(); // Harvest snapshot dependencies for any local (`file:`) package // that showed up in the importers loop. The canonical snapshot // key for a local dep is `@` — e.g. // `foo@file:./vendor/foo` — so we construct it from each // synthesized entry and pull its `dependencies` block out of the // raw snapshots map. for local_pkg in local_packages.values_mut() { if let Some(ref local) = local_pkg.local_source { let canonical = format!("{}@{}", local_pkg.name, local.specifier()); let snapshot_key = local_snapshot_keys .get(&local_pkg.dep_path) .map(String::as_str) .unwrap_or(canonical.as_str()); // URL-based direct deps have their peer-context suffix // stripped (see `push_direct`), but the matching snapshot // entry pnpm wrote still carries the suffix. Fall back to // any snapshot whose peer-stripped canonical matches so // transitive dependency metadata still flows through. let snap = raw .snapshots .get(snapshot_key) .or_else(|| { if snapshot_key == canonical { None } else { raw.snapshots.get(&canonical) } }) .or_else(|| { raw.snapshots.iter().find_map(|(k, v)| { parse_dep_path(k) .filter(|(n, ver)| format!("{n}@{ver}") == canonical) .map(|_| v) }) }); if let Some(snap) = snap && let Some(mut deps) = snap.dependencies.clone() { rewrite_snapshot_alias_deps(&mut deps, &mut alias_remaps, &reserved_package_keys); local_pkg.dependencies = deps; } if let Some(snap) = snap && let Some(mut opt_deps) = snap.optional_dependencies.clone() { rewrite_snapshot_alias_deps( &mut opt_deps, &mut alias_remaps, &reserved_package_keys, ); local_pkg.dependencies.extend(opt_deps.clone()); local_pkg.optional_dependencies = opt_deps; } // pnpm records a local tarball's semver, SHA-512 and bundled // children on its `packages:` entry; carry them so a rewrite // writes them back instead of the importer's `file:` version. if matches!(local, LocalSource::Tarball(_)) && let Some(info) = raw .packages .get(snapshot_key) .or_else(|| raw.packages.get(&canonical)) { if let Some(version) = &info.version { local_pkg.version = version.clone(); } local_pkg.integrity = info.resolution.as_ref().and_then(|r| r.integrity.clone()); if let Some(bundled) = &info.bundled_dependencies { local_pkg.bundled_dependencies = bundled .names(&local_pkg.dependencies) .into_iter() .map(str::to_string) .collect(); } } // Prefer the authoritative LocalSource classification // from the `resolution:` block over the guess the // importers loop made from the bare specifier. For git // deps, preserve any `path:` selector already captured // from the importer's `version:` URL — pnpm v9 encodes // the subpath in the snapshot key and doesn't always // echo it on the resolution block. let pkg_info = raw.packages.get(&canonical).or_else(|| match local { LocalSource::Git(git) => raw.packages.iter().find_map(|(key, pkg_info)| { parse_dep_path(key) .filter(|(name, _)| name == &local_pkg.name) .and(pkg_info.resolution.as_ref()) .and_then(local_source_from_resolution) .and_then(|candidate| match candidate { LocalSource::Git(candidate) if git_commits_match(&candidate.resolved, &git.resolved) && candidate.subpath == git.subpath => { Some(pkg_info) } _ => None, }) }), LocalSource::RemoteTarball(tarball) => { raw.packages.iter().find_map(|(key, pkg_info)| { parse_dep_path(key) .filter(|(name, _)| name == &local_pkg.name) .and(pkg_info.resolution.as_ref()) .and_then(local_source_from_resolution) .and_then(|candidate| match candidate { LocalSource::RemoteTarball(candidate) if candidate.url == tarball.url => { Some(pkg_info) } _ => None, }) }) } _ => None, }); if let Some(pkg_info) = pkg_info && let Some(ref res) = pkg_info.resolution && let Some(mut ls) = local_source_from_resolution(res) { if let Some(importer_path) = local_importers.get(&local_pkg.dep_path) { ls = rebase_importer_local(ls, importer_path); } if matches!(ls, LocalSource::Git(_) | LocalSource::RemoteTarball(_)) { local_pkg.integrity = res.integrity.clone(); } if let LocalSource::Git(ref mut g) = ls && let Some(LocalSource::Git(prior)) = &local_pkg.local_source { if git_commits_match(&g.resolved, &prior.resolved) { g.resolved = prior.resolved.clone(); } if g.subpath.is_none() { g.subpath = prior.subpath.clone(); } } local_pkg.local_source = Some(ls); } } } // Rebuild keys in case the local_source rewrite above changed // the classification — kind alone doesn't affect the encoded // dep_path (the hash is over the path string only), but the // `resolution:` block can also hand us a *different path* than // the importer's specifier, which does. Recompute both the map // key and the struct field from the final `local_source` so // `graph.packages.get(&dep.dep_path)` stays consistent with how // DirectDeps were keyed up in the importer loop above. Resolution // metadata can refine the importer `version:` form (for example, // preserving a `git+ssh://` repo URL), so update DirectDeps for // any key that shifts during reclassification. let mut rekeyed: BTreeMap = BTreeMap::new(); let mut local_rekeys: BTreeMap = BTreeMap::new(); for (old_key, mut pkg) in local_packages { let new_key = pkg.local_source.as_ref().unwrap().dep_path(&pkg.name); pkg.dep_path = new_key.clone(); if old_key != new_key { local_rekeys.insert(old_key, new_key.clone()); } rekeyed.insert(new_key, pkg); } let local_packages = rekeyed; if !local_rekeys.is_empty() { for deps in importers.values_mut() { for dep in deps { if let Some(new_key) = local_rekeys.get(&dep.dep_path) { dep.dep_path.clone_from(new_key); } } } } // Canonical keys the main loop should ignore — those are the // snapshot keys we already absorbed above. let mut local_canonical_keys: std::collections::HashSet = local_packages .values() .filter_map(|p| { p.local_source .as_ref() .map(|l| format!("{}@{}", p.name, l.specifier())) }) .collect(); local_canonical_keys.extend(all_local_snapshot_keys); let snapshot_keys: Vec = if raw.snapshots.is_empty() { raw.packages.keys().cloned().collect() } else { raw.snapshots.keys().cloned().collect() }; for dep_path in snapshot_keys { if local_canonical_keys.contains(&dep_path) { continue; } let (name, mut version) = parse_dep_path(&dep_path) .ok_or_else(|| Error::parse(path, format!("invalid dep path: {dep_path}")))?; let qualified_alias = version.strip_prefix("npm:").and_then(parse_dep_path); if let Some((_, resolved)) = &qualified_alias { version = resolved.clone(); } if let Some(registry_name) = registry_name_from_qualified_version(&version) { return Err(Error::UnsupportedNamedRegistry { path: path.to_path_buf(), dep_path, registry_name: registry_name.to_string(), }); } // Runtime pin entries (`node@runtime:24.4.1`) are not packages // — they're absorbed into `graph.runtimes` below. Skipping them // here keeps them out of the package table so the fetch/link // pipeline never sees them. if version.starts_with("runtime:") { continue; } // URL-based direct deps are absorbed into `local_packages` // under the peer-stripped URL form (see `push_direct`), but the // snapshot key still carries any `(peer@ver)` suffix pnpm // appended. Check the peer-stripped canonical too so we don't // create a duplicate entry that round-trips as a stray // `packages:` block. if local_canonical_keys.contains(&format!("{name}@{version}")) { continue; } // Look up the canonical package entry by stripping any peer suffix. let canonical_key = version_to_dep_path(&name, &version); let pkg_info = if qualified_alias.is_some() { raw.packages .get(dep_path.split('(').next().unwrap_or(&dep_path)) } else { raw.packages .get(&canonical_key) .or_else(|| raw.packages.get(&dep_path)) }; let integrity = pkg_info .and_then(|p| p.resolution.as_ref()) .and_then(|r| r.integrity.clone()); // Registry packages record a `tarball:` URL only when // `lockfileIncludeTarballUrl=true` was active at write time. // Preserve it on read so the round-trip writes the same URL // back without having to reconsult the registry client. // // pnpm also writes a `tarball:` entry for non-registry transitive // deps whose key is a URL (remote tarball from a github override, // pkg.pr.new, etc.) — capture those on the same field so the // install path can fetch them verbatim instead of deriving a // registry URL that would 404. let tarball_url = pkg_info .and_then(|p| p.resolution.as_ref()) .and_then(|r| r.tarball.as_ref()) .filter(|t| t.starts_with("http://") || t.starts_with("https://")) .cloned(); let registry_git_hosted = pkg_info .and_then(|p| p.resolution.as_ref()) .is_some_and(|r| r.git_hosted); // pnpm writes `version: ` alongside non-registry entries // whose dep-path key is a URL. Prefer that over the URL itself // when the dep-path version isn't a real semver — the install // path uses `pkg.version` for the store-content cross-check, // and comparing a URL to the tarball's declared `2.4.1` would // fail every github override'd package. // // Gated on `tarball_url.is_some()` so the swap only applies to // the remote-tarball case (where the URL is recoverable from // `resolution.tarball` at write time). `git+`/`git://` / // `.git#sha` transitive entries resolve through // `resolution: {type: git, commit, repo}` and keep the URL as // their version so install can fetch the git source. let version_is_http_url = version.starts_with("http://") || version.starts_with("https://"); let dep_path_git_commit = git_commit_from_dep_path_version(&version).map(str::to_string); let version = if version_is_http_url && tarball_url.is_some() { pkg_info.and_then(|p| p.version.clone()).unwrap_or(version) } else { version }; let snapshot = raw.snapshots.get(&dep_path); let mut optional_dependencies = snapshot .and_then(|s| s.optional_dependencies.clone()) .unwrap_or_default(); let mut dependencies = snapshot .and_then(|s| s.dependencies.clone()) .unwrap_or_default(); rewrite_snapshot_alias_deps(&mut dependencies, &mut alias_remaps, &reserved_package_keys); rewrite_snapshot_alias_deps( &mut optional_dependencies, &mut alias_remaps, &reserved_package_keys, ); dependencies.extend(optional_dependencies.clone()); // pnpm records `bundledDependencies` on the package entry; older // aube lockfiles wrote it on the snapshot instead. let bundled_dependencies = pkg_info .and_then(|p| p.bundled_dependencies.as_ref()) .map(|bundled| { bundled .names(&dependencies) .into_iter() .map(str::to_string) .collect() }) .or_else(|| snapshot.and_then(|s| s.bundled_dependencies.clone())) .unwrap_or_default(); let optional = snapshot.and_then(|s| s.optional).unwrap_or(false); let transitive_peer_dependencies = snapshot .and_then(|s| s.transitive_peer_dependencies.clone()) .unwrap_or_default(); let peer_dependencies = pkg_info .and_then(|p| p.peer_dependencies.clone()) .unwrap_or_default(); let peer_dependencies_meta = pkg_info .and_then(|p| p.peer_dependencies_meta.clone()) .unwrap_or_default() .into_iter() .map(|(k, v)| { ( k, PeerDepMeta { optional: v.optional, }, ) }) .collect(); let os = pkg_info.map(|p| p.os.clone()).unwrap_or_default(); let cpu = pkg_info.map(|p| p.cpu.clone()).unwrap_or_default(); let libc = pkg_info.map(|p| p.libc.clone()).unwrap_or_default(); let engines = pkg_info.map(|p| p.engines.clone()).unwrap_or_default(); // pnpm records a registry `deprecated:` reason on package // entries; stash it on the generic meta map so the writer can // re-emit it (matching how bun round-trips the same field). let mut extra_meta = pkg_info .and_then(|p| p.deprecated.clone()) .map(|msg| BTreeMap::from([("deprecated".to_string(), serde_json::Value::String(msg))])) .unwrap_or_default(); if tarball_url .as_deref() .is_some_and(tarball_url_needs_preserve) { extra_meta.insert( crate::EXTRA_PRESERVE_TARBALL_URL.to_string(), serde_json::Value::Bool(true), ); } // pnpm's lockfile only stores `hasBin: true/false` (no paths); // reconstruct an opaque single-entry map on parse so // `!bin.is_empty()` stays equivalent to `hasBin`, then let // downstream writers fill in real paths when they have them. // The map key + value are placeholders — writers that care // about bin names (bun) read from richer sources. let bin = if pkg_info.map(|p| p.has_bin).unwrap_or(false) { let mut m = BTreeMap::new(); m.insert(String::new(), String::new()); m } else { BTreeMap::new() }; // Aube-specific extension (see `WritablePackageInfo::alias_of`) // — ordinary pnpm lockfiles never carry it, so this stays // `None` on pnpm-authored input and round-trips the resolver- // emitted value for aliased packages. let alias_of = pkg_info .and_then(|p| p.alias_of.clone()) .or_else(|| qualified_alias.map(|(name, _)| name)); // Reclassify transitive URL-keyed entries — github forks, // pkg.pr.new, `file:` targets — so they round-trip with the // right `local_source`. Without this, the install path sees // `local_source: None` + a URL-form version and tries to // fetch the dep from the npm registry (404). let local_source = pkg_info .and_then(|p| p.resolution.as_ref()) .and_then(local_source_from_resolution); let local_source = local_source.map(|mut source| { if let LocalSource::Git(git) = &mut source && let Some(commit) = dep_path_git_commit.as_deref() && git_commits_match(&git.resolved, commit) { git.resolved = commit.to_string(); } source }); // `lockfileIncludeTarballUrl` puts registry tarball URLs on // ordinary `name@version` entries; only URL-keyed entries are // true remote-tarball deps. let local_source = match local_source { Some(LocalSource::RemoteTarball(_)) if !version_is_http_url => None, other => other, }; if options.strict_store_integrity && integrity.is_none() && resolution_requires_integrity( pkg_info.and_then(|p| p.resolution.as_ref()), &local_source, ) { return Err(Error::parse( path, format!( "lockfile entry {dep_path:?} has a remote tarball resolution without integrity" ), )); } packages.insert( dep_path.clone(), LockedPackage { name, version, integrity, dependencies, optional_dependencies, peer_dependencies, peer_dependencies_meta, dep_path, local_source, os: os.into(), cpu: cpu.into(), libc: libc.into(), bundled_dependencies, optional, transitive_peer_dependencies, tarball_url, registry_git_hosted, alias_of, yarn_checksum: None, engines, bin, // pnpm's `snapshots:` only records resolved pins, so // the parser has no declared ranges to restore. Left // empty; npm / yarn / bun writers fall back to pins // when re-emitting a pnpm-sourced graph into one of // their formats. declared_dependencies: BTreeMap::new(), // pnpm's format doesn't carry per-package license or // funding metadata, so a pnpm → npm conversion // degrades to empty rather than re-fetching each // packument. npm writers skip these fields when // `None`. license: None, funding_url: None, extra_meta, }, ); } // Synthesize alias-keyed LockedPackages for npm-aliased importer // deps. pnpm v9 only writes the canonical (real-name-keyed) entry // in `packages:`; we clone it under the alias dep_path with // `name=alias` and `alias_of=Some(real)` so the linker — which // already supports this shape via the resolver-fresh path — can // create `node_modules/` symlinks correctly. for (alias_dep_path, real_dep_path, alias_name, real_name) in alias_remaps { // Skip if the alias entry already exists (aube-written // lockfile that emitted both `aliasOf:` and an alias-keyed // packages entry). if packages.contains_key(&alias_dep_path) { continue; } let Some(real_pkg) = packages .get(&real_dep_path) .or_else(|| peerless_alias_target(&packages, &real_dep_path)) else { return Err(Error::parse( path, format!( "npm-alias references missing package {real_dep_path} (alias dep_path: {alias_dep_path})" ), )); }; let mut aliased = real_pkg.clone(); aliased.name = alias_name; aliased.dep_path = alias_dep_path.clone(); aliased.alias_of = Some(real_name); packages.insert(alias_dep_path, aliased); } for (k, v) in local_packages { packages.insert(k, v); } // Normalize git / remote-tarball references so a lockfile round-trip // produces the same graph a fresh resolve does. pnpm (and aube's own // writer) record these deps by their resolved URL, but aube's linker // and graph hasher look them up under the FS-safe hashed form // (`request@url+` / `request@git+`) — the same form // `push_direct` already uses for *direct* git/tarball deps. Two // independent rewrites are needed, and both run whenever a git/tarball // dep is present (not only when a peer suffix exists): // // 1. Package keys. A *transitive* git/tarball package keyed by URL // (`@https://codeload.…/`) has its own virtual-store // dir materialized at the escaped `https+++…` name, while every // parent's sibling symlink (and the hasher's child lookup, via // `shared_local_dep_path`) targets the `url+` name. The // symlink then dangles — Node throws `Cannot find module ''` // — and the child's content/engine taint never reaches the // parent's GVS hash. Re-key the head to the canonical hashed form. // 2. Peer suffixes. `request-promise-core@1.1.4(request@https://…/)` // → `…(request@url+)`, the inverse of the writer's // hashed→spec pass. Without it a registry package that peers with // a git/tarball dep would re-key on the next install, busting the // warm path (and emitting a churned lockfile). let spec_peer_to_hashed = |reference: &str| -> Option { let (name, value) = parse_dep_path(reference)?; let head = crate::shared_local_dep_path(&name, &value)?; let suffix = reference.find('(').map_or("", |i| &reference[i..]); Some(format!("{head}{suffix}")) }; // Canonicalize a git/remote-tarball/`file:` package's own `name@` // head to the hashed form, preserving any peer suffix verbatim (specs // aube keys never contain `(`, so the first `(` always starts the // suffix). A transitive `file:` child is keyed by its path here as it is // by a fresh resolve, and edges find it by `dep_edge_key`. let canonical_local_head = |key: &str, pkg: &LockedPackage| -> Option { let local @ (LocalSource::Git(_) | LocalSource::RemoteTarball(_) | LocalSource::Directory(_) | LocalSource::Tarball(_)) = pkg.local_source.as_ref()? else { return None; }; let suffix = key.find('(').map_or("", |i| &key[i..]); let new_key = format!("{}{suffix}", local.dep_path(&pkg.name)); (new_key != key).then_some(new_key) }; let has_local_source = packages.values().any(|p| { matches!( p.local_source, Some( LocalSource::Git(_) | LocalSource::RemoteTarball(_) | LocalSource::Directory(_) | LocalSource::Tarball(_) ) ) }); if has_local_source { let rekeyed: BTreeMap = std::mem::take(&mut packages) .into_iter() .map(|(key, mut pkg)| { let head = canonical_local_head(&key, &pkg).unwrap_or(key); let new_key = rewrite_peer_suffix(&head, &spec_peer_to_hashed); pkg.dep_path = new_key.clone(); pkg.dependencies = pkg .dependencies .into_iter() .map(|(n, v)| (n, rewrite_peer_suffix(&v, &spec_peer_to_hashed))) .collect(); pkg.optional_dependencies = pkg .optional_dependencies .into_iter() .map(|(n, v)| (n, rewrite_peer_suffix(&v, &spec_peer_to_hashed))) .collect(); (new_key, pkg) }) .collect(); packages = rekeyed; for deps in importers.values_mut() { for dep in deps { dep.dep_path = rewrite_peer_suffix(&dep.dep_path, &spec_peer_to_hashed); } } } adopt_link_dependency_edges(&mut packages); let settings = raw .settings .map(|s| crate::LockfileSettings { auto_install_peers: s.auto_install_peers.unwrap_or(true), exclude_links_from_lockfile: s.exclude_links_from_lockfile.unwrap_or(false), lockfile_include_tarball_url: s.lockfile_include_tarball_url.unwrap_or(false), }) .unwrap_or_default(); let times = raw.time.unwrap_or_default(); let catalogs = raw .catalogs .unwrap_or_default() .into_iter() .map(|(name, entries)| { let inner = entries .into_iter() .map(|(pkg, e)| { ( pkg, CatalogEntry { specifier: e.specifier, version: e.version, }, ) }) .collect(); (name, inner) }) .collect(); let patched_dependencies: BTreeMap = raw .patched_dependencies .unwrap_or_default() .into_iter() .map(|(k, v)| (k, v.into_value())) .collect(); // Lift the synthetic runtime importer deps recorded above into // typed pins, pulling the per-platform artifact list out of the // matching `@runtime:` packages entry. A missing or // variant-less packages entry still yields a pin (version intent // survives); installs on platforms not covered by the variant list // re-resolve against live SHASUMS. let mut runtimes = BTreeMap::new(); for (name, (specifier, version, dev)) in runtime_imports { let pkg_info = raw.packages.get(&format!("{name}@runtime:{version}")); let variants = pkg_info .and_then(|p| p.resolution.as_ref()) .and_then(|r| r.variants.as_ref()) .map(|vs| { vs.iter() .map(|v| convert_runtime_variant(&name, v)) .collect() }) .unwrap_or_default(); let has_bin = pkg_info.map(|p| p.has_bin).unwrap_or(true); runtimes.insert( name, RuntimePin { specifier, version, dev, has_bin, variants, }, ); } Ok(LockfileGraph { importers, packages, settings, overrides: raw.overrides.unwrap_or_default(), package_extensions_checksum: raw.package_extensions_checksum, pnpmfile_checksum: raw.pnpmfile_checksum, ignored_optional_dependencies: raw .ignored_optional_dependencies .unwrap_or_default() .into_iter() .collect(), times, skipped_optional_dependencies, catalogs, bun_config_version: None, patched_dependencies, trusted_dependencies: Vec::new(), runtimes, extra_fields: BTreeMap::new(), workspace_extra_fields: BTreeMap::new(), }) } /// pnpm records a snapshot dependency on a workspace package as /// `: link:`, for example the /// workspace peer of a `file:` package. Rewrite each such edge to the tail /// of the `LocalSource::Link` package the importer loop would key the same /// target under, adding that package when no importer links it, so the /// linker can point the sibling symlink at the target directory. fn adopt_link_dependency_edges(packages: &mut BTreeMap) { let mut link_packages: BTreeMap = BTreeMap::new(); for pkg in packages.values_mut() { let edges = pkg .dependencies .iter_mut() .chain(pkg.optional_dependencies.iter_mut()); for (name, value) in edges { if !value.starts_with("link:") { continue; } let Some(local @ LocalSource::Link(_)) = LocalSource::parse(value, Path::new("")) else { continue; }; let dep_path = local.dep_path(name); *value = dep_path .strip_prefix(&format!("{name}@")) .unwrap_or(&dep_path) .to_string(); link_packages .entry(dep_path.clone()) .or_insert_with(|| LockedPackage { name: name.clone(), version: "0.0.0".to_string(), dep_path, local_source: Some(local), ..Default::default() }); } } for (dep_path, link) in link_packages { packages.entry(dep_path).or_insert(link); } } fn tarball_url_needs_preserve(url: &str) -> bool { let Some((host, _)) = super::http_url_host_and_path(url) else { return false; }; !matches!(host.as_str(), "registry.npmjs.org" | "registry.yarnpkg.com") } /// Convert a raw `variations` variant into the typed graph shape. /// pnpm's bare-string `bin:` form names a single executable after the /// runtime itself (`bin: bin/node` on the `node` entry). fn convert_runtime_variant(runtime_name: &str, raw: &RawRuntimeVariant) -> RuntimeVariant { let (bin, bin_is_bare_string) = match &raw.resolution.bin { Some(RawBinSpec::Single(path)) => { let mut m = BTreeMap::new(); m.insert(runtime_name.to_string(), path.clone()); (m, true) } Some(RawBinSpec::Map(m)) => (m.clone(), false), None => (BTreeMap::new(), false), }; RuntimeVariant { targets: raw .targets .iter() .map(|t| RuntimeTarget { os: t.os.clone(), cpu: t.cpu.clone(), libc: t.libc.clone(), }) .collect(), archive: raw .resolution .archive .clone() .unwrap_or_else(|| "tarball".to_string()), url: raw.resolution.url.clone(), integrity: raw.resolution.integrity.clone().unwrap_or_default(), bin, bin_is_bare_string, prefix: raw.resolution.prefix.clone(), } } fn resolution_requires_integrity( resolution: Option<&super::raw::Resolution>, local_source: &Option, ) -> bool { let Some(resolution) = resolution else { return false; }; if resolution.integrity.is_some() || resolution.git_hosted { return false; } match local_source { Some(LocalSource::Tarball(_)) | Some(LocalSource::Directory(_)) | Some(LocalSource::Link(_)) | Some(LocalSource::Portal(_)) | Some(LocalSource::Git(_)) | Some(LocalSource::Exec(_)) => false, Some(LocalSource::RemoteTarball(t)) => { !t.git_hosted && !super::tarball_url_is_hosted_git(&t.url) } None => resolution .tarball .as_deref() .is_some_and(|t| is_http_url(t) && !super::tarball_url_is_hosted_git(t)), } } fn is_http_url(url: &str) -> bool { url.starts_with("http://") || url.starts_with("https://") } fn git_commit_from_dep_path_version(version: &str) -> Option<&str> { let (_, fragment) = version.rsplit_once('#')?; let commit = fragment.split('&').next().unwrap_or(fragment); if commit.len() == 40 && commit.bytes().all(|b| b.is_ascii_hexdigit()) { Some(commit) } else { None } }