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:
Zoltan Kochan authored and GitHub committed 2026-05-29 15:52:57 +02:00
1 parent 49e6074644
commit 90fe4dc2d2
9 files changed
+136 -2

No files matched your search

Generated
+1
View File
@@ -2179,6 +2179,7 @@ dependencies = [
"pacquet-patching",
"pacquet-store-dir",
"pacquet-testing-utils",
"pacquet-workspace-state",
"pipe-trait",
"pretty_assertions",
"serde",
+1
View File
@@ -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 }
+10
View File
@@ -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(),
+22 -1
View File
@@ -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,
}