fix(package-manager): record configDependencies in workspace state (#12065)
* fix(package-manager): record configDependencies in workspace state
build_workspace_state hardcoded config_dependencies: None, so a pacquet
install wrote .pnpm-workspace-state-v1.json without the configDependencies
map. On the next pnpm run/node/exec, pnpm's checkDepsStatus compared the
live config's configDependencies against the missing recorded value, judged
node_modules out of sync, and reinstalled every time. When devEngines.runtime
is set with onFail: download, that reinstall also re-provisions the runtime.
Parse configDependencies from pnpm-workspace.yaml into Config (workspace-only,
cleared from global config.yaml like patchedDependencies) and write it through
build_workspace_state, mirroring pnpm's createWorkspaceState.
* refactor(package-manager): drop redundant comment in build_workspace_state
The fn doc comment and the WorkspaceSettings.config_dependencies field doc
already explain the createWorkspaceState parity and reinstall consequence.
* fix(config): model both configDependencies value shapes
The string-only Option<BTreeMap<String, String>> made an object-form
configDependencies entry ({ tarball?, integrity }) — valid in pnpm — fail
deserialization, turning a supported manifest into a hard config-load error.
Introduce ConfigDependency (untagged: VersionWithIntegrity string or
{ tarball?, integrity }) in the workspace-state crate and thread it through
Config and the workspace-state writer so both shapes round-trip verbatim,
matching pnpm. Also add the trailing comma perfectionist/dylint requires on
the new multi-line assert_eq! invocations.
This commit is contained in:
1 parent
49e6074644
commit
90fe4dc2d2
9 files changed
+136
-2
No files matched your search
Generated
+1
@@ -2179,6 +2179,7 @@ dependencies = [
|
||||
"pacquet-patching",
|
||||
"pacquet-store-dir",
|
||||
"pacquet-testing-utils",
|
||||
"pacquet-workspace-state",
|
||||
"pipe-trait",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
|
||||
@@ -18,6 +18,7 @@ pacquet-network = { workspace = true }
|
||||
pacquet-package-is-installable = { workspace = true }
|
||||
pacquet-patching = { workspace = true }
|
||||
pacquet-store-dir = { workspace = true }
|
||||
pacquet-workspace-state = { workspace = true }
|
||||
|
||||
derive_more = { workspace = true }
|
||||
home = { workspace = true }
|
||||
|
||||
@@ -12,6 +12,7 @@ pub use crate::api::{EnvVar, EnvVarOs, GetCurrentDir, GetHomeDir, Host, LinkProb
|
||||
use indexmap::IndexMap;
|
||||
use pacquet_patching::{PatchGroupRecord, ResolvePatchedDependenciesError, resolve_and_group};
|
||||
use pacquet_store_dir::StoreDir;
|
||||
use pacquet_workspace_state::ConfigDependency;
|
||||
use pipe_trait::Pipe;
|
||||
use serde::Deserialize;
|
||||
use smart_default::SmartDefault;
|
||||
@@ -895,6 +896,15 @@ pub struct Config {
|
||||
/// [`addSettingsFromWorkspaceManifestToConfig`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/index.ts#L803-L831).
|
||||
pub patched_dependencies: Option<IndexMap<String, String>>,
|
||||
|
||||
/// Raw `configDependencies` from `pnpm-workspace.yaml`: package
|
||||
/// name → version-with-integrity spec. Recorded verbatim in the
|
||||
/// workspace-state file so pnpm's `checkDepsStatus` sees the same
|
||||
/// value it holds in the live config and doesn't treat the install
|
||||
/// as stale. See [`WorkspaceSettings::config_dependencies`].
|
||||
///
|
||||
/// [`WorkspaceSettings::config_dependencies`]: crate::workspace_yaml::WorkspaceSettings::config_dependencies
|
||||
pub config_dependencies: Option<BTreeMap<String, ConfigDependency>>,
|
||||
|
||||
/// `pnpm.allowBuilds` from `pnpm-workspace.yaml`: package names
|
||||
/// (or `name@version` keys) that are allowed to run lifecycle
|
||||
/// scripts. pnpm 11 denies scripts by default; the allow-list is
|
||||
|
||||
@@ -8,6 +8,7 @@ use miette::Diagnostic;
|
||||
use pacquet_env_replace::env_replace_lossy;
|
||||
use pacquet_package_is_installable::SupportedArchitectures;
|
||||
use pacquet_store_dir::StoreDir;
|
||||
use pacquet_workspace_state::ConfigDependency;
|
||||
use pipe_trait::Pipe;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::{
|
||||
@@ -205,6 +206,17 @@ pub struct WorkspaceSettings {
|
||||
/// [`BTreeMap`]: std::collections::BTreeMap
|
||||
pub patched_dependencies: Option<IndexMap<String, String>>,
|
||||
|
||||
/// `configDependencies` from `pnpm-workspace.yaml`: package name →
|
||||
/// version-with-integrity spec. pnpm records this verbatim in the
|
||||
/// workspace-state file so that `checkDepsStatus` can detect when a
|
||||
/// config dependency changed and force a reinstall. Pacquet must
|
||||
/// write the same value back (see
|
||||
/// [`build_workspace_state`](../../package-manager/src/install.rs)),
|
||||
/// otherwise pnpm reads a missing `configDependencies` on the next
|
||||
/// `pnpm run` / `pnpm node`, compares it against the live config,
|
||||
/// and reinstalls on every invocation.
|
||||
pub config_dependencies: Option<BTreeMap<String, ConfigDependency>>,
|
||||
|
||||
/// Map of `name[@version]` → `true` / `false`. Drives pnpm 11's
|
||||
/// default-deny build policy: a package's lifecycle scripts only
|
||||
/// run when an entry here resolves to `true`. Mirrors upstream's
|
||||
@@ -550,6 +562,7 @@ impl WorkspaceSettings {
|
||||
self.hoisting_limits = None;
|
||||
self.external_dependencies = None;
|
||||
self.patched_dependencies = None;
|
||||
self.config_dependencies = None;
|
||||
self.allow_builds = None;
|
||||
self.supported_architectures = None;
|
||||
self.ignored_optional_dependencies = None;
|
||||
@@ -716,6 +729,9 @@ impl WorkspaceSettings {
|
||||
if let Some(v) = self.patched_dependencies {
|
||||
config.patched_dependencies = Some(v);
|
||||
}
|
||||
if let Some(v) = self.config_dependencies {
|
||||
config.config_dependencies = Some(v);
|
||||
}
|
||||
if let Some(v) = self.allow_builds {
|
||||
config.allow_builds = v;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
ScriptsPrependNodePath, TrustPolicy, api::EnvVar,
|
||||
};
|
||||
use pacquet_store_dir::StoreDir;
|
||||
use pacquet_workspace_state::{ConfigDependency, ConfigDependencyDetail};
|
||||
use pipe_trait::Pipe;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::{fs, path::Path};
|
||||
@@ -326,6 +327,65 @@ patchedDependencies:
|
||||
assert_eq!(map.get("lodash@4.17.21").map(String::as_str), Some("patches/lodash@4.17.21.patch"));
|
||||
}
|
||||
|
||||
/// `configDependencies` is a map of package name → version-with-integrity
|
||||
/// spec. pacquet records it into the workspace-state file so pnpm's
|
||||
/// `checkDepsStatus` doesn't treat the install as stale on the next
|
||||
/// `pnpm run` / `pnpm node`. Guards the camelCase rename, optionality,
|
||||
/// and `apply_to` wiring.
|
||||
#[test]
|
||||
fn parses_config_dependencies_from_yaml_and_applies() {
|
||||
let yaml = r#"
|
||||
configDependencies:
|
||||
"@pnpm/pacquet": 0.2.2-14
|
||||
"#;
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let expected = settings.config_dependencies.clone();
|
||||
assert_eq!(
|
||||
expected.as_ref().and_then(|m| m.get("@pnpm/pacquet")),
|
||||
Some(&ConfigDependency::VersionWithIntegrity("0.2.2-14".to_string())),
|
||||
);
|
||||
|
||||
let mut config = Config::new();
|
||||
assert!(config.config_dependencies.is_none(), "default is None");
|
||||
settings.apply_to(&mut config, Path::new("/irrelevant"));
|
||||
assert_eq!(config.config_dependencies, expected);
|
||||
}
|
||||
|
||||
/// pnpm's `configDependencies` value can also be the `{ tarball?, integrity }`
|
||||
/// object form. It must parse (not error) and round-trip, otherwise an
|
||||
/// upstream-supported manifest becomes a hard config-load failure.
|
||||
#[test]
|
||||
fn parses_object_form_config_dependencies() {
|
||||
let yaml = r#"
|
||||
configDependencies:
|
||||
"@scope/dep":
|
||||
integrity: sha512-abc
|
||||
tarball: https://example.test/dep.tgz
|
||||
"#;
|
||||
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
let map = settings.config_dependencies.expect("field present");
|
||||
assert_eq!(
|
||||
map.get("@scope/dep"),
|
||||
Some(&ConfigDependency::Detailed(ConfigDependencyDetail {
|
||||
integrity: "sha512-abc".to_string(),
|
||||
tarball: Some("https://example.test/dep.tgz".to_string()),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// `configDependencies` is workspace-only: it must not be honored from
|
||||
/// the global `config.yaml`, matching pnpm's `isConfigFileKey` filter.
|
||||
#[test]
|
||||
fn config_dependencies_cleared_as_workspace_only_field() {
|
||||
let yaml = r#"
|
||||
configDependencies:
|
||||
"@pnpm/pacquet": 0.2.2-14
|
||||
"#;
|
||||
let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
|
||||
settings.clear_workspace_only_fields();
|
||||
assert!(settings.config_dependencies.is_none());
|
||||
}
|
||||
|
||||
/// `allowBuilds` is a map of `name[@version]` → bool. Same camelCase
|
||||
/// rename + `apply_to` wiring as the other yaml-sourced settings.
|
||||
/// pnpm 10+ moved this out of `package.json#pnpm` (matches
|
||||
|
||||
@@ -1597,7 +1597,7 @@ fn build_workspace_state(
|
||||
// `false` so pnpm doesn't treat the install as partial and
|
||||
// skip the cache.
|
||||
filtered_install: false,
|
||||
config_dependencies: None,
|
||||
config_dependencies: config.config_dependencies.clone(),
|
||||
// Settings construction is shared with
|
||||
// `optimistic_repeat_install::current_settings` so the
|
||||
// freshness check sees the same byte shape this writer
|
||||
|
||||
@@ -900,6 +900,8 @@ mod build_workspace_state_tests {
|
||||
use pacquet_config::Config;
|
||||
use pacquet_modules_yaml::IncludedDependencies;
|
||||
use pacquet_package_manifest::PackageManifest;
|
||||
use pacquet_workspace_state::ConfigDependency;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -965,6 +967,28 @@ mod build_workspace_state_tests {
|
||||
assert!(packages.contains(&entry.name.as_deref().unwrap_or_default(),));
|
||||
}
|
||||
}
|
||||
|
||||
/// pnpm's `createWorkspaceState` records `configDependencies`
|
||||
/// verbatim. When pacquet is the install engine for a project that
|
||||
/// declares one (the `@pnpm/pacquet` configDependency itself), the
|
||||
/// written state must carry the same map — otherwise pnpm's
|
||||
/// `checkDepsStatus` reads a missing value, treats the install as
|
||||
/// stale, and reinstalls on every `pnpm run` / `pnpm node`.
|
||||
#[test]
|
||||
fn records_config_dependencies_from_config() {
|
||||
let mut config = Config::new();
|
||||
config.config_dependencies = Some(BTreeMap::from([(
|
||||
"@pnpm/pacquet".to_string(),
|
||||
ConfigDependency::VersionWithIntegrity("0.2.2-14".to_string()),
|
||||
)]));
|
||||
let state = build_workspace_state(
|
||||
&config,
|
||||
pacquet_config::NodeLinker::default(),
|
||||
IncludedDependencies::default(),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(state.config_dependencies, config.config_dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports `'do not fail on an optional dependency that has a non-optional
|
||||
|
||||
@@ -74,6 +74,7 @@ fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path)
|
||||
npmrc_auth_file: None,
|
||||
workspace_dir: None,
|
||||
patched_dependencies: None,
|
||||
config_dependencies: None,
|
||||
allow_builds: Default::default(),
|
||||
dangerously_allow_all_builds: false,
|
||||
scripts_prepend_node_path: Default::default(),
|
||||
|
||||
@@ -45,6 +45,27 @@ pub struct ProjectEntry {
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// A single `configDependencies` value. Mirrors pnpm's
|
||||
/// `VersionWithIntegrity | { tarball?, integrity }` at
|
||||
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/core/types/src/package.ts>.
|
||||
/// Untagged so it round-trips both shapes verbatim; pnpm compares the
|
||||
/// recorded value against the live config with a deep, order-independent
|
||||
/// equality check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ConfigDependency {
|
||||
VersionWithIntegrity(String),
|
||||
Detailed(ConfigDependencyDetail),
|
||||
}
|
||||
|
||||
/// The `{ tarball?, integrity }` form of a [`ConfigDependency`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConfigDependencyDetail {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tarball: Option<String>,
|
||||
pub integrity: String,
|
||||
}
|
||||
|
||||
/// Typed view of `.pnpm-workspace-state-v1.json`.
|
||||
///
|
||||
/// Mirrors upstream's [`WorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts).
|
||||
@@ -59,7 +80,7 @@ pub struct WorkspaceState {
|
||||
pub pnpmfiles: Vec<String>,
|
||||
pub filtered_install: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_dependencies: Option<BTreeMap<String, String>>,
|
||||
pub config_dependencies: Option<BTreeMap<String, ConfigDependency>>,
|
||||
pub settings: WorkspaceStateSettings,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user