feat!: native dependency patching (npm patch add/commit/update/ls/rm)#9439
Open
manzoorwanijk wants to merge 28 commits into
Open
feat!: native dependency patching (npm patch add/commit/update/ls/rm)#9439manzoorwanijk wants to merge 28 commits into
manzoorwanijk wants to merge 28 commits into
Conversation
This was referenced May 30, 2026
f28737a to
e4eaf2a
Compare
…atches, surface ls ambiguity
…, scope ls ambiguity to ranges
…s under install-strategy=linked
…raw patch FS errors
…s cli-only and reject them in npm ci
…tent-addressed side-store
…on skipped linked patches, and exclude store nodes from the registry check
…roject-root containment check
…he patched files are reverted
…s (linked store, extraneous)
…too and cover the mixed registry/file: case
e4eaf2a to
d745145
Compare
d745145 to
26264da
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements native dependency patching per RFC #862: a first-class way to apply small, local modifications to an installed dependency and have them re-applied automatically on every install, with no external tooling or postinstall scripts.
Patches are declared in a new
patchedDependenciesfield of the rootpackage.json, stored as plain unified diffs underpatches/, and recorded with a content hash inpackage-lock.json. Because the patch is applied during the install itself, it works for transitive dependencies, across everyinstall-strategy, and is not disabled by--ignore-scripts.The
npm patchcommandA new command with five subcommands (and a bare
npm patch <pkg>shorthand foradd):npm patch add <pkg>[@<version>]— extracts a clean copy of the resolved registry tarball into a temp directory outsidenode_modulesand prints the path to edit. Ambiguous when multiple versions are installed; the error lists the exact selectors to retry with.npm patch commit <edit-dir>— diffs the edited directory against a fresh copy of the original tarball, writes<patches-dir>/<name>@<version>.patch, adds thepatchedDependenciesentry, and reifies to apply the patch and record its integrity in the lockfile.package.jsonis excluded from the diff — Arborist resolves the pre-patch manifest, so a patched manifest would change resolution-affecting fields on disk without being honored (silent partial application);commitwarns when an edit only touches it.npm patch update <pkg>[@<old-version>] [--to <new-version>]— rebases an existing patch onto a new version. It reads the target from--toor the lockfile, 3-way-merges the existing patch onto the new tarball in a throwaway git repo, and rewritespackage.json+package-lock.jsonwithout touchingnode_modules(so it works from a failed-install state). On conflict it leaves an edit dir with<<<<<<<markers, finalized bynpm patch commit. Exact selectors are renamed; range/name-only selectors gain a new exact entry and keep the old one while it still wins another installed node.npm patch ls— lists registered patches and how many installed nodes each matches (flagging overlapping range selectors that conflict on a node).npm patch rm <pkg>[@<version>]— removes the matching entries, deletes the patch file when no other entry references it, and reifies to revert the files.Install-time apply pipeline
Patch resolution and application live in Arborist so every install path honors them:
resolvePatchedDependenciesresolves the rootpatchedDependenciesmap against the ideal tree, attachingnode.patched = { path, integrity }to each matched node. Selector precedence is exact > range-subset > name-only, with ambiguous overlapping ranges surfaced as a hard error.diff.jsforces re-extraction when a node's patch integrity changes, and re-extracts to revert when a previously-patched node loses its selector (patchRemoved).install-strategy=linkedis supported via a content-addressed side-store: the store key is suffixed with the patch identity (+patch) so a patched and unpatched copy of the same version coexist without collision. A failed patch under linked strategy is always a hard error (the side-store cannot represent unpatched contents at a patched key without later installs silently trusting it).Lockfile
Patches require
lockfileVersion: 4so that older npm clients abort rather than silently installing unpatched code. When any node is patched, npm writes version 4 and warns if this upgrades a lower pinnedlockfile-version(the safety gate cannot be honored otherwise).npm cirevalidates each patch's existence and integrity against the lockfile before installing.Failure modes
By default any patch problem is a hard error that aborts the install: a patch that fails to apply, a registered patch that matches no installed package, a missing patch file, or a patch whose hash does not match the lockfile. Two CLI-only relax flags cover one-off cases —
--allow-unused-patchesand--ignore-patch-failures— and are rejected innpm ciand when set anywhere other than the command line.Non-registry dependencies
Patches need a stable registry tarball as their baseline, so a dependency reached through a non-registry consumer edge (
file:,git:,http(s):) is rejected withEPATCHNONREGISTRY, both bynpm patch addand at install time. The check is edge-based (the consuming spec's type), not node-based, so it does not falsely reject edgeless nodes such as linked-store entries or extraneous installs, which are still registry deps.npm:registry aliases are correctly classified as registry deps and are supported by the install engine; thenpm patch add <alias>ergonomics will land in a fast-follow.Publish / pack
patchedDependenciesis stripped from the published registry manifest (libnpmpublish) so the field never leaks to the packument. Stripping it from the tarball's ownpackage.jsonand excluding thepatches/directory from the tarball is a coordinated follow-up inpacote+npm-packlist(those packages own the packed file list and the manifest written into the tarball, neither editable from the CLI) — see Follow-up work.Other surfaces
npm lsannotates patched dependencies in its output.patches-dir,edit-dir,ignore-existing,keep-edit-dir, plus the two relax flags.npm-patchman page and nav entry.Tests
Unit and integration coverage for every subcommand (including
update's clean rebase, conflict→commit, and selector-rename/range-fork paths), the apply pipeline, selector matching, linked-strategy apply/removal, lockfile validation, publish stripping, and the relax flags. Arborist and CLI suites pass at 100% coverage.Follow-up work
A few additive pieces are deliberately deferred — nothing in this PR depends on them.
Tarball-side strip for publish/pack — stripping
patchedDependenciesfrom the tarball's ownpackage.jsonand excluding thepatches/directory from the published tarball. This can't be done in the CLI: the tarball's file list and manifest come frompacote(packs the raw on-disk files) andnpm-packlist, so it needs coordinated changes there. Raised in the RFC review; the registry-manifest strip in this PR already prevents the field from being honored or appearing in the packument.npm patch add <alias>ergonomics fornpm:registry aliases — the install engine already treatsnpm:aliases as registry dependencies and applies a hand-written<alias>@<version>selector correctly today. What remains is theadd/commitconvenience: resolving the alias to its realname@versiontarball as the baseline and keying the written selector on the alias name. Currentlynpm patch add <alias>resolves the alias name as a real package and fails.Binary files — patches are unified text diffs, so binary files (images, wasm, native addons) cannot be patched. This is a limitation of the whole feature (shared with
patch-package), not a regression; a binary-aware path could be added later.References
Implements npm/rfcs#862