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

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

@zkochan

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions