feat: native dependency patching (npm patch add/commit/update/ls/rm) (#9439)
Implements native dependency patching per [RFC
#862](https://github.com/npm/rfcs/pull/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 `patchedDependencies` field of the root
`package.json`, stored as plain unified diffs under `patches/`, and
recorded with a content hash in `package-lock.json`. Because the patch
is applied during the install itself, it works for transitive
dependencies, across every `install-strategy`, and is **not** disabled
by `--ignore-scripts`.
## The `npm patch` command
A new command with five subcommands (and a bare `npm patch <pkg>`
shorthand for `add`):
- **`npm patch add <pkg>[@<version>]`** — extracts a clean copy of the
resolved registry tarball into a temp directory outside `node_modules`
and 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 the `patchedDependencies`
entry, and reifies to apply the patch and record its integrity in the
lockfile. `package.json` is 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); `commit` warns 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
`--to` or the lockfile, 3-way-merges the existing patch onto the new
tarball in a throwaway git repo, and rewrites `package.json` +
`package-lock.json` **without touching `node_modules`** (so it works
from a failed-install state). On conflict it leaves an edit dir with
`<<<<<<<` markers, finalized by `npm 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:
- **`resolvePatchedDependencies`** resolves the root
`patchedDependencies` map against the ideal tree, attaching
`node.patched = { path, integrity }` to each matched node. Selector
precedence is exact > range-subset > name-only, with ambiguous
overlapping ranges surfaced as a hard error.
- **reify** applies the diff after extraction and records the patched
integrity in the lockfile. `diff.js` forces 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=linked`** is 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: 4` so 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
pinned `lockfile-version` (the safety gate cannot be honored otherwise).
`npm ci` revalidates 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-patches` and `--ignore-patch-failures` — and are
rejected in `npm ci` and 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 with `EPATCHNONREGISTRY`, both by `npm
patch add` and 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; the `npm patch
add <alias>` ergonomics will land in a fast-follow.
## Publish / pack
`patchedDependencies` is stripped from the published **registry
manifest** (libnpmpublish) so the field never leaks to the packument.
Stripping it from the **tarball's own `package.json`** and excluding the
`patches/` directory from the tarball is a coordinated follow-up in
`pacote` + `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 ls` annotates patched dependencies in its output.
- New config: `patches-dir`, `edit-dir`, `ignore-existing`,
`keep-edit-dir`, plus the two relax flags.
- New `npm-patch` man 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
`patchedDependencies` from the tarball's own `package.json` and
excluding the `patches/` directory from the published tarball. This
can't be done in the CLI: the tarball's file list and manifest come from
`pacote` (packs the raw on-disk files) and `npm-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 for `npm:` registry aliases** —
the install engine already treats `npm:` aliases as registry
dependencies and applies a hand-written `<alias>@<version>` selector
correctly today. What remains is the `add`/`commit` convenience:
resolving the alias to its real `name@version` tarball as the baseline
and keying the written selector on the alias name. Currently `npm 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 https://github.com/npm/rfcs/pull/862 M
Manzoor Wani committed
1db885c84b2dfc5126ab663abb12262b533922c1
Parent: fc80bb3
Committed by GitHub <noreply@github.com>
on 6/18/2026, 4:19:00 PM