From 1386b5987d91735d0f52fc46d46e9bbc2dae42e6 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 21 May 2026 17:53:03 +0200 Subject: [PATCH] feat(pacquet): honor preferFrozenLockfile in the install dispatch (#11824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- pacquet/crates/cli/src/cli_args/install.rs | 32 ++ pacquet/crates/package-manager/src/add.rs | 9 + pacquet/crates/package-manager/src/install.rs | 484 +++++++++++------- .../package-manager/src/install/tests.rs | 251 +++++++++ .../src/install_with_fresh_lockfile.rs | 26 +- 5 files changed, 608 insertions(+), 194 deletions(-) diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index 8e19af700b..a491ea17c8 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -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`: `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, diff --git a/pacquet/crates/package-manager/src/add.rs b/pacquet/crates/package-manager/src/add.rs index 56e7bcc9a7..12a7482fbf 100644 --- a/pacquet/crates/package-manager/src/add.rs +++ b/pacquet/crates/package-manager/src/add.rs @@ -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, diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index b133626e2b..94eb7070e4 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -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`] so the dispatch can + /// tell a per-invocation override apart from the config default. + pub prefer_frozen_lockfile: Option, /// 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 + // . // - // 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 . + // + // 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>, crate::SkippedSnapshots, Option, - ) = 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 - // . - // 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": ""` on - // every install. Mirrors upstream's - // `parseOverrides(overrides, catalogs)` → - // `createOverridesMapFromParsed(...)` pipeline at - // - // and - // . - // 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> = - 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 = 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 - // . 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::() .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 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 +/// . +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": ""`. Mirrors + // upstream's + // + // → `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> = 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 = 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 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`), diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index 8dc732990d..7897142532 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -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::() + .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::() + .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::() + .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:?}", + ); +} diff --git a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs index 7ad4780e4e..d44a13cd10 100644 --- a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -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, + /// 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 + /// . + 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 . 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], );