From 0dd1ec445c7fd14177c8dbb681b00a6de447fc83 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 21 May 2026 11:17:42 +0200 Subject: [PATCH] feat(pacquet): add --ignore-manifest-check to skip frozen-lockfile manifest gate (#11811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces a narrow CLI flag on `pacquet install` that gates only `satisfies_package_manifest`. Settings-drift checks (`overrides`, `ignoredOptionalDependencies`, …) still fire, and the broader `--ignore-package-manifest` name is reserved for a future port of pnpm's `pnpm fetch` semantics (which skip linking / hoisting / pruning too). Intended for the pnpm CLI's `configDependencies` delegation path (issue #11797): pnpm resolves and writes the lockfile, then hands materialization to pacquet but hasn't yet written the post-mutation `package.json`. With the flag set, the freshness gate skips the per-importer manifest check that would otherwise reject every `pnpm up` / `add` / `remove` with `ERR_PNPM_OUTDATED_LOCKFILE`. The matching pnpm-side change to forward the flag lands separately. Refs #11797. --- Written by an agent (Claude Code, claude-opus-4-7). --- pacquet/crates/cli/src/cli_args/install.rs | 22 +++++ .../crates/cli/src/cli_args/install/tests.rs | 13 +++ pacquet/crates/package-manager/src/add.rs | 1 + pacquet/crates/package-manager/src/install.rs | 39 ++++++-- .../package-manager/src/install/tests.rs | 96 +++++++++++++++++++ 5 files changed, 164 insertions(+), 7 deletions(-) diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index 9b5ae73507..8e19af700b 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -80,6 +80,26 @@ pub struct InstallArgs { #[clap(long)] pub 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 + /// lockfile themselves (today: the pnpm CLI delegating + /// materialization to pacquet via `configDependencies`), where + /// the manifest may still be the pre-mutation copy while the + /// lockfile is already post-mutation — the upstream resolver + /// will rewrite the manifest right after pacquet returns. See + /// . + /// + /// Narrow on purpose: only gates + /// [`pacquet_lockfile::satisfies_package_manifest`]. Settings + /// drift (`overrides`, `ignoredOptionalDependencies`, + /// `pnpmfileChecksum`, …) still aborts. A future broader flag + /// matching pnpm's internal `ignorePackageManifest` (used by + /// `pnpm fetch`) would skip linking / hoisting / pruning too; + /// that's deliberately a separate name. + #[clap(long)] + pub ignore_manifest_check: bool, + /// Skip the install of any runtime dependencies /// (`node@runtime:`, `deno@runtime:`, `bun@runtime:`). /// Their archives aren't fetched, their slots aren't @@ -140,6 +160,7 @@ impl InstallArgs { dependency_options, supported_architectures, frozen_lockfile, + ignore_manifest_check, no_runtime, node_linker, offline: _, @@ -183,6 +204,7 @@ impl InstallArgs { lockfile_path: lockfile_path.as_deref(), dependency_groups: dependency_options.dependency_groups(), frozen_lockfile, + ignore_manifest_check, skip_runtimes, resolved_packages, supported_architectures, diff --git a/pacquet/crates/cli/src/cli_args/install/tests.rs b/pacquet/crates/cli/src/cli_args/install/tests.rs index 58370a7e91..6c84c55fde 100644 --- a/pacquet/crates/cli/src/cli_args/install/tests.rs +++ b/pacquet/crates/cli/src/cli_args/install/tests.rs @@ -109,6 +109,19 @@ fn node_linker_invalid_value_rejected() { assert!(msg.contains("bogus"), "error mentions bad value: {msg}"); } +/// `--ignore-manifest-check` parses to `true`. Absent → `false`. +/// Surfaced for the pnpm CLI `configDependencies` delegation path +/// (issue #11797); see the field doc on `InstallArgs::ignore_manifest_check`. +#[test] +fn ignore_manifest_check_flag_parses() { + let parsed = InstallArgsHarness::try_parse_from(["pacquet-test"]).expect("parses"); + assert!(!parsed.args.ignore_manifest_check, "flag absent → false"); + + let parsed = InstallArgsHarness::try_parse_from(["pacquet-test", "--ignore-manifest-check"]) + .expect("parses --ignore-manifest-check"); + assert!(parsed.args.ignore_manifest_check, "flag present → true"); +} + /// `NodeLinkerArg::into_config` maps every variant 1:1 to the /// canonical `pacquet_config::NodeLinker` enum. Tied to the /// `ValueEnum` derive's kebab-case rename — if a future variant diff --git a/pacquet/crates/package-manager/src/add.rs b/pacquet/crates/package-manager/src/add.rs index faa8f405d5..56e7bcc9a7 100644 --- a/pacquet/crates/package-manager/src/add.rs +++ b/pacquet/crates/package-manager/src/add.rs @@ -94,6 +94,7 @@ where lockfile_path, dependency_groups: list_dependency_groups(), frozen_lockfile: false, + ignore_manifest_check: false, skip_runtimes: config.skip_runtimes, resolved_packages, supported_architectures, diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 7e60271e12..5c0fd55186 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -64,6 +64,18 @@ where pub lockfile_path: Option<&'a Path>, pub dependency_groups: DependencyGroupList, pub frozen_lockfile: bool, + /// Skip the per-importer `package.json` ↔ `pnpm-lock.yaml` + /// freshness check ([`satisfies_package_manifest`]) that + /// normally guards `--frozen-lockfile`. Surfaced as + /// `--ignore-manifest-check` on the CLI; intended for the pnpm + /// CLI's `configDependencies` delegation path, where pnpm has + /// just resolved and written the lockfile but hasn't yet written + /// the updated manifest. Settings-drift checks (`overrides`, + /// `ignoredOptionalDependencies`, …) still run — they don't + /// inspect the manifest and the bug this flag addresses is + /// specifically the per-dep specifier mismatch from + /// . + pub ignore_manifest_check: bool, /// When `true`, runtime dependencies (`node@runtime:` / /// `deno@runtime:` / `bun@runtime:`) are skipped — their /// archives aren't fetched, their slots aren't materialized, @@ -237,6 +249,7 @@ where lockfile_path, dependency_groups, frozen_lockfile, + ignore_manifest_check, skip_runtimes, supported_architectures, node_linker, @@ -510,13 +523,25 @@ where .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(|reason| InstallError::OutdatedLockfile { reason })?; + // `--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, diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index 38942ec604..c4947ec164 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, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -115,6 +116,7 @@ async fn should_error_when_frozen_lockfile_is_requested_but_none_exists() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -155,6 +157,7 @@ async fn should_error_when_writable_lockfile_mode_is_used() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -224,6 +227,7 @@ async fn frozen_lockfile_flag_overrides_config_lockfile_false() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -286,6 +290,7 @@ async fn npm_alias_dependency_installs_under_alias_key() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -365,6 +370,7 @@ async fn unversioned_npm_alias_defaults_to_latest() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -429,6 +435,7 @@ async fn frozen_lockfile_flag_with_no_lockfile_errors() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -513,6 +520,7 @@ async fn install_emits_pnpm_event_sequence() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -654,6 +662,7 @@ async fn install_writes_modules_yaml() { // groups to the on-disk `included` field. dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -751,6 +760,7 @@ async fn install_writes_workspace_state() { // dispatched groups. dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -865,6 +875,7 @@ async fn install_optional_failing_postinstall_dep_via_registry_mock_succeeds() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: false, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -984,6 +995,7 @@ async fn warm_reinstall_skips_snapshot_when_current_lockfile_matches() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -1078,6 +1090,7 @@ async fn warm_reinstall_emits_broken_modules_when_dir_is_missing() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -1180,6 +1193,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -1226,6 +1240,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -1314,6 +1329,7 @@ async fn warm_reinstall_reports_added_zero_and_emits_no_imported_events() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -1404,6 +1420,7 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, resolved_packages: &Default::default(), supported_architectures: None, @@ -1421,6 +1438,67 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() { drop(dir); } +/// `--ignore-manifest-check` (`Install::ignore_manifest_check = true`) +/// bypasses the [`satisfies_package_manifest`] gate, so an install +/// whose manifest has drifted from the lockfile proceeds past the +/// freshness check. Same setup as +/// `frozen_lockfile_errors_when_manifest_drifts_from_lockfile`, just +/// with the flag flipped: we now expect the install to reach the +/// fetch site and fail there (network / integrity error against the +/// bogus tarball URL) rather than abort early with `OutdatedLockfile`. +/// +/// Issue context: . +#[tokio::test] +async fn ignore_manifest_check_bypasses_manifest_freshness_gate() { + 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 leave the `placeholder` dep out — same drift the + // sibling test exercises. With `ignore_manifest_check: true` the + // install must accept the drift and move on to materialization. + let manifest = PackageManifest::create_if_needed(manifest_path).unwrap(); + + let mut config = Config::new(); + 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: true, + ignore_manifest_check: true, + skip_runtimes: false, + resolved_packages: &Default::default(), + supported_architectures: None, + node_linker: pacquet_config::NodeLinker::default(), + } + .run::() + .await; + + let err = result.expect_err("bogus tarball URL must still surface a downstream error"); + assert!( + !matches!(err, InstallError::OutdatedLockfile { .. }), + "ignore_manifest_check should bypass the freshness gate, got OutdatedLockfile: {err:?}", + ); + + drop(dir); +} + /// `pnpm.overrides` drift between the lockfile-recorded map and the /// current config surfaces as `OutdatedLockfile` with a /// `StalenessReason::OverridesChanged` payload. Mirrors upstream's @@ -1465,6 +1543,7 @@ async fn frozen_lockfile_errors_when_overrides_drift_from_lockfile() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, resolved_packages: &Default::default(), supported_architectures: None, @@ -1552,6 +1631,7 @@ async fn frozen_lockfile_applies_overrides_to_manifest_before_freshness_check() lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, resolved_packages: &Default::default(), supported_architectures: None, @@ -1609,6 +1689,7 @@ async fn frozen_lockfile_errors_when_lockfile_has_no_root_importer() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, resolved_packages: &Default::default(), supported_architectures: None, @@ -1693,6 +1774,7 @@ async fn frozen_lockfile_under_gvs_registers_project_and_runs_clean() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, resolved_packages: &Default::default(), supported_architectures: None, @@ -1767,6 +1849,7 @@ async fn frozen_lockfile_with_gvs_off_skips_project_registry() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, resolved_packages: &Default::default(), supported_architectures: None, @@ -1847,6 +1930,7 @@ async fn frozen_lockfile_under_gvs_registers_each_workspace_importer() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, resolved_packages: &Default::default(), supported_architectures: None, @@ -2045,6 +2129,7 @@ async fn frozen_install_preserves_seeded_skipped_across_reinstall() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -2165,6 +2250,7 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -2261,6 +2347,7 @@ async fn frozen_install_propagates_non_optional_fetch_failure() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -2363,6 +2450,7 @@ async fn frozen_install_no_optional_drops_optional_only_snapshots() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -2450,6 +2538,7 @@ async fn frozen_install_optional_included_surfaces_missing_metadata() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -2540,6 +2629,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, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -2627,6 +2717,7 @@ async fn hoisted_node_linker_empty_lockfile_writes_modules_yaml() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::Hoisted, @@ -2711,6 +2802,7 @@ async fn hoisted_node_linker_does_not_create_virtual_store_root() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::Hoisted, @@ -2801,6 +2893,7 @@ async fn frozen_lockfile_install_errors_when_no_variant_matches_host() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, resolved_packages: &Default::default(), @@ -2889,6 +2982,7 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: true, supported_architectures: None, resolved_packages: &Default::default(), @@ -2983,6 +3077,7 @@ async fn install_rejects_invalid_minimum_release_age_exclude_pattern() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), @@ -3079,6 +3174,7 @@ async fn frozen_lockfile_gate_rejects_under_huge_minimum_release_age() { lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, + ignore_manifest_check: false, skip_runtimes: false, supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(),