Repository navigation
Closed
Labels
area: lockfilearea: monorepoEverything related to the pnpm workspace featureEverything related to the pnpm workspace featuretype: refactor
Description
Found by the defect-hotspots skill (pnpm/pnpm#17021) on 2026-10-17 over 90 days of main (086165da7a). The root cause comes from reading the commits and code; it was not reproduced. The design steps are proposals for a maintainer to settle.
Loop: Injected workspace dependencies (about 20 fixes, 2 open)
Properties.
- A: non-frozen and frozen installs agree on whether the lockfile matches every injected package's manifest, with shared and per-project lockfiles.
- B: an injected copy holds the publish directory's packlist whichever path wrote it.
Members.
- A: fix: update injected workspace dependencies when lockfile is not shared pnpm#15356 (the origin), fix: reject frozen install when a workspace dependency drifts from the lockfile pnpm#15435, fix: refresh injected copies on a repeat install pnpm#15679, fix(install): accept an injected dependency's peer resolved to a workspace link pnpm#16346, fix(package-manager): accept injected optional peers in frozen installs pnpm#16429, fix(lockfile): accept injected catalog peers in frozen installs pnpm#16559, fix(install): keep the repeat-install fast path for injected workspace deps pnpm#16404, fix(package-manager): reuse the lockfile when injected projects come from memory pnpm#16815. Open: injectWorkspacePackages: true skips changed injected dependency manifests during optimistic install (12.14.0) pnpm#17007 (v12), 11.x: --frozen-lockfile accepts stale injected workspace dependencies with separate lockfiles pnpm#17008 (v11).
- B: feat(injected-deps-syncer): sync injected copies after the named scripts pnpm#13834, fix(install): refresh injected copies after a workspace package's own install scripts pnpm#15446, fix(install): hard link injected packages with sharedWorkspaceLockfile false pnpm#15685, fix: build an injected workspace dependency's publish directory before relying on it pnpm#15702, fix: take injected sync sources from the manifests read before scripts ran pnpm#15751, fix: publish injected dependency edits while scripts run pnpm#15881, fix(workspace): stop linking node_modules into the publish directory pnpm#16233, fix(install): sync only the package files into injected copies with dedicated lockfiles pnpm#16684, fix(injected-deps-syncer): diff injected copies without parsing their manifests pnpm#16749.
Flaw.
- Five deciders of "is this injected". The fast path decides from a partial project list, which likely explains injectWorkspacePackages: true skips changed injected dependency manifests during optimistic install (12.14.0) pnpm#17007. That was inferred from code, not reproduced.
- The frozen freshness check inverts the resolver by hand (
directory_deps.rs,peers.rs,spec.rs) and gained one resolver rule per fix. v11 has the same inverse but skips it on frozen installs (skipLocalDirectoryDependencies), which is 11.x: --frozen-lockfile accepts stale injected workspace dependencies with separate lockfiles pnpm#17008. That is why v11 never hitpnpm install --frozen-lockfilerejects a freshly generated lockfile when an injected workspace package has peer dependencies pnpm#16332,pnpm install --frozen-lockfilestill rejects a fresh lockfile when an injected package's peer is optional and satisfied by a sibling importer pnpm#16428, or --frozen-lockfile fails with "local dependency is outdated" when an injected workspace package has a catalog: peer dependency pnpm#16557. - Several sources for an injected copy's manifest, directory, and file selector.
Design change.
- Read injection from the lockfile (
injected_deps(lockfile, importer)). This fixes injectWorkspacePackages: true skips changed injected dependency manifests during optimistic install (12.14.0) pnpm#17007 in one PR. - One
InjectedSourceper directory, used by the resolver, freshness, fetch, and both syncers. - Record an injected package's declared specifiers in its
packages:entry. Freshness becomes plain equality, and v11 can drop the frozen skip, which fixes 11.x: --frozen-lockfile accepts stale injected workspace dependencies with separate lockfiles pnpm#17008.
Tests.
- Sequence matrix in both stacks: {shared, per-project lockfile} × {three injection forms} × {edit dep, peer, optional peer, catalog peer, none} × {filtered, not}. Steps: install, mutate, install, then frozen install from a clean tree.
- Copy-equivalence: every writer's copy equals the packlist.
The shared test harnesses that cover this loop and the others are tracked in #141.
Written by an agent (Claude Code, claude-opus-5-5).
Metadata
Metadata
Assignees
Labels
area: lockfilearea: monorepoEverything related to the pnpm workspace featureEverything related to the pnpm workspace featuretype: refactor
Activity
zkochan commentedon Oct 17, 2026
Step 1 (read injection from the lockfile) is in pnpm/pnpm#17025, which also fixes pnpm/pnpm#17007. Steps 2 and 3 are still open.
Written by an agent (Claude Code, claude-opus-5-5).
fix(package-manager): read injected dependencies from the lockfile in…
zkochan commentedon Oct 17, 2026
Step 2, property B (file selection): pnpm/pnpm#17033 makes every writer of an injected copy select the files the install selects. That covers the lifecycle refresh during install, the
syncInjectedDepsAfterScriptssync, and the edit watcher, in both v11 and v12. The symlink difference it leaves is filed separately. pnpm/pnpm#17022 is the v11 fix for pnpm/pnpm#17008, and it has a changes-requested review.Written by an agent (Claude Code, claude-opus-5-5).
test(cli): let the oracle accept the injected dedicated-lockfile fast…
fix: sync only the package files into injected copies after scripts (#…
zkochan commentedon Oct 17, 2026
Progress:
Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 17, 2026
Progress:
publishConfigwhen a Node-APIdependency_manifestand the importer manifest differ.Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 17, 2026
The last step-2 item is not a bug in practice. Only
@pnpm/napiembedders can makepublishConfigdiffer between a project's importer manifest and itsdependencyManifest. The embedders that usedependencyManifest(Bit) strip workspace-sibling dependencies, notpublishConfig. The resolver readspublishConfigfrom the dependency view. The syncer reads it from the importer manifest, which is the only one the lifecycle runner holds. Plumbing the other view through for a divergence no caller produces isn't worth it now. If an embedder ever needs it, that's where to start.Step 2 is done apart from that. #147 tracks the symlink follow-ups. What remains is step 3: record an injected package's declared specs in its
packages:entry and make freshness an equality check. That changes the lockfile format in both versions.Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 17, 2026
Step 3 design proposal: record an injected package's declared dependencies
Status: proposal for a maintainer to settle. Nothing implemented.
Goal
The lockfile freshness check for an injected (
directory-resolved) package becomes an equality comparison between what the lockfile recorded and what the package declares now. Today it compares the manifest against the resolved snapshot by inverting the resolver's rules:directory_deps.rs,peers.rs,spec.rslocalDirectoryDepIsUpToDate, skipped on frozen installs; fix: reject frozen install when an injected package's dependencies drift with separate lockfiles pnpm#17022 turns it onImporters already work by equality: each records its
specifier.Proposed format
Only
packages:entries with adirectoryresolution get the new field:dependencies: v11 mergespackages:andsnapshots:entries in memory, and snapshots already usedependenciesfor resolved versions. Alternatives arespecifiers(mirroring the importers'specifier) ormanifest.package.jsonforfile:;devDependenciesare left out, because an injected copy doesn't install them;peerDependenciesandpeerDependenciesMetafields stay as they are (the resolver's post-hook values).Why raw, pre-hook values
Every transformation between the raw manifest and the snapshot is already tracked by its own lockfile field:
overridesoverrides:packageExtensionspackageExtensionsChecksumreadPackagepnpmfileChecksum,untrackedPnpmfileReadPackageHookcatalogs:, importers only, see question 3Recording raw values means the freshness check never runs hooks. That would let the
injected_manifestshooking step from pnpm/pnpm#17047 go away again.Freshness after the change
^1.0.0→^1.0.1with 1.0.5 locked) triggers a re-resolve, as it does for importers.Compatibility
packages:field when they save the lockfile: v12'sPackageMetadatahas no catch-all, and v11'sconvertToLockfileObjectkeeps an allow-listed set of keys. In a team mixing versions, the field appears and disappears in diffs until everyone upgrades. The fallback keeps frozen installs working throughout.Questions to settle
declared,specifiers, ormanifest), and whether peers are recorded raw inside it as proposed.catalogs:to the catalog entries injected packages use, which keeps the recorded values raw. The alternative is to recordcatalog:specs already dereferenced, which mixes raw and resolved values in one field.--frozen-lockfile: keep the fallback as proposed, or fail and require one non-frozen install. Failing would break CI for every injected workspace until it re-locks.9.0and rely on the fallback, or bump it so that a missing field can be treated as outdated and the inverse check deleted sooner.Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 17, 2026
Step 3, revised: share the resolver's rules instead of recording specs
Status: replaces the proposal above, which is withdrawn. A lockfile field would be additive for readers, but older pnpm versions drop it when they save. Mixed-version teams would see it churn, and the hand-written check could only go after a lockfile-version bump.
Approach
The freshness check of an injected (
directory-resolved) package calls the resolver's own rules instead of inverting them by hand. The resolver already has both halves:child_specs_of:extract_children, specs shadowed by the package's own peers dropped,catalog:specs resolveddirectory_deps.rscheck_local_dep_group_freshness,peers.rsPeerShadowingkeeps_locked_version, the reuse gatespec.rsspec_satisfies_snapshot_depBoth resolver functions currently take the resolver's internal context. The work is to split out context-free versions that the resolver and the freshness check both call. After that,
peers.rsandspec.rsare deleted. A resolver rule added later applies to the check without a second edit, which is the loop this task describes.No lockfile change: no new field, no
lockfileVersionbump, and nothing changes for older pnpm versions.Plan
resolving-deps-resolverinto context-free form. First, run them beside the current check in the freshness tests and the injected e2e suites, so differences surface. Then switch the check over and delete the hand-written rules.getNonDevWantedDependencies, peer handling, and the locked-version reuse check), replacing the body oflocalDirectoryDepIsUpToDate. fix: reject frozen install when an injected package's dependencies drift with separate lockfiles pnpm#17022 either lands first and gets simplified by this, or is folded in.Still unchanged
The check keeps running pnpmfile hooks and
packageExtensions, because the resolver runs them. pnpm/pnpm#17047 supplies the hooked manifests for a project with its own lockfile.Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 17, 2026
Step 3 progress (revised approach). The v12 part is done:
manifest_child_specs, peer shadowing, and catalogs through onewalked_child_specsshared with the walk and the warm-up. This fixed frozen-install rejections forbundledDependenciesand for a name declared in bothdependenciesandoptionalDependencies.locked_child_admits) whether a locked version still satisfies a spec. This fixed named-registry spec changes going unnoticed, and the resolver reusing the wrong package for a bare named-registry spec.file:,link:andworkspace:specs (spec.rs) and the peer comparison (peers.rs, which already uses the resolver'speer_shadowed_dependency_namesandown_dependency_names). The resolver has no reuse gate for local specs, because it resolves them every time.Open: the v11 part. Either
localDirectoryDepIsUpToDatecalls v11's resolver functions, or pnpm/pnpm#17022 lands as v11's fix (it has a changes-requested review).Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 17, 2026
Step 3 progress: the v11 part is in pnpm/pnpm#17065 (draft until CI is green).
localDirectoryDepIsUpToDatenow uses the resolver's rules, as the v12 check does:getNonDevWantedDependenciesandprepareDependencyManifest(newly extracted) decide the children.peerDependenciesWithoutOwngives the peers.lockedVersionSatisfies, built ongetRangeOfLockedPackage, judges a locked version.skipLocalDirectoryDependenciesis removed.file:directory without apackage.jsonused to fail. It now passes.After pnpm/pnpm#17065, step 3 is done in both stacks. The sequence-matrix tests are still open.
Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 18, 2026
pnpm/pnpm#17065 merged, so step 3 is done in both stacks, and pnpm/pnpm#17008 is closed. Two things changed from the earlier progress note:
pnpm deploystill skips the check. A deploy lockfile records bound peers as plain dependencies, so the check would reject it.--frozen-lockfileinstall accepts what the lockfile recorded. A normal install re-resolves.Next: the sequence-matrix tests from the issue. In v12 they will follow
repeat_install_oracle.rs(#141). The copy-equivalence tests are still open too.Written by an agent (Claude Code, claude-opus-5-5).
zkochan commentedon Oct 18, 2026
The sequence-matrix tests are in pnpm/pnpm#17078, for both CLIs. Each runs 60 cases: {shared, per-project lockfile} × {three injection forms} × {no edit, a dependency, a peer, an optional peer, a catalog peer} × {filtered, full}.
catalog:peer of an injected package is recorded in the lockfile. That is v11/v12 parity: catalog peer of an injected package recorded differently in the lockfile #153.Still open: the copy-equivalence tests.
Written by an agent (Claude Code, claude-opus-5-5).
test: check edits to injected packages across a layout matrix (#17078)
zkochan commentedon Oct 18, 2026
The copy-equivalence tests are in pnpm/pnpm#17083, for both CLIs. Every writer of an injected copy must leave exactly the files the install selects: the install with its refresh after
prepare, the sync after a listed script, the same sync after a source edit, and a frozen install into a clean tree. The cases cover {shared, per-project lockfile} × {isolated, hoisted} ×deployAllFiles.The tests found a bug in both CLIs: with
sharedWorkspaceLockfile: false, the sync after asyncInjectedDepsAfterScriptsscript, and the edit watcher, never updated the injected copies. The same PR fixes it.That is the last open item from this task.
Written by an agent (Claude Code, claude-opus-5-5).
fix: sync injected copies after listed scripts without a shared lockf…
zkochan commentedon Oct 18, 2026
All steps are done:
syncInjectedDepsAfterScriptsscripts without a shared lockfile, in both CLIs. It also fixed v11's sync of a package with apublishConfig.directory.Follow-ups tracked separately:
catalog:peer of an injected package differently.Written by an agent (Claude Code, claude-opus-5-5).
fix(lockfile): reject a changed tarball URL or git commit under a loc…