denoland/deno · #36727
fix(npm): correct the pnpm lockfile import against real-world lockfiles
libs/resolver/pnpm_lockfile_import.rs848 + / 20 −
@@ -44,10 +44,7 @@ pub fn pnpm_lock_to_deno_lock_v5( let syntax = yaml_parser::parse(yaml_text).map_err(PnpmLockfileImportError::Parse)?; let root_map = Root::cast(syntax)- .and_then(|root| root.documents().next())- .and_then(|doc| doc.block())- .and_then(|block| block.block_map())- .map(MapNode::Block)+ .and_then(select_project_document) .ok_or(PnpmLockfileImportError::EmptyOrInvalid)?; let version = root_map@@ -72,8 +69,10 @@ pub fn pnpm_lock_to_deno_lock_v5( let mut integrity: HashMap<String, String> = HashMap::new(); if let Some(packages) = root_map.get("packages").and_then(Node::into_map) { for (key, value) in packages.entries() {- let key = normalize_package_key(&key);- let base = strip_peer_suffix(&key).to_string();+ let base = normalize_package_key(strip_peer_suffix(&key));+ if !is_package_id(&base) {+ continue;+ } if let Some(integ) = value .into_map() .and_then(|m| m.get("resolution"))@@ -97,11 +96,13 @@ pub fn pnpm_lock_to_deno_lock_v5( continue; }; for (raw_key, value) in snaps.entries() {- let normalized = normalize_package_key(&raw_key);- let base = strip_peer_suffix(&normalized).to_string(); // Snapshot keys may include peer-suffix parens; for our purposes,- // collapse to the base `name@version`. First entry wins.- if npm.contains_key(&base) {+ // collapse to the base `name@version`. First entry wins. The suffix+ // comes off before normalizing because the `@` inside it would+ // otherwise hide the v6 `name/version` form from+ // `normalize_package_key`.+ let base = normalize_package_key(strip_peer_suffix(&raw_key));+ if !is_package_id(&base) || npm.contains_key(&base) { continue; } let Some(integ) = integrity.get(&base) else {@@ -181,6 +182,15 @@ pub fn pnpm_lock_to_deno_lock_v5( } // pnpm v6 places top-level deps directly on the document root. if major == 6 {+ // 6.0 gives each of them the same `{ specifier, version }` shape an+ // importer uses. Older 6.x lockfiles instead put the resolved version+ // here and the requirement in a separate `specifiers` section, which the+ // loop below reads.+ root_dep_keys.extend(collect_importer_specifiers(+ &root_map,+ &catalogs,+ &mut specifiers,+ )); let specifiers_section = root_map.get("specifiers").and_then(Node::into_map); for section in ["dependencies", "devDependencies", "optionalDependencies"] {@@ -512,8 +522,16 @@ fn build_workspace( } } -/// Build a sorted list of `dep@version` strings from a pnpm dependency-/// mapping (e.g. `{ ansi-styles: 4.3.0, color-convert: 2.0.1 }`).+/// Build a sorted list of dependency entries from a pnpm dependency mapping+/// (e.g. `{ ansi-styles: 4.3.0, color-convert: 2.0.1 }`).+///+/// Entries are usually `dep@version`, but an aliased dependency becomes+/// `dep@npm:name@version`. A dependency that deno.lock has no spelling for —+/// a workspace link, a path, a tarball url — is **silently dropped**, so the+/// package ends up described with fewer dependencies than it really has. That+/// is the deliberate trade against emitting an id that cannot be parsed back+/// in, and it matches what `is_supported_spec` already does for root+/// specifiers. fn collect_deps(node: Option<MapNode>) -> Vec<String> { let Some(map) = node else { return Vec::new();@@ -522,37 +540,193 @@ fn collect_deps(node: Option<MapNode>) -> Vec<String> { .entries() .into_iter() .filter_map(|(name, value)| {- let ver = value.into_string()?;- let ver = strip_peer_suffix(&ver);- Some(format!("{}@{}", name, ver))+ let value = value.into_string()?;+ dep_entry(&name, strip_peer_suffix(&value)) }) .collect(); out.sort(); out.dedup(); out } +/// Build the deno.lock dependency entry for one pnpm dependency mapping.+///+/// The value is normally a bare version (`ansi-styles: 4.3.0`), but an aliased+/// dependency names the package it resolves to instead+/// (`string-width-cjs: string-width@4.2.3`) and a workspace dependency points+/// at a directory (`vite: link:packages/vite`).+///+/// `value` must already have its peer suffix stripped.+fn dep_entry(name: &str, value: &str) -> Option<String> {+ // `link:`/`file:` paths, urls and the other non-registry schemes have no+ // deno.lock spelling, so leave them out and let resolution handle them.+ // This has to happen before `normalize_package_key`, whose `name/version`+ // -> `name@version` fallback would otherwise rewrite such a value into+ // something that reads as a valid alias: `file:vendor/1.0.0.tgz` would+ // become `file:vendor@1.0.0.tgz` and then `name@npm:file:vendor@1.0.0.tgz`.+ if !is_supported_spec(value) {+ return None;+ }+ // pnpm v6 prefixes ids with `/` in dependency values too, not just in the+ // `packages` keys.+ let value = normalize_package_key(value);+ if starts_with_digit(&value) {+ return Some(format!("{}@{}", name, value));+ }+ match value.rfind('@') {+ // A leading `@` is a scope, not a separator. deno.lock spells an alias+ // `key@npm:name@version`. Telling an alias apart from a path rests on npm+ // versions always starting with a digit, which semver guarantees.+ Some(idx) if idx > 0 && starts_with_digit(&value[idx + 1..]) => {+ // Naming the package it already is isn't an alias — that's just the v6+ // `/name/version` id spelled out. Recording it as one would point at+ // `name@npm:name@version`, which the lock has no entry for.+ if &value[..idx] == name {+ Some(format!("{}@{}", name, &value[idx + 1..]))+ } else {+ Some(format!("{}@npm:{}", name, value))+ }+ }+ _ => None,+ }+}++fn starts_with_digit(value: &str) -> bool {+ value.starts_with(|c: char| c.is_ascii_digit())+}++/// Whether a `packages`/`snapshots` key is a `name@version` deno.lock can+/// hold. A package installed from a path or a tarball is keyed by where it+/// came from, e.g. `fake-data-pkg@file:packages/…/fake-data-pkg-1.0.0.tgz`,+/// and has no npm id to record.+fn is_package_id(base: &str) -> bool {+ // A leading `@` is a scope, not a separator.+ let Some(idx) = base.rfind('@').filter(|idx| *idx > 0) else {+ return false;+ };+ if !starts_with_digit(&base[idx + 1..]) {+ return false;+ }+ // An npm name carries a `/` only as the scope separator, so anything else+ // is a registry host or a path that `strip_registry_prefix` didn't peel+ // off. Those parse as an id but name a package the lock has no entry for.+ match base[..idx].strip_prefix('@') {+ Some(scoped) => scoped.matches('/').count() == 1,+ None => !base[..idx].contains('/'),+ }+}++/// Pick the document that describes the project.+///+/// pnpm writes `pnpm-lock.yaml` as more than one YAML document once the+/// project pins its own package manager: the packages that make up pnpm+/// itself come first, in a document whose importers carry+/// `packageManagerDependencies`, and the project's lockfile comes last.+fn select_project_document(root: Root) -> Option<MapNode> {+ let mut package_manager_doc = None;+ let mut project_doc = None;+ for doc in root.documents() {+ let Some(map) = doc+ .block()+ .and_then(|block| block.block_map())+ .map(MapNode::Block)+ else {+ continue;+ };+ if is_package_manager_document(&map) {+ package_manager_doc.get_or_insert(map);+ } else {+ project_doc = Some(map);+ }+ }+ // Fall back to the package manager's document rather than nothing at all,+ // so a lockfile that only has one stays readable whatever it holds.+ project_doc.or(package_manager_doc)+}++fn is_package_manager_document(doc: &MapNode) -> bool {+ let Some(importers) = doc.get("importers").and_then(Node::into_map) else {+ return false;+ };+ importers.entries().into_iter().any(|(_, importer)| {+ importer+ .into_map()+ .and_then(|importer| importer.get("packageManagerDependencies"))+ .is_some()+ })+}+ /// In pnpm v6 the keys in `packages` and reference paths are prefixed with /// `/`, e.g. `/lodash@4.17.21` or `/@babel/core@7.0.0`. Strip it. fn normalize_package_key(key: &str) -> String { let stripped = key.strip_prefix('/').unwrap_or(key);+ let stripped = strip_registry_prefix(stripped); // pnpm v6 sometimes used `/name/version` instead of `/name@version`. We // detect the `/version` form by checking whether the last `/` is followed // by what looks like a semver number.- if !stripped.contains('@') || stripped.starts_with('@') {- // For scoped packages, the only `@` may be at the start. Check the- // `name/version` form by splitting on the last `/`.+ //+ // The `@` that rules the form out is the one separating name from version,+ // so a scoped id is judged on what follows its scope: `@babel/core@7.0.0`+ // is already `name@version`, while `@babel/core/7.0.0` is not. Looking at+ // the whole string instead would miss the boundary for a scoped package+ // whose name starts with a digit — `@types/3d-view@1.0.0` would be+ // rewritten to `@types@3d-view@1.0.0`.+ let slash_form =+ match stripped.strip_prefix('@').and_then(|s| s.split_once('/')) {+ Some((_, after_scope)) => !after_scope.contains('@'),+ None => !stripped.contains('@'),+ };+ if slash_form {+ // The version is whatever follows the last `/`. if let Some(idx) = stripped.rfind('/') { let (name, ver) = stripped.split_at(idx); let ver = &ver[1..];- if ver.chars().next().is_some_and(|c| c.is_ascii_digit()) {- return format!("{}@{}", name, ver);+ if starts_with_digit(ver) {+ // An unscoped mirror id — `registry.npmmirror.com/lodash/4.17.21` —+ // only becomes strippable once the version boundary is an `@`, so+ // the host gets another chance here.+ let rewritten = format!("{}@{}", name, ver);+ return strip_registry_prefix(&rewritten).to_string(); } } } stripped.to_string() } +/// Drop the registry host from the front of a pnpm id.+///+/// A lockfile written against a mirror keys its packages by where they came+/// from, e.g. `registry.npmmirror.com/@nodelib/fs.stat@2.0.5`. An npm name+/// carries a `/` only as the scope separator, so a segment sitting in front+/// of an `@scope` — or in front of the `name@version` of an unscoped package+/// — is the host rather than part of the name.+///+/// Expects the leading `/` and any peer suffix to be gone already.+fn strip_registry_prefix(id: &str) -> &str {+ // A key that names where the package came from is a path or a url, not a+ // host-prefixed id, and its last `/` segment can look like a `name@version`+ // by accident: `fake-pkg@file:vendor/pkg@1.0.0.tgz` would be cut down to+ // `pkg@1.0.0.tgz`, which reads as an id but names no package. A registry+ // host never carries a scheme, and an npm id never carries a `:`, so the+ // presence of one is enough to tell the two apart.+ if id.contains(':') {+ return id;+ }+ // Already a scope, so there is nothing in front of the name.+ if id.starts_with('@') {+ return id;+ }+ if let Some(idx) = id.find("/@") {+ return &id[idx + 1..];+ }+ match id.rsplit_once('/') {+ // Only strip when the last segment is itself a `name@version`. Otherwise+ // this is the v6 `name/version` form, whose `/` the caller rewrites.+ Some((_, last)) if is_package_id(last) => last,+ _ => id,+ }+}+ /// Strip pnpm's peer-dependency suffix from a package id. E.g. /// `chalk@5.0.0(react@18.0.0)` -> `chalk@5.0.0`. fn strip_peer_suffix(key: &str) -> &str {@@ -575,6 +749,8 @@ fn is_supported_spec(req: &str) -> bool { && !req.starts_with("http:") && !req.starts_with("https:") && !req.starts_with("npm:")+ // `runtime:` pins a language runtime, e.g. `node: runtime:26.8.1`.+ && !req.starts_with("runtime:") // `catalog:` specifiers are resolved before this check (see // `collect_importer_specifiers`), so they never reach here. }@@ -727,6 +903,46 @@ packages: assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-LODASH"); } + #[test]+ fn translates_v6_root_dependencies() {+ // A single-project pnpm 6.0 lockfile has no `specifiers` section: the+ // root deps carry their own `specifier` the way an importer's do.+ let input = r#"+lockfileVersion: '6.0'++settings:+ autoInstallPeers: true++dependencies:+ '@vueuse/core':+ specifier: ^9.13.0+ version: 9.13.0(vue@3.2.47)+ lodash:+ specifier: ^4.17.21+ version: 4.17.21++devDependencies:+ typescript:+ specifier: ^5.0.0+ version: 5.0.4++packages:+ /@vueuse/core@9.13.0:+ resolution: {integrity: sha512-VUSE}+ /lodash@4.17.21:+ resolution: {integrity: sha512-LODASH}+ /typescript@5.0.4:+ resolution: {integrity: sha512-TS}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ // The peer suffix on the resolved version is dropped, as elsewhere.+ assert_eq!(v["specifiers"]["npm:@vueuse/core@^9.13.0"], "9.13.0");+ assert_eq!(v["specifiers"]["npm:lodash@^4.17.21"], "4.17.21");+ assert_eq!(v["specifiers"]["npm:typescript@^5.0.0"], "5.0.4");+ assert_eq!(v["npm"]["@vueuse/core@9.13.0"]["integrity"], "sha512-VUSE");+ }+ #[test] fn skips_aliased_specifier() { // An aliased dependency (`my-lodash: npm:lodash@^4`) must not produce a@@ -756,6 +972,618 @@ snapshots: assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-AAA"); } + #[test]+ fn aliased_snapshot_dependency() {+ // `string-width-cjs: string-width@4.2.3` names a package, not a version,+ // so it must not collapse into `string-width-cjs@string-width@4.2.3`.+ let input = r#"+lockfileVersion: '9.0'++importers:+ .:+ dependencies:+ wrap-ansi:+ specifier: ^8.1.0+ version: 8.1.0++packages:+ wrap-ansi@8.1.0:+ resolution: {integrity: sha512-WRAP}+ ansi-styles@6.2.1:+ resolution: {integrity: sha512-ANSI}+ string-width@4.2.3:+ resolution: {integrity: sha512-WIDTH}+ '@scope/pkg@1.0.0':+ resolution: {integrity: sha512-SCOPED}++snapshots:+ wrap-ansi@8.1.0:+ dependencies:+ ansi-styles: 6.2.1+ string-width-cjs: string-width@4.2.3+ scoped-alias: '@scope/pkg@1.0.0'+ ansi-styles@6.2.1: {}+ string-width@4.2.3: {}+ '@scope/pkg@1.0.0': {}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ // Every entry has to survive the parsers that read a deno.lock back in.+ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for pkg in content.packages.npm.values() {+ for dep in pkg.dependencies.values() {+ deno_npm::NpmPackageId::from_serialized(dep).unwrap();+ }+ }++ let deps = v["npm"]["wrap-ansi@8.1.0"]["dependencies"]+ .as_array()+ .unwrap();+ assert_eq!(+ deps.as_slice(),+ [+ "ansi-styles@6.2.1",+ "scoped-alias@npm:@scope/pkg@1.0.0",+ "string-width-cjs@npm:string-width@4.2.3",+ ]+ );+ }++ #[test]+ fn workspace_link_snapshot_dependency() {+ // A dependency satisfied by a workspace package is recorded as+ // `vite: link:packages/vite`. That has no deno.lock spelling, so it must+ // be left out rather than turned into `vite@link:packages/vite`.+ let input = r#"+lockfileVersion: '9.0'++importers:+ .:+ dependencies:+ some-plugin:+ specifier: ^1.0.0+ version: 1.0.0++packages:+ some-plugin@1.0.0:+ resolution: {integrity: sha512-PLUGIN}+ cac@7.0.0:+ resolution: {integrity: sha512-CAC}++snapshots:+ some-plugin@1.0.0:+ dependencies:+ cac: 7.0.0+ optionalDependencies:+ vite: link:packages/vite+ cac@7.0.0: {}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ let plugin = &v["npm"]["some-plugin@1.0.0"];+ assert_eq!(+ plugin["dependencies"].as_array().unwrap().as_slice(),+ ["cac@7.0.0"]+ );+ // The only optional dependency was the workspace link.+ assert!(plugin.get("optionalDependencies").is_none());++ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for pkg in content.packages.npm.values() {+ for dep in pkg.dependencies.values() {+ deno_npm::NpmPackageId::from_serialized(dep).unwrap();+ }+ }+ }++ #[test]+ fn path_and_url_snapshot_dependencies() {+ // `normalize_package_key`'s `name/version` -> `name@version` fallback+ // fires on anything whose last `/` segment starts with a digit, which a+ // path or a tarball url can. Those have to be rejected before they reach+ // it, or they come back out dressed as aliases+ // (`tarball-dep@npm:file:vendor@1.0.0.tgz`) — ids that `LockfileContent`+ // accepts but `NpmPackageId` cannot parse.+ let input = r#"+lockfileVersion: '9.0'++importers:+ .:+ dependencies:+ some-plugin:+ specifier: ^1.0.0+ version: 1.0.0++packages:+ some-plugin@1.0.0:+ resolution: {integrity: sha512-PLUGIN}+ cac@7.0.0:+ resolution: {integrity: sha512-CAC}++snapshots:+ some-plugin@1.0.0:+ dependencies:+ cac: 7.0.0+ tarball-dep: file:vendor/1.0.0.tgz+ url-dep: https://host/pkg/1.2.3.tgz+ link-dep: link:packages/2fa-utils+ git-dep: github:owner/repo#1.0.0+ cac@7.0.0: {}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ // Only the one dependency that has a deno.lock spelling survives.+ assert_eq!(+ v["npm"]["some-plugin@1.0.0"]["dependencies"]+ .as_array()+ .unwrap()+ .as_slice(),+ ["cac@7.0.0"]+ );++ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for pkg in content.packages.npm.values() {+ for dep in pkg.dependencies.values() {+ deno_npm::NpmPackageId::from_serialized(dep).unwrap();+ }+ }+ }++ #[test]+ fn aliased_snapshot_dependency_with_peer_suffix() {+ // An alias can carry a peer suffix. It comes off before the value is+ // normalized, so the entry still resolves to the aliased package.+ let input = r#"+lockfileVersion: '9.0'++importers:+ .:+ dependencies:+ wrap-ansi:+ specifier: ^8.1.0+ version: 8.1.0++packages:+ wrap-ansi@8.1.0:+ resolution: {integrity: sha512-WRAP}+ string-width@4.2.3:+ resolution: {integrity: sha512-WIDTH}+ emoji-regex@8.0.0:+ resolution: {integrity: sha512-EMOJI}++snapshots:+ wrap-ansi@8.1.0:+ dependencies:+ string-width-cjs: string-width@4.2.3(emoji-regex@8.0.0)+ string-width@4.2.3: {}+ emoji-regex@8.0.0: {}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(+ v["npm"]["wrap-ansi@8.1.0"]["dependencies"]+ .as_array()+ .unwrap()+ .as_slice(),+ ["string-width-cjs@npm:string-width@4.2.3"]+ );++ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for (key, pkg) in &content.packages.npm {+ for dep in pkg.dependencies.values() {+ deno_npm::NpmPackageId::from_serialized(dep).unwrap();+ assert!(+ content.packages.npm.contains_key(dep),+ "{key} depends on {dep}, which isn't in the lock"+ );+ }+ }+ }++ #[test]+ fn v6_slash_form_key_with_peer_suffix() {+ // pnpm v6 also wrote ids as `/name/version`. The peer suffix has to come+ // off before `normalize_package_key`, otherwise the `@` inside the parens+ // hides that form and the package is dropped, leaving `specifiers`+ // pointing at an id the `npm` section doesn't have.+ let input = r#"+lockfileVersion: '6.0'++specifiers:+ foo: ^1.0.0++dependencies:+ foo: 1.0.0(bar@2.0.0)++packages:+ /foo/1.0.0(bar@2.0.0):+ resolution: {integrity: sha512-FOO}+ dependencies:+ bar: /bar/2.0.0+ /bar/2.0.0:+ resolution: {integrity: sha512-BAR}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(v["npm"]["foo@1.0.0"]["integrity"], "sha512-FOO");+ assert_eq!(+ v["npm"]["foo@1.0.0"]["dependencies"]+ .as_array()+ .unwrap()+ .as_slice(),+ ["bar@2.0.0"]+ );+ assert_eq!(v["specifiers"]["npm:foo@^1.0.0"], "1.0.0");++ // Nothing dangles: every specifier and dependency names a package the+ // lock actually holds.+ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for (key, pkg) in &content.packages.npm {+ for dep in pkg.dependencies.values() {+ assert!(+ content.packages.npm.contains_key(dep),+ "{key} depends on {dep}, which isn't in the lock"+ );+ }+ }+ }++ #[test]+ fn mirror_registry_package_keys() {+ // A lockfile written against a mirror keys its packages by where they+ // came from. The host is not part of the name, so it comes off rather+ // than reaching the lock as `registry.npmmirror.com/@nodelib/fs.stat`.+ let input = r#"+lockfileVersion: '6.0'++specifiers:+ '@nodelib/fs.stat': ^2.0.5+ lodash: ^4.17.21++dependencies:+ '@nodelib/fs.stat': 2.0.5+ lodash: 4.17.21++packages:+ /registry.npmmirror.com/@nodelib/fs.stat@2.0.5:+ resolution: {integrity: sha512-STAT}+ dependencies:+ run-parallel: /registry.npmmirror.com/run-parallel@1.2.0+ /registry.npmmirror.com/lodash@4.17.21:+ resolution: {integrity: sha512-LODASH}+ /registry.npmmirror.com/run-parallel@1.2.0:+ resolution: {integrity: sha512-PARALLEL}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(+ v["npm"]["@nodelib/fs.stat@2.0.5"]["integrity"],+ "sha512-STAT"+ );+ assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-LODASH");+ // The host comes off dependency values too, so the reference resolves.+ assert_eq!(+ v["npm"]["@nodelib/fs.stat@2.0.5"]["dependencies"]+ .as_array()+ .unwrap()+ .as_slice(),+ ["run-parallel@1.2.0"]+ );+ assert_eq!(v["specifiers"]["npm:@nodelib/fs.stat@^2.0.5"], "2.0.5");++ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for (key, pkg) in &content.packages.npm {+ deno_npm::NpmPackageId::from_serialized(key).unwrap();+ for dep in pkg.dependencies.values() {+ assert!(+ content.packages.npm.contains_key(dep),+ "{key} depends on {dep}, which isn't in the lock"+ );+ }+ }+ }++ #[test]+ fn mirror_registry_slash_version_keys() {+ // The same mirror ids in the older `name/version` spelling, where the+ // host can only be told apart from the name once the version boundary+ // has been rewritten to an `@`.+ let input = r#"+lockfileVersion: '6.0'++specifiers:+ lodash: ^4.17.21++dependencies:+ lodash: 4.17.21++packages:+ /registry.npmmirror.com/lodash/4.17.21:+ resolution: {integrity: sha512-LODASH}+ /registry.npmmirror.com/@nodelib/fs.stat/2.0.5:+ resolution: {integrity: sha512-STAT}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-LODASH");+ assert_eq!(+ v["npm"]["@nodelib/fs.stat@2.0.5"]["integrity"],+ "sha512-STAT"+ );++ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for key in content.packages.npm.keys() {+ deno_npm::NpmPackageId::from_serialized(key).unwrap();+ }+ }++ #[test]+ fn scoped_package_keys_are_not_mistaken_for_a_host() {+ // `@scope/name` has a `/` of its own; it must survive untouched.+ assert_eq!(+ normalize_package_key("/@babel/core@7.0.0"),+ "@babel/core@7.0.0"+ );+ assert_eq!(+ normalize_package_key("/@babel/core/7.0.0"),+ "@babel/core@7.0.0"+ );+ assert_eq!(normalize_package_key("/lodash@4.17.21"), "lodash@4.17.21");+ assert_eq!(normalize_package_key("/lodash/4.17.21"), "lodash@4.17.21");+ // A scoped name may itself start with a digit. The `@` that decides+ // between the two forms is the one after the scope, so this is already+ // `name@version` and must not be rewritten to `@types@3d-view@1.0.0`.+ assert_eq!(+ normalize_package_key("/@types/3d-view@1.0.0"),+ "@types/3d-view@1.0.0"+ );+ assert_eq!(+ normalize_package_key("/@types/3d-view/1.0.0"),+ "@types/3d-view@1.0.0"+ );+ // A path is not a host-prefixed id, however much its last segment looks+ // like one.+ assert_eq!(+ normalize_package_key("other-pkg@file:vendor/pkg@1.0.0.tgz"),+ "other-pkg@file:vendor/pkg@1.0.0.tgz"+ );+ // A name that still carries a host is not an id we can record.+ assert!(!is_package_id("registry.npmmirror.com/lodash@4.17.21"));+ assert!(is_package_id("@babel/core@7.0.0"));+ assert!(is_package_id("@types/3d-view@1.0.0"));+ assert!(is_package_id("lodash@4.17.21"));+ assert!(!is_package_id("fake-pkg@file:vendor/fake-pkg-1.0.0.tgz"));+ assert!(!is_package_id("other-pkg@file:vendor/pkg@1.0.0.tgz"));+ }++ #[test]+ fn scoped_dependency_whose_name_starts_with_a_digit() {+ // An alias pointing at such a package has to keep naming it, or the+ // entry dangles: the `npm` section holds `@types/3d-view@1.0.0` while+ // the dependency points at `@types@3d-view@1.0.0`.+ let input = r#"+lockfileVersion: '9.0'++importers:+ .:+ dependencies:+ host-pkg:+ specifier: ^1.0.0+ version: 1.0.0++packages:+ host-pkg@1.0.0:+ resolution: {integrity: sha512-HOST}+ '@types/3d-view@1.0.0':+ resolution: {integrity: sha512-3D}++snapshots:+ host-pkg@1.0.0:+ dependencies:+ view-alias: '@types/3d-view@1.0.0'+ '@types/3d-view@1.0.0': {}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(v["npm"]["@types/3d-view@1.0.0"]["integrity"], "sha512-3D");+ assert_eq!(+ v["npm"]["host-pkg@1.0.0"]["dependencies"]+ .as_array()+ .unwrap()+ .as_slice(),+ ["view-alias@npm:@types/3d-view@1.0.0"]+ );++ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for (key, pkg) in &content.packages.npm {+ deno_npm::NpmPackageId::from_serialized(key).unwrap();+ for dep in pkg.dependencies.values() {+ assert!(+ content.packages.npm.contains_key(dep),+ "{key} depends on {dep}, which isn't in the lock"+ );+ }+ }+ }++ #[test]+ fn v6_aliased_snapshot_dependency() {+ // pnpm v6 prefixes ids with `/` in dependency values too, so+ // `string-width-cjs: /string-width@4.2.3` has to end up pointing at the+ // same package as the `/string-width@4.2.3` key.+ let input = r#"+lockfileVersion: '6.0'++specifiers:+ '@isaacs/cliui': ^8.0.2++dependencies:+ '@isaacs/cliui': 8.0.2++packages:+ /@isaacs/cliui@8.0.2:+ resolution: {integrity: sha512-CLIUI}+ dependencies:+ string-width: 5.1.2+ string-width-cjs: /string-width@4.2.3+ /string-width@5.1.2:+ resolution: {integrity: sha512-W512}+ /string-width@4.2.3:+ resolution: {integrity: sha512-W423}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(+ v["npm"]["@isaacs/cliui@8.0.2"]["dependencies"]+ .as_array()+ .unwrap()+ .as_slice(),+ [+ "string-width-cjs@npm:string-width@4.2.3",+ "string-width@5.1.2"+ ]+ );++ // Both point at packages the lock actually has.+ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for (key, pkg) in &content.packages.npm {+ for dep in pkg.dependencies.values() {+ assert!(+ content.packages.npm.contains_key(dep),+ "{key} depends on {dep}, which isn't in the lock"+ );+ }+ }+ }++ #[test]+ fn package_installed_from_a_path_is_left_out() {+ // A package keyed by where it came from has no npm id to record, so it+ // must not reach the npm section as `fake-pkg@file:…`.+ //+ // The second one is the case `strip_registry_prefix` has to keep its+ // hands off: its last `/` segment reads as a `name@version`, so without+ // the scheme check it would be cut down to `pkg@1.0.0.tgz` — an id that+ // looks recordable but names nothing, and that `NpmPackageId` rejects.+ let input = r#"+lockfileVersion: '9.0'++importers:+ .:+ dependencies:+ lodash:+ specifier: ^4.17.21+ version: 4.17.21++packages:+ lodash@4.17.21:+ resolution: {integrity: sha512-LODASH}+ fake-pkg@file:vendor/fake-pkg-1.0.0.tgz:+ resolution: {integrity: sha512-FAKE, tarball: file:vendor/fake-pkg-1.0.0.tgz}+ other-pkg@file:vendor/pkg@1.0.0.tgz:+ resolution: {integrity: sha512-OTHER, tarball: file:vendor/pkg@1.0.0.tgz}++snapshots:+ lodash@4.17.21: {}+ fake-pkg@file:vendor/fake-pkg-1.0.0.tgz: {}+ other-pkg@file:vendor/pkg@1.0.0.tgz: {}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-LODASH");+ assert_eq!(v["npm"].as_object().unwrap().len(), 1);++ let content = deno_lockfile::LockfileContent::from_json(+ serde_json::from_str(&out).unwrap(),+ )+ .unwrap();+ for key in content.packages.npm.keys() {+ deno_npm::NpmPackageId::from_serialized(key).unwrap();+ }+ }++ #[test]+ fn multi_document_lockfile() {+ // The packages that make up pnpm itself come first; the project's own+ // lockfile is the document after it.+ let input = r#"---+lockfileVersion: '9.0'++importers:++ .:+ configDependencies: {}+ packageManagerDependencies:+ pnpm:+ specifier: 11.10.0+ version: 11.10.0++packages:++ pnpm@11.10.0:+ resolution: {integrity: sha512-PNPM}+ hasBin: true++snapshots:++ pnpm@11.10.0: {}+---+lockfileVersion: '9.0'++importers:+ .:+ dependencies:+ lodash:+ specifier: ^4.17.21+ version: 4.17.21+ node:+ specifier: runtime:26.8.1+ version: runtime:26.8.1++packages:+ lodash@4.17.21:+ resolution: {integrity: sha512-LODASH}++snapshots:+ lodash@4.17.21: {}+"#;+ let out = pnpm_lock_to_deno_lock_v5(input).unwrap();+ let v: Value = serde_json::from_str(&out).unwrap();+ assert_eq!(v["specifiers"]["npm:lodash@^4.17.21"], "4.17.21");+ assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-LODASH");+ // pnpm's own packages are not the project's dependencies.+ assert!(v["npm"].get("pnpm@11.10.0").is_none());+ // A pinned runtime is not an npm package either.+ assert_eq!(v["specifiers"].as_object().unwrap().len(), 1);+ }+ #[test] fn captures_optional_dependencies() { let input = r#"