Skip to content
This repository was archived by the owner on Jul 21, 2026. It is now read-only.
This repository was archived by the owner on Jul 21, 2026. It is now read-only.

Frozen-lockfile freshness check: error when package.json drifts from pnpm-lock.yaml #447

Description

@zkochan

Background

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.

How upstream does it

References at pnpm v11 94240bc046:

The check's logic (filtered to what's relevant for pacquet today — single importer, no catalogs, no workspaces, no link: filtering):

  1. Importer must exist in the wanted lockfile (importers["."]).
  2. 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.
  3. publishDirectory matches publishConfig.directory.
  4. dependenciesMeta matches.
  5. 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 OutdatedLockfile before 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.
  • Workspace packages. linkedPackagesAreUpToDate only matters when workspace packages exist. Scope to single-importer until Add workspace support to pacquet install --frozen-lockfile #431 lands; the per-importer loop reduces to one iteration here.
  • 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.

Related


Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions