You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on May 14, 2026. It is now read-only.
pacquet install --frozen-lockfile materializes node_modules from a pre-existing pnpm-lock.yaml, on the assumption that the lockfile is up-to-date with the project's package.json. Today pacquet doesn't verify that assumption: if a dev edits package.json (adds, removes, or bumps a dependency) without re-running pnpm install --lockfile-only, pacquet happily installs whatever the lockfile says, masking the drift.
Upstream pnpm catches this at the dispatch site. The frozen-lockfile path calls satisfiesPackageManifest(importer, pkg) against every project's package.json (pkg-manager/core/src/install/index.ts:813-832) and throws ERR_PNPM_OUTDATED_LOCKFILE with a detailed reason when the lockfile is stale.
This is a Tier 1 Foundation item on the Roadmap (#299): the freshness check belongs in pacquet's frozen-lockfile dispatch alongside the partial-install path that just landed in #442. Adding it closes the CI-correctness gap where stale pnpm-lock.yaml silently installs the wrong shape of node_modules.
Dispatch site.pkg-manager/core/src/install/index.ts:781-832. Before linking, pnpm calls allProjectsAreUpToDate(projects, ...) to decide whether a frozen install is possible; the frozen-lockfile-explicit path then calls satisfiesPackageManifest per project and throws OUTDATED_LOCKFILE on mismatch.
The check itself.lockfile/verification/src/satisfiesPackageManifest.ts. Returns { satisfies: boolean, detailedReason?: string }. The detailed-reason strings are what pnpm's CLI surfaces — they pinpoint added/removed/modified specifiers so the user can see what drifted.
The check's logic (filtered to what's relevant for pacquet today — single importer, no catalogs, no workspaces, no link: filtering):
Importer must exist in the wanted lockfile (importers["."]).
The flat union devDependencies ∪ dependencies ∪ optionalDependencies from the manifest must match the importer's specifiers exactly (no added / no removed / no modified entries). pnpm reports a per-bucket diff in the error message.
publishDirectory matches publishConfig.directory.
dependenciesMeta matches.
Per dep field (dependencies / devDependencies / optionalDependencies):
The set of dep names in the manifest matches the set in the importer entry (with cross-field exclusion so a dep declared as optional doesn't double-count in prod).
For each name, the importer's recorded specifier matches the manifest's.
If the manifest specifier is a valid semver range, the importer's resolved version must satisfy it.
Today
Pacquet:
Has Lockfile + PackageManifest types with all the fields the check needs (crates/lockfile/src/project_snapshot.rs, crates/lockfile/src/resolved_dependency.rs, crates/package-manifest/).
v9 lockfile shape co-locates the specifier with each dep (react: { specifier: ^17, version: 17.0.2 }), so the comparison is per-dep-specifier rather than against a top-level specifiers map. Simpler than upstream's v6/v7-compatible shape.
Calls neither satisfiesPackageManifest nor allProjectsAreUpToDate. Install::run dispatches directly into InstallFrozenLockfile::run when frozen_lockfile == true and a lockfile is present (crates/package-manager/src/install.rs:195-220). Drift is silently ignored.
Plan
A. Port the check
Add a satisfies_package_manifest(importer: &ProjectSnapshot, manifest: &PackageManifest) -> Result<(), DetailedReason> helper. House it in crates/lockfile (next to the Lockfile type it operates on) or crates/package-manifest (next to the manifest type) — whichever crate already depends on the other. The body is mostly a per-field structural comparison; no async I/O.
Port diffFlatRecords as a small helper. Used inside the check to produce a user-readable list of added/removed/modified entries for the detailed reason.
DetailedReason is the structured error variant — keep it as a typed enum (NoImporter, SpecifiersDiffer { added, removed, modified }, DepCountMismatch { field }, DepSpecifierMismatch { field, name, expected, found }, VersionDoesNotSatisfy { field, name, version, range }, etc.) so the Display impl produces messages structurally equivalent to upstream's strings, and tests can match on the discriminant rather than format.
B. Wire it into Install::run
In the frozen_lockfile branch, call the check against the root importer + manifest before dispatching into InstallFrozenLockfile. On mismatch, return an InstallError::OutdatedLockfile { detailed_reason: DetailedReason }.
Surface as ERR_PNPM_OUTDATED_LOCKFILE via miette diagnostic with code(pacquet_package_manager::outdated_lockfile) and the hint from pnpm's site (Note that in CI environments this setting is true by default. If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile").
C. Tests
Unit tests in the verification module: happy-path (matching manifest+lockfile), missing importer, specifier added in manifest, specifier removed from manifest, specifier modified, dep count mismatch per field, dep missing from importer, version-doesn't-satisfy-range.
Integration test in crates/package-manager/src/install/tests.rs: frozen_lockfile_errors_when_manifest_drifts_from_lockfile. Fixture manifest + lockfile where the manifest has a dep the lockfile doesn't (or vice versa); the install should fail with OutdatedLockfilebefore any fetch attempt. Easy to verify: point the lockfile at a bogus tarball URL so a fetch attempt would also fail — only the early OutdatedLockfile makes the test pass cleanly.
Interactions / out of scope
Catalogs. Upstream's allCatalogsAreUpToDate is part of the same allProjectsAreUpToDate pipeline; pacquet doesn't support catalogs yet so this stays absent.
link: / file: filtering. Upstream's excludeLinksFromLockfile toggle and the pickNonLinkedDeps filter live on top of the basic check. Until pacquet supports link: resolutions (Add workspace support to pacquet install --frozen-lockfile #431 territory), no-op these.
preferFrozenLockfile. Upstream's allProjectsAreUpToDate is also called when preferFrozenLockfile is set, to decide whether to attempt a frozen install. Pacquet doesn't have writable-lockfile installs yet (chore(gitignore): claude local settings and hooks #338-ish), so the only call site is frozen_lockfile == true.
auto-install-peers. The peer-deps pre-pass in upstream's check (lines 21-30) compounds peer deps into the manifest's effective dep set. Pacquet has no separate auto-install-peers mode; the lockfile is generated by pnpm with peers already resolved. Skip for now.
Background
pacquet install --frozen-lockfilematerializesnode_modulesfrom a pre-existingpnpm-lock.yaml, on the assumption that the lockfile is up-to-date with the project'spackage.json. Today pacquet doesn't verify that assumption: if a dev editspackage.json(adds, removes, or bumps a dependency) without re-runningpnpm install --lockfile-only, pacquet happily installs whatever the lockfile says, masking the drift.Upstream pnpm catches this at the dispatch site. The frozen-lockfile path calls
satisfiesPackageManifest(importer, pkg)against every project'spackage.json(pkg-manager/core/src/install/index.ts:813-832) and throwsERR_PNPM_OUTDATED_LOCKFILEwith a detailed reason when the lockfile is stale.This is a Tier 1 Foundation item on the Roadmap (#299): the freshness check belongs in pacquet's frozen-lockfile dispatch alongside the partial-install path that just landed in #442. Adding it closes the CI-correctness gap where stale
pnpm-lock.yamlsilently installs the wrong shape ofnode_modules.How upstream does it
References at pnpm v11
94240bc046:pkg-manager/core/src/install/index.ts:781-832. Before linking, pnpm callsallProjectsAreUpToDate(projects, ...)to decide whether a frozen install is possible; the frozen-lockfile-explicit path then callssatisfiesPackageManifestper project and throwsOUTDATED_LOCKFILEon mismatch.lockfile/verification/src/satisfiesPackageManifest.ts. Returns{ satisfies: boolean, detailedReason?: string }. The detailed-reason strings are what pnpm's CLI surfaces — they pinpoint added/removed/modified specifiers so the user can see what drifted.lockfile/verification/src/diffFlatRecords.ts. Buckets entries into added/removed/modified.The check's logic (filtered to what's relevant for pacquet today — single importer, no catalogs, no workspaces, no
link:filtering):importers["."]).devDependencies ∪ dependencies ∪ optionalDependenciesfrom the manifest must match the importer's specifiers exactly (no added / no removed / no modified entries). pnpm reports a per-bucket diff in the error message.publishDirectorymatchespublishConfig.directory.dependenciesMetamatches.dependencies/devDependencies/optionalDependencies):Today
Pacquet:
Lockfile+PackageManifesttypes with all the fields the check needs (crates/lockfile/src/project_snapshot.rs,crates/lockfile/src/resolved_dependency.rs,crates/package-manifest/).react: { specifier: ^17, version: 17.0.2 }), so the comparison is per-dep-specifier rather than against a top-levelspecifiersmap. Simpler than upstream's v6/v7-compatible shape.satisfiesPackageManifestnorallProjectsAreUpToDate.Install::rundispatches directly intoInstallFrozenLockfile::runwhenfrozen_lockfile == trueand a lockfile is present (crates/package-manager/src/install.rs:195-220). Drift is silently ignored.Plan
A. Port the check
satisfies_package_manifest(importer: &ProjectSnapshot, manifest: &PackageManifest) -> Result<(), DetailedReason>helper. House it incrates/lockfile(next to theLockfiletype it operates on) orcrates/package-manifest(next to the manifest type) — whichever crate already depends on the other. The body is mostly a per-field structural comparison; no async I/O.diffFlatRecordsas a small helper. Used inside the check to produce a user-readable list of added/removed/modified entries for the detailed reason.DetailedReasonis the structured error variant — keep it as a typed enum (NoImporter,SpecifiersDiffer { added, removed, modified },DepCountMismatch { field },DepSpecifierMismatch { field, name, expected, found },VersionDoesNotSatisfy { field, name, version, range }, etc.) so theDisplayimpl produces messages structurally equivalent to upstream's strings, and tests can match on the discriminant rather than format.B. Wire it into
Install::runfrozen_lockfilebranch, call the check against the root importer +manifestbefore dispatching intoInstallFrozenLockfile. On mismatch, return anInstallError::OutdatedLockfile { detailed_reason: DetailedReason }.ERR_PNPM_OUTDATED_LOCKFILEviamiettediagnostic withcode(pacquet_package_manager::outdated_lockfile)and the hint from pnpm's site (Note that in CI environments this setting is true by default. If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile").C. Tests
crates/package-manager/src/install/tests.rs:frozen_lockfile_errors_when_manifest_drifts_from_lockfile. Fixture manifest + lockfile where the manifest has a dep the lockfile doesn't (or vice versa); the install should fail withOutdatedLockfilebefore any fetch attempt. Easy to verify: point the lockfile at a bogus tarball URL so a fetch attempt would also fail — only the earlyOutdatedLockfilemakes the test pass cleanly.Interactions / out of scope
allCatalogsAreUpToDateis part of the sameallProjectsAreUpToDatepipeline; pacquet doesn't support catalogs yet so this stays absent.linkedPackagesAreUpToDateonly matters when workspace packages exist. Scope to single-importer until Add workspace support topacquet install --frozen-lockfile#431 lands; the per-importer loop reduces to one iteration here.link:/file:filtering. Upstream'sexcludeLinksFromLockfiletoggle and thepickNonLinkedDepsfilter live on top of the basic check. Until pacquet supportslink:resolutions (Add workspace support topacquet install --frozen-lockfile#431 territory), no-op these.preferFrozenLockfile. Upstream'sallProjectsAreUpToDateis also called whenpreferFrozenLockfileis set, to decide whether to attempt a frozen install. Pacquet doesn't have writable-lockfile installs yet (chore(gitignore): claude local settings and hooks #338-ish), so the only call site isfrozen_lockfile == true.auto-install-peers. The peer-deps pre-pass in upstream's check (lines 21-30) compounds peer deps into the manifest's effective dep set. Pacquet has no separate auto-install-peers mode; the lockfile is generated by pnpm with peers already resolved. Skip for now.Related
--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433 / merged in feat: partial install with --frozen-lockfile (#433) #442pacquet install --frozen-lockfile#431Written by an agent (Claude Code, claude-opus-4-7).