Commit 1db885c
authored
feat: native dependency patching (npm patch add/commit/update/ls/rm) (#9439)
Implements native dependency patching per [RFC
#862](npm/rfcs#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 npm/rfcs#8621 parent fc80bb3 commit 1db885c
53 files changed
Lines changed: 4386 additions & 56 deletions
File tree
- docs/lib/content
- commands
- lib
- commands
- utils
- smoke-tests/tap-snapshots/test
- tap-snapshots/test/lib
- commands
- utils
- test/lib
- commands
- utils
- workspaces
- arborist
- lib
- arborist
- tap-snapshots/test
- test
- arborist
- config
- lib/definitions
- tap-snapshots/test
- libnpmpublish
- lib
- test
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
540 | 540 | | |
541 | 541 | | |
542 | 542 | | |
| 543 | + | |
543 | 544 | | |
544 | 545 | | |
545 | 546 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
123 | 126 | | |
124 | 127 | | |
125 | 128 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
62 | 63 | | |
63 | 64 | | |
64 | 65 | | |
| 66 | + | |
| 67 | + | |
65 | 68 | | |
66 | 69 | | |
67 | 70 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
47 | 56 | | |
48 | 57 | | |
49 | 58 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
3 | 4 | | |
4 | 5 | | |
5 | 6 | | |
| |||
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
| 51 | + | |
50 | 52 | | |
51 | 53 | | |
52 | 54 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
152 | 153 | | |
153 | 154 | | |
154 | 155 | | |
| 156 | + | |
| 157 | + | |
155 | 158 | | |
156 | 159 | | |
157 | 160 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| |||
70 | 71 | | |
71 | 72 | | |
72 | 73 | | |
| 74 | + | |
73 | 75 | | |
74 | 76 | | |
75 | 77 | | |
| |||
119 | 121 | | |
120 | 122 | | |
121 | 123 | | |
| 124 | + | |
122 | 125 | | |
123 | 126 | | |
124 | 127 | | |
| |||
145 | 148 | | |
146 | 149 | | |
147 | 150 | | |
| 151 | + | |
148 | 152 | | |
149 | 153 | | |
150 | 154 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
333 | 333 | | |
334 | 334 | | |
335 | 335 | | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
336 | 341 | | |
337 | 342 | | |
338 | 343 | | |
| |||
389 | 394 | | |
390 | 395 | | |
391 | 396 | | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
392 | 401 | | |
393 | 402 | | |
394 | 403 | | |
| |||
0 commit comments