diff --git a/Cargo.lock b/Cargo.lock index 76795c8cfb..f979dc4b32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2199,6 +2199,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "pacquet-exportable-manifest" +version = "0.0.1" +dependencies = [ + "derive_more", + "miette 7.6.0", + "pacquet-package-manifest", + "serde_json", + "tempfile", +] + [[package]] name = "pacquet-fs" version = "0.0.1" @@ -2637,6 +2648,8 @@ dependencies = [ "pacquet-resolving-jsr-specifier-parser", "pacquet-resolving-local-resolver", "pacquet-resolving-resolver-base", + "pacquet-workspace-range-resolver", + "pacquet-workspace-spec", "pipe-trait", "pretty_assertions", "reqwest", @@ -2763,6 +2776,17 @@ dependencies = [ "wax", ] +[[package]] +name = "pacquet-workspace-range-resolver" +version = "0.0.1" +dependencies = [ + "node-semver", +] + +[[package]] +name = "pacquet-workspace-spec" +version = "0.0.1" + [[package]] name = "pacquet-workspace-state" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 12bdc5d1eb..fca1c51cab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ pacquet-modules-yaml = { path = "pacquet/crates/modules-yam pacquet-network = { path = "pacquet/crates/network" } pacquet-config = { path = "pacquet/crates/config" } pacquet-executor = { path = "pacquet/crates/executor" } +pacquet-exportable-manifest = { path = "pacquet/crates/exportable-manifest" } pacquet-directory-fetcher = { path = "pacquet/crates/directory-fetcher" } pacquet-git-fetcher = { path = "pacquet/crates/git-fetcher" } pacquet-deps-path = { path = "pacquet/crates/deps-path" } @@ -57,6 +58,8 @@ pacquet-resolving-parse-wanted-dependency = { path = "pacquet/crates/resolving-p pacquet-resolving-resolver-base = { path = "pacquet/crates/resolving-resolver-base" } pacquet-resolving-tarball-resolver = { path = "pacquet/crates/resolving-tarball-resolver" } pacquet-workspace = { path = "pacquet/crates/workspace" } +pacquet-workspace-range-resolver = { path = "pacquet/crates/workspace-range-resolver" } +pacquet-workspace-spec = { path = "pacquet/crates/workspace-spec" } pacquet-workspace-state = { path = "pacquet/crates/workspace-state" } # Tasks diff --git a/pacquet/crates/exportable-manifest/Cargo.toml b/pacquet/crates/exportable-manifest/Cargo.toml new file mode 100644 index 0000000000..cc8f4c6099 --- /dev/null +++ b/pacquet/crates/exportable-manifest/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pacquet-exportable-manifest" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pacquet-package-manifest = { workspace = true } + +derive_more = { workspace = true } +miette = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/pacquet/crates/exportable-manifest/src/lib.rs b/pacquet/crates/exportable-manifest/src/lib.rs new file mode 100644 index 0000000000..898e79e5df --- /dev/null +++ b/pacquet/crates/exportable-manifest/src/lib.rs @@ -0,0 +1,25 @@ +//! Pacquet port of pnpm's +//! [`@pnpm/releasing.exportable-manifest`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts). +//! +//! Today only the workspace-protocol rewrite is in scope — +//! [`replace_workspace_protocol`] and +//! [`replace_workspace_protocol_peer_dependency`]. The rest of +//! `createExportableManifest` (catalog / jsr rewrite, pre-pack hooks, +//! `publishConfig` overrides, manifest serialization) lands as pacquet +//! ports the surrounding commands. +//! +//! Both functions mirror upstream's two `replaceWorkspaceProtocol*` +//! helpers verbatim — same regex shapes, same fall-through ordering, +//! same `npm:`-aliasing output rules — so when pacquet grows a +//! `publish` / `pack` command the existing call sites can be reused +//! unmodified. + +mod replace; + +#[cfg(test)] +mod tests; + +pub use replace::{ + CannotResolveWorkspaceProtocolError, ReplaceWorkspaceProtocolError, replace_workspace_protocol, + replace_workspace_protocol_peer_dependency, +}; diff --git a/pacquet/crates/exportable-manifest/src/replace.rs b/pacquet/crates/exportable-manifest/src/replace.rs new file mode 100644 index 0000000000..0bd823d186 --- /dev/null +++ b/pacquet/crates/exportable-manifest/src/replace.rs @@ -0,0 +1,331 @@ +//! Workspace-protocol rewriting for [`@pnpm/releasing.exportable-manifest`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L139-L194). +//! +//! Two free functions match upstream's two `replaceWorkspaceProtocol*` +//! helpers: +//! +//! - [`replace_workspace_protocol`] — the regular-dependency form. +//! Resolves `workspace:` specs against the dependency's already- +//! installed `package.json` in `node_modules`. +//! - [`replace_workspace_protocol_peer_dependency`] — the +//! peer-dependency form. Accepts the broader `>=`/`<=`/`>`/`<` +//! comparators upstream allows in peer specs and rewrites every +//! `workspace:` segment in place so a compound `a || workspace:>=` +//! round-trips correctly. + +use std::path::{Path, PathBuf}; + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_package_manifest::{PackageManifestError, safe_read_package_json_from_dir}; +use serde_json::Value; + +/// Error returned when the lookup against the dependency's installed +/// `package.json` fails. Mirrors pnpm's +/// [`CANNOT_RESOLVE_WORKSPACE_PROTOCOL`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L117-L127) +/// error code; preserve the public message so reporters that key off +/// `ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL` keep matching. +#[derive(Debug, Display, Error, Diagnostic, Clone)] +#[display( + "Cannot resolve workspace protocol of dependency \"{dep_name}\" \ + because this dependency is not installed. Try running \"pnpm install\"." +)] +#[diagnostic(code(ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL))] +pub struct CannotResolveWorkspaceProtocolError { + #[error(not(source))] + pub dep_name: String, +} + +/// Error envelope for both rewrite helpers. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum ReplaceWorkspaceProtocolError { + /// The dependency's directory was found but the `package.json` + /// lacked one of the required fields. Most common reason: the + /// project hasn't been installed yet. + #[diagnostic(transparent)] + CannotResolve(#[error(source)] CannotResolveWorkspaceProtocolError), + + /// Reading `/package.json` itself failed (malformed JSON, IO + /// error other than ENOENT, …). Propagated so the caller can + /// surface the underlying cause. + ReadManifest(#[error(source)] PackageManifestError), +} + +/// Port of upstream's +/// [`replaceWorkspaceProtocol`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L139-L168) +/// — rewrites a single `dependencies` / `devDependencies` / +/// `optionalDependencies` value at publish time. +/// +/// Returns `dep_spec` unchanged when it doesn't start with `workspace:` +/// so the caller can fold the helper into a generic per-field rewrite +/// without branching on the protocol. +pub fn replace_workspace_protocol( + dep_name: &str, + dep_spec: &str, + dir: &Path, + modules_dir: Option<&Path>, +) -> Result { + let Some(rest) = dep_spec.strip_prefix("workspace:") else { + return Ok(dep_spec.to_string()); + }; + + if let Some(parsed) = parse_version_alias_spec(rest) { + let modules_dir_owned: PathBuf; + let modules_dir = match modules_dir { + Some(path) => path, + None => { + modules_dir_owned = dir.join("node_modules"); + &modules_dir_owned + } + }; + let manifest = read_and_check_manifest(dep_name, &modules_dir.join(dep_name))?; + let semver_range_token = match parsed.sentinel { + Some('^') => "^", + Some('~') => "~", + _ => "", + }; + if dep_name != manifest.name { + return Ok(format!( + "npm:{name}@{token}{version}", + name = manifest.name, + token = semver_range_token, + version = manifest.version, + )); + } + return Ok(format!( + "{token}{version}", + token = semver_range_token, + version = manifest.version, + )); + } + + if let Some(relative) = strip_workspace_relative_prefix(dep_spec) { + let manifest = read_and_check_manifest(dep_name, &dir.join(relative))?; + if manifest.name == dep_name { + return Ok(manifest.version); + } + return Ok(format!( + "npm:{name}@{version}", + name = manifest.name, + version = manifest.version, + )); + } + + if rest.contains('@') { + return Ok(format!("npm:{rest}")); + } + Ok(rest.to_string()) +} + +/// Port of upstream's +/// [`replaceWorkspaceProtocolPeerDependency`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L170-L194) +/// — rewrites a `peerDependencies` value. +/// +/// `peerDependencies` allows compound ranges (`workspace:>= || ^3.9.0`), +/// so this helper accepts the broader comparator set (`>=`, `<=`, `>`, +/// `<` alongside `^`, `~`, `*`) and rewrites every `workspace:` +/// segment in place rather than swapping the whole string. +pub fn replace_workspace_protocol_peer_dependency( + dep_name: &str, + dep_spec: &str, + dir: &Path, + modules_dir: Option<&Path>, +) -> Result { + if !dep_spec.contains("workspace:") { + return Ok(dep_spec.to_string()); + } + // Mirror upstream's JS `.replace('workspace:', '')`, which removes + // only the first occurrence. Rust's `str::replace` is all-occurrence; + // use `replacen(_, _, 1)` so compound peer specs like + // `^1.0.0 || workspace:>=1 || workspace:>=2` keep parity with pnpm. + let Some(matched) = find_workspace_peer_segment(dep_spec) else { + return Ok(dep_spec.replacen("workspace:", "", 1)); + }; + + if !matched.version.is_empty() { + return Ok(dep_spec.replacen("workspace:", "", 1)); + } + + let modules_dir_owned: PathBuf; + let modules_dir = match modules_dir { + Some(path) => path, + None => { + modules_dir_owned = dir.join("node_modules"); + &modules_dir_owned + } + }; + let manifest = read_and_check_manifest(dep_name, &modules_dir.join(dep_name))?; + let token = if matched.range_group == "*" { "" } else { matched.range_group }; + + let mut rewritten = String::with_capacity(dep_spec.len()); + rewritten.push_str(&dep_spec[..matched.start]); + rewritten.push_str(token); + rewritten.push_str(&manifest.version); + rewritten.push_str(&dep_spec[matched.end..]); + Ok(rewritten) +} + +/// Read `/package.json` and verify the `name` / `version` +/// fields are present. Surfaces the same +/// [`CANNOT_RESOLVE_WORKSPACE_PROTOCOL`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L117-L127) +/// error pnpm raises when the dependency hasn't been installed yet. +fn read_and_check_manifest( + dep_name: &str, + dependency_dir: &Path, +) -> Result { + let value = match safe_read_package_json_from_dir(dependency_dir) { + Ok(Some(value)) => value, + Ok(None) => { + return Err(ReplaceWorkspaceProtocolError::CannotResolve( + CannotResolveWorkspaceProtocolError { dep_name: dep_name.to_string() }, + )); + } + Err(err) => return Err(ReplaceWorkspaceProtocolError::ReadManifest(err)), + }; + let Some(name) = value.get("name").and_then(Value::as_str) else { + return Err(ReplaceWorkspaceProtocolError::CannotResolve( + CannotResolveWorkspaceProtocolError { dep_name: dep_name.to_string() }, + )); + }; + let Some(version) = value.get("version").and_then(Value::as_str) else { + return Err(ReplaceWorkspaceProtocolError::CannotResolve( + CannotResolveWorkspaceProtocolError { dep_name: dep_name.to_string() }, + )); + }; + Ok(DependencyManifest { name: name.to_string(), version: version.to_string() }) +} + +/// The two fields the rewriters consult on the dependency's manifest. +/// Mirrors the slice pnpm's `tryReadProjectManifest` returns for this +/// codepath. +struct DependencyManifest { + name: String, + version: String, +} + +/// Output of [`parse_version_alias_spec`]: the optional sentinel +/// character (`^`/`~`/`*`). Upstream's regex captures the alias +/// portion too, but pacquet's branch only consults the version-token +/// captured by the second group; the alias is implied by the spec +/// shape and re-read from the dependency manifest, never from the +/// regex group. +struct VersionAliasMatch { + sentinel: Option, +} + +/// Port of upstream's `^workspace:(?:(.+)@)?([\^~*])?$` regex. +/// +/// The TS impl uses JS-regex greedy backtracking on `.+@`, so the +/// alias spans up to (and including) the **last** `@` in the suffix. +/// Returns `None` when the input has trailing characters past an +/// optional `^`/`~`/`*` sentinel — same shape as the regex failing. +fn parse_version_alias_spec(after_protocol: &str) -> Option { + let after_alias = match after_protocol.rfind('@') { + Some(idx) if idx >= 1 => &after_protocol[idx + 1..], + _ => after_protocol, + }; + let sentinel = match after_alias.chars().count() { + 0 => None, + 1 => { + let first_char = after_alias.chars().next().expect("char count == 1"); + if matches!(first_char, '^' | '~' | '*') { + Some(first_char) + } else { + return None; + } + } + _ => return None, + }; + Some(VersionAliasMatch { sentinel }) +} + +/// Strip the `workspace:` prefix and return the path portion of a +/// relative `workspace:./` or `workspace:../` spec. Mirrors upstream's +/// `depSpec.slice(10)` on this branch. +fn strip_workspace_relative_prefix(dep_spec: &str) -> Option<&str> { + if dep_spec.starts_with("workspace:./") || dep_spec.starts_with("workspace:../") { + return Some(&dep_spec["workspace:".len()..]); + } + None +} + +/// One match of upstream's peer-dependency regex +/// `workspace:([\^~*]|>=|>|<=|<)?((\d+|[xX*])(\.(\d+|[xX*])){0,2})?`. +struct WorkspacePeerSegment<'a> { + /// Byte offset of the leading `workspace:` in the input. + start: usize, + /// Byte offset one past the end of the matched region. + end: usize, + /// The semver-range comparator captured by the regex's first + /// group, or the empty string when no comparator preceded the + /// version component. + range_group: &'a str, + /// The version component, if any. Empty string when the regex's + /// second group didn't match. + version: &'a str, +} + +/// Locate the first `workspace:`-led peer segment in `spec` and return +/// the slice information [`replace_workspace_protocol_peer_dependency`] +/// needs to rewrite it in place. +fn find_workspace_peer_segment(spec: &str) -> Option> { + let start = spec.find("workspace:")?; + let after = start + "workspace:".len(); + let bytes = spec.as_bytes(); + + let range_len = parse_peer_range_comparator(bytes, after); + let comparator_end = after + range_len; + let version_end = parse_peer_version(bytes, comparator_end); + + Some(WorkspacePeerSegment { + start, + end: version_end, + range_group: &spec[after..comparator_end], + version: &spec[comparator_end..version_end], + }) +} + +/// Parse the leading comparator (`>=`, `<=`, `>`, `<`, `^`, `~`, `*`) +/// starting at `pos`. Returns the byte length of the comparator, or +/// zero when no comparator is present. +fn parse_peer_range_comparator(bytes: &[u8], pos: usize) -> usize { + match (bytes.get(pos), bytes.get(pos + 1)) { + (Some(b'>'), Some(b'=')) | (Some(b'<'), Some(b'=')) => 2, + (Some(b'>' | b'<' | b'^' | b'~' | b'*'), _) => 1, + _ => 0, + } +} + +/// Consume `(\d+|[xX*])(\.(\d+|[xX*])){0,2}` starting at `pos`. Returns +/// the byte offset one past the consumed region — or `pos` when the +/// first character isn't a valid part. +fn parse_peer_version(bytes: &[u8], pos: usize) -> usize { + let Some(end) = parse_peer_version_part(bytes, pos) else { + return pos; + }; + let mut cur = end; + for _ in 0..2 { + if bytes.get(cur) != Some(&b'.') { + break; + } + let Some(next) = parse_peer_version_part(bytes, cur + 1) else { + break; + }; + cur = next; + } + cur +} + +/// Consume one part of the version regex (`\d+` or one of `x`/`X`/`*`). +fn parse_peer_version_part(bytes: &[u8], start: usize) -> Option { + match bytes.get(start)? { + b'x' | b'X' | b'*' => Some(start + 1), + b if b.is_ascii_digit() => { + let mut end = start + 1; + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + Some(end) + } + _ => None, + } +} diff --git a/pacquet/crates/exportable-manifest/src/tests.rs b/pacquet/crates/exportable-manifest/src/tests.rs new file mode 100644 index 0000000000..8d1fe74df8 --- /dev/null +++ b/pacquet/crates/exportable-manifest/src/tests.rs @@ -0,0 +1,169 @@ +//! Port of pnpm's +//! [`releasing/exportable-manifest/test/index.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/test/index.test.ts) +//! covering the workspace-protocol rewrite. The upstream test goes +//! through `createExportableManifest` (which performs a full install +//! and reads from the resulting `node_modules`); pacquet's test +//! materializes the same `node_modules` layout directly with +//! `tempfile::TempDir` so we exercise `replace_workspace_protocol` +//! and `replace_workspace_protocol_peer_dependency` in isolation. + +use std::{fs, path::Path}; + +use tempfile::TempDir; + +use super::{ + CannotResolveWorkspaceProtocolError, ReplaceWorkspaceProtocolError, replace_workspace_protocol, + replace_workspace_protocol_peer_dependency, +}; + +/// Materialize the install tree upstream's +/// [`workspace deps are replaced`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/test/index.test.ts#L96-L197) +/// case sets up via `pnpm install`. Returns `(temp_root, project_dir)`: +/// `project_dir = /workspace-protocol-package` so the relative +/// `workspace:../xerox` resolves to a sibling at `/xerox`. +fn workspace_fixture() -> (TempDir, std::path::PathBuf) { + let temp = TempDir::new().expect("tempdir"); + let project = temp.path().join("workspace-protocol-package"); + let modules = project.join("node_modules"); + fs::create_dir_all(&modules).unwrap(); + + // The local alias `bar` resolves to the workspace project named + // `@foo/bar`; `node_modules/bar/package.json` carries the resolved + // manifest (same name, copied / linked there by `pnpm install`). + write_dep(&modules.join("bar"), "@foo/bar", "3.2.1"); + write_dep(&modules.join("baz"), "baz", "1.2.3"); + write_dep(&modules.join("foo"), "foo", "4.5.6"); + write_dep(&modules.join("qux"), "qux", "1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay"); + write_dep(&modules.join("quux"), "quux", "7.8.9"); + write_dep(&modules.join("waldo"), "waldo", "1.9.0"); + + // `workspace:../xerox` reads the sibling project directly, not from + // `node_modules`. + write_dep(&temp.path().join("xerox"), "xerox", "4.5.6"); + (temp, project) +} + +fn write_dep(dir: &Path, name: &str, version: &str) { + fs::create_dir_all(dir).unwrap(); + let manifest = serde_json::json!({ "name": name, "version": version }); + fs::write(dir.join("package.json"), serde_json::to_string(&manifest).unwrap()).unwrap(); +} + +fn rewrite(dep_name: &str, dep_spec: &str, dir: &Path) -> String { + replace_workspace_protocol(dep_name, dep_spec, dir, None).expect("replace succeeds") +} + +fn rewrite_peer(dep_name: &str, dep_spec: &str, dir: &Path) -> String { + replace_workspace_protocol_peer_dependency(dep_name, dep_spec, dir, None) + .expect("replace succeeds") +} + +#[test] +fn passes_through_non_workspace_specs() { + let dir = TempDir::new().unwrap(); + assert_eq!(rewrite("foo", "^1.0.0", dir.path()), "^1.0.0"); + assert_eq!(rewrite("foo", "npm:bar@1", dir.path()), "npm:bar@1"); +} + +/// Workspace fixture mirrors upstream's `dependencies` field in the +/// `workspace deps are replaced` test. Each line exercises one of the +/// branches inside `replaceWorkspaceProtocol`. +#[test] +fn workspace_dep_rewrites_match_upstream() { + let (_fixture, project) = workspace_fixture(); + let dir = project.as_path(); + + assert_eq!(rewrite("bar", "workspace:@foo/bar@*", dir), "npm:@foo/bar@3.2.1"); + assert_eq!(rewrite("baz", "workspace:baz@^", dir), "^1.2.3"); + assert_eq!(rewrite("foo", "workspace:*", dir), "4.5.6"); + assert_eq!( + rewrite("qux", "workspace:^", dir), + "^1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay", + ); + assert_eq!(rewrite("quux", "workspace:", dir), "7.8.9"); + assert_eq!(rewrite("waldo", "workspace:^", dir), "^1.9.0"); + assert_eq!(rewrite("xerox", "workspace:../xerox", dir), "4.5.6"); + assert_eq!(rewrite("xeroxAlias", "workspace:../xerox", dir), "npm:xerox@4.5.6"); + assert_eq!(rewrite("corge", "workspace:1.0.0", dir), "1.0.0"); + assert_eq!(rewrite("grault", "workspace:^1.0.0", dir), "^1.0.0"); + assert_eq!(rewrite("garply", "workspace:plugh@2.0.0", dir), "npm:plugh@2.0.0"); +} + +/// Peer-dependency variant. The compound `||` cases exercise the +/// in-place rewriting branch of upstream's regex. +#[test] +fn peer_workspace_dep_rewrites_match_upstream() { + let (_fixture, project) = workspace_fixture(); + let dir = project.as_path(); + + assert_eq!(rewrite_peer("foo", "workspace:>= || ^3.9.0", dir), ">=4.5.6 || ^3.9.0"); + assert_eq!(rewrite_peer("baz", "^1.0.0 || workspace:>", dir), "^1.0.0 || >1.2.3"); + assert_eq!(rewrite_peer("bar", "workspace:^3.0.0", dir), "^3.0.0"); + assert_eq!( + rewrite_peer("qux", "workspace:^", dir), + "^1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay", + ); + assert_eq!(rewrite_peer("waldo", "workspace:^1.x", dir), "^1.x"); +} + +/// Upstream uses JS `String.prototype.replace('workspace:', '')`, which +/// strips only the first occurrence. Locks the parity: a compound +/// peer spec carrying two `workspace:` segments must leave the second +/// segment untouched. +#[test] +fn peer_workspace_strip_only_removes_first_occurrence() { + let (_fixture, project) = workspace_fixture(); + let dir = project.as_path(); + + assert_eq!( + rewrite_peer("baz", "workspace:^1.0.0 || workspace:^2.0.0", dir), + "^1.0.0 || workspace:^2.0.0", + ); +} + +#[test] +fn missing_dependency_surfaces_cannot_resolve_error() { + let fixture = TempDir::new().unwrap(); + let dir = fixture.path(); + fs::create_dir_all(dir.join("node_modules")).unwrap(); + + let err = replace_workspace_protocol("ghost", "workspace:*", dir, None).unwrap_err(); + assert!(matches!( + err, + ReplaceWorkspaceProtocolError::CannotResolve(CannotResolveWorkspaceProtocolError { + dep_name, + }) if dep_name == "ghost" + )); +} + +#[test] +fn missing_dependency_surfaces_cannot_resolve_error_for_peer() { + let fixture = TempDir::new().unwrap(); + let dir = fixture.path(); + fs::create_dir_all(dir.join("node_modules")).unwrap(); + + let err = + replace_workspace_protocol_peer_dependency("ghost", "workspace:^", dir, None).unwrap_err(); + assert!(matches!( + err, + ReplaceWorkspaceProtocolError::CannotResolve(CannotResolveWorkspaceProtocolError { + dep_name, + }) if dep_name == "ghost" + )); +} + +/// `workspace:*` doesn't reach into the manifest for the version +/// token (the version comes from the lookup), so when the dep name +/// doesn't match the resolved manifest's name the output is an +/// `npm:`-aliased reference. The unaliased case is covered by +/// [`workspace_dep_rewrites_match_upstream`]; this case locks the +/// aliased branch. +#[test] +fn dep_name_mismatch_routes_to_npm_alias() { + let fixture = TempDir::new().unwrap(); + let dir = fixture.path(); + let modules = dir.join("node_modules"); + write_dep(&modules.join("local-name"), "actual-name", "1.2.3"); + + assert_eq!(rewrite("local-name", "workspace:*", dir), "npm:actual-name@1.2.3"); +} diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 2a24529b1a..99d5cad335 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -180,6 +180,9 @@ pub enum InstallError { #[diagnostic(transparent)] InvalidCatalogsConfiguration(#[error(source)] InvalidCatalogsConfigurationError), + #[diagnostic(transparent)] + FindWorkspaceProjects(#[error(source)] pacquet_workspace::FindWorkspaceProjectsError), + /// Building the verifier list from config rejected a /// `minimumReleaseAgeExclude` or `trustPolicyExclude` pattern. /// Mirrors upstream's `INVALID_MINIMUM_RELEASE_AGE_EXCLUDE` / @@ -538,6 +541,16 @@ where // The no-lockfile path has no installability check (no // `packages:` metadata to evaluate constraints against), // so its skip set is empty by construction. + // Build the workspace-sibling lookup the npm resolver + // consults for `workspace:` specs. `None` when the install + // isn't inside a `pnpm-workspace.yaml` workspace (no + // workspace root was found), so the resolver errors out + // on any `workspace:` spec rather than silently skipping + // to a registry lookup. Mirrors upstream's posture at + // . + let workspace_packages = + build_workspace_packages_map(&workspace_root, workspace_manifest.as_ref()) + .map_err(InstallError::FindWorkspaceProjects)?; let hd = InstallWithoutLockfile { tarball_mem_cache, resolved_packages, @@ -549,6 +562,8 @@ where logged_methods: &logged_methods, requester: &prefix, catalogs, + lockfile_dir: &workspace_root, + workspace_packages, } .run::() .await @@ -754,6 +769,50 @@ fn manifest_string_field(manifest: &PackageManifest, key: &str) -> Option, +) -> Result< + Option, + pacquet_workspace::FindWorkspaceProjectsError, +> { + // No `pnpm-workspace.yaml` → no workspace install. Skip the project + // walk entirely. + let Some(manifest) = workspace_manifest else { return Ok(None) }; + let opts = pacquet_workspace::FindWorkspaceProjectsOpts { patterns: manifest.packages.clone() }; + let projects = pacquet_workspace::find_workspace_projects(workspace_root, &opts)?; + + let mut map: pacquet_resolving_resolver_base::WorkspacePackages = + std::collections::BTreeMap::new(); + for project in projects { + let name = manifest_string_field(&project.manifest, "name"); + let version = manifest_string_field(&project.manifest, "version"); + let (Some(name), Some(version)) = (name, version) else { continue }; + map.entry(name).or_default().insert( + version, + pacquet_resolving_resolver_base::WorkspacePackage { + root_dir: project.root_dir, + manifest: project.manifest.value().clone(), + }, + ); + } + Ok(Some(map)) +} + /// Build the `projects` map for [`WorkspaceState`]. Mirrors upstream's /// `Object.fromEntries(opts.allProjects.map(...))` at /// . diff --git a/pacquet/crates/package-manager/src/install_without_lockfile.rs b/pacquet/crates/package-manager/src/install_without_lockfile.rs index 412609ae86..0005affa79 100644 --- a/pacquet/crates/package-manager/src/install_without_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_without_lockfile.rs @@ -89,6 +89,22 @@ pub struct InstallWithoutLockfile<'a, DependencyGroupList> { /// Catalogs parsed from `pnpm-workspace.yaml`. Empty for projects /// without a workspace manifest. pub catalogs: Catalogs, + /// Lockfile root for the install, used by the resolver chain to + /// compute `link:` / `file:` relative paths and to anchor + /// workspace-package resolution. Mirrors upstream's `lockfileDir` + /// argument on `resolveDependencies`. Equal to the manifest's + /// parent directory under single-project installs and to the + /// `pnpm-workspace.yaml` root under monorepos. + pub lockfile_dir: &'a Path, + /// Workspace-sibling lookup the [`NpmResolver`] consults when it + /// sees a `workspace:` spec. `None` when this install isn't inside + /// a `pnpm-workspace.yaml` workspace; the resolver then errors out + /// on any `workspace:` spec via + /// `ResolveFromWorkspaceError::WorkspacePackagesNotLoaded` — + /// matching pnpm's + /// [`Cannot resolve package from workspace because opts.workspacePackages is not defined`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L828-L830) + /// behavior. + pub workspace_packages: Option, } /// Error type of [`InstallWithoutLockfile`]. @@ -175,6 +191,8 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { logged_methods, requester, catalogs, + lockfile_dir, + workspace_packages, } = self; let store_dir: &'static _ = &config.store_dir; @@ -308,6 +326,12 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { &[manifest], ); + // Thread the manifest's directory and the lockfile root into + // the resolver's `ResolveOptions` so `workspace:` and `link:` + // resolutions can compute the right relative paths. + let project_dir = + manifest.path().parent().expect("manifest path always has a parent dir").to_path_buf(); + let importer_opts = ResolveImporterOptions { auto_install_peers: config.auto_install_peers, auto_install_peers_from_highest_match: config.auto_install_peers_from_highest_match, @@ -317,6 +341,9 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> { default_tag: Some("latest".to_string()), published_by, published_by_exclude, + project_dir, + lockfile_dir: lockfile_dir.to_path_buf(), + workspace_packages, ..ResolveOptions::default() }, catalogs, diff --git a/pacquet/crates/resolving-npm-resolver/Cargo.toml b/pacquet/crates/resolving-npm-resolver/Cargo.toml index 814ef99338..6063b7eab1 100644 --- a/pacquet/crates/resolving-npm-resolver/Cargo.toml +++ b/pacquet/crates/resolving-npm-resolver/Cargo.toml @@ -17,6 +17,8 @@ pacquet-network = { workspace = true } pacquet-registry = { workspace = true } pacquet-resolving-jsr-specifier-parser = { workspace = true } pacquet-resolving-resolver-base = { workspace = true } +pacquet-workspace-range-resolver = { workspace = true } +pacquet-workspace-spec = { workspace = true } chrono = { workspace = true } dashmap = { workspace = true } diff --git a/pacquet/crates/resolving-npm-resolver/src/lib.rs b/pacquet/crates/resolving-npm-resolver/src/lib.rs index ef8be4db47..e8d83ef3b7 100644 --- a/pacquet/crates/resolving-npm-resolver/src/lib.rs +++ b/pacquet/crates/resolving-npm-resolver/src/lib.rs @@ -32,8 +32,10 @@ mod parse_bare_specifier; mod pick_package; mod pick_package_from_meta; mod registry_url; +mod resolve_from_workspace; mod trust_checks; mod violation_codes; +mod workspace_pref_to_npm; pub use create_npm_resolution_verifier::{ CreateNpmResolutionVerifierOptions, NpmResolutionVerifier, create_npm_resolution_verifier, @@ -64,7 +66,11 @@ pub use pick_package_from_meta::{ RegistryPackageSpec, RegistryPackageSpecType, filter_pkg_metadata_by_publish_date, pick_lowest_version_by_version_range, pick_package_from_meta, pick_version_by_version_range, }; +pub use resolve_from_workspace::{ + ResolveFromWorkspaceError, ResolveFromWorkspaceOptions, try_resolve_from_workspace, +}; pub use trust_checks::{ TrustCheckOptions, TrustEvidence, TrustViolation, fail_if_trust_downgraded, get_trust_evidence, }; pub use violation_codes::{MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE}; +pub use workspace_pref_to_npm::{InvalidWorkspaceSpecError, workspace_pref_to_npm}; diff --git a/pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs b/pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs index 1cc5e6d448..3cde585ff1 100644 --- a/pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs +++ b/pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs @@ -10,11 +10,16 @@ //! cache; the trait implementation parses the bare specifier, picks a //! version, and maps the result to [`ResolveResult`]. //! +//! Workspace handling intentionally lives on the npm-resolver side +//! (mirroring upstream): non-path `workspace:` specs route through +//! [`try_resolve_from_workspace`](crate::try_resolve_from_workspace()) +//! to a `link:` / `file:` resolution against the install's workspace +//! package map; the path-relative forms (`workspace:./foo`, +//! `workspace:../bar`) return `Ok(None)` so the local-resolver in the +//! chain claims them. +//! //! Out of scope for this port: //! -//! - **Workspace resolution.** `workspace:` specs return `Ok(None)` so -//! the dispatcher falls through to the workspace resolver when that -//! crate lands. //! - **`peekManifestFromStore` fast path.** Upstream short-circuits a //! registry fetch when the lockfile-pinned tarball is already in the //! store. Pacquet today goes through the picker unconditionally; @@ -40,6 +45,7 @@ use crate::{ parse_bare_specifier::{parse_bare_specifier, parse_jsr_specifier_to_registry_package_spec}, pick_package::{PackageMetaCache, PickPackageContext, PickPackageOptions, pick_package}, pick_package_from_meta::{RegistryPackageSpec, RegistryPackageSpecType}, + resolve_from_workspace::{ResolveFromWorkspaceOptions, try_resolve_from_workspace}, violation_codes::MINIMUM_RELEASE_AGE_VIOLATION_CODE, }; @@ -122,14 +128,34 @@ impl NpmResolver { ) -> Result, ResolveError> { let default_tag = opts.default_tag.as_deref().unwrap_or("latest"); - // `workspace:` is owned by the workspace resolver. Decline so - // the chain dispatches there once that crate lands. - if wanted_dependency - .bare_specifier - .as_deref() - .is_some_and(|bare| bare.starts_with("workspace:")) + // `workspace:` is intercepted before the npm pick — only the + // path-relative forms (`workspace:./foo`, `workspace:../bar`) + // fall through here so the local-resolver in the chain claims + // them. Everything else routes through + // [`try_resolve_from_workspace`], mirroring upstream's + // [`resolveNpm`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L412-L429) + // gate. + if let Some(bare) = wanted_dependency.bare_specifier.as_deref() + && bare.starts_with("workspace:") { - return Ok(None); + if bare.starts_with("workspace:.") { + return Ok(None); + } + let registry = pick_registry_for_package( + &self.registries, + wanted_dependency.alias.as_deref().unwrap_or_default(), + wanted_dependency.bare_specifier.as_deref(), + ); + let ws_opts = ResolveFromWorkspaceOptions { + project_dir: opts.project_dir.as_path(), + lockfile_dir: opts.lockfile_dir.as_path(), + registry: ®istry, + default_tag, + workspace_packages: opts.workspace_packages.as_ref(), + inject_workspace_packages: opts.inject_workspace_packages, + }; + return try_resolve_from_workspace(wanted_dependency, &ws_opts) + .map_err(|err| Box::new(err) as ResolveError); } // `jsr:` resolves through the `@jsr` registry under the diff --git a/pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs b/pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs index 9b6a2b5c60..0541cd11b7 100644 --- a/pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs +++ b/pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs @@ -127,7 +127,25 @@ async fn range_specifier_picks_max_in_range() { } #[tokio::test] -async fn workspace_specifier_returns_none_for_chain_fallthrough() { +async fn workspace_path_form_falls_through_to_local_resolver() { + let server = mockito::Server::new_async().await; + let registry = format!("{}/", server.url()); + let (resolver, _tempdir) = build_resolver(®istry); + + // `workspace:./foo` and `workspace:../foo` are owned by the local + // resolver in the chain — `try_resolve_from_workspace` defers on + // them so the dispatcher falls through. + let wanted = WantedDependency { + alias: Some("acme".to_string()), + bare_specifier: Some("workspace:./acme".to_string()), + ..WantedDependency::default() + }; + let result = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap(); + assert!(result.is_none()); +} + +#[tokio::test] +async fn workspace_version_without_workspace_packages_surfaces_error() { let server = mockito::Server::new_async().await; let registry = format!("{}/", server.url()); let (resolver, _tempdir) = build_resolver(®istry); @@ -137,8 +155,19 @@ async fn workspace_specifier_returns_none_for_chain_fallthrough() { bare_specifier: Some("workspace:*".to_string()), ..WantedDependency::default() }; - let result = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap(); - assert!(result.is_none()); + // Mirrors pnpm's + // [`Cannot resolve package from workspace because opts.workspacePackages is not defined`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L828-L830) + // throw when the resolver receives a `workspace:` spec but the + // install caller never populated `workspace_packages`. + let err = resolver + .resolve(&wanted, &ResolveOptions::default()) + .await + .expect_err("workspace_packages must be populated for workspace: specifiers"); + let message = err.to_string(); + assert!( + message.contains("workspace packages were not loaded"), + "unexpected error message: {message}", + ); } #[tokio::test] diff --git a/pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs b/pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs new file mode 100644 index 0000000000..87e6cf720c --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs @@ -0,0 +1,347 @@ +//! Port of pnpm's +//! [`tryResolveFromWorkspace`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L806-L844) +//! and its inner +//! [`tryResolveFromWorkspacePackages`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L846-L888) +//! / [`pickMatchingLocalVersionOrNull`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L890-L906) +//! / [`resolveFromLocalPackage`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L908-L951) +//! helpers. +//! +//! The npm-resolver intercepts every `workspace:`-shaped wanted +//! dependency *except* the path-relative forms (`workspace:./foo`, +//! `workspace:../bar`) — those flow through unchanged so the +//! local-resolver in the chain takes them as `link:`-shaped +//! directory specs. + +use std::path::{Path, PathBuf}; + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use node_semver::Version; +use pacquet_lockfile::{DirectoryResolution, LockfileResolution}; +use pacquet_resolving_resolver_base::{ + PkgResolutionId, ResolveResult, WantedDependency, WorkspacePackage, WorkspacePackages, + WorkspacePackagesByVersion, +}; +use pacquet_workspace_range_resolver::resolve_workspace_range; + +use crate::{ + parse_bare_specifier::parse_bare_specifier, + pick_package_from_meta::{RegistryPackageSpec, RegistryPackageSpecType}, + workspace_pref_to_npm::{InvalidWorkspaceSpecError, workspace_pref_to_npm}, +}; + +/// Options threaded into [`try_resolve_from_workspace`]. Mirrors +/// upstream's per-call bag at +/// [`tryResolveFromWorkspace`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L808-L819). +#[derive(Debug, Clone)] +pub struct ResolveFromWorkspaceOptions<'a> { + /// Workspace-relative `/pnpm-workspace.yaml` directory the + /// `link:` entry's relative path is rendered against. + pub project_dir: &'a Path, + /// Lockfile root. Mirrors upstream's `lockfileDir` argument — + /// `link:`-shaped resolutions render relative to `project_dir` + /// regardless, but `file:`-shaped (injected) resolutions use this + /// as the relativity anchor. + pub lockfile_dir: &'a Path, + /// Registry URL passed through to [`parse_bare_specifier`] so + /// `npm:@` outputs flow through the same parsing + /// path as a plain npm spec. + pub registry: &'a str, + /// Default tag (`latest`) the parser falls back on when the + /// translated spec is bare (no version after the alias). + pub default_tag: &'a str, + /// Workspace packages map indexed by name → version → manifest. + /// `None` when the install caller never populated one; this + /// surfaces as a `WORKSPACE_PACKAGES_NOT_LOADED` error. + pub workspace_packages: Option<&'a WorkspacePackages>, + /// `true` materialises the dependency as a `file:` (hard-linked + /// copy) resolution instead of a `link:` symlink. Mirrors upstream's + /// `injectWorkspacePackages` + per-dep `injected` toggle. + pub inject_workspace_packages: bool, +} + +/// Error envelope for [`try_resolve_from_workspace`]. The two pnpm +/// codes (`WORKSPACE_PKG_NOT_FOUND`, `NO_MATCHING_VERSION_INSIDE_WORKSPACE`) +/// are reproduced verbatim. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum ResolveFromWorkspaceError { + /// The translated workspace spec failed to parse. Mirrors + /// upstream's `throw new Error('Invalid workspace: spec ...')` + /// branch — practically unreachable since the caller checks the + /// `workspace:` prefix before invoking this entry point. + #[diagnostic(transparent)] + InvalidWorkspaceSpec(#[error(source)] InvalidWorkspaceSpecError), + + /// `workspace_packages` was `None`. Mirrors upstream's + /// [`Cannot resolve package from workspace because opts.workspacePackages is not defined`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L829) + /// throw. + #[display( + "Cannot resolve package from workspace because workspace packages were not loaded into the resolver" + )] + #[diagnostic(code(pacquet_resolving_npm_resolver::workspace_packages_not_loaded))] + WorkspacePackagesNotLoaded, + + /// The npm parser refused the translated bare specifier. Mirrors + /// upstream's `throw new Error('Invalid workspace: spec (${...})')`. + #[display("Invalid workspace: spec ({bare_specifier})")] + #[diagnostic(code(pacquet_resolving_npm_resolver::invalid_workspace_translated_spec))] + UnparsableSpec { + #[error(not(source))] + bare_specifier: String, + }, + + /// Workspace map didn't carry the requested package name. Mirrors + /// pnpm's + /// [`WORKSPACE_PKG_NOT_FOUND`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L862-L868). + #[display( + "In {project_dir}: \"{name}@{bare_specifier}\" is in the dependencies but no package named \"{name}\" is present in the workspace" + )] + #[diagnostic(code(ERR_PNPM_WORKSPACE_PKG_NOT_FOUND), help("{hint}"))] + WorkspacePkgNotFound { + name: String, + bare_specifier: String, + project_dir: String, + #[error(not(source))] + hint: String, + }, + + /// Workspace map carried the name but no version satisfied the + /// range. Mirrors pnpm's + /// [`NO_MATCHING_VERSION_INSIDE_WORKSPACE`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L877-L885). + #[display( + "In {project_dir}: No matching version found for {alias}@{bare_specifier} inside the workspace{available}" + )] + #[diagnostic(code(ERR_PNPM_NO_MATCHING_VERSION_INSIDE_WORKSPACE))] + NoMatchingVersionInsideWorkspace { + alias: String, + bare_specifier: String, + project_dir: String, + #[error(not(source))] + available: String, + }, +} + +/// Try to resolve a `workspace:` wanted dependency against the project +/// workspace. Returns `Ok(None)` when the wanted dep isn't +/// workspace-prefixed (so the caller can fall through to the npm +/// path); returns `Ok(Some(_))` with a `link:` / `file:` resolution +/// otherwise. +/// +/// Mirrors upstream's +/// [`tryResolveFromWorkspace`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L806-L844). +pub fn try_resolve_from_workspace( + wanted_dependency: &WantedDependency, + opts: &ResolveFromWorkspaceOptions<'_>, +) -> Result, ResolveFromWorkspaceError> { + let Some(bare) = wanted_dependency.bare_specifier.as_deref() else { + return Ok(None); + }; + if !bare.starts_with("workspace:") { + return Ok(None); + } + // `workspace:./foo` / `workspace:../foo` are owned by the local + // resolver; let those fall through so the chain doesn't claim + // them here. + if bare.starts_with("workspace:.") { + return Ok(None); + } + + let translated = + workspace_pref_to_npm(bare).map_err(ResolveFromWorkspaceError::InvalidWorkspaceSpec)?; + let spec = parse_bare_specifier( + &translated, + wanted_dependency.alias.as_deref(), + opts.default_tag, + opts.registry, + ) + .ok_or_else(|| ResolveFromWorkspaceError::UnparsableSpec { + bare_specifier: bare.to_string(), + })?; + + let workspace_packages = + opts.workspace_packages.ok_or(ResolveFromWorkspaceError::WorkspacePackagesNotLoaded)?; + + let result = + try_resolve_from_workspace_packages(workspace_packages, &spec, wanted_dependency, opts)?; + Ok(Some(result)) +} + +fn try_resolve_from_workspace_packages( + workspace_packages: &WorkspacePackages, + spec: &RegistryPackageSpec, + wanted_dependency: &WantedDependency, + opts: &ResolveFromWorkspaceOptions<'_>, +) -> Result { + let matching_name = workspace_packages.get(spec.name.as_str()).ok_or_else(|| { + let names = workspace_packages.keys().cloned().collect::>().join(", "); + ResolveFromWorkspaceError::WorkspacePkgNotFound { + name: spec.name.clone(), + bare_specifier: wanted_dependency.bare_specifier.clone().unwrap_or_default(), + project_dir: opts.project_dir.display().to_string(), + hint: format!("Packages found in the workspace: {names}"), + } + })?; + + let picked = pick_matching_local_version_or_null(matching_name, spec).ok_or_else(|| { + let mut versions: Vec = matching_name.keys().cloned().collect(); + versions.sort_by(|a, b| rcompare_versions(a, b)); + let available = if versions.is_empty() { + String::new() + } else { + format!(". Available versions: {}", versions.join(", ")) + }; + ResolveFromWorkspaceError::NoMatchingVersionInsideWorkspace { + alias: wanted_dependency.alias.clone().unwrap_or_default(), + bare_specifier: wanted_dependency.bare_specifier.clone().unwrap_or_default(), + project_dir: opts.project_dir.display().to_string(), + available, + } + })?; + let local_package = + matching_name.get(&picked).expect("picked version came from the matching set"); + + Ok(resolve_from_local_package( + local_package, + wanted_dependency, + opts.inject_workspace_packages || wanted_dependency.injected.unwrap_or(false), + opts.project_dir, + opts.lockfile_dir, + )) +} + +/// Mirror upstream's +/// [`pickMatchingLocalVersionOrNull`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L890-L906). +fn pick_matching_local_version_or_null( + versions: &WorkspacePackagesByVersion, + spec: &RegistryPackageSpec, +) -> Option { + match spec.spec_type { + RegistryPackageSpecType::Tag => { + let raw: Vec = versions.keys().cloned().collect(); + resolve_workspace_range("*", &raw) + } + RegistryPackageSpecType::Version => { + if versions.contains_key(&spec.fetch_spec) { + Some(spec.fetch_spec.clone()) + } else { + None + } + } + RegistryPackageSpecType::Range => { + let raw: Vec = versions.keys().cloned().collect(); + resolve_workspace_range(&spec.fetch_spec, &raw) + } + } +} + +/// Mirror upstream's +/// [`resolveFromLocalPackage`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L908-L951). +/// +/// The TS branch also derives a `normalizedBareSpecifier` for the +/// add / update paths via `calcSpecifierForWorkspaceDep`; pacquet +/// doesn't carry the pinned-version / save-workspace-protocol config +/// through to the resolver yet, so the field stays `None` until those +/// land. +fn resolve_from_local_package( + local_package: &WorkspacePackage, + wanted_dependency: &WantedDependency, + hard_link_local_packages: bool, + project_dir: &Path, + lockfile_dir: &Path, +) -> ResolveResult { + let local_dir = resolve_local_package_dir(local_package); + + let (id_text, directory) = if hard_link_local_packages { + let relative_to_lockfile = forward_slashes(relative_path(lockfile_dir, &local_dir)); + let id = format!("file:{relative_to_lockfile}"); + (id, relative_to_lockfile) + } else { + let relative_to_project = forward_slashes(relative_path(project_dir, &local_dir)); + let id = format!("link:{relative_to_project}"); + (id, relative_to_project) + }; + + ResolveResult { + id: PkgResolutionId::from(id_text), + name_ver: None, + latest: None, + published_at: None, + manifest: Some(local_package.manifest.clone()), + resolution: LockfileResolution::Directory(DirectoryResolution { directory }), + resolved_via: "workspace".to_string(), + normalized_bare_specifier: None, + alias: wanted_dependency.alias.clone(), + policy_violation: None, + } +} + +/// Mirror upstream's +/// [`resolveLocalPackageDir`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L992-L998). +/// Honours `publishConfig.directory` when `publishConfig.linkDirectory` +/// is unset or `true`; otherwise the project's own `rootDir`. +fn resolve_local_package_dir(local_package: &WorkspacePackage) -> PathBuf { + let publish_config = local_package.manifest.get("publishConfig"); + let publish_dir = + publish_config.and_then(|cfg| cfg.get("directory")).and_then(serde_json::Value::as_str); + let link_directory = publish_config + .and_then(|cfg| cfg.get("linkDirectory")) + .and_then(serde_json::Value::as_bool); + if publish_dir.is_none() || link_directory == Some(false) { + return local_package.root_dir.clone(); + } + local_package.root_dir.join(publish_dir.expect("guard above")) +} + +fn relative_path(base: &Path, target: &Path) -> String { + pathdiff_string(base, target).unwrap_or_else(|| target.display().to_string()) +} + +fn forward_slashes(input: String) -> String { + if input.contains('\\') { input.replace('\\', "/") } else { input } +} + +/// Tiny pathdiff fallback. The npm-resolver crate doesn't pull in +/// `pathdiff` today; this helper keeps the dependency footprint +/// unchanged. +fn pathdiff_string(base: &Path, target: &Path) -> Option { + use std::path::Component; + + let mut base_components: Vec> = base.components().collect(); + let mut target_components: Vec> = target.components().collect(); + + // Strip the common prefix. + let mut common = 0; + while common < base_components.len() + && common < target_components.len() + && base_components[common] == target_components[common] + { + common += 1; + } + base_components.drain(..common); + target_components.drain(..common); + + let mut out = PathBuf::new(); + for _ in base_components.iter().filter(|component| !matches!(component, Component::CurDir)) { + out.push(".."); + } + for component in target_components { + out.push(component.as_os_str()); + } + if out.as_os_str().is_empty() { + out.push("."); + } + Some(out.display().to_string()) +} + +/// Compare semver versions in *descending* order for the "available +/// versions" hint. Versions that don't parse fall back to lexicographic +/// reverse so the message at least stays stable. +fn rcompare_versions(left: &str, right: &str) -> std::cmp::Ordering { + match (Version::parse(left), Version::parse(right)) { + (Ok(left_parsed), Ok(right_parsed)) => right_parsed.cmp(&left_parsed), + _ => right.cmp(left), + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace/tests.rs b/pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace/tests.rs new file mode 100644 index 0000000000..5b6bdca207 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace/tests.rs @@ -0,0 +1,235 @@ +//! Unit tests for [`try_resolve_from_workspace`]. The upstream tests +//! exercise this via the full `resolveNpm` flow; pacquet's tests +//! invoke the helper directly with a hand-built +//! [`WorkspacePackages`] map. + +use std::{collections::BTreeMap, path::Path}; + +use pacquet_lockfile::LockfileResolution; +use pacquet_resolving_resolver_base::{ + WantedDependency, WorkspacePackage, WorkspacePackages, WorkspacePackagesByVersion, +}; +use serde_json::json; + +use super::{ResolveFromWorkspaceError, ResolveFromWorkspaceOptions, try_resolve_from_workspace}; + +fn build_packages() -> WorkspacePackages { + let mut foo: WorkspacePackagesByVersion = BTreeMap::new(); + foo.insert( + "1.0.0".to_string(), + WorkspacePackage { + root_dir: Path::new("/repo/packages/foo").to_path_buf(), + manifest: json!({ "name": "foo", "version": "1.0.0" }), + }, + ); + foo.insert( + "2.0.0".to_string(), + WorkspacePackage { + root_dir: Path::new("/repo/packages/foo-2").to_path_buf(), + manifest: json!({ "name": "foo", "version": "2.0.0" }), + }, + ); + + let mut bar: WorkspacePackagesByVersion = BTreeMap::new(); + bar.insert( + "0.1.2".to_string(), + WorkspacePackage { + root_dir: Path::new("/repo/packages/bar").to_path_buf(), + manifest: json!({ "name": "bar", "version": "0.1.2" }), + }, + ); + + let mut packages: WorkspacePackages = BTreeMap::new(); + packages.insert("foo".to_string(), foo); + packages.insert("bar".to_string(), bar); + packages +} + +fn opts<'a>(packages: &'a WorkspacePackages) -> ResolveFromWorkspaceOptions<'a> { + ResolveFromWorkspaceOptions { + project_dir: Path::new("/repo/packages/consumer"), + lockfile_dir: Path::new("/repo"), + registry: "https://registry.npmjs.org/", + default_tag: "latest", + workspace_packages: Some(packages), + inject_workspace_packages: false, + } +} + +fn wanted(alias: &str, bare: &str) -> WantedDependency { + WantedDependency { + alias: Some(alias.to_string()), + bare_specifier: Some(bare.to_string()), + ..WantedDependency::default() + } +} + +#[test] +fn non_workspace_spec_returns_none() { + let packages = build_packages(); + let opts = opts(&packages); + let result = try_resolve_from_workspace(&wanted("foo", "^1.0.0"), &opts).expect("ok").is_none(); + assert!(result); +} + +#[test] +fn workspace_path_form_defers_to_local_resolver() { + let packages = build_packages(); + let opts = opts(&packages); + let result = try_resolve_from_workspace(&wanted("foo", "workspace:../foo"), &opts).expect("ok"); + assert!(result.is_none()); +} + +#[test] +fn workspace_star_resolves_to_link_against_highest_version() { + let packages = build_packages(); + let opts = opts(&packages); + let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts) + .expect("ok") + .expect("some"); + assert_eq!(result.id.as_str(), "link:../foo-2"); + assert_eq!(result.resolved_via, "workspace"); + match &result.resolution { + LockfileResolution::Directory(dir) => assert_eq!(dir.directory, "../foo-2"), + other => panic!("expected directory resolution, got {other:?}"), + } + assert_eq!(result.alias.as_deref(), Some("foo")); +} + +#[test] +fn workspace_caret_range_picks_lower_when_pinned_range_excludes_higher() { + let packages = build_packages(); + let opts = opts(&packages); + let result = try_resolve_from_workspace(&wanted("foo", "workspace:^1.0.0"), &opts) + .expect("ok") + .expect("some"); + assert_eq!(result.id.as_str(), "link:../foo"); +} + +#[test] +fn workspace_exact_version_picks_that_entry() { + let packages = build_packages(); + let opts = opts(&packages); + let result = try_resolve_from_workspace(&wanted("bar", "workspace:0.1.2"), &opts) + .expect("ok") + .expect("some"); + assert_eq!(result.id.as_str(), "link:../bar"); +} + +#[test] +fn aliased_workspace_form_routes_through_package_name() { + let packages = build_packages(); + let opts = opts(&packages); + let result = try_resolve_from_workspace(&wanted("bar", "workspace:foo@^1.0.0"), &opts) + .expect("ok") + .expect("some"); + assert_eq!(result.id.as_str(), "link:../foo"); + assert_eq!(result.alias.as_deref(), Some("bar")); +} + +#[test] +fn missing_workspace_package_surfaces_pnpm_error_code() { + let packages = build_packages(); + let opts = opts(&packages); + let err = try_resolve_from_workspace(&wanted("missing", "workspace:*"), &opts).unwrap_err(); + assert!(matches!( + err, + ResolveFromWorkspaceError::WorkspacePkgNotFound { ref name, .. } if name == "missing", + )); +} + +#[test] +fn no_matching_version_surfaces_pnpm_error_code() { + let packages = build_packages(); + let opts = opts(&packages); + let err = try_resolve_from_workspace(&wanted("foo", "workspace:^99.0.0"), &opts).unwrap_err(); + assert!(matches!(err, ResolveFromWorkspaceError::NoMatchingVersionInsideWorkspace { .. })); +} + +#[test] +fn workspace_packages_unset_surfaces_error() { + let packages = build_packages(); + let mut opts = opts(&packages); + opts.workspace_packages = None; + let err = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts).unwrap_err(); + assert!(matches!(err, ResolveFromWorkspaceError::WorkspacePackagesNotLoaded)); +} + +#[test] +fn inject_workspace_packages_writes_file_resolution() { + let packages = build_packages(); + let mut opts = opts(&packages); + opts.inject_workspace_packages = true; + let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts) + .expect("ok") + .expect("some"); + assert_eq!(result.id.as_str(), "file:packages/foo-2"); + match &result.resolution { + LockfileResolution::Directory(dir) => assert_eq!(dir.directory, "packages/foo-2"), + other => panic!("expected directory resolution, got {other:?}"), + } +} + +#[test] +fn publish_config_directory_overrides_root_when_link_directory_is_unset() { + let mut packages: WorkspacePackages = BTreeMap::new(); + let mut entries: WorkspacePackagesByVersion = BTreeMap::new(); + entries.insert( + "1.0.0".to_string(), + WorkspacePackage { + root_dir: Path::new("/repo/packages/foo").to_path_buf(), + manifest: json!({ + "name": "foo", + "version": "1.0.0", + "publishConfig": { "directory": "dist" }, + }), + }, + ); + packages.insert("foo".to_string(), entries); + + let opts = ResolveFromWorkspaceOptions { + project_dir: Path::new("/repo/packages/consumer"), + lockfile_dir: Path::new("/repo"), + registry: "https://registry.npmjs.org/", + default_tag: "latest", + workspace_packages: Some(&packages), + inject_workspace_packages: false, + }; + + let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts) + .expect("ok") + .expect("some"); + assert_eq!(result.id.as_str(), "link:../foo/dist"); +} + +#[test] +fn publish_config_link_directory_false_keeps_root() { + let mut packages: WorkspacePackages = BTreeMap::new(); + let mut entries: WorkspacePackagesByVersion = BTreeMap::new(); + entries.insert( + "1.0.0".to_string(), + WorkspacePackage { + root_dir: Path::new("/repo/packages/foo").to_path_buf(), + manifest: json!({ + "name": "foo", + "version": "1.0.0", + "publishConfig": { "directory": "dist", "linkDirectory": false }, + }), + }, + ); + packages.insert("foo".to_string(), entries); + + let opts = ResolveFromWorkspaceOptions { + project_dir: Path::new("/repo/packages/consumer"), + lockfile_dir: Path::new("/repo"), + registry: "https://registry.npmjs.org/", + default_tag: "latest", + workspace_packages: Some(&packages), + inject_workspace_packages: false, + }; + + let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts) + .expect("ok") + .expect("some"); + assert_eq!(result.id.as_str(), "link:../foo"); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/workspace_pref_to_npm.rs b/pacquet/crates/resolving-npm-resolver/src/workspace_pref_to_npm.rs new file mode 100644 index 0000000000..df4df6717f --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/workspace_pref_to_npm.rs @@ -0,0 +1,50 @@ +//! Port of pnpm's +//! [`workspacePrefToNpm`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/workspacePrefToNpm.ts). +//! +//! Translates a `workspace:` bare specifier into the npm-shaped form +//! the [`crate::parse_bare_specifier()`] flow consumes: +//! +//! - `workspace:` / `workspace:*` / `workspace:^` / `workspace:~` → `*` +//! - `workspace:` → `` +//! - `workspace:@` → `npm:@` +//! (the `^`/`~`/empty sentinels collapse to `*` for the version +//! token too). + +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_workspace_spec::WorkspaceSpec; + +/// Error raised when the input does not start with `workspace:` (and +/// therefore does not parse as a [`WorkspaceSpec`]). Callers are +/// expected to ensure the prefix is present before invoking +/// [`workspace_pref_to_npm`], so this surfaces as a programming error +/// rather than a user-facing diagnostic. +#[derive(Debug, Display, Error, Diagnostic, Clone)] +#[display("Invalid workspace spec: {bare_specifier}")] +pub struct InvalidWorkspaceSpecError { + #[error(not(source))] + pub bare_specifier: String, +} + +/// Translate a `workspace:` bare specifier into its npm-shape +/// equivalent. Mirrors upstream's +/// [`workspacePrefToNpm`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/workspacePrefToNpm.ts#L3-L14). +pub fn workspace_pref_to_npm( + workspace_bare_specifier: &str, +) -> Result { + let Some(parsed) = WorkspaceSpec::parse(workspace_bare_specifier) else { + return Err(InvalidWorkspaceSpecError { + bare_specifier: workspace_bare_specifier.to_string(), + }); + }; + let WorkspaceSpec { alias, version } = parsed; + let version_part = + if version == "^" || version == "~" || version.is_empty() { "*" } else { version.as_str() }; + Ok(match alias { + Some(alias) => format!("npm:{alias}@{version_part}"), + None => version_part.to_string(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/resolving-npm-resolver/src/workspace_pref_to_npm/tests.rs b/pacquet/crates/resolving-npm-resolver/src/workspace_pref_to_npm/tests.rs new file mode 100644 index 0000000000..529ffe7d4c --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/workspace_pref_to_npm/tests.rs @@ -0,0 +1,48 @@ +//! Port of pnpm's +//! [`resolving/npm-resolver/test/workspacePrefToNpm.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/test/workspacePrefToNpm.test.ts). + +use super::workspace_pref_to_npm; + +#[test] +fn resolves_workspace_only_version_aliases() { + assert_eq!(workspace_pref_to_npm("workspace:").unwrap(), "*"); + assert_eq!(workspace_pref_to_npm("workspace:*").unwrap(), "*"); + assert_eq!(workspace_pref_to_npm("workspace:^").unwrap(), "*"); + assert_eq!(workspace_pref_to_npm("workspace:~").unwrap(), "*"); +} + +#[test] +fn resolves_package_name_aliases() { + assert_eq!( + workspace_pref_to_npm("workspace:is-positive@3.0.0").unwrap(), + "npm:is-positive@3.0.0", + ); + assert_eq!(workspace_pref_to_npm("workspace:is-positive@*").unwrap(), "npm:is-positive@*"); + assert_eq!(workspace_pref_to_npm("workspace:is-positive@^").unwrap(), "npm:is-positive@*"); +} + +#[test] +fn resolves_scoped_package_name_aliases() { + assert_eq!( + workspace_pref_to_npm("workspace:@scope/is-positive@1.2.3").unwrap(), + "npm:@scope/is-positive@1.2.3", + ); + assert_eq!( + workspace_pref_to_npm("workspace:@scope/is-positive@^1.2.3").unwrap(), + "npm:@scope/is-positive@^1.2.3", + ); + assert_eq!( + workspace_pref_to_npm("workspace:@scope/is-positive@*").unwrap(), + "npm:@scope/is-positive@*", + ); + assert_eq!( + workspace_pref_to_npm("workspace:@scope/is-positive@~").unwrap(), + "npm:@scope/is-positive@*", + ); +} + +#[test] +fn raises_invalid_workspace_spec_for_non_workspace_input() { + let err = workspace_pref_to_npm("*").unwrap_err(); + assert_eq!(err.bare_specifier, "*"); +} diff --git a/pacquet/crates/workspace-range-resolver/Cargo.toml b/pacquet/crates/workspace-range-resolver/Cargo.toml new file mode 100644 index 0000000000..bb5971c346 --- /dev/null +++ b/pacquet/crates/workspace-range-resolver/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pacquet-workspace-range-resolver" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +node-semver = { workspace = true } + +[lints] +workspace = true diff --git a/pacquet/crates/workspace-range-resolver/src/lib.rs b/pacquet/crates/workspace-range-resolver/src/lib.rs new file mode 100644 index 0000000000..88bfeb3515 --- /dev/null +++ b/pacquet/crates/workspace-range-resolver/src/lib.rs @@ -0,0 +1,92 @@ +//! Pacquet port of pnpm's +//! [`@pnpm/workspace.range-resolver`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/range-resolver/src/index.ts). +//! +//! Picks the best workspace-sibling version for one of the `workspace:` +//! range tokens — `*`, `^`, `~`, the empty string, or an arbitrary +//! semver range. + +use node_semver::{Range, Version}; + +/// Pick the highest workspace-sibling version matching `range`. +/// +/// `range` is the `` portion of a `workspace:` specifier (see +/// `pacquet-workspace-spec`'s `WorkspaceSpec`). The four sentinel tokens +/// (`*`, `^`, `~`, `""`) widen the search to *all* versions, prereleases +/// included — mirroring the +/// [`includePrerelease: true`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/range-resolver/src/index.ts#L4-L8) +/// branch upstream takes. Any other input is treated as a node-semver +/// range and prereleases are excluded the same way `semver.maxSatisfying` +/// excludes them in the non-`includePrerelease` branch. +/// +/// Returns the matching raw version string (one of the entries in +/// `versions`) or `None` when nothing satisfies. +pub fn resolve_workspace_range(range: &str, versions: &[String]) -> Option { + if is_wildcard(range) { + return max_version_including_prerelease(versions); + } + max_satisfying(versions, range) +} + +fn is_wildcard(range: &str) -> bool { + matches!(range, "*" | "^" | "~" | "") +} + +/// Highest version overall, including prereleases. The TS impl reaches +/// `semver.maxSatisfying(versions, '*', { includePrerelease: true })` +/// for this; since `*` matches everything when prereleases are allowed, +/// pacquet collapses that to a direct max. +fn max_version_including_prerelease(versions: &[String]) -> Option { + let mut best: Option<(Version, &str)> = None; + for raw in versions { + let Ok(parsed) = Version::parse(raw) else { continue }; + match &best { + Some((current, _)) if current >= &parsed => {} + _ => best = Some((parsed, raw.as_str())), + } + } + best.map(|(_, raw)| raw.to_string()) +} + +/// Highest version satisfying `range`. Prereleases are excluded unless +/// the range itself contains a prerelease tag — mirroring the +/// non-`includePrerelease` branch of `semver.maxSatisfying`. +fn max_satisfying(versions: &[String], range: &str) -> Option { + let parsed_range = Range::parse(range).ok()?; + let range_allows_prereleases = range_allows_prereleases(range); + let mut best: Option<(Version, &str)> = None; + for raw in versions { + let Ok(parsed) = Version::parse(raw) else { continue }; + if !parsed.satisfies(&parsed_range) { + continue; + } + if !parsed.pre_release.is_empty() && !range_allows_prereleases { + continue; + } + match &best { + Some((current, _)) if current >= &parsed => {} + _ => best = Some((parsed, raw.as_str())), + } + } + best.map(|(_, raw)| raw.to_string()) +} + +/// Heuristic for whether `range` would let `semver.maxSatisfying` +/// surface prereleases without the `includePrerelease` flag. +/// +/// In node-semver a range only matches prereleases when one of its +/// comparators carries a `-
` tag (e.g. `>=1.2.3-rc.0` matches
+/// `1.2.3-rc.1` but not `1.2.4-rc.0`). The exact rule is involved, but
+/// for our purposes "the range string contains a `-` after a digit"
+/// is a tight enough approximation — same heuristic upstream's
+/// `maxSatisfying` uses when constructing the comparator filter.
+fn range_allows_prereleases(range: &str) -> bool {
+    let bytes = range.as_bytes();
+    let mut prev_is_digit = false;
+    for &byte in bytes {
+        if byte == b'-' && prev_is_digit {
+            return true;
+        }
+        prev_is_digit = byte.is_ascii_digit();
+    }
+    false
+}
diff --git a/pacquet/crates/workspace-range-resolver/tests/resolve.rs b/pacquet/crates/workspace-range-resolver/tests/resolve.rs
new file mode 100644
index 0000000000..59c97782f1
--- /dev/null
+++ b/pacquet/crates/workspace-range-resolver/tests/resolve.rs
@@ -0,0 +1,40 @@
+//! Port of pnpm's
+//! [`workspace/range-resolver/test/index.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/range-resolver/test/index.test.ts).
+
+use pacquet_workspace_range_resolver::resolve_workspace_range;
+
+fn versions() -> Vec {
+    vec!["1.0.0".to_string(), "2.0.0".to_string(), "3.0.0-beta.1".to_string()]
+}
+
+#[test]
+fn resolves_star_to_max_version_including_prereleases() {
+    assert_eq!(resolve_workspace_range("*", &versions()).as_deref(), Some("3.0.0-beta.1"));
+}
+
+#[test]
+fn resolves_caret_to_max_version_including_prereleases() {
+    assert_eq!(resolve_workspace_range("^", &versions()).as_deref(), Some("3.0.0-beta.1"));
+}
+
+#[test]
+fn resolves_tilde_to_max_version_including_prereleases() {
+    assert_eq!(resolve_workspace_range("~", &versions()).as_deref(), Some("3.0.0-beta.1"));
+}
+
+#[test]
+fn resolves_empty_string_to_max_version_including_prereleases() {
+    assert_eq!(resolve_workspace_range("", &versions()).as_deref(), Some("3.0.0-beta.1"));
+}
+
+#[test]
+fn resolves_semver_range() {
+    assert_eq!(resolve_workspace_range("^1.0.0", &versions()).as_deref(), Some("1.0.0"));
+    assert_eq!(resolve_workspace_range("^2.0.0", &versions()).as_deref(), Some("2.0.0"));
+    assert_eq!(resolve_workspace_range(">=1.0.0", &versions()).as_deref(), Some("2.0.0"));
+}
+
+#[test]
+fn returns_none_when_no_version_satisfies_range() {
+    assert_eq!(resolve_workspace_range("^4.0.0", &versions()), None);
+}
diff --git a/pacquet/crates/workspace-spec/Cargo.toml b/pacquet/crates/workspace-spec/Cargo.toml
new file mode 100644
index 0000000000..e67a5de3a6
--- /dev/null
+++ b/pacquet/crates/workspace-spec/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name                  = "pacquet-workspace-spec"
+version               = "0.0.1"
+publish               = false
+authors.workspace     = true
+description.workspace = true
+edition.workspace     = true
+homepage.workspace    = true
+keywords.workspace    = true
+license.workspace     = true
+repository.workspace  = true
+
+[lints]
+workspace = true
diff --git a/pacquet/crates/workspace-spec/src/lib.rs b/pacquet/crates/workspace-spec/src/lib.rs
new file mode 100644
index 0000000000..913e013537
--- /dev/null
+++ b/pacquet/crates/workspace-spec/src/lib.rs
@@ -0,0 +1,95 @@
+//! Pacquet port of pnpm's
+//! [`@pnpm/workspace.spec-parser`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts).
+//!
+//! Parses the `workspace:` family of bare specifiers:
+//!
+//! - `workspace:*` / `workspace:^` / `workspace:~` — pick the highest
+//!   matching version from the workspace, with the version token kept
+//!   verbatim so the npm resolver can translate it to a range when it
+//!   rewrites the specifier.
+//! - `workspace:1.2.3` / `workspace:^1.2.3` — exact / range against
+//!   workspace siblings.
+//! - `workspace:@` / `workspace:@scope/@`
+//!   — npm-alias form, used when the importer wants to install a
+//!   workspace package under a different local name.
+//!
+//! The parser is deliberately permissive — it just splits the
+//! `@` shape. Validity of `` (semver vs.
+//! `*`/`^`/`~`/empty) is the caller's responsibility; see
+//! `pacquet-workspace-range-resolver`'s `resolve_workspace_range` for
+//! the matching range-pick logic.
+
+use std::fmt;
+
+/// Parsed `workspace:` bare specifier. Mirrors upstream's
+/// [`WorkspaceSpec`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts#L3-L22)
+/// class — a record of `(alias, version)` plus a `Display` impl that
+/// round-trips back to the original `workspace:` form.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct WorkspaceSpec {
+    /// The optional `` portion of `workspace:@`.
+    /// `None` for the un-aliased `workspace:` form.
+    pub alias: Option,
+    /// The `` portion — `*`, `^`, `~`, the empty string, an
+    /// exact version, or a semver range. Kept as a raw string so the
+    /// caller can decide how to interpret it.
+    pub version: String,
+}
+
+impl WorkspaceSpec {
+    /// Construct a [`WorkspaceSpec`] directly. The TS class exposes a
+    /// `new WorkspaceSpec(version, alias?)` constructor; pacquet keeps
+    /// the same shape so call sites don't have to translate.
+    pub fn new(version: impl Into, alias: Option>) -> Self {
+        Self { alias: alias.map(Into::into), version: version.into() }
+    }
+
+    /// Parse a bare specifier. Returns `None` when the input does not
+    /// start with `workspace:` so the caller can fall through to the
+    /// next protocol in the resolver chain.
+    ///
+    /// Mirrors upstream's
+    /// [`WorkspaceSpec.parse`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts#L12-L16)
+    /// — same regex (`/^workspace:(?:(?[^._/][^@]*)@)?(?.*)$/`)
+    /// expressed as a hand-rolled split so the crate carries no regex
+    /// dependency.
+    pub fn parse(bare_specifier: &str) -> Option {
+        let suffix = bare_specifier.strip_prefix("workspace:")?;
+        let (alias, version) = split_alias_version(suffix);
+        Some(Self { alias: alias.map(str::to_string), version: version.to_string() })
+    }
+}
+
+impl fmt::Display for WorkspaceSpec {
+    /// Round-trip the spec back to its `workspace:` form. Mirrors
+    /// upstream's
+    /// [`toString`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts#L18-L21).
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match &self.alias {
+            Some(alias) => write!(f, "workspace:{alias}@{version}", version = self.version),
+            None => write!(f, "workspace:{version}", version = self.version),
+        }
+    }
+}
+
+/// Split the post-`workspace:` portion into `(alias?, version)` using
+/// the same shape upstream's `[^._/][^@]*@` regex implements: the
+/// alias must start with a character that is **not** `.`, `_`, or `/`,
+/// be at least one character long, and be followed by a literal `@`.
+fn split_alias_version(suffix: &str) -> (Option<&str>, &str) {
+    let mut chars = suffix.char_indices();
+    let Some((_, first)) = chars.next() else {
+        return (None, suffix);
+    };
+    if matches!(first, '.' | '_' | '/') {
+        return (None, suffix);
+    }
+    let Some(at_offset) = suffix[first.len_utf8()..].find('@') else {
+        return (None, suffix);
+    };
+    let split_at = first.len_utf8() + at_offset;
+    (Some(&suffix[..split_at]), &suffix[split_at + 1..])
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/pacquet/crates/workspace-spec/src/tests.rs b/pacquet/crates/workspace-spec/src/tests.rs
new file mode 100644
index 0000000000..1cb193a584
--- /dev/null
+++ b/pacquet/crates/workspace-spec/src/tests.rs
@@ -0,0 +1,86 @@
+//! Port of pnpm's
+//! [`workspace/spec-parser/test/workspace-spec.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/test/workspace-spec.test.ts).
+
+use super::WorkspaceSpec;
+
+fn ws(version: &str, alias: Option<&str>) -> WorkspaceSpec {
+    WorkspaceSpec { alias: alias.map(str::to_string), version: version.to_string() }
+}
+
+#[test]
+fn parse_valid_workspace_spec() {
+    assert_eq!(WorkspaceSpec::parse("workspace:*"), Some(ws("*", None)));
+    assert_eq!(WorkspaceSpec::parse("workspace:^"), Some(ws("^", None)));
+    assert_eq!(WorkspaceSpec::parse("workspace:~"), Some(ws("~", None)));
+    assert_eq!(WorkspaceSpec::parse("workspace:0.1.2"), Some(ws("0.1.2", None)));
+    assert_eq!(WorkspaceSpec::parse("workspace:foo@*"), Some(ws("*", Some("foo"))));
+    assert_eq!(WorkspaceSpec::parse("workspace:foo@^"), Some(ws("^", Some("foo"))));
+    assert_eq!(WorkspaceSpec::parse("workspace:foo@~"), Some(ws("~", Some("foo"))));
+    assert_eq!(WorkspaceSpec::parse("workspace:foo@0.1.2"), Some(ws("0.1.2", Some("foo"))));
+    assert_eq!(WorkspaceSpec::parse("workspace:@foo/bar@*"), Some(ws("*", Some("@foo/bar"))));
+    assert_eq!(WorkspaceSpec::parse("workspace:@foo/bar@^"), Some(ws("^", Some("@foo/bar"))));
+    assert_eq!(WorkspaceSpec::parse("workspace:@foo/bar@~"), Some(ws("~", Some("@foo/bar"))));
+    assert_eq!(
+        WorkspaceSpec::parse("workspace:@foo/bar@0.1.2"),
+        Some(ws("0.1.2", Some("@foo/bar"))),
+    );
+}
+
+#[test]
+fn parse_invalid_workspace_spec() {
+    assert_eq!(WorkspaceSpec::parse("npm:foo@0.1.2"), None);
+    assert_eq!(WorkspaceSpec::parse("*"), None);
+}
+
+#[test]
+fn to_string_round_trips() {
+    assert_eq!(ws("*", None).to_string(), "workspace:*");
+    assert_eq!(ws("^", None).to_string(), "workspace:^");
+    assert_eq!(ws("~", None).to_string(), "workspace:~");
+    assert_eq!(ws("0.1.2", None).to_string(), "workspace:0.1.2");
+    assert_eq!(ws("*", Some("foo")).to_string(), "workspace:foo@*");
+    assert_eq!(ws("^", Some("foo")).to_string(), "workspace:foo@^");
+    assert_eq!(ws("~", Some("foo")).to_string(), "workspace:foo@~");
+    assert_eq!(ws("0.1.2", Some("foo")).to_string(), "workspace:foo@0.1.2");
+    assert_eq!(ws("*", Some("@foo/bar")).to_string(), "workspace:@foo/bar@*");
+    assert_eq!(ws("^", Some("@foo/bar")).to_string(), "workspace:@foo/bar@^");
+    assert_eq!(ws("~", Some("@foo/bar")).to_string(), "workspace:@foo/bar@~");
+    assert_eq!(ws("0.1.2", Some("@foo/bar")).to_string(), "workspace:@foo/bar@0.1.2");
+}
+
+/// Upstream's
+/// [`mutate alias and version`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/test/workspace-spec.test.ts#L40-L49)
+/// case exercises `delete spec.alias` and `spec.version = ...`. Pacquet
+/// keeps the fields plain (no setters / phantom state), so the port
+/// mutates them directly and re-renders.
+#[test]
+fn mutate_alias_and_version() {
+    let mut spec = WorkspaceSpec::parse("workspace:*").expect("parses");
+    assert_eq!(spec.to_string(), "workspace:*");
+    spec.version = "^".to_string();
+    assert_eq!(spec.to_string(), "workspace:^");
+    spec.alias = Some("foo".to_string());
+    assert_eq!(spec.to_string(), "workspace:foo@^");
+    spec.alias = None;
+    assert_eq!(spec.to_string(), "workspace:^");
+}
+
+#[test]
+fn empty_version_is_preserved() {
+    assert_eq!(WorkspaceSpec::parse("workspace:"), Some(ws("", None)));
+    assert_eq!(
+        WorkspaceSpec::parse("workspace:").map(|spec| spec.to_string()).as_deref(),
+        Some("workspace:"),
+    );
+}
+
+/// Upstream's regex rejects aliases that start with `.`, `_`, or `/`
+/// — those characters get rolled into the version instead. Locks the
+/// behavior so future refactors don't drift.
+#[test]
+fn alias_first_char_class_excludes_dot_underscore_slash() {
+    assert_eq!(WorkspaceSpec::parse("workspace:./foo"), Some(ws("./foo", None)));
+    assert_eq!(WorkspaceSpec::parse("workspace:../foo"), Some(ws("../foo", None)));
+    assert_eq!(WorkspaceSpec::parse("workspace:_foo@1.0.0"), Some(ws("_foo@1.0.0", None)));
+    assert_eq!(WorkspaceSpec::parse("workspace:/abs/path"), Some(ws("/abs/path", None)));
+}