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:
Zoltan Kochan
2026-05-21 17:53:03 +02:00
committed by GitHub
parent 5881b57115
commit 1386b5987d
5 changed files with 608 additions and 194 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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`),

View File

@@ -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:?}",
);
}

View File

@@ -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],
);