mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-24 08:35:19 -04:00
feat(pacquet): honor preferFrozenLockfile in the install dispatch (#11824)
`pacquet install` (no flag) didn't consult `preferFrozenLockfile`. A fresh lockfile got re-resolved from the registry instead of taking the cheap frozen path, and a stale lockfile was silently overwritten without seeding the resolver from the existing pins. Closes pnpm/pnpm#11815. The install dispatch now has four ordered states: 1. `--frozen-lockfile` flag → frozen path (lockfile required, freshness check fatal). 2. No flag + lockfile present + effective `preferFrozenLockfile == true` + freshness check passes → frozen path (same code as state 1). 3. No flag + lockfile present + opt-out or stale → fresh-resolve, seeded from the existing lockfile's snapshots so unrelated pins survive the rewrite (mirrors upstream's `update: false` resolver mode). 4. No lockfile → fresh-resolve with no seed. `check_lockfile_freshness` is the shared helper: it runs `pnpm.overrides` parsing, `check_lockfile_settings`, the overrides-aware manifest re-apply, and `satisfies_package_manifest`. State 1 surfaces its `Err` as `InstallError`; state 2 treats a stale-lockfile `Err` as fall-through and surfaces `InvalidOverrides` as fatal. CLI exposes `--prefer-frozen-lockfile` / `--no-prefer-frozen-lockfile` mirroring pnpm so users can override per invocation; `pacquet add` opts out of the fast path explicitly since the manifest is necessarily stale by the time the install dispatch runs.
This commit is contained in:
@@ -80,6 +80,22 @@ pub struct InstallArgs {
|
||||
#[clap(long)]
|
||||
pub frozen_lockfile: bool,
|
||||
|
||||
/// Force-enable `preferFrozenLockfile` for this invocation.
|
||||
/// Overrides `pnpm-workspace.yaml` / `PNPM_CONFIG_PREFER_FROZEN_LOCKFILE`.
|
||||
/// Mirrors pnpm's `--prefer-frozen-lockfile`. Conflicts with
|
||||
/// [`Self::no_prefer_frozen_lockfile`] so a single invocation
|
||||
/// can't both force-on and force-off.
|
||||
#[clap(long = "prefer-frozen-lockfile")]
|
||||
pub prefer_frozen_lockfile: bool,
|
||||
|
||||
/// Force-disable `preferFrozenLockfile` for this invocation.
|
||||
/// Overrides `pnpm-workspace.yaml` / `PNPM_CONFIG_PREFER_FROZEN_LOCKFILE`.
|
||||
/// Mirrors pnpm's `--no-prefer-frozen-lockfile`. Useful for CI
|
||||
/// runs that want to force a re-resolve against the registry
|
||||
/// without setting the flag globally.
|
||||
#[clap(long = "no-prefer-frozen-lockfile", conflicts_with = "prefer_frozen_lockfile")]
|
||||
pub no_prefer_frozen_lockfile: bool,
|
||||
|
||||
/// Skip the per-importer `package.json` ↔ `pnpm-lock.yaml`
|
||||
/// freshness check that normally guards `--frozen-lockfile`.
|
||||
/// Intended for callers that just resolved and wrote the
|
||||
@@ -160,6 +176,8 @@ impl InstallArgs {
|
||||
dependency_options,
|
||||
supported_architectures,
|
||||
frozen_lockfile,
|
||||
prefer_frozen_lockfile,
|
||||
no_prefer_frozen_lockfile,
|
||||
ignore_manifest_check,
|
||||
no_runtime,
|
||||
node_linker,
|
||||
@@ -167,6 +185,19 @@ impl InstallArgs {
|
||||
prefer_offline: _,
|
||||
} = self;
|
||||
|
||||
// `--prefer-frozen-lockfile` / `--no-prefer-frozen-lockfile`
|
||||
// map to `Option<bool>`: `Some(true)` / `Some(false)` when
|
||||
// either flag is set, `None` otherwise (use config). Clap's
|
||||
// `conflicts_with` on the off-flag ensures the two aren't
|
||||
// both set, so the precedence here is straightforward.
|
||||
let prefer_frozen_lockfile = if prefer_frozen_lockfile {
|
||||
Some(true)
|
||||
} else if no_prefer_frozen_lockfile {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Merge CLI overrides with the yaml-derived value before
|
||||
// handing off to the install pipeline. `state.config` is a
|
||||
// shared `&'static Config`, so we compute the effective
|
||||
@@ -204,6 +235,7 @@ impl InstallArgs {
|
||||
lockfile_path: lockfile_path.as_deref(),
|
||||
dependency_groups: dependency_options.dependency_groups(),
|
||||
frozen_lockfile,
|
||||
prefer_frozen_lockfile,
|
||||
ignore_manifest_check,
|
||||
skip_runtimes,
|
||||
resolved_packages,
|
||||
|
||||
@@ -94,6 +94,15 @@ where
|
||||
lockfile_path,
|
||||
dependency_groups: list_dependency_groups(),
|
||||
frozen_lockfile: false,
|
||||
// `pacquet add` mutates the manifest, so the lockfile is
|
||||
// necessarily stale by the time the install dispatch
|
||||
// runs — short-circuit the prefer-frozen fast path so we
|
||||
// always re-resolve. `None` would fall back to
|
||||
// `config.prefer_frozen_lockfile`, which is `true` by
|
||||
// default and the dispatch would discover the staleness
|
||||
// anyway; explicit `Some(false)` keeps `pacquet add`
|
||||
// behaviour self-evident at the call site.
|
||||
prefer_frozen_lockfile: Some(false),
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: config.skip_runtimes,
|
||||
resolved_packages,
|
||||
|
||||
@@ -10,6 +10,7 @@ use miette::Diagnostic;
|
||||
use pacquet_catalogs_config::{
|
||||
InvalidCatalogsConfigurationError, get_catalogs_from_workspace_manifest,
|
||||
};
|
||||
use pacquet_catalogs_types::Catalogs;
|
||||
use pacquet_config::{Config, NodeLinker};
|
||||
use pacquet_lockfile::{
|
||||
LoadLockfileError, Lockfile, SaveLockfileError, StalenessReason, satisfies_package_manifest,
|
||||
@@ -64,6 +65,14 @@ where
|
||||
pub lockfile_path: Option<&'a Path>,
|
||||
pub dependency_groups: DependencyGroupList,
|
||||
pub frozen_lockfile: bool,
|
||||
/// `preferFrozenLockfile` value to honor for *this* invocation.
|
||||
/// `None` (no CLI flag) means "use `config.prefer_frozen_lockfile`";
|
||||
/// `Some(true)` forces the auto-frozen fast path on, `Some(false)`
|
||||
/// forces it off. Computed at the CLI layer from the
|
||||
/// `--prefer-frozen-lockfile` / `--no-prefer-frozen-lockfile`
|
||||
/// flags. Threaded as an [`Option<bool>`] so the dispatch can
|
||||
/// tell a per-invocation override apart from the config default.
|
||||
pub prefer_frozen_lockfile: Option<bool>,
|
||||
/// Skip the per-importer `package.json` ↔ `pnpm-lock.yaml`
|
||||
/// freshness check ([`satisfies_package_manifest`]) that
|
||||
/// normally guards `--frozen-lockfile`. Surfaced as
|
||||
@@ -271,42 +280,18 @@ where
|
||||
lockfile_path,
|
||||
dependency_groups,
|
||||
frozen_lockfile,
|
||||
prefer_frozen_lockfile,
|
||||
ignore_manifest_check,
|
||||
skip_runtimes,
|
||||
supported_architectures,
|
||||
node_linker,
|
||||
} = self;
|
||||
|
||||
// Fail fast on flag combinations the fresh-lockfile path
|
||||
// doesn't honor yet so we don't silently produce a
|
||||
// `node_modules` + `pnpm-lock.yaml` that diverges from what
|
||||
// the user asked for:
|
||||
//
|
||||
// - `nodeLinker: hoisted` on the fresh path would need a
|
||||
// port of upstream's hoist pass against the freshly-built
|
||||
// graph (the frozen path uses `link_hoisted_modules` over
|
||||
// the lockfile's snapshots). Falling through to the
|
||||
// isolated linker would lay out `node_modules` in the
|
||||
// wrong shape, so refuse the install instead.
|
||||
// - `skip_runtimes` (CLI `--no-runtime`) on the fresh path
|
||||
// would need a runtime-filter at the materialization step
|
||||
// matching the frozen path's
|
||||
// [`installing/deps-installer/src/install/index.ts:1374-1387`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/index.ts#L1374-L1387)
|
||||
// filter. Without it, runtime archives get fetched +
|
||||
// materialized despite the opt-out.
|
||||
//
|
||||
// Both are flagged up front, before any reporter event fires
|
||||
// or any state file is written, so a follow-up retry under
|
||||
// `--frozen-lockfile` (with an existing lockfile) lands on
|
||||
// the supported path.
|
||||
if !frozen_lockfile {
|
||||
if matches!(node_linker, NodeLinker::Hoisted) {
|
||||
return Err(InstallError::UnsupportedFreshInstallNodeLinker { node_linker });
|
||||
}
|
||||
if skip_runtimes {
|
||||
return Err(InstallError::UnsupportedFreshInstallSkipRuntimes);
|
||||
}
|
||||
}
|
||||
// Resolve the effective `preferFrozenLockfile` for the
|
||||
// dispatch: a per-invocation CLI flag wins over
|
||||
// `config.prefer_frozen_lockfile`.
|
||||
let prefer_frozen_lockfile =
|
||||
prefer_frozen_lockfile.unwrap_or(config.prefer_frozen_lockfile);
|
||||
|
||||
// Collect once so the same set drives both the install dispatch
|
||||
// and the `included` field of `.modules.yaml` written below.
|
||||
@@ -441,176 +426,99 @@ where
|
||||
|
||||
tracing::info!(target: "pacquet::install", "Start all");
|
||||
|
||||
// Dispatch priority, matching pnpm's CLI semantics:
|
||||
// Dispatch priority, matching pnpm's CLI + `preferFrozenLockfile`
|
||||
// semantics:
|
||||
//
|
||||
// 1. `--frozen-lockfile` is the strongest signal. If the user
|
||||
// passed the flag, use the frozen-lockfile path regardless of
|
||||
// `config.lockfile`. The prior `match` treated
|
||||
// `config.lockfile=false` as "skip the lockfile entirely" and
|
||||
// silently dropped the CLI flag — so pacquet's new-config
|
||||
// default (lockfile unset → `false`) turned every
|
||||
// `--frozen-lockfile` install into a registry-resolving
|
||||
// no-lockfile install, which is also what the integrated
|
||||
// benchmark has been measuring.
|
||||
// 1. `--frozen-lockfile` flag → frozen path. Lockfile must exist
|
||||
// and the freshness check (settings + per-importer specifier
|
||||
// match) must pass, otherwise fail. Mirrors upstream's
|
||||
// headless install at
|
||||
// <https://github.com/pnpm/pnpm/blob/94240bc046/pkg-manager/core/src/install/index.ts#L815-L832>.
|
||||
//
|
||||
// 2. Otherwise the fresh-lockfile path runs: the resolver
|
||||
// walks the manifest, materializes `node_modules`, and the
|
||||
// resolved graph is serialized to `pnpm-lock.yaml`. The
|
||||
// save-to-disk step honors `config.lockfile`: setting it to
|
||||
// `false` keeps the resolver running but skips the write,
|
||||
// matching pnpm's documented opt-out.
|
||||
// The third tuple element is `hoisted_locations`: the
|
||||
// per-depPath list of lockfile-relative directories the
|
||||
// hoisted linker placed each package at. Empty under the
|
||||
// isolated linker (and under the no-lockfile path); non-
|
||||
// empty only when the frozen-lockfile install runs with
|
||||
// `nodeLinker: hoisted`. Threaded into
|
||||
// 2. No flag, lockfile present, `prefer_frozen_lockfile == true`,
|
||||
// and the freshness check passes → frozen path (same code as
|
||||
// state 1). Mirrors upstream's
|
||||
// [`preferFrozenLockfile`](https://pnpm.io/settings#preferfrozenlockfile)
|
||||
// fast path: when the lockfile matches the manifest, pnpm
|
||||
// silently goes headless instead of re-resolving against the
|
||||
// registry.
|
||||
//
|
||||
// 3. No flag, lockfile present, but either `prefer_frozen_lockfile`
|
||||
// is off or the freshness check fails → fresh-resolve path,
|
||||
// seeded from the existing lockfile so unrelated entries keep
|
||||
// their pins. Mirrors upstream's `update: false` resolver mode
|
||||
// at <https://github.com/pnpm/pnpm/blob/097983fbca/lockfile/preferred-versions/src/index.ts#L13-L33>.
|
||||
//
|
||||
// 4. No lockfile → fresh-resolve path with no seed, writes a
|
||||
// brand-new `pnpm-lock.yaml`.
|
||||
//
|
||||
// The third tuple element is `hoisted_locations`: the per-depPath
|
||||
// list of lockfile-relative directories the hoisted linker placed
|
||||
// each package at. Empty under the isolated linker (and under the
|
||||
// no-lockfile path); non-empty only when the frozen-lockfile
|
||||
// install runs with `nodeLinker: hoisted`. Threaded into
|
||||
// `build_modules_manifest` so the field is persisted into
|
||||
// `.modules.yaml.hoisted_locations` for the next install
|
||||
// and for the rebuild path (which throws
|
||||
// `MISSING_HOISTED_LOCATIONS` when this field is gone).
|
||||
// `.modules.yaml.hoisted_locations` for the next install and for
|
||||
// the rebuild path (which throws `MISSING_HOISTED_LOCATIONS` when
|
||||
// this field is gone).
|
||||
|
||||
// Compute the dispatch decision once. `take_frozen_path` is true
|
||||
// for both state 1 (--frozen-lockfile) and state 2 (auto-frozen
|
||||
// via prefer-frozen-lockfile). The freshness check fires for both
|
||||
// — fatal for state 1, fall-through for state 2.
|
||||
let take_frozen_path = if frozen_lockfile {
|
||||
let Some(lockfile) = lockfile else {
|
||||
return Err(InstallError::NoLockfile);
|
||||
};
|
||||
// Run the freshness gates; on failure surface a fatal
|
||||
// InstallError via `FreshnessCheckError`'s `From` impl.
|
||||
// The check is run for its side effect (the typed
|
||||
// outcome) — the borrowed lockfile / manifest are consumed
|
||||
// again inside the frozen branch below.
|
||||
check_lockfile_freshness(lockfile, manifest, config, &catalogs, ignore_manifest_check)
|
||||
.map_err(InstallError::from)?;
|
||||
true
|
||||
} else if let Some(lockfile) = lockfile {
|
||||
// Auto-frozen via `preferFrozenLockfile`. Skip when the
|
||||
// user opted out (`--no-prefer-frozen-lockfile` /
|
||||
// `preferFrozenLockfile: false`); otherwise consult the
|
||||
// freshness gate. A `Stale` / `NoImporter` outcome routes
|
||||
// to the fresh-resolve path; a malformed
|
||||
// `pnpm.overrides` is a user-config error that surfaces
|
||||
// regardless of dispatch.
|
||||
if !prefer_frozen_lockfile {
|
||||
false
|
||||
} else {
|
||||
match check_lockfile_freshness(
|
||||
lockfile,
|
||||
manifest,
|
||||
config,
|
||||
&catalogs,
|
||||
ignore_manifest_check,
|
||||
) {
|
||||
Ok(()) => true,
|
||||
Err(FreshnessCheckError::Stale(_) | FreshnessCheckError::NoImporter { .. }) => {
|
||||
false
|
||||
}
|
||||
Err(error @ FreshnessCheckError::InvalidOverrides(_)) => {
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let (hoisted_dependencies, hoisted_locations, frozen_skipped, fresh_lockfile): (
|
||||
HoistedDependencies,
|
||||
BTreeMap<String, Vec<String>>,
|
||||
crate::SkippedSnapshots,
|
||||
Option<Lockfile>,
|
||||
) = if frozen_lockfile {
|
||||
let Some(lockfile) = lockfile else {
|
||||
return Err(InstallError::NoLockfile);
|
||||
};
|
||||
) = if take_frozen_path {
|
||||
let lockfile = lockfile.expect("dispatch verified lockfile is present");
|
||||
let Lockfile { lockfile_version, importers, packages, snapshots, .. } = lockfile;
|
||||
assert_eq!(lockfile_version.major, 9); // compatibility check already happens at serde, but this still helps preventing programmer mistakes.
|
||||
|
||||
// Freshness check: verify the on-disk `package.json`
|
||||
// still matches the lockfile's importer entry before we
|
||||
// commit to materializing `node_modules` from it. Mirrors
|
||||
// upstream's `satisfiesPackageManifest` gate at
|
||||
// <https://github.com/pnpm/pnpm/blob/94240bc046/pkg-manager/core/src/install/index.ts#L808-L832>.
|
||||
// Pacquet has only one importer today (#431 tracks
|
||||
// workspaces), so the root project is the only thing to
|
||||
// verify; once workspaces land this becomes a per-project
|
||||
// loop over `importers`.
|
||||
let importer = importers.get(Lockfile::ROOT_IMPORTER_KEY).ok_or_else(|| {
|
||||
InstallError::NoImporter { importer_id: Lockfile::ROOT_IMPORTER_KEY.to_string() }
|
||||
})?;
|
||||
// Outdated-settings gate (umbrella #434 slice 7): check
|
||||
// `ignoredOptionalDependencies` drift between the
|
||||
// lockfile-recorded set and the current config before
|
||||
// the per-importer specifier check. Mirrors upstream's
|
||||
// [`getOutdatedLockfileSetting`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts).
|
||||
// Upstream flips `needsFullResolution` and re-runs the
|
||||
// resolver; pacquet has no resolver, so the matching
|
||||
// action is to abort with `OutdatedLockfile`.
|
||||
//
|
||||
// `pnpm.overrides` values can use the `catalog:` protocol,
|
||||
// which pnpm resolves against the workspace's catalogs
|
||||
// *before* writing them to `pnpm-lock.yaml#overrides`. We
|
||||
// mirror that here: parse the raw map through
|
||||
// `parse_overrides_iter` with the current catalogs to get
|
||||
// the resolved specifiers, then flatten to a plain map for
|
||||
// the freshness comparison. Without this step, an override
|
||||
// declared as `"foo": "catalog:"` would compare unequal to
|
||||
// the lockfile's already-resolved `"foo": "<concrete>"` on
|
||||
// every install. Mirrors upstream's
|
||||
// `parseOverrides(overrides, catalogs)` →
|
||||
// `createOverridesMapFromParsed(...)` pipeline at
|
||||
// <https://github.com/pnpm/pnpm/blob/4a36b9a110/config/parse-overrides/src/index.ts#L20-L44>
|
||||
// and
|
||||
// <https://github.com/pnpm/pnpm/blob/4a36b9a110/lockfile/settings-checker/src/createOverridesMapFromParsed.ts>.
|
||||
// Parsing the same overrides twice (once for the freshness
|
||||
// check, once for the `VersionsOverrider` apply below)
|
||||
// would double-walk the map and double-resolve `catalog:`
|
||||
// values, so we parse once and share.
|
||||
let parsed_overrides_opt = match config.overrides.as_ref() {
|
||||
Some(map) if !map.is_empty() => Some(
|
||||
pacquet_config_parse_overrides::parse_overrides_iter(map.iter(), &catalogs)
|
||||
.map_err(InstallError::InvalidOverrides)?,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let overrides_map: Option<std::collections::HashMap<String, String>> =
|
||||
parsed_overrides_opt
|
||||
.as_deref()
|
||||
.map(pacquet_config_parse_overrides::create_overrides_map_from_parsed);
|
||||
pacquet_lockfile::check_lockfile_settings(
|
||||
lockfile,
|
||||
overrides_map.as_ref(),
|
||||
config.ignored_optional_dependencies.as_deref(),
|
||||
)
|
||||
.map_err(|reason| InstallError::OutdatedLockfile { reason })?;
|
||||
|
||||
// Apply `pnpm.overrides` to a *cloned* manifest before
|
||||
// the freshness check, so the lockfile's importer
|
||||
// specifiers — which were written by pnpm with overrides
|
||||
// already applied — match the on-disk manifest's deps.
|
||||
// Without this, an install on a repo with overrides
|
||||
// trips `SpecifiersDiffer` on every dep an override
|
||||
// rewrites. The caller's manifest stays pristine, since
|
||||
// upstream's read-package-hook conceptually returns a
|
||||
// new manifest from the perspective of every consumer
|
||||
// downstream of the resolver.
|
||||
let overrider_manifest_holder;
|
||||
let manifest_for_freshness: &PackageManifest =
|
||||
if let Some(parsed) = parsed_overrides_opt.as_deref() {
|
||||
let root_dir = manifest.path().parent().unwrap_or_else(|| Path::new("."));
|
||||
let overrider = crate::VersionsOverrider::new(parsed, root_dir);
|
||||
overrider_manifest_holder = {
|
||||
let mut cloned: PackageManifest = (*manifest).clone();
|
||||
overrider.apply(&mut cloned, Some(root_dir));
|
||||
cloned
|
||||
};
|
||||
&overrider_manifest_holder
|
||||
} else {
|
||||
manifest
|
||||
};
|
||||
// Build the `ignoredOptionalDependencies` filter set.
|
||||
// Mirrors upstream's
|
||||
// [`createOptionalDependenciesRemover`](https://github.com/pnpm/pnpm/blob/94240bc046/hooks/read-package-hook/src/createOptionalDependenciesRemover.ts):
|
||||
// the hook iterates `manifest.optionalDependencies`
|
||||
// and deletes matches from BOTH the `optional` and
|
||||
// `dependencies` maps. A name only present in
|
||||
// `dependencies` (not `optionalDependencies`) that
|
||||
// happens to match the pattern is NOT removed —
|
||||
// that's why the predicate is set-based ("name was
|
||||
// in optionalDependencies AND matched") rather than
|
||||
// pure pattern matching. `devDependencies` is
|
||||
// untouched on purpose; the group gate inside
|
||||
// `satisfies_package_manifest` enforces that.
|
||||
let ignored_set: std::collections::HashSet<String> = config
|
||||
.ignored_optional_dependencies
|
||||
.as_deref()
|
||||
.filter(|patterns| !patterns.is_empty())
|
||||
.map(|patterns| {
|
||||
let matcher = pacquet_config::matcher::create_matcher(patterns);
|
||||
manifest_for_freshness
|
||||
.dependencies([pacquet_package_manifest::DependencyGroup::Optional])
|
||||
.filter(|(name, _)| matcher.matches(name))
|
||||
.map(|(name, _)| name.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let is_ignored_optional: &dyn Fn(&str) -> bool =
|
||||
&|name: &str| ignored_set.contains(name);
|
||||
// `--ignore-manifest-check` skips this gate. The pnpm CLI
|
||||
// passes it when delegating materialization through
|
||||
// `configDependencies`: pnpm has just resolved the tree
|
||||
// and written the lockfile, but hasn't yet written the
|
||||
// post-mutation `package.json` to disk (it does that
|
||||
// after `mutateModules` returns), so the freshness check
|
||||
// would always fire on `pnpm up` / `add` / `remove`. See
|
||||
// <https://github.com/pnpm/pnpm/issues/11797>. Settings
|
||||
// drift (`overrides`, `ignoredOptionalDependencies`)
|
||||
// already ran above and is unaffected.
|
||||
if !ignore_manifest_check {
|
||||
satisfies_package_manifest(
|
||||
importer,
|
||||
manifest_for_freshness,
|
||||
Lockfile::ROOT_IMPORTER_KEY,
|
||||
is_ignored_optional,
|
||||
)
|
||||
.map_err(|reason| InstallError::OutdatedLockfile { reason })?;
|
||||
}
|
||||
|
||||
let frozen_result = InstallFrozenLockfile {
|
||||
http_client,
|
||||
config,
|
||||
@@ -683,6 +591,30 @@ where
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
// Flag combinations the fresh-lockfile path doesn't honor
|
||||
// yet are validated here, after the dispatch decision so an
|
||||
// auto-frozen install (state 2 of [`Install::run`]) doesn't
|
||||
// get rejected up front:
|
||||
//
|
||||
// - `nodeLinker: hoisted` on the fresh path would need a
|
||||
// port of upstream's hoist pass against the freshly-built
|
||||
// graph (the frozen path uses `link_hoisted_modules` over
|
||||
// the lockfile's snapshots). Falling through to the
|
||||
// isolated linker would lay out `node_modules` in the
|
||||
// wrong shape, so refuse the install instead.
|
||||
// - `skip_runtimes` (CLI `--no-runtime`) on the fresh path
|
||||
// would need a runtime-filter at the materialization step
|
||||
// matching the frozen path's
|
||||
// [`installing/deps-installer/src/install/index.ts:1374-1387`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/index.ts#L1374-L1387)
|
||||
// filter. Without it, runtime archives get fetched +
|
||||
// materialized despite the opt-out.
|
||||
if matches!(node_linker, NodeLinker::Hoisted) {
|
||||
return Err(InstallError::UnsupportedFreshInstallNodeLinker { node_linker });
|
||||
}
|
||||
if skip_runtimes {
|
||||
return Err(InstallError::UnsupportedFreshInstallSkipRuntimes);
|
||||
}
|
||||
|
||||
// The fresh-lockfile path has no installability check
|
||||
// (no `packages:` metadata to evaluate constraints
|
||||
// against), so its skip set is empty by construction.
|
||||
@@ -709,6 +641,16 @@ where
|
||||
catalogs,
|
||||
lockfile_dir: &workspace_root,
|
||||
workspace_packages,
|
||||
// States 3 and 4 of the dispatch share this branch.
|
||||
// State 3 (lockfile present but stale or
|
||||
// `preferFrozenLockfile: false`) passes the existing
|
||||
// lockfile so the resolver seeds
|
||||
// `getPreferredVersionsFromLockfileAndManifests` with
|
||||
// already-pinned `(name, version)` pairs — unrelated
|
||||
// entries keep their pins on rewrite, matching
|
||||
// upstream's `update: false` mode. State 4 (no
|
||||
// lockfile) passes `None`.
|
||||
wanted_lockfile: lockfile,
|
||||
}
|
||||
.run::<Reporter>()
|
||||
.await
|
||||
@@ -844,6 +786,170 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Run every gate the frozen-lockfile dispatch consults before
|
||||
/// committing to materializing `node_modules` from `lockfile`:
|
||||
/// `pnpm.overrides` parsing, the settings-drift check
|
||||
/// ([`pacquet_lockfile::check_lockfile_settings`]), and the
|
||||
/// per-importer manifest specifier check
|
||||
/// ([`pacquet_lockfile::satisfies_package_manifest`]).
|
||||
///
|
||||
/// Shared between dispatch states 1 and 2 so the explicit
|
||||
/// `--frozen-lockfile` flag and the implicit `preferFrozenLockfile:
|
||||
/// true` fast path agree on what "lockfile is up to date" means.
|
||||
/// Callers in state 1 surface any `Err` as [`InstallError`]; callers
|
||||
/// in state 2 treat a stale-lockfile `Err` as fall-through to the
|
||||
/// fresh-resolve path (and surface the rest as fatal — see the
|
||||
/// `From<FreshnessCheckError> for InstallError` impl below).
|
||||
///
|
||||
/// `ignore_manifest_check` skips the per-importer specifier gate.
|
||||
/// The pnpm CLI passes it when delegating materialization through
|
||||
/// `configDependencies`: pnpm has just resolved the tree and written
|
||||
/// the lockfile, but hasn't yet written the post-mutation
|
||||
/// `package.json` to disk, so the freshness check would always fire
|
||||
/// on `pnpm up` / `add` / `remove`. Settings drift (`overrides`,
|
||||
/// `ignoredOptionalDependencies`) still runs — see
|
||||
/// <https://github.com/pnpm/pnpm/issues/11797>.
|
||||
fn check_lockfile_freshness(
|
||||
lockfile: &Lockfile,
|
||||
manifest: &PackageManifest,
|
||||
config: &Config,
|
||||
catalogs: &Catalogs,
|
||||
ignore_manifest_check: bool,
|
||||
) -> Result<(), FreshnessCheckError> {
|
||||
// Pacquet has only one importer today (#431 tracks workspaces),
|
||||
// so the root project is the only thing to verify; once
|
||||
// workspaces land this becomes a per-project loop over
|
||||
// `lockfile.importers`.
|
||||
let importer = lockfile.importers.get(Lockfile::ROOT_IMPORTER_KEY).ok_or_else(|| {
|
||||
FreshnessCheckError::NoImporter { importer_id: Lockfile::ROOT_IMPORTER_KEY.to_string() }
|
||||
})?;
|
||||
|
||||
// `pnpm.overrides` values can use the `catalog:` protocol, which
|
||||
// pnpm resolves against the workspace's catalogs *before* writing
|
||||
// them to `pnpm-lock.yaml#overrides`. Mirror that here so an
|
||||
// override declared as `"foo": "catalog:"` compares equal to the
|
||||
// lockfile's already-resolved `"foo": "<concrete>"`. Mirrors
|
||||
// upstream's
|
||||
// <https://github.com/pnpm/pnpm/blob/4a36b9a110/config/parse-overrides/src/index.ts#L20-L44>
|
||||
// → `createOverridesMapFromParsed` pipeline.
|
||||
let parsed_overrides_opt = match config.overrides.as_ref() {
|
||||
Some(map) if !map.is_empty() => Some(
|
||||
pacquet_config_parse_overrides::parse_overrides_iter(map.iter(), catalogs)
|
||||
.map_err(FreshnessCheckError::InvalidOverrides)?,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let overrides_map: Option<std::collections::HashMap<String, String>> = parsed_overrides_opt
|
||||
.as_deref()
|
||||
.map(pacquet_config_parse_overrides::create_overrides_map_from_parsed);
|
||||
|
||||
// Outdated-settings gate (umbrella #434 slice 7): check
|
||||
// `ignoredOptionalDependencies` + `overrides` drift between the
|
||||
// lockfile-recorded set and the current config before the
|
||||
// per-importer specifier check. Mirrors upstream's
|
||||
// [`getOutdatedLockfileSetting`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts).
|
||||
pacquet_lockfile::check_lockfile_settings(
|
||||
lockfile,
|
||||
overrides_map.as_ref(),
|
||||
config.ignored_optional_dependencies.as_deref(),
|
||||
)
|
||||
.map_err(FreshnessCheckError::Stale)?;
|
||||
|
||||
if ignore_manifest_check {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Apply `pnpm.overrides` to a *cloned* manifest before the
|
||||
// per-importer specifier check so the lockfile's specifiers —
|
||||
// written with overrides already applied — match the on-disk
|
||||
// manifest's deps. The caller's manifest stays pristine since
|
||||
// upstream's read-package-hook conceptually returns a new manifest
|
||||
// from the perspective of every consumer downstream of the
|
||||
// resolver.
|
||||
let overrider_manifest_holder;
|
||||
let manifest_for_freshness: &PackageManifest =
|
||||
if let Some(parsed) = parsed_overrides_opt.as_deref() {
|
||||
let root_dir = manifest.path().parent().unwrap_or_else(|| Path::new("."));
|
||||
let overrider = crate::VersionsOverrider::new(parsed, root_dir);
|
||||
overrider_manifest_holder = {
|
||||
let mut cloned: PackageManifest = manifest.clone();
|
||||
overrider.apply(&mut cloned, Some(root_dir));
|
||||
cloned
|
||||
};
|
||||
&overrider_manifest_holder
|
||||
} else {
|
||||
manifest
|
||||
};
|
||||
|
||||
// Build the `ignoredOptionalDependencies` filter set. Mirrors
|
||||
// upstream's
|
||||
// [`createOptionalDependenciesRemover`](https://github.com/pnpm/pnpm/blob/94240bc046/hooks/read-package-hook/src/createOptionalDependenciesRemover.ts):
|
||||
// the hook iterates `manifest.optionalDependencies` and deletes
|
||||
// matches from BOTH the `optional` and `dependencies` maps. A
|
||||
// name only present in `dependencies` that happens to match the
|
||||
// pattern is NOT removed — set-based ("name was in
|
||||
// optionalDependencies AND matched") rather than pure pattern
|
||||
// matching. `devDependencies` is untouched on purpose; the group
|
||||
// gate inside `satisfies_package_manifest` enforces that.
|
||||
let ignored_set: std::collections::HashSet<String> = config
|
||||
.ignored_optional_dependencies
|
||||
.as_deref()
|
||||
.filter(|patterns| !patterns.is_empty())
|
||||
.map(|patterns| {
|
||||
let matcher = pacquet_config::matcher::create_matcher(patterns);
|
||||
manifest_for_freshness
|
||||
.dependencies([pacquet_package_manifest::DependencyGroup::Optional])
|
||||
.filter(|(name, _)| matcher.matches(name))
|
||||
.map(|(name, _)| name.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let is_ignored_optional: &dyn Fn(&str) -> bool = &|name: &str| ignored_set.contains(name);
|
||||
|
||||
satisfies_package_manifest(
|
||||
importer,
|
||||
manifest_for_freshness,
|
||||
Lockfile::ROOT_IMPORTER_KEY,
|
||||
is_ignored_optional,
|
||||
)
|
||||
.map_err(FreshnessCheckError::Stale)
|
||||
}
|
||||
|
||||
/// Outcome of [`check_lockfile_freshness`]. Splits "user
|
||||
/// configuration is malformed" (always fatal) from "lockfile is stale"
|
||||
/// (fatal for `--frozen-lockfile`, fall-through to the fresh-resolve
|
||||
/// path under `preferFrozenLockfile: true`).
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
enum FreshnessCheckError {
|
||||
/// The lockfile has no entry for the root importer.
|
||||
#[display(
|
||||
r#"Cannot install with "frozen-lockfile" because pnpm-lock.yaml has no `importers["{importer_id}"]` entry. Regenerate the lockfile with `pnpm install --lockfile-only`."#
|
||||
)]
|
||||
#[diagnostic(code(pacquet_package_manager::no_importer))]
|
||||
NoImporter { importer_id: String },
|
||||
|
||||
/// A value in `pnpm.overrides` couldn't be parsed.
|
||||
#[diagnostic(transparent)]
|
||||
InvalidOverrides(#[error(source)] pacquet_config_parse_overrides::ParseOverridesError),
|
||||
|
||||
/// `pnpm-lock.yaml` doesn't match the on-disk `package.json` /
|
||||
/// current settings.
|
||||
#[display("{_0}")]
|
||||
Stale(#[error(not(source))] StalenessReason),
|
||||
}
|
||||
|
||||
impl From<FreshnessCheckError> for InstallError {
|
||||
fn from(error: FreshnessCheckError) -> InstallError {
|
||||
match error {
|
||||
FreshnessCheckError::NoImporter { importer_id } => {
|
||||
InstallError::NoImporter { importer_id }
|
||||
}
|
||||
FreshnessCheckError::InvalidOverrides(inner) => InstallError::InvalidOverrides(inner),
|
||||
FreshnessCheckError::Stale(reason) => InstallError::OutdatedLockfile { reason },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate pacquet's [`Config::node_linker`] into the
|
||||
/// [`pacquet_modules_yaml::NodeLinker`] enum used on disk. The two
|
||||
/// enums share the same variant set (`isolated`, `hoisted`, `pnp`),
|
||||
|
||||
@@ -58,6 +58,7 @@ async fn should_install_dependencies() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -124,6 +125,7 @@ async fn should_error_when_frozen_lockfile_is_requested_but_none_exists() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -194,6 +196,7 @@ async fn frozen_lockfile_flag_overrides_config_lockfile_false() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -257,6 +260,7 @@ async fn npm_alias_dependency_installs_under_alias_key() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -337,6 +341,7 @@ async fn unversioned_npm_alias_defaults_to_latest() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -402,6 +407,7 @@ async fn frozen_lockfile_flag_with_no_lockfile_errors() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -487,6 +493,7 @@ async fn install_emits_pnpm_event_sequence() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -629,6 +636,7 @@ async fn install_writes_modules_yaml() {
|
||||
// groups to the on-disk `included` field.
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -727,6 +735,7 @@ async fn install_writes_workspace_state() {
|
||||
// dispatched groups.
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -842,6 +851,7 @@ async fn install_optional_failing_postinstall_dep_via_registry_mock_succeeds() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -962,6 +972,7 @@ async fn warm_reinstall_skips_snapshot_when_current_lockfile_matches() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -1057,6 +1068,7 @@ async fn warm_reinstall_emits_broken_modules_when_dir_is_missing() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -1160,6 +1172,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -1207,6 +1220,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -1296,6 +1310,7 @@ async fn warm_reinstall_reports_added_zero_and_emits_no_imported_events() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -1387,6 +1402,7 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -1448,6 +1464,7 @@ async fn ignore_manifest_check_bypasses_manifest_freshness_gate() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: true,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -1510,6 +1527,7 @@ async fn frozen_lockfile_errors_when_overrides_drift_from_lockfile() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -1598,6 +1616,7 @@ async fn frozen_lockfile_applies_overrides_to_manifest_before_freshness_check()
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -1702,6 +1721,7 @@ async fn frozen_lockfile_resolves_catalog_protocol_in_overrides_before_freshness
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -1760,6 +1780,7 @@ async fn frozen_lockfile_errors_when_lockfile_has_no_root_importer() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -1845,6 +1866,7 @@ async fn frozen_lockfile_under_gvs_registers_project_and_runs_clean() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -1920,6 +1942,7 @@ async fn frozen_lockfile_with_gvs_off_skips_project_registry() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -2001,6 +2024,7 @@ async fn frozen_lockfile_under_gvs_registers_each_workspace_importer() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
resolved_packages: &Default::default(),
|
||||
@@ -2200,6 +2224,7 @@ async fn frozen_install_preserves_seeded_skipped_across_reinstall() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2321,6 +2346,7 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2418,6 +2444,7 @@ async fn frozen_install_propagates_non_optional_fetch_failure() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2521,6 +2548,7 @@ async fn frozen_install_no_optional_drops_optional_only_snapshots() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2609,6 +2637,7 @@ async fn frozen_install_optional_included_surfaces_missing_metadata() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2700,6 +2729,7 @@ async fn frozen_install_no_optional_keeps_shared_non_optional_snapshot() {
|
||||
// `--no-optional` shape: Optional NOT in the dispatch list.
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2788,6 +2818,7 @@ async fn hoisted_node_linker_empty_lockfile_writes_modules_yaml() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2873,6 +2904,7 @@ async fn hoisted_node_linker_does_not_create_virtual_store_root() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -2964,6 +2996,7 @@ async fn frozen_lockfile_install_errors_when_no_variant_matches_host() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3053,6 +3086,7 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: true,
|
||||
supported_architectures: None,
|
||||
@@ -3148,6 +3182,7 @@ async fn install_rejects_invalid_minimum_release_age_exclude_pattern() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3245,6 +3280,7 @@ async fn frozen_lockfile_gate_rejects_under_huge_minimum_release_age() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: true,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3328,6 +3364,7 @@ async fn fresh_install_writes_pnpm_lock_yaml_with_expected_shape() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3409,6 +3446,7 @@ async fn fresh_install_splits_dev_and_prod_dependency_sections() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3476,6 +3514,7 @@ async fn fresh_install_records_user_written_specifier() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3539,6 +3578,7 @@ async fn fresh_install_lockfile_round_trips_through_load_save_load() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3601,6 +3641,7 @@ async fn fresh_install_with_lockfile_disabled_does_not_write_a_lockfile() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3666,6 +3707,7 @@ async fn fresh_install_also_writes_current_lockfile_under_virtual_store() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3747,6 +3789,7 @@ async fn fresh_install_with_lockfile_disabled_skips_current_lockfile_too() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3806,6 +3849,7 @@ async fn fresh_install_marks_optional_snapshots_in_pnpm_lock_yaml() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3888,6 +3932,7 @@ async fn fresh_install_refuses_hoisted_node_linker_before_writing_state() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
@@ -3936,6 +3981,7 @@ async fn fresh_install_refuses_skip_runtimes_before_writing_state() {
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: true,
|
||||
supported_architectures: None,
|
||||
@@ -3952,3 +3998,208 @@ async fn fresh_install_refuses_skip_runtimes_before_writing_state() {
|
||||
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
/// Dispatch state 2: no `--frozen-lockfile` flag, lockfile present and
|
||||
/// fresh, `preferFrozenLockfile: true` (the default) → auto-frozen.
|
||||
/// We prove the frozen path was taken the same way
|
||||
/// `warm_reinstall_skips_snapshot_when_current_lockfile_matches` does:
|
||||
/// the lockfile points at a bogus tarball URL, and the install is
|
||||
/// pre-seeded with a matching current lockfile + virtual-store slot,
|
||||
/// so only the snapshot-skip path inside the frozen install can
|
||||
/// produce a successful run. If the dispatch silently fell through to
|
||||
/// the fresh-resolve path, the bogus URL would be fetched and the
|
||||
/// install would error out.
|
||||
#[tokio::test]
|
||||
async fn prefer_frozen_lockfile_takes_frozen_path_when_lockfile_is_fresh() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store_dir = dir.path().join("pacquet-store");
|
||||
let project_root = dir.path().join("project");
|
||||
let modules_dir = project_root.join("node_modules");
|
||||
let virtual_store_dir = modules_dir.join(".pacquet");
|
||||
|
||||
let manifest_path = dir.path().join("package.json");
|
||||
let mut manifest = PackageManifest::create_if_needed(manifest_path).unwrap();
|
||||
manifest.add_dependency("placeholder", "1.0.0", DependencyGroup::Prod).unwrap();
|
||||
manifest.save().unwrap();
|
||||
|
||||
let mut config = Config::new();
|
||||
// Same legacy-layout opt-out as the sibling skip test — the seed
|
||||
// helper writes the flat-name slot shape.
|
||||
config.enable_global_virtual_store = false;
|
||||
config.store_dir = store_dir.into();
|
||||
config.modules_dir = modules_dir.clone();
|
||||
config.virtual_store_dir = virtual_store_dir.clone();
|
||||
let config = config.leak();
|
||||
|
||||
let lockfile: Lockfile = serde_saphyr::from_str(PARTIAL_INSTALL_LOCKFILE)
|
||||
.expect("parse partial-install fixture lockfile");
|
||||
|
||||
std::fs::create_dir_all(&virtual_store_dir).unwrap();
|
||||
lockfile.save_current_to_virtual_store_dir(&virtual_store_dir).expect("seed current lockfile");
|
||||
seed_placeholder_virtual_store_slot(&virtual_store_dir);
|
||||
|
||||
Install {
|
||||
tarball_mem_cache: &Default::default(),
|
||||
http_client: &Default::default(),
|
||||
http_client_arc: std::sync::Arc::new(Default::default()),
|
||||
config,
|
||||
manifest: &manifest,
|
||||
lockfile: Some(&lockfile),
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
// No `--frozen-lockfile`; the dispatch must auto-go-frozen
|
||||
// via `config.prefer_frozen_lockfile` (defaults to `true`).
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
node_linker: pacquet_config::NodeLinker::default(),
|
||||
resolved_packages: &Default::default(),
|
||||
}
|
||||
.run::<SilentReporter>()
|
||||
.await
|
||||
.expect(
|
||||
"auto-frozen dispatch must short-circuit the bogus fetch via the skip path \
|
||||
(would otherwise error out on the invalid URL)",
|
||||
);
|
||||
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
/// Dispatch state 3a: lockfile present + matching manifest, but
|
||||
/// `Install::prefer_frozen_lockfile = Some(false)` (the CLI's
|
||||
/// `--no-prefer-frozen-lockfile` opt-out). The dispatch must route to
|
||||
/// the fresh-resolve path even though the frozen fast path would have
|
||||
/// applied. We prove it by pointing at an unreachable registry: the
|
||||
/// fresh-resolve path will hit the resolver and fail, whereas the
|
||||
/// frozen fast path would short-circuit the network entirely via the
|
||||
/// skip cache.
|
||||
#[tokio::test]
|
||||
async fn no_prefer_frozen_lockfile_flag_forces_fresh_resolve() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store_dir = dir.path().join("pacquet-store");
|
||||
let project_root = dir.path().join("project");
|
||||
let modules_dir = project_root.join("node_modules");
|
||||
let virtual_store_dir = modules_dir.join(".pacquet");
|
||||
|
||||
let manifest_path = dir.path().join("package.json");
|
||||
let mut manifest = PackageManifest::create_if_needed(manifest_path).unwrap();
|
||||
manifest.add_dependency("placeholder", "1.0.0", DependencyGroup::Prod).unwrap();
|
||||
manifest.save().unwrap();
|
||||
|
||||
let mut config = Config::new();
|
||||
// Force the resolver onto an unreachable registry so the
|
||||
// fresh-resolve path errors out clearly; the frozen path would
|
||||
// never consult the registry at all.
|
||||
config.registry = "http://invalid.local/".to_string();
|
||||
config.enable_global_virtual_store = false;
|
||||
config.store_dir = store_dir.into();
|
||||
config.modules_dir = modules_dir.clone();
|
||||
config.virtual_store_dir = virtual_store_dir.clone();
|
||||
let config = config.leak();
|
||||
|
||||
let lockfile: Lockfile = serde_saphyr::from_str(PARTIAL_INSTALL_LOCKFILE)
|
||||
.expect("parse partial-install fixture lockfile");
|
||||
|
||||
// Seed exactly as the auto-frozen test does — if dispatch did go
|
||||
// frozen, the skip cache would carry the install to success.
|
||||
std::fs::create_dir_all(&virtual_store_dir).unwrap();
|
||||
lockfile.save_current_to_virtual_store_dir(&virtual_store_dir).expect("seed current lockfile");
|
||||
seed_placeholder_virtual_store_slot(&virtual_store_dir);
|
||||
|
||||
let result = Install {
|
||||
tarball_mem_cache: &Default::default(),
|
||||
http_client: &Default::default(),
|
||||
http_client_arc: std::sync::Arc::new(Default::default()),
|
||||
config,
|
||||
manifest: &manifest,
|
||||
lockfile: Some(&lockfile),
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: false,
|
||||
// Opt-out at the call site (the `--no-prefer-frozen-lockfile`
|
||||
// CLI flag would land here).
|
||||
prefer_frozen_lockfile: Some(false),
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
node_linker: pacquet_config::NodeLinker::default(),
|
||||
resolved_packages: &Default::default(),
|
||||
}
|
||||
.run::<SilentReporter>()
|
||||
.await;
|
||||
|
||||
let err = result.expect_err(
|
||||
"fresh-resolve dispatch must consult the unreachable registry and fail; \
|
||||
a success would mean the dispatch silently took the frozen fast path",
|
||||
);
|
||||
assert!(
|
||||
!matches!(err, InstallError::OutdatedLockfile { .. }),
|
||||
"fresh-resolve fall-through must not surface as OutdatedLockfile, got {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatch state 3b: lockfile present, but the manifest has drifted
|
||||
/// from it; no `--frozen-lockfile` flag. The freshness gate inside the
|
||||
/// auto-frozen branch must fail, and the dispatch must fall through
|
||||
/// to the fresh-resolve path instead of surfacing `OutdatedLockfile`
|
||||
/// the way state 1 would. We assert via the same "unreachable
|
||||
/// registry" sentinel as the previous test.
|
||||
#[tokio::test]
|
||||
async fn stale_lockfile_under_no_flag_falls_through_to_fresh_resolve() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store_dir = dir.path().join("pacquet-store");
|
||||
let project_root = dir.path().join("project");
|
||||
let modules_dir = project_root.join("node_modules");
|
||||
let virtual_store_dir = modules_dir.join(".pacquet");
|
||||
|
||||
let manifest_path = dir.path().join("package.json");
|
||||
// Deliberately omit the `placeholder` dep — this drifts from
|
||||
// `PARTIAL_INSTALL_LOCKFILE`'s importer entry.
|
||||
let mut manifest = PackageManifest::create_if_needed(manifest_path).unwrap();
|
||||
manifest
|
||||
.add_dependency("@pnpm.e2e/hello-world-js-bin", "1.0.0", DependencyGroup::Prod)
|
||||
.unwrap();
|
||||
manifest.save().unwrap();
|
||||
|
||||
let mut config = Config::new();
|
||||
config.registry = "http://invalid.local/".to_string();
|
||||
config.enable_global_virtual_store = false;
|
||||
config.store_dir = store_dir.into();
|
||||
config.modules_dir = modules_dir.clone();
|
||||
config.virtual_store_dir = virtual_store_dir;
|
||||
let config = config.leak();
|
||||
|
||||
let lockfile: Lockfile = serde_saphyr::from_str(PARTIAL_INSTALL_LOCKFILE)
|
||||
.expect("parse partial-install fixture lockfile");
|
||||
|
||||
let result = Install {
|
||||
tarball_mem_cache: &Default::default(),
|
||||
http_client: &Default::default(),
|
||||
http_client_arc: std::sync::Arc::new(Default::default()),
|
||||
config,
|
||||
manifest: &manifest,
|
||||
lockfile: Some(&lockfile),
|
||||
lockfile_path: None,
|
||||
dependency_groups: [DependencyGroup::Prod],
|
||||
frozen_lockfile: false,
|
||||
prefer_frozen_lockfile: None,
|
||||
ignore_manifest_check: false,
|
||||
skip_runtimes: false,
|
||||
supported_architectures: None,
|
||||
node_linker: pacquet_config::NodeLinker::default(),
|
||||
resolved_packages: &Default::default(),
|
||||
}
|
||||
.run::<SilentReporter>()
|
||||
.await;
|
||||
|
||||
let err = result.expect_err(
|
||||
"fresh-resolve dispatch must consult the unreachable registry and fail; \
|
||||
a success would mean the dispatch silently took the auto-frozen path",
|
||||
);
|
||||
assert!(
|
||||
!matches!(err, InstallError::OutdatedLockfile { .. }),
|
||||
"stale-lockfile fall-through must not surface as OutdatedLockfile, got {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,15 @@ pub struct InstallWithFreshLockfile<'a, DependencyGroupList> {
|
||||
/// [`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<pacquet_resolving_resolver_base::WorkspacePackages>,
|
||||
/// Existing `pnpm-lock.yaml` to seed `getPreferredVersionsFromLockfileAndManifests`
|
||||
/// with already-pinned `(name, version)` pairs. `Some` on the
|
||||
/// stale-lockfile / `preferFrozenLockfile: false` rewrite path
|
||||
/// — the resolver biases toward the seeded versions when they
|
||||
/// still satisfy the spec so unrelated dependencies keep their
|
||||
/// pins. `None` on the no-lockfile path. Mirrors upstream's
|
||||
/// `update: false` resolver mode at
|
||||
/// <https://github.com/pnpm/pnpm/blob/097983fbca/lockfile/preferred-versions/src/index.ts#L13-L33>.
|
||||
pub wanted_lockfile: Option<&'a Lockfile>,
|
||||
}
|
||||
|
||||
/// Error type of [`InstallWithFreshLockfile`].
|
||||
@@ -238,6 +247,7 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
|
||||
catalogs,
|
||||
lockfile_dir,
|
||||
workspace_packages,
|
||||
wanted_lockfile,
|
||||
} = self;
|
||||
|
||||
let store_dir: &'static _ = &config.store_dir;
|
||||
@@ -371,13 +381,19 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
|
||||
.transpose()
|
||||
.map_err(InstallWithFreshLockfileError::MinimumReleaseAgeExclude)?;
|
||||
|
||||
// Seed `allPreferredVersions` from the importer manifest. No
|
||||
// wanted lockfile is available on this path (install-without-
|
||||
// lockfile), so the lockfile-snapshot arm of upstream's
|
||||
// `getPreferredVersionsFromLockfileAndManifests` is skipped.
|
||||
// Seed `allPreferredVersions` from the importer manifest +
|
||||
// the wanted lockfile's snapshots (when an existing one is
|
||||
// present and is being rewritten). Mirrors upstream's
|
||||
// `getPreferredVersionsFromLockfileAndManifests` shape: the
|
||||
// manifest contributes direct-dep specifiers, the lockfile
|
||||
// contributes concrete `(name, version)` pins that bump the
|
||||
// weight of an already-matching direct-dep entry. Without the
|
||||
// lockfile-side seed, every install on a stale lockfile would
|
||||
// resolve unrelated entries from scratch and lose their
|
||||
// recorded pins; see <https://pnpm.io/settings#preferfrozenlockfile>.
|
||||
let all_preferred_versions =
|
||||
pacquet_lockfile_preferred_versions::get_preferred_versions_from_lockfile_and_manifests(
|
||||
None,
|
||||
wanted_lockfile.and_then(|lockfile| lockfile.snapshots.as_ref()),
|
||||
&[manifest],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user