Skip to content

feat(cli): ship only the dependency patches a code bundle applies [RED-893] [ship] - #1446

Merged
sorccu merged 3 commits into
mainfrom
simo/red-893-prune-unused-pnpm-patches
Aug 24, 2026
Merged

feat(cli): ship only the dependency patches a code bundle applies [RED-893] [ship]#1446
sorccu merged 3 commits into
mainfrom
simo/red-893-prune-unused-pnpm-patches

Conversation

@sorccu

@sorccu sorccu commented Aug 24, 2026

Copy link
Copy Markdown
Member

Linear: RED-893

Affected Components

  • CLI
  • Test
  • Docs

What and why

A code bundle ships a subset of the workspace but the workspace's entire
pnpm patchedDependencies map. A patch whose package belongs to an unbundled
member therefore applies to nothing, and pnpm 10/11 abort with
ERR_PNPM_UNUSED_PATCH as soon as they re-resolve.

The victim today is the lockfile pruner's own temp-dir install (RED-886): for
any patched workspace that bundles a subset of itself, pruning fails and the
unpruned lockfile ships with a warning — the outcome pruning exists to prevent.

Three commits, one per phase:

  1. --config.allowUnusedPatches=true on the pnpm lockfile-only install, so
    the prune produces a lockfile instead of failing.
  2. patched-dependencies.ts, a pure planner that decides which declarations
    no longer apply and produces the rewritten config/lockfile bytes.
  3. Wiring into Bundler.finalize(), between pruning and embedded-package
    materialization, mirroring the existing filterTarballsByLockfile pattern.

How "unused" is decided

pnpm records each declaration's hash in the lockfile's patchedDependencies
section and repeats it as patch_hash=<hash> wherever the patch was applied.
The section mirrors the config, not the dependency graph, so pruning leaves an
unused declaration in place — the marker is what tells the two apart.

The test is differential: the marker must be present in the original
lockfile and absent from the pruned one. Absence alone would mean a lockfile
whose markers we cannot read classifies every declaration as unused and strips
them all, which is the one outcome that silently unpatches dependencies.

Notes for the Reviewer

The risk to weigh throughout is dropping a declaration that is genuinely in
force. Everything below is shaped by that.

  • The removable-path allow-list is deliberately narrow. A declared patch
    path is user input that reaches a delete, and it can name any bundled file —
    an .npmrc carrying registry auth, a pnpmfile the lockfile records a checksum
    for, or a .patch fixture a check's own include glob pulled in. Only
    patches/<name>.patch is removable; a patch kept elsewhere loses its config
    and lockfile entry while the file stays (inert). The directory constant is
    shared with getAutoIncludes so the two cannot drift.
  • Cases it deliberately declines, leaving the bundle exactly as pruning
    produced it: a declaration the project's own lockfile never recorded (the pnpm
    that wrote it may not read that declaration site — pnpm 11 ignores the
    package.json pnpm field, and pnpm-workspace.yaml is pnpm 10+); both
    declaration sites populated at once (pnpm honors one wholesale and which one
    depends on the major); any unreadable config or lockfile; any rewrite whose
    reparsed result is not the original minus exactly the removed keys.
  • Atomicity is load-bearing. A half-applied filter ships a config/lockfile
    pair that fails the remote install (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, or
    ENOENT on a missing patch file). The apply step is pure map mutation over an
    already-computed plan — no IO, no parsing.
  • Only the bundled copies are rewritten. The user's files are read and never
    written; a test asserts they are byte-identical after bundling.
  • Cache hash: a rewritten root manifest ships as a synthesized file, so it
    is hashed the way on-disk manifests are, with version stripped. Hashing it
    verbatim would discard the runner's dependency cache on a release bump that
    changed no install input.
  • Lockfile edits are a structural YAML edit, not a second install. Verified
    byte-identical to what pnpm itself writes for a config that never declared the
    patch, and a real-pnpm test asserts exactly that so a future pnpm formatting
    change fails the suite rather than shipping a subtly different lockfile.

Known residual, not addressed here: a rewritten pnpm-workspace.yaml is not
itself a cache-hash input, so the key tracks it only via the pruned lockfile
bytes. Closing that means adding a record type to a hash format with a
documented cross-language mirror, which wants coordinating separately.

pnpm 8/9 are EOL and out of scope. Bun and Yarn are unaffected: bun does not
raise this error, and Yarn Berry encodes patches as patch: locators inside the
lockfile, so pruning already removes them.

Verification

Behaviour verified empirically against pnpm 10.33.4, 10.34.5 and 11.23.0.
Coverage includes real-pnpm tests at three levels: the prune succeeding on a
patched partial-workspace bundle, the YAML edit reproducing pnpm's own bytes,
and an end-to-end case driving Bundler over a committed fixture and asserting
the produced tarball drops the unused patch file, its declaration and its
lockfile entry while keeping the still-applied one. Every fail-safe branch is
mutation-tested. Full unit suite: 2269 passing.

No new dependencies.

Not marked [ship] — release timing is yours to call.

sorccu and others added 3 commits August 25, 2026 06:41
A code bundle carries the workspace's whole pnpm `patchedDependencies` map
but only a subset of its members, so a patch whose package belongs to an
unbundled member applies to nothing. pnpm 10+ then aborts the lockfile
pruner's temp-dir install with ERR_PNPM_UNUSED_PATCH and the unpruned
lockfile ships instead — the outcome pruning exists to avoid.

Pass --config.allowUnusedPatches=true so the prune produces a lockfile.
The now-unused declaration still travels in the bundle; filtering it out
follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…[RED-893]

Adds patched-dependencies.ts: given a bundle's declaring config plus the
lockfile before and after pruning, it works out which `patchedDependencies`
declarations no longer apply and produces the rewritten config and lockfile
bytes that drop them. Nothing calls it yet.

pnpm records each declaration's hash in the lockfile's `patchedDependencies`
section and repeats it as `patch_hash=<hash>` wherever the patch was applied.
The section mirrors the config rather than the dependency graph, so pruning
leaves an unused declaration in place and the marker is what tells the two
apart. The test is differential — the marker must be present before pruning
and absent after — so a lockfile whose markers cannot be read drops nothing
instead of dropping everything.

Every other uncertainty declines the same way, leaving the bundle untouched:
an unreadable config or lockfile, both declaration sites populated at once
(pnpm honors only one, and which one depends on the major), a declaration the
project's own lockfile never recorded, or a rewrite whose reparsed result does
not match the original minus exactly the removed keys. Patch files are dropped
only by normalized archive path, and only when no surviving declaration
references them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the patch planner into the bundler: after the lockfile is pruned,
declarations the pruned lockfile shows no longer apply are removed from the
bundled pnpm config, their patch files are left out of the archive, and the
matching lockfile entries go with them. The whole plan is applied in one pass
of map mutation, because a half-applied filter ships a config and lockfile
that disagree and fails the remote install either way it lands.

Only a `.patch` directly under the conventional `patches/` directory is ever
removed. A declared path can name any bundled file — an .npmrc carrying
registry auth, a pnpmfile the lockfile records a checksum for, a fixture a
check's own include glob pulled in — so a declaration pointing elsewhere loses
its config and lockfile entry while the file stays.

Whatever the step does or declines to do, it then checks what actually ships:
if the bundled config still declares a patch the bundled lockfile does not
record, the CLI says so, since that pair is what fails the runner's install.

A rewritten root manifest ships as a synthesized file, so it is hashed the way
on-disk manifests are, with `version` stripped — hashing it verbatim would
discard the runner's dependency cache on a release bump that changed no
install input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sorccu
sorccu force-pushed the simo/red-893-prune-unused-pnpm-patches branch from 2faa9e3 to 2b1e36f Compare August 24, 2026 21:43
@sorccu sorccu changed the title feat(cli): ship only the dependency patches a code bundle applies [RED-893] feat(cli): ship only the dependency patches a code bundle applies [RED-893] [ship] Aug 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: ship/show PR from a same-repo branch.

@sorccu
sorccu merged commit 5f753ad into main Aug 24, 2026
28 of 30 checks passed
@sorccu
sorccu deleted the simo/red-893-prune-unused-pnpm-patches branch August 24, 2026 22:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant