mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
feat(pacquet): port catalogs (types, protocol-parser, resolver, config) (#11787)
* feat(pacquet): port catalogs (types, protocol-parser, resolver, config)
Adds four new crates mirroring upstream's `catalogs/*` packages:
- pacquet-catalogs-types: `Catalog`/`Catalogs` map aliases plus
`DEFAULT_CATALOG_NAME`.
- pacquet-catalogs-protocol-parser: `parse_catalog_protocol`; folds
`catalog:` shorthand into `"default"` to match upstream.
- pacquet-catalogs-resolver: `resolve_from_catalog` with the four
upstream `PnpmError` codes (`CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`,
`CATALOG_ENTRY_INVALID_RECURSIVE_DEFINITION`,
`CATALOG_ENTRY_INVALID_WORKSPACE_SPEC`,
`CATALOG_ENTRY_INVALID_SPEC`). Rust callers `match` on the result
enum directly instead of porting the TS-ergonomic
`matchCatalogResolveResult` visitor.
- pacquet-catalogs-config: `get_catalogs_from_workspace_manifest` plus
the `INVALID_CATALOGS_CONFIGURATION` mutual-exclusion check.
`pacquet-workspace`'s `WorkspaceManifest` now actually deserializes
the `catalog` and `catalogs` fields (previously dropped). The
resolver is not yet wired into the install path — `deps-installer`
hasn't been ported — but the crates are ready for that next step.
Tests are 1:1 ports of the upstream Jest suites.
* fix(pacquet): satisfy rustdoc and perfectionist lints in catalogs port
- catalogs-resolver: drop the intra-doc-link form on the
`pacquet_resolving_resolver_base::WantedDependency` reference; the
crate isn't a dependency, so rustdoc failed to resolve it under
`-D rustdoc::broken-intra-doc-links`.
- catalogs-resolver, catalogs-config: reorder the `CatalogResolutionError`
/ `InvalidCatalogsConfigurationError` derive lists to
`Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq` so they
match the `prefix_then_alphabetical` rule that the CI-only
Perfectionist dylint enforces. `just ready` doesn't surface this lint
locally.
* feat(pacquet): resolve catalog: specifiers during install
Wires the catalogs port into the install path:
- `install.rs`: read `pnpm-workspace.yaml` after `find_workspace_dir`
and normalize via `get_catalogs_from_workspace_manifest` into a
`Catalogs` map. Adds `InstallError::{ReadWorkspaceManifest,
InvalidCatalogsConfiguration}` so the upstream
`ERR_PNPM_INVALID_CATALOGS_CONFIGURATION` propagates verbatim.
- `install_without_lockfile.rs`: threads the `Catalogs` map into
`ResolveDependencyTreeOptions`. (Frozen-lockfile catalog handling
needs a lockfile-snapshot pass and is a separate slice.)
- `resolve_dependency_tree`: replaces direct (importer-level)
`catalog:` bare specifiers with the catalog's recorded version
before the resolver chain dispatches. Catalog resolution does NOT
run on transitive deps, matching upstream's importer-only scope.
Misconfigured entries surface as `CatalogMisconfiguration` with
the upstream `ERR_PNPM_CATALOG_ENTRY_*` code instead of leaking
through to `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.
Tests:
- deps-resolver: two new unit tests prove direct-dep rewriting and
the misconfiguration code path.
- cli (e2e): `install_resolves_catalog_protocol` runs the binary
against a workspace with a `catalog:` entry and checks the
virtual-store layout; `install_surfaces_catalog_misconfiguration`
asserts the upstream message is surfaced when the catalog has no
matching alias.
* style(pacquet): apply rustfmt to catalogs test after rebase
* fix(pacquet): rename single-letter closure param in catalogs e2e test
Perfectionist's `single_letter_closure_param` lint (CI-only via
Dylint) flagged `|c|` in the box-drawing-strip filter.
This commit is contained in:
36
Cargo.lock
generated
36
Cargo.lock
generated
@@ -1971,6 +1971,37 @@ version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-catalogs-config"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"miette 7.6.0",
|
||||
"pacquet-catalogs-types",
|
||||
"pacquet-workspace",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-catalogs-protocol-parser"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"pacquet-catalogs-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-catalogs-resolver"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"miette 7.6.0",
|
||||
"pacquet-catalogs-protocol-parser",
|
||||
"pacquet-catalogs-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-catalogs-types"
|
||||
version = "0.0.1"
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-cli"
|
||||
version = "0.0.1"
|
||||
@@ -2366,6 +2397,8 @@ dependencies = [
|
||||
"insta",
|
||||
"miette 7.6.0",
|
||||
"node-semver",
|
||||
"pacquet-catalogs-config",
|
||||
"pacquet-catalogs-types",
|
||||
"pacquet-cmd-shim",
|
||||
"pacquet-config",
|
||||
"pacquet-crypto-hash",
|
||||
@@ -2527,6 +2560,8 @@ dependencies = [
|
||||
"futures-util",
|
||||
"miette 7.6.0",
|
||||
"node-semver",
|
||||
"pacquet-catalogs-resolver",
|
||||
"pacquet-catalogs-types",
|
||||
"pacquet-deps-path",
|
||||
"pacquet-lockfile",
|
||||
"pacquet-package-manifest",
|
||||
@@ -2719,6 +2754,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"miette 7.6.0",
|
||||
"pacquet-catalogs-types",
|
||||
"pacquet-package-manifest",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
|
||||
@@ -13,6 +13,10 @@ repository = "https://github.com/pnpm/pacquet"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Crates
|
||||
pacquet-catalogs-config = { path = "pacquet/crates/catalogs-config" }
|
||||
pacquet-catalogs-protocol-parser = { path = "pacquet/crates/catalogs-protocol-parser" }
|
||||
pacquet-catalogs-resolver = { path = "pacquet/crates/catalogs-resolver" }
|
||||
pacquet-catalogs-types = { path = "pacquet/crates/catalogs-types" }
|
||||
pacquet-cli = { path = "pacquet/crates/cli" }
|
||||
pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" }
|
||||
pacquet-crypto-hash = { path = "pacquet/crates/crypto-hash" }
|
||||
|
||||
21
pacquet/crates/catalogs-config/Cargo.toml
Normal file
21
pacquet/crates/catalogs-config/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "pacquet-catalogs-config"
|
||||
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-catalogs-types = { workspace = true }
|
||||
pacquet-workspace = { workspace = true }
|
||||
|
||||
derive_more = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
81
pacquet/crates/catalogs-config/src/lib.rs
Normal file
81
pacquet/crates/catalogs-config/src/lib.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! Pacquet port of pnpm's
|
||||
//! [`@pnpm/catalogs.config`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts).
|
||||
//!
|
||||
//! Normalizes the two surface forms `pnpm-workspace.yaml` supports for
|
||||
//! defining the default catalog — `catalog:` at the top level vs.
|
||||
//! `catalogs.default` nested under the named catalogs — into the
|
||||
//! single flat [`Catalogs`] map every resolver consumer expects.
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_catalogs_types::{Catalogs, DEFAULT_CATALOG_NAME};
|
||||
use pacquet_workspace::WorkspaceManifest;
|
||||
|
||||
/// Raised when the workspace manifest defines the default catalog
|
||||
/// twice — once via the top-level `catalog:` shorthand and once via
|
||||
/// the explicit `catalogs.default` key.
|
||||
///
|
||||
/// Mirrors upstream's `INVALID_CATALOGS_CONFIGURATION` `PnpmError`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts#L32-L37)).
|
||||
#[derive(Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum InvalidCatalogsConfigurationError {
|
||||
#[display(
|
||||
"The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both."
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_CATALOGS_CONFIGURATION))]
|
||||
DefaultDefinedMultipleTimes,
|
||||
}
|
||||
|
||||
/// Project the catalog-shaped fields from a parsed workspace manifest
|
||||
/// into a single flat [`Catalogs`] map.
|
||||
///
|
||||
/// Returns an empty map when `workspace_manifest` is `None`, matching
|
||||
/// the upstream "no workspace manifest → no catalogs" branch.
|
||||
///
|
||||
/// Mirrors upstream's `getCatalogsFromWorkspaceManifest`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts#L5-L30)).
|
||||
pub fn get_catalogs_from_workspace_manifest(
|
||||
workspace_manifest: Option<&WorkspaceManifest>,
|
||||
) -> Result<Catalogs, InvalidCatalogsConfigurationError> {
|
||||
let Some(manifest) = workspace_manifest else {
|
||||
return Ok(Catalogs::new());
|
||||
};
|
||||
|
||||
check_default_catalog_is_defined_once(manifest)?;
|
||||
|
||||
// Upstream spreads `workspace.catalogs` after writing `default`, so
|
||||
// an explicit `catalogs.default` overrides the (already-validated
|
||||
// to be absent) `catalog` field. With `catalog`/`catalogs.default`
|
||||
// mutually exclusive only one branch ever populates the key.
|
||||
let mut catalogs = Catalogs::new();
|
||||
if let Some(default) = &manifest.catalog {
|
||||
catalogs.insert(DEFAULT_CATALOG_NAME.to_string(), default.clone());
|
||||
}
|
||||
if let Some(named) = &manifest.catalogs {
|
||||
for (name, catalog) in named {
|
||||
catalogs.insert(name.clone(), catalog.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(catalogs)
|
||||
}
|
||||
|
||||
/// Validate that the default catalog is defined through at most one of
|
||||
/// the two surface forms.
|
||||
///
|
||||
/// Mirrors upstream's `checkDefaultCatalogIsDefinedOnce`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts#L32-L40)).
|
||||
pub fn check_default_catalog_is_defined_once(
|
||||
manifest: &WorkspaceManifest,
|
||||
) -> Result<(), InvalidCatalogsConfigurationError> {
|
||||
if manifest.catalog.is_some()
|
||||
&& manifest.catalogs.as_ref().is_some_and(|c| c.contains_key(DEFAULT_CATALOG_NAME))
|
||||
{
|
||||
return Err(InvalidCatalogsConfigurationError::DefaultDefinedMultipleTimes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
68
pacquet/crates/catalogs-config/src/tests.rs
Normal file
68
pacquet/crates/catalogs-config/src/tests.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Ports of `catalogs/config/test/getCatalogsFromWorkspaceManifest.test.ts`
|
||||
//! ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/test/getCatalogsFromWorkspaceManifest.test.ts)).
|
||||
|
||||
use super::{InvalidCatalogsConfigurationError, get_catalogs_from_workspace_manifest};
|
||||
use pacquet_catalogs_types::{Catalog, Catalogs};
|
||||
use pacquet_workspace::WorkspaceManifest;
|
||||
|
||||
fn catalog_from(entries: &[(&str, &str)]) -> Catalog {
|
||||
entries.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combines_implicit_default_and_named_catalogs() {
|
||||
let manifest = WorkspaceManifest {
|
||||
catalog: Some(catalog_from(&[("foo", "^1.0.0")])),
|
||||
catalogs: Some(Catalogs::from([("bar".to_string(), catalog_from(&[("baz", "^2.0.0")]))])),
|
||||
..WorkspaceManifest::default()
|
||||
};
|
||||
|
||||
let expected = Catalogs::from([
|
||||
("default".to_string(), catalog_from(&[("foo", "^1.0.0")])),
|
||||
("bar".to_string(), catalog_from(&[("baz", "^2.0.0")])),
|
||||
]);
|
||||
assert_eq!(get_catalogs_from_workspace_manifest(Some(&manifest)).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combines_explicit_default_and_named_catalogs() {
|
||||
let manifest = WorkspaceManifest {
|
||||
catalog: None,
|
||||
catalogs: Some(Catalogs::from([
|
||||
("default".to_string(), catalog_from(&[("foo", "^1.0.0")])),
|
||||
("bar".to_string(), catalog_from(&[("baz", "^2.0.0")])),
|
||||
])),
|
||||
..WorkspaceManifest::default()
|
||||
};
|
||||
|
||||
let expected = Catalogs::from([
|
||||
("default".to_string(), catalog_from(&[("foo", "^1.0.0")])),
|
||||
("bar".to_string(), catalog_from(&[("baz", "^2.0.0")])),
|
||||
]);
|
||||
assert_eq!(get_catalogs_from_workspace_manifest(Some(&manifest)).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throws_if_default_catalog_is_defined_multiple_times() {
|
||||
let manifest = WorkspaceManifest {
|
||||
catalog: Some(catalog_from(&[("bar", "^2.0.0")])),
|
||||
catalogs: Some(Catalogs::from([(
|
||||
"default".to_string(),
|
||||
catalog_from(&[("foo", "^1.0.0")]),
|
||||
)])),
|
||||
..WorkspaceManifest::default()
|
||||
};
|
||||
|
||||
let err = get_catalogs_from_workspace_manifest(Some(&manifest)).unwrap_err();
|
||||
assert_eq!(err, InvalidCatalogsConfigurationError::DefaultDefinedMultipleTimes);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"The 'default' catalog was defined multiple times. \
|
||||
Use the 'catalog' field or 'catalogs.default', but not both.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_empty_map_for_missing_workspace_manifest() {
|
||||
assert_eq!(get_catalogs_from_workspace_manifest(None).unwrap(), Catalogs::new());
|
||||
}
|
||||
17
pacquet/crates/catalogs-protocol-parser/Cargo.toml
Normal file
17
pacquet/crates/catalogs-protocol-parser/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "pacquet-catalogs-protocol-parser"
|
||||
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-catalogs-types = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
27
pacquet/crates/catalogs-protocol-parser/src/lib.rs
Normal file
27
pacquet/crates/catalogs-protocol-parser/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Pacquet port of pnpm's
|
||||
//! [`@pnpm/catalogs.protocol-parser`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/protocol-parser/src/parseCatalogProtocol.ts).
|
||||
//!
|
||||
//! Splits the `catalog:` protocol prefix off a manifest bare specifier
|
||||
//! and returns the requested catalog name. Used by the resolver chain
|
||||
//! to decide whether a wanted dependency should be looked up in a
|
||||
//! catalog before falling through to the npm / git / tarball resolvers.
|
||||
|
||||
use pacquet_catalogs_types::DEFAULT_CATALOG_NAME;
|
||||
|
||||
const CATALOG_PROTOCOL: &str = "catalog:";
|
||||
|
||||
/// Parse a package.json dependency specifier using the `catalog:`
|
||||
/// protocol.
|
||||
///
|
||||
/// Returns `None` if the specifier does not start with `catalog:`.
|
||||
/// An empty `catalog:` is shorthand for [`DEFAULT_CATALOG_NAME`].
|
||||
///
|
||||
/// Mirrors upstream's `parseCatalogProtocol`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/protocol-parser/src/parseCatalogProtocol.ts#L3-L16)).
|
||||
pub fn parse_catalog_protocol(bare_specifier: &str) -> Option<&str> {
|
||||
let raw = bare_specifier.strip_prefix(CATALOG_PROTOCOL)?.trim();
|
||||
Some(if raw.is_empty() { DEFAULT_CATALOG_NAME } else { raw })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
25
pacquet/crates/catalogs-protocol-parser/src/tests.rs
Normal file
25
pacquet/crates/catalogs-protocol-parser/src/tests.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Ports of `catalogs/protocol-parser/test/parseCatalogProtocol.test.ts`
|
||||
//! ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/protocol-parser/test/parseCatalogProtocol.test.ts)).
|
||||
|
||||
use super::parse_catalog_protocol;
|
||||
|
||||
#[test]
|
||||
fn parses_named_catalog() {
|
||||
assert_eq!(parse_catalog_protocol("catalog:foo"), Some("foo"));
|
||||
assert_eq!(parse_catalog_protocol("catalog:bar"), Some("bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_specifier_not_using_catalog_protocol() {
|
||||
assert_eq!(parse_catalog_protocol("^1.0.0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_default_catalog() {
|
||||
assert_eq!(parse_catalog_protocol("catalog:default"), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_implicit_default_catalog() {
|
||||
assert_eq!(parse_catalog_protocol("catalog:"), Some("default"));
|
||||
}
|
||||
21
pacquet/crates/catalogs-resolver/Cargo.toml
Normal file
21
pacquet/crates/catalogs-resolver/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "pacquet-catalogs-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]
|
||||
pacquet-catalogs-protocol-parser = { workspace = true }
|
||||
pacquet-catalogs-types = { workspace = true }
|
||||
|
||||
derive_more = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
184
pacquet/crates/catalogs-resolver/src/lib.rs
Normal file
184
pacquet/crates/catalogs-resolver/src/lib.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Pacquet port of pnpm's
|
||||
//! [`@pnpm/catalogs.resolver`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts).
|
||||
//!
|
||||
//! Dereferences a `catalog:` bare specifier against a parsed
|
||||
//! [`Catalogs`] map and returns either the configured version or one of
|
||||
//! the upstream misconfiguration errors. The npm-resolver chain calls
|
||||
//! [`resolve_from_catalog`] before its own protocol dispatch so a
|
||||
//! resolved [`CatalogResolutionFound::resolution`] feeds back in as a
|
||||
//! plain bare specifier.
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_catalogs_protocol_parser::parse_catalog_protocol;
|
||||
use pacquet_catalogs_types::Catalogs;
|
||||
|
||||
/// Subset of `pacquet-resolving-resolver-base`'s `WantedDependency`
|
||||
/// that catalog resolution needs. Modeled as its own type so this
|
||||
/// crate doesn't depend on the resolver-base crate; the conversion
|
||||
/// is a trivial field copy at the call site.
|
||||
///
|
||||
/// Mirrors upstream's `WantedDependency` interface
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L5-L8)).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WantedDependency {
|
||||
pub alias: String,
|
||||
pub bare_specifier: String,
|
||||
}
|
||||
|
||||
/// Outcome of [`resolve_from_catalog`].
|
||||
///
|
||||
/// Mirrors upstream's `CatalogResolutionResult` discriminated union
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L19)).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CatalogResolutionResult {
|
||||
/// The catalog protocol resolved to a usable specifier.
|
||||
Found(CatalogResolutionFound),
|
||||
/// The catalog entry was missing or used a forbidden protocol.
|
||||
Misconfiguration(CatalogResolutionMisconfiguration),
|
||||
/// The wanted dependency does not use the catalog protocol.
|
||||
Unused,
|
||||
}
|
||||
|
||||
/// Successful catalog dereference.
|
||||
///
|
||||
/// Mirrors upstream's `CatalogResolutionFound`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L21-L24)).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CatalogResolutionFound {
|
||||
pub resolution: CatalogResolution,
|
||||
}
|
||||
|
||||
/// Resolved (catalog name, specifier) pair.
|
||||
///
|
||||
/// Mirrors upstream's `CatalogResolution`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L26-L38)).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CatalogResolution {
|
||||
/// Catalog the entry was found in.
|
||||
pub catalog_name: String,
|
||||
/// Version specifier the catalog entry resolved to.
|
||||
pub specifier: String,
|
||||
}
|
||||
|
||||
/// A user-misconfigured catalog entry. Carries the error so the call
|
||||
/// site can rethrow or render it.
|
||||
///
|
||||
/// Mirrors upstream's `CatalogResolutionMisconfiguration`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L40-L52)).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CatalogResolutionMisconfiguration {
|
||||
pub catalog_name: String,
|
||||
pub error: CatalogResolutionError,
|
||||
}
|
||||
|
||||
/// The four ways a `catalog:` lookup can fail. Each variant maps
|
||||
/// byte-for-byte to the upstream `PnpmError` code.
|
||||
///
|
||||
/// Mirrors the four errors raised by upstream's `resolveFromCatalog`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L60-L120)).
|
||||
#[derive(Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum CatalogResolutionError {
|
||||
#[display("No catalog entry '{alias}' was found for catalog '{catalog_name}'.")]
|
||||
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC))]
|
||||
EntryNotFoundForSpec { alias: String, catalog_name: String },
|
||||
|
||||
#[display(
|
||||
"Found invalid catalog entry using the catalog protocol recursively. The entry for '{alias}' in catalog '{catalog_name}' is invalid."
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_INVALID_RECURSIVE_DEFINITION))]
|
||||
EntryInvalidRecursiveDefinition { alias: String, catalog_name: String },
|
||||
|
||||
#[display(
|
||||
"The workspace protocol cannot be used as a catalog value. The entry for '{alias}' in catalog '{catalog_name}' is invalid."
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_INVALID_WORKSPACE_SPEC))]
|
||||
EntryInvalidWorkspaceSpec { alias: String, catalog_name: String },
|
||||
|
||||
#[display(
|
||||
"The entry for '{alias}' in catalog '{catalog_name}' declares a dependency using the '{protocol}' protocol. This is not yet supported, but may be in a future version of pnpm."
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC))]
|
||||
EntryInvalidSpec { alias: String, catalog_name: String, protocol: String },
|
||||
}
|
||||
|
||||
/// Resolve a wanted dependency through the catalogs map.
|
||||
///
|
||||
/// - Non-`catalog:` specifiers return [`CatalogResolutionResult::Unused`]
|
||||
/// so the caller falls through to the next resolver.
|
||||
/// - Missing entries, recursive entries, and forbidden protocols
|
||||
/// (`workspace:`, `link:`, `file:`) return
|
||||
/// [`CatalogResolutionResult::Misconfiguration`].
|
||||
/// - A valid entry returns [`CatalogResolutionResult::Found`].
|
||||
///
|
||||
/// Mirrors upstream's `resolveFromCatalog`
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L60-L130)).
|
||||
pub fn resolve_from_catalog(
|
||||
catalogs: &Catalogs,
|
||||
wanted_dependency: &WantedDependency,
|
||||
) -> CatalogResolutionResult {
|
||||
let Some(catalog_name) = parse_catalog_protocol(&wanted_dependency.bare_specifier) else {
|
||||
return CatalogResolutionResult::Unused;
|
||||
};
|
||||
|
||||
let catalog_lookup =
|
||||
catalogs.get(catalog_name).and_then(|catalog| catalog.get(&wanted_dependency.alias));
|
||||
let Some(catalog_lookup) = catalog_lookup else {
|
||||
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
|
||||
catalog_name: catalog_name.to_string(),
|
||||
error: CatalogResolutionError::EntryNotFoundForSpec {
|
||||
alias: wanted_dependency.alias.clone(),
|
||||
catalog_name: catalog_name.to_string(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if parse_catalog_protocol(catalog_lookup).is_some() {
|
||||
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
|
||||
catalog_name: catalog_name.to_string(),
|
||||
error: CatalogResolutionError::EntryInvalidRecursiveDefinition {
|
||||
alias: wanted_dependency.alias.clone(),
|
||||
catalog_name: catalog_name.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Banning `workspace:` matches upstream's three-part justification
|
||||
// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L84-L100)):
|
||||
// it's silly to indirect through a catalog when the workspace
|
||||
// protocol resolves directly, and `link:` resolutions cannot be
|
||||
// cached in `pnpm-lock.yaml` across importers the way semver
|
||||
// selectors can.
|
||||
let protocol_of_lookup = catalog_lookup.split(':').next().unwrap_or("");
|
||||
if protocol_of_lookup == "workspace" {
|
||||
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
|
||||
catalog_name: catalog_name.to_string(),
|
||||
error: CatalogResolutionError::EntryInvalidWorkspaceSpec {
|
||||
alias: wanted_dependency.alias.clone(),
|
||||
catalog_name: catalog_name.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(protocol_of_lookup, "link" | "file") {
|
||||
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
|
||||
catalog_name: catalog_name.to_string(),
|
||||
error: CatalogResolutionError::EntryInvalidSpec {
|
||||
alias: wanted_dependency.alias.clone(),
|
||||
catalog_name: catalog_name.to_string(),
|
||||
protocol: protocol_of_lookup.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
CatalogResolutionResult::Found(CatalogResolutionFound {
|
||||
resolution: CatalogResolution {
|
||||
catalog_name: catalog_name.to_string(),
|
||||
specifier: catalog_lookup.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
187
pacquet/crates/catalogs-resolver/src/tests.rs
Normal file
187
pacquet/crates/catalogs-resolver/src/tests.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Ports of `catalogs/resolver/test/resolveFromCatalog.test.ts`
|
||||
//! ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/test/resolveFromCatalog.test.ts)).
|
||||
|
||||
use super::{
|
||||
CatalogResolution, CatalogResolutionError, CatalogResolutionFound, CatalogResolutionResult,
|
||||
WantedDependency, resolve_from_catalog,
|
||||
};
|
||||
use pacquet_catalogs_types::{Catalog, Catalogs};
|
||||
|
||||
fn catalogs_from(entries: &[(&str, &[(&str, &str)])]) -> Catalogs {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(name, items)| {
|
||||
let catalog: Catalog =
|
||||
items.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect();
|
||||
((*name).to_string(), catalog)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wanted(alias: &str, bare_specifier: &str) -> WantedDependency {
|
||||
WantedDependency { alias: alias.to_string(), bare_specifier: bare_specifier.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_catalog_resolves_using_implicit_name() {
|
||||
let catalogs = catalogs_from(&[("default", &[("foo", "1.0.0")])]);
|
||||
assert_eq!(
|
||||
resolve_from_catalog(&catalogs, &wanted("foo", "catalog:")),
|
||||
CatalogResolutionResult::Found(CatalogResolutionFound {
|
||||
resolution: CatalogResolution {
|
||||
catalog_name: "default".to_string(),
|
||||
specifier: "1.0.0".to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_catalog_resolves_using_explicit_name() {
|
||||
let catalogs = catalogs_from(&[("default", &[("foo", "1.0.0")])]);
|
||||
assert_eq!(
|
||||
resolve_from_catalog(&catalogs, &wanted("foo", "catalog:default")),
|
||||
CatalogResolutionResult::Found(CatalogResolutionFound {
|
||||
resolution: CatalogResolution {
|
||||
catalog_name: "default".to_string(),
|
||||
specifier: "1.0.0".to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_named_catalog() {
|
||||
let catalogs = catalogs_from(&[("foo", &[("bar", "1.0.0")])]);
|
||||
assert_eq!(
|
||||
resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo")),
|
||||
CatalogResolutionResult::Found(CatalogResolutionFound {
|
||||
resolution: CatalogResolution {
|
||||
catalog_name: "foo".to_string(),
|
||||
specifier: "1.0.0".to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_unused_for_specifier_not_using_catalog_protocol() {
|
||||
let catalogs = catalogs_from(&[("foo", &[("bar", "1.0.0")])]);
|
||||
assert_eq!(
|
||||
resolve_from_catalog(&catalogs, &wanted("bar", "^2.0.0")),
|
||||
CatalogResolutionResult::Unused,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_for_missing_unresolved_catalog() {
|
||||
let catalogs = catalogs_from(&[("foo", &[("bar", "1.0.0")])]);
|
||||
for (alias, bare, expected_catalog) in [
|
||||
("bar", "catalog:", "default"),
|
||||
("bar", "catalog:baz", "baz"),
|
||||
("foo", "catalog:foo", "foo"),
|
||||
] {
|
||||
let result = resolve_from_catalog(&catalogs, &wanted(alias, bare));
|
||||
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
|
||||
panic!("expected misconfiguration for ({alias}, {bare}), got {result:?}");
|
||||
};
|
||||
assert_eq!(misconfig.catalog_name, expected_catalog);
|
||||
assert_eq!(
|
||||
misconfig.error,
|
||||
CatalogResolutionError::EntryNotFoundForSpec {
|
||||
alias: alias.to_string(),
|
||||
catalog_name: expected_catalog.to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
misconfig.error.to_string(),
|
||||
format!("No catalog entry '{alias}' was found for catalog '{expected_catalog}'."),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_for_recursive_catalog() {
|
||||
let catalogs = catalogs_from(&[("foo", &[("bar", "catalog:foo")])]);
|
||||
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
|
||||
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
|
||||
panic!("expected misconfiguration, got {result:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
misconfig.error,
|
||||
CatalogResolutionError::EntryInvalidRecursiveDefinition {
|
||||
alias: "bar".to_string(),
|
||||
catalog_name: "foo".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
misconfig.error.to_string(),
|
||||
"Found invalid catalog entry using the catalog protocol recursively. \
|
||||
The entry for 'bar' in catalog 'foo' is invalid.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_for_workspace_protocol_in_catalog() {
|
||||
let catalogs = catalogs_from(&[("foo", &[("bar", "workspace:*")])]);
|
||||
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
|
||||
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
|
||||
panic!("expected misconfiguration, got {result:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
misconfig.error,
|
||||
CatalogResolutionError::EntryInvalidWorkspaceSpec {
|
||||
alias: "bar".to_string(),
|
||||
catalog_name: "foo".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
misconfig.error.to_string(),
|
||||
"The workspace protocol cannot be used as a catalog value. \
|
||||
The entry for 'bar' in catalog 'foo' is invalid.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_for_file_protocol_in_catalog() {
|
||||
let catalogs = catalogs_from(&[("foo", &[("bar", "file:./bar.tgz")])]);
|
||||
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
|
||||
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
|
||||
panic!("expected misconfiguration, got {result:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
misconfig.error,
|
||||
CatalogResolutionError::EntryInvalidSpec {
|
||||
alias: "bar".to_string(),
|
||||
catalog_name: "foo".to_string(),
|
||||
protocol: "file".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
misconfig.error.to_string(),
|
||||
"The entry for 'bar' in catalog 'foo' declares a dependency using the 'file' protocol. \
|
||||
This is not yet supported, but may be in a future version of pnpm.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_error_for_link_protocol_in_catalog() {
|
||||
let catalogs = catalogs_from(&[("foo", &[("bar", "link:./bar")])]);
|
||||
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
|
||||
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
|
||||
panic!("expected misconfiguration, got {result:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
misconfig.error,
|
||||
CatalogResolutionError::EntryInvalidSpec {
|
||||
alias: "bar".to_string(),
|
||||
catalog_name: "foo".to_string(),
|
||||
protocol: "link".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
misconfig.error.to_string(),
|
||||
"The entry for 'bar' in catalog 'foo' declares a dependency using the 'link' protocol. \
|
||||
This is not yet supported, but may be in a future version of pnpm.",
|
||||
);
|
||||
}
|
||||
16
pacquet/crates/catalogs-types/Cargo.toml
Normal file
16
pacquet/crates/catalogs-types/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "pacquet-catalogs-types"
|
||||
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]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
30
pacquet/crates/catalogs-types/src/lib.rs
Normal file
30
pacquet/crates/catalogs-types/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! Pacquet port of pnpm's
|
||||
//! [`@pnpm/catalogs.types`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/types/src/index.ts).
|
||||
//!
|
||||
//! Defines [`Catalog`] and [`Catalogs`], the normalized in-memory shape
|
||||
//! every other catalogs crate consumes. Both are plain typed maps in
|
||||
//! upstream — `interface Catalog { [name: string]: string | undefined }`
|
||||
//! and `interface Catalogs { default?: Catalog; [name: string]: Catalog
|
||||
//! | undefined }` — so pacquet exposes them as `BTreeMap` aliases. The
|
||||
//! `"default"` catalog is just the well-known key inside [`Catalogs`];
|
||||
//! no separate field is needed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The well-known name of the default catalog. Matches the literal pnpm
|
||||
/// uses everywhere a catalog name is implicit.
|
||||
pub const DEFAULT_CATALOG_NAME: &str = "default";
|
||||
|
||||
/// One catalog: a map of dependency name to version specifier.
|
||||
///
|
||||
/// Mirrors upstream's `Catalog` interface
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/types/src/index.ts#L27-L29)).
|
||||
pub type Catalog = BTreeMap<String, String>;
|
||||
|
||||
/// The full set of catalogs parsed from `pnpm-workspace.yaml`. The
|
||||
/// default catalog lives at the [`DEFAULT_CATALOG_NAME`] key; any other
|
||||
/// key is a user-defined named catalog.
|
||||
///
|
||||
/// Mirrors upstream's `Catalogs` interface
|
||||
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/types/src/index.ts#L1-L25)).
|
||||
pub type Catalogs = BTreeMap<String, Catalog>;
|
||||
@@ -364,3 +364,84 @@ fn auto_install_peers_hoists_missing_peers_at_importer() {
|
||||
|
||||
drop((root, mock_instance)); // cleanup
|
||||
}
|
||||
|
||||
/// `catalog:` on a direct dep should be dereferenced through
|
||||
/// `pnpm-workspace.yaml`'s `catalog` section before the npm resolver
|
||||
/// sees it. The fetched virtual-store entry is the catalog's resolved
|
||||
/// version, not the literal `catalog:` string.
|
||||
///
|
||||
/// Mirrors the upstream end-to-end coverage in
|
||||
/// [`installing/deps-installer/test/catalogs.ts`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-installer/test/catalogs.ts).
|
||||
#[test]
|
||||
fn install_resolves_catalog_protocol() {
|
||||
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
||||
CommandTempCwd::init().add_mocked_registry();
|
||||
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
|
||||
|
||||
eprintln!("Appending catalog to pnpm-workspace.yaml...");
|
||||
let workspace_yaml = workspace.join("pnpm-workspace.yaml");
|
||||
let mut existing = fs::read_to_string(&workspace_yaml).expect("read pnpm-workspace.yaml");
|
||||
existing.push_str("catalog:\n '@pnpm.e2e/hello-world-js-bin-parent': '1.0.0'\n");
|
||||
fs::write(&workspace_yaml, existing).expect("write pnpm-workspace.yaml");
|
||||
|
||||
eprintln!("Creating package.json that uses the catalog protocol...");
|
||||
let manifest_path = workspace.join("package.json");
|
||||
let package_json_content = serde_json::json!({
|
||||
"dependencies": {
|
||||
"@pnpm.e2e/hello-world-js-bin-parent": "catalog:",
|
||||
},
|
||||
});
|
||||
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
|
||||
|
||||
eprintln!("Executing command...");
|
||||
pacquet.with_arg("install").assert().success();
|
||||
|
||||
eprintln!("Make sure the package is installed at the catalog's version");
|
||||
let symlink_path = workspace.join("node_modules/@pnpm.e2e/hello-world-js-bin-parent");
|
||||
assert!(is_symlink_or_junction(&symlink_path).unwrap());
|
||||
let virtual_path =
|
||||
workspace.join("node_modules/.pnpm/@pnpm.e2e+hello-world-js-bin-parent@1.0.0");
|
||||
assert!(virtual_path.exists(), "expected virtual store entry at {virtual_path:?}");
|
||||
|
||||
drop((root, mock_instance)); // cleanup
|
||||
}
|
||||
|
||||
/// A misconfigured catalog (specifier points at a missing entry) must
|
||||
/// fail the install with the upstream `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`
|
||||
/// rather than the chain's `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.
|
||||
#[test]
|
||||
fn install_surfaces_catalog_misconfiguration() {
|
||||
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
||||
CommandTempCwd::init().add_mocked_registry();
|
||||
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
|
||||
|
||||
eprintln!("Creating package.json with a catalog: dep but no matching catalog entry...");
|
||||
let manifest_path = workspace.join("package.json");
|
||||
let package_json_content = serde_json::json!({
|
||||
"dependencies": {
|
||||
"@pnpm.e2e/hello-world-js-bin-parent": "catalog:",
|
||||
},
|
||||
});
|
||||
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
|
||||
|
||||
eprintln!("Executing command...");
|
||||
let output = pacquet.with_arg("install").assert().failure();
|
||||
let stderr = String::from_utf8_lossy(&output.get_output().stderr);
|
||||
eprintln!("stderr={stderr}");
|
||||
// The miette report hard-wraps the message and inserts a leading
|
||||
// `│` on the wrapped line. Strip all whitespace and box-drawing
|
||||
// characters before substring-matching so wrap position can't
|
||||
// make the assertion brittle.
|
||||
let flattened: String = stderr
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace() && !matches!(ch, '│' | '├' | '╰' | '─' | '▶' | '×'))
|
||||
.collect();
|
||||
assert!(
|
||||
flattened.contains(
|
||||
"Nocatalogentry'@pnpm.e2e/hello-world-js-bin-parent'wasfoundforcatalog'default'.",
|
||||
),
|
||||
"stderr did not mention the missing-catalog-entry error: {stderr}",
|
||||
);
|
||||
|
||||
drop((root, mock_instance)); // cleanup
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-catalogs-config = { workspace = true }
|
||||
pacquet-catalogs-types = { workspace = true }
|
||||
pacquet-cmd-shim = { workspace = true }
|
||||
pacquet-crypto-hash = { workspace = true }
|
||||
pacquet-deps-path = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,9 @@ use crate::{
|
||||
};
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_catalogs_config::{
|
||||
InvalidCatalogsConfigurationError, get_catalogs_from_workspace_manifest,
|
||||
};
|
||||
use pacquet_config::{Config, NodeLinker};
|
||||
use pacquet_lockfile::{
|
||||
LoadLockfileError, Lockfile, SaveLockfileError, StalenessReason, satisfies_package_manifest,
|
||||
@@ -166,6 +169,17 @@ pub enum InstallError {
|
||||
#[diagnostic(transparent)]
|
||||
FindWorkspaceDir(#[error(source)] pacquet_workspace::FindWorkspaceDirError),
|
||||
|
||||
/// Reading `pnpm-workspace.yaml` to extract its `catalog` /
|
||||
/// `catalogs` sections failed.
|
||||
#[diagnostic(transparent)]
|
||||
ReadWorkspaceManifest(#[error(source)] pacquet_workspace::ReadWorkspaceManifestError),
|
||||
|
||||
/// `pnpm-workspace.yaml` defined the `default` catalog twice
|
||||
/// (once via the top-level `catalog:` field and once via
|
||||
/// `catalogs.default`).
|
||||
#[diagnostic(transparent)]
|
||||
InvalidCatalogsConfiguration(#[error(source)] InvalidCatalogsConfigurationError),
|
||||
|
||||
/// Building the verifier list from config rejected a
|
||||
/// `minimumReleaseAgeExclude` or `trustPolicyExclude` pattern.
|
||||
/// Mirrors upstream's `INVALID_MINIMUM_RELEASE_AGE_EXCLUDE` /
|
||||
@@ -239,9 +253,24 @@ where
|
||||
//
|
||||
// [bunyan]: https://github.com/trentm/node-bunyan
|
||||
let manifest_dir = manifest.path().parent().expect("manifest path always has a parent dir");
|
||||
let workspace_root = pacquet_workspace::find_workspace_dir(manifest_dir)
|
||||
.map_err(InstallError::FindWorkspaceDir)?
|
||||
.unwrap_or_else(|| manifest_dir.to_path_buf());
|
||||
let workspace_dir_opt = pacquet_workspace::find_workspace_dir(manifest_dir)
|
||||
.map_err(InstallError::FindWorkspaceDir)?;
|
||||
let workspace_root =
|
||||
workspace_dir_opt.clone().unwrap_or_else(|| manifest_dir.to_path_buf());
|
||||
|
||||
// Read `pnpm-workspace.yaml` for the catalog sections. Only
|
||||
// consulted when a workspace manifest exists — single-project
|
||||
// installs have no `catalog:` to honor. Mirrors upstream's
|
||||
// `getCatalogsFromWorkspaceManifest(readWorkspaceManifest(...))`
|
||||
// pipeline at
|
||||
// <https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts>.
|
||||
let workspace_manifest = match workspace_dir_opt.as_deref() {
|
||||
Some(dir) => pacquet_workspace::read_workspace_manifest(dir)
|
||||
.map_err(InstallError::ReadWorkspaceManifest)?,
|
||||
None => None,
|
||||
};
|
||||
let catalogs = get_catalogs_from_workspace_manifest(workspace_manifest.as_ref())
|
||||
.map_err(InstallError::InvalidCatalogsConfiguration)?;
|
||||
// Use `to_string_lossy` rather than `to_str().expect(...)` so a
|
||||
// valid filesystem path with non-UTF-8 bytes (possible on Unix)
|
||||
// doesn't panic the installer. `prefix` is used only for
|
||||
@@ -519,6 +548,7 @@ where
|
||||
dependency_groups,
|
||||
logged_methods: &logged_methods,
|
||||
requester: &prefix,
|
||||
catalogs,
|
||||
}
|
||||
.run::<Reporter>()
|
||||
.await
|
||||
|
||||
@@ -7,6 +7,7 @@ use dashmap::{DashMap, mapref::entry::Entry};
|
||||
use derive_more::{Display, Error};
|
||||
use futures_util::future;
|
||||
use miette::Diagnostic;
|
||||
use pacquet_catalogs_types::Catalogs;
|
||||
use pacquet_cmd_shim::{Host, LinkBinsError, link_bins};
|
||||
use pacquet_config::Config;
|
||||
use pacquet_engine_runtime_bun_resolver::BunResolver;
|
||||
@@ -85,6 +86,9 @@ pub struct InstallWithoutLockfile<'a, DependencyGroupList> {
|
||||
pub logged_methods: &'a AtomicU8,
|
||||
/// Install root, threaded into reporter `requester` fields.
|
||||
pub requester: &'a str,
|
||||
/// Catalogs parsed from `pnpm-workspace.yaml`. Empty for projects
|
||||
/// without a workspace manifest.
|
||||
pub catalogs: Catalogs,
|
||||
}
|
||||
|
||||
/// Error type of [`InstallWithoutLockfile`].
|
||||
@@ -170,6 +174,7 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
|
||||
resolved_packages,
|
||||
logged_methods,
|
||||
requester,
|
||||
catalogs,
|
||||
} = self;
|
||||
|
||||
let store_dir: &'static _ = &config.store_dir;
|
||||
@@ -314,6 +319,7 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
|
||||
published_by_exclude,
|
||||
..ResolveOptions::default()
|
||||
},
|
||||
catalogs,
|
||||
};
|
||||
|
||||
let importer_result =
|
||||
|
||||
@@ -11,6 +11,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-catalogs-resolver = { workspace = true }
|
||||
pacquet-catalogs-types = { workspace = true }
|
||||
pacquet-deps-path = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
pacquet-resolving-resolver-base = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,11 @@ use async_recursion::async_recursion;
|
||||
use derive_more::{Display, Error};
|
||||
use futures_util::future;
|
||||
use miette::Diagnostic;
|
||||
use pacquet_catalogs_resolver::{
|
||||
CatalogResolutionError, CatalogResolutionResult, WantedDependency as CatalogWantedDependency,
|
||||
resolve_from_catalog,
|
||||
};
|
||||
use pacquet_catalogs_types::Catalogs;
|
||||
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
|
||||
use pacquet_resolving_resolver_base::{ResolveError, ResolveOptions, Resolver, WantedDependency};
|
||||
use pipe_trait::Pipe;
|
||||
@@ -48,6 +53,13 @@ pub enum ResolveDependencyTreeError {
|
||||
#[error(not(source))]
|
||||
specifier: String,
|
||||
},
|
||||
|
||||
/// A `catalog:` specifier on a direct dependency referenced a
|
||||
/// missing entry, used a forbidden protocol, or was otherwise
|
||||
/// misconfigured. The inner error carries the upstream
|
||||
/// `ERR_PNPM_CATALOG_ENTRY_*` code and message.
|
||||
#[diagnostic(transparent)]
|
||||
CatalogMisconfiguration(#[error(source)] CatalogResolutionError),
|
||||
}
|
||||
|
||||
/// Walk `manifest` plus the entries in `dependency_groups`, dispatch
|
||||
@@ -306,6 +318,35 @@ where
|
||||
Ok(Some(DirectDep { alias, node_id, id }))
|
||||
}
|
||||
|
||||
/// Replace `catalog:` bare specifiers on direct dependencies with the
|
||||
/// version recorded in the catalogs map. Non-`catalog:` specifiers
|
||||
/// pass through unchanged.
|
||||
///
|
||||
/// Catalog resolution runs only on importer-level deps, matching
|
||||
/// upstream's
|
||||
/// [importer-only scope](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-resolver/src/resolveDependencies.ts#L592-L600).
|
||||
/// A misconfigured entry surfaces immediately rather than masquerading
|
||||
/// as a `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.
|
||||
pub(crate) fn resolve_catalog_specifiers(
|
||||
specs: Vec<(String, String)>,
|
||||
catalogs: &Catalogs,
|
||||
) -> Result<Vec<(String, String)>, ResolveDependencyTreeError> {
|
||||
specs
|
||||
.into_iter()
|
||||
.map(|(name, range)| {
|
||||
let wanted =
|
||||
CatalogWantedDependency { alias: name.clone(), bare_specifier: range.clone() };
|
||||
match resolve_from_catalog(catalogs, &wanted) {
|
||||
CatalogResolutionResult::Found(found) => Ok((name, found.resolution.specifier)),
|
||||
CatalogResolutionResult::Unused => Ok((name, range)),
|
||||
CatalogResolutionResult::Misconfiguration(misconfig) => {
|
||||
Err(ResolveDependencyTreeError::CatalogMisconfiguration(misconfig.error))
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render `{alias}@{bare}` (either half dropped when absent) for the
|
||||
/// error message. Mirrors upstream's `render_specifier` shape in
|
||||
/// `default-resolver`.
|
||||
|
||||
@@ -24,6 +24,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_catalogs_types::Catalogs;
|
||||
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
|
||||
use pacquet_resolving_resolver_base::{
|
||||
PreferredVersions, ResolveOptions, Resolver, VersionSelectorEntry, VersionSelectorType,
|
||||
@@ -36,7 +37,9 @@ use crate::{
|
||||
HoistPeersOptions, MissingPeerInfo, WorkspaceRootDep, get_hoistable_optional_peers,
|
||||
hoist_peers,
|
||||
},
|
||||
resolve_dependency_tree::{ResolveDependencyTreeError, TreeCtx, extend_tree},
|
||||
resolve_dependency_tree::{
|
||||
ResolveDependencyTreeError, TreeCtx, extend_tree, resolve_catalog_specifiers,
|
||||
},
|
||||
resolve_peers::{ResolvePeersOptions, ResolvePeersResult, resolve_peers},
|
||||
resolved_tree::ResolvedTree,
|
||||
};
|
||||
@@ -72,6 +75,12 @@ pub struct ResolveImporterOptions {
|
||||
pub all_preferred_versions: PreferredVersions,
|
||||
|
||||
pub base_opts: ResolveOptions,
|
||||
|
||||
/// Catalogs parsed from `pnpm-workspace.yaml`. Applied only to the
|
||||
/// importer's direct dependencies; transitive `catalog:` entries
|
||||
/// are not resolved through the catalog, matching upstream's
|
||||
/// [importer-only catalog scope](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-resolver/src/resolveDependencies.ts#L592-L600).
|
||||
pub catalogs: Catalogs,
|
||||
}
|
||||
|
||||
/// Result of [`fn@resolve_importer`] — the fully-walked tree plus the
|
||||
@@ -113,6 +122,7 @@ where
|
||||
resolve_peers_from_workspace_root,
|
||||
mut all_preferred_versions,
|
||||
base_opts,
|
||||
catalogs,
|
||||
} = opts;
|
||||
|
||||
let ctx = TreeCtx::new(base_opts);
|
||||
@@ -131,6 +141,7 @@ where
|
||||
.dependencies(groups)
|
||||
.map(|(name, range)| (name.to_string(), range.to_string()))
|
||||
.collect();
|
||||
let initial_wanted = resolve_catalog_specifiers(initial_wanted, &catalogs)?;
|
||||
let mut direct = extend_tree(&ctx, resolver, initial_wanted).await?;
|
||||
update_preferred_versions_with_ctx(&ctx, &mut all_preferred_versions).await;
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ use pacquet_resolving_resolver_base::{
|
||||
};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::{DepPath, resolve_importer, resolve_importer::ResolveImporterOptions};
|
||||
use crate::{
|
||||
DepPath, ResolveDependencyTreeError, resolve_importer,
|
||||
resolve_importer::{ResolveImporterError, ResolveImporterOptions},
|
||||
};
|
||||
|
||||
struct StubResolver {
|
||||
table: HashMap<(String, String), ResolveResult>,
|
||||
@@ -86,6 +89,7 @@ fn default_opts() -> ResolveImporterOptions {
|
||||
resolve_peers_from_workspace_root: false,
|
||||
all_preferred_versions: PreferredVersions::new(),
|
||||
base_opts: ResolveOptions::default(),
|
||||
catalogs: pacquet_catalogs_types::Catalogs::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,3 +770,57 @@ async fn auto_install_hoisted_peer_dep_reuses_regular_dep_version() {
|
||||
"expected one c@2.0.0 entry (not a second copy via the peer arm), got: {c_entries:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `catalog:` on a direct dependency is rewritten to the catalog's
|
||||
/// recorded specifier before the resolver chain sees the wanted dep.
|
||||
/// Mirrors upstream's
|
||||
/// [importer-only catalog dereference](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-resolver/src/resolveDependencies.ts#L592-L611).
|
||||
#[tokio::test]
|
||||
async fn catalog_protocol_on_direct_dep_is_rewritten() {
|
||||
let mut table = HashMap::new();
|
||||
table.insert(
|
||||
("foo".to_string(), "^1.0.0".to_string()),
|
||||
fake_result("foo", "1.2.0", serde_json::json!({ "name": "foo", "version": "1.2.0" })),
|
||||
);
|
||||
let resolver = StubResolver { table, calls: Mutex::new(Vec::new()) };
|
||||
let (_tmp, manifest) = fake_manifest(serde_json::json!({ "foo": "catalog:" }));
|
||||
|
||||
let mut catalogs = pacquet_catalogs_types::Catalogs::new();
|
||||
catalogs.insert(
|
||||
"default".to_string(),
|
||||
[("foo".to_string(), "^1.0.0".to_string())].into_iter().collect(),
|
||||
);
|
||||
|
||||
let opts = ResolveImporterOptions { catalogs, ..default_opts() };
|
||||
let result =
|
||||
resolve_importer(&resolver, &manifest, [DependencyGroup::Prod], opts).await.unwrap();
|
||||
assert_eq!(result.resolved_tree.direct.len(), 1);
|
||||
assert_eq!(result.resolved_tree.direct[0].alias, "foo");
|
||||
// The resolver chain only sees the catalog-rewritten range.
|
||||
let calls = resolver.calls.lock().unwrap();
|
||||
assert_eq!(&*calls, &[("foo".to_string(), "^1.0.0".to_string())]);
|
||||
}
|
||||
|
||||
/// A misconfigured `catalog:` entry (here: missing alias) short-
|
||||
/// circuits resolution with the upstream `CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`
|
||||
/// error rather than falling through to `SpecNotSupported`.
|
||||
#[tokio::test]
|
||||
async fn catalog_misconfiguration_surfaces_pnpm_error_code() {
|
||||
let resolver = StubResolver { table: HashMap::new(), calls: Mutex::new(Vec::new()) };
|
||||
let (_tmp, manifest) = fake_manifest(serde_json::json!({ "foo": "catalog:" }));
|
||||
|
||||
let err = resolve_importer(&resolver, &manifest, [DependencyGroup::Prod], default_opts())
|
||||
.await
|
||||
.expect_err("missing catalog entry must error");
|
||||
match err {
|
||||
ResolveImporterError::Resolve(ResolveDependencyTreeError::CatalogMisconfiguration(
|
||||
inner,
|
||||
)) => {
|
||||
assert_eq!(
|
||||
inner.to_string(),
|
||||
"No catalog entry 'foo' was found for catalog 'default'.",
|
||||
);
|
||||
}
|
||||
other => panic!("expected CatalogMisconfiguration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pacquet-catalogs-types = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
|
||||
derive_more = { workspace = true }
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_catalogs_types::{Catalog, Catalogs};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
fs,
|
||||
@@ -41,11 +42,6 @@ pub const WORKSPACE_MANIFEST_FILENAME: &str = "pnpm-workspace.yaml";
|
||||
/// Splitting along the upstream package boundary keeps each reader
|
||||
/// focused on the shape its callers actually need and avoids a
|
||||
/// monolithic struct that has to grow with every new pnpm setting.
|
||||
///
|
||||
/// Catalogs are accepted at parse time but not yet consumed downstream
|
||||
/// — they belong to Stage 2 of the workspace roadmap. Parsing them now
|
||||
/// keeps `pnpm-workspace.yaml` files with catalog entries valid input
|
||||
/// to pacquet rather than tripping `deny_unknown_fields`.
|
||||
#[derive(Debug, Default, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceManifest {
|
||||
@@ -61,6 +57,17 @@ pub struct WorkspaceManifest {
|
||||
/// promote `packages: []` into a recursive scan.
|
||||
#[serde(default)]
|
||||
pub packages: Option<Vec<String>>,
|
||||
|
||||
/// Top-level shorthand for the default catalog. Mutually exclusive
|
||||
/// with `catalogs.default` — `pacquet_catalogs_config` enforces
|
||||
/// that.
|
||||
#[serde(default)]
|
||||
pub catalog: Option<Catalog>,
|
||||
|
||||
/// Named catalogs. Includes a `default` key when the user opted for
|
||||
/// the explicit form over the top-level [`Self::catalog`] field.
|
||||
#[serde(default)]
|
||||
pub catalogs: Option<Catalogs>,
|
||||
}
|
||||
|
||||
/// Raised when `pnpm-workspace.yaml` parses as YAML but fails an
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
InvalidWorkspaceManifestError, ReadWorkspaceManifestError, WORKSPACE_MANIFEST_FILENAME,
|
||||
WorkspaceManifest, read_workspace_manifest,
|
||||
};
|
||||
use pacquet_catalogs_types::{Catalog, Catalogs};
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
@@ -65,6 +66,40 @@ fn empty_packages_array_preserved_as_some_empty() {
|
||||
assert_eq!(manifest.packages, Some(Vec::<String>::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_top_level_catalog_field() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"catalog:\n foo: ^1.0.0\n bar: ^2.0.0\n",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = read_workspace_manifest(tmp.path()).unwrap().unwrap();
|
||||
let mut expected = Catalog::new();
|
||||
expected.insert("foo".to_string(), "^1.0.0".to_string());
|
||||
expected.insert("bar".to_string(), "^2.0.0".to_string());
|
||||
assert_eq!(manifest.catalog, Some(expected));
|
||||
assert_eq!(manifest.catalogs, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_named_catalogs_field() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join(WORKSPACE_MANIFEST_FILENAME),
|
||||
"catalogs:\n default:\n foo: ^1.0.0\n legacy:\n bar: ^2.0.0\n",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = read_workspace_manifest(tmp.path()).unwrap().unwrap();
|
||||
let mut expected = Catalogs::new();
|
||||
expected
|
||||
.insert("default".to_string(), Catalog::from([("foo".to_string(), "^1.0.0".to_string())]));
|
||||
expected
|
||||
.insert("legacy".to_string(), Catalog::from([("bar".to_string(), "^2.0.0".to_string())]));
|
||||
assert_eq!(manifest.catalog, None);
|
||||
assert_eq!(manifest.catalogs, Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_package_entry_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user