Skip to content

RFC: Native Dependency Patching#862

Merged
owlstronaut merged 7 commits into
npm:mainfrom
manzoorwanijk:rfc-native-dependency-patching
Jun 5, 2026
Merged

RFC: Native Dependency Patching#862
owlstronaut merged 7 commits into
npm:mainfrom
manzoorwanijk:rfc-native-dependency-patching

Conversation

@manzoorwanijk

@manzoorwanijk manzoorwanijk commented May 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds first-class, install-time patching of installed dependencies to the npm CLI, on parity with pnpm patch, yarn patch, and bun patch. Introduces a new npm patch command with subcommands add / commit / update / ls / rm (with npm patch <pkg> as shorthand for npm patch add <pkg>), a patchedDependencies field in package.json, and a patched.{path,integrity} record in package-lock.json (lockfileVersion: 4). Patches apply during Arborist's reify step, uniformly across every supported install-strategy (hoisted, nested, shallow, linked).

Why now

The third-party patch-package is currently the only path to dependency patching for npm users, and it is structurally limited:

  • Silently disabled by --ignore-scripts. patch-package runs as a postinstall script. In environments that disable lifecycle scripts — increasingly common in hardened CI and after recent supply-chain incidents like the Shai-Hulud worm (Sept/Nov 2025) — declared patches simply do not apply, with no error and no warning. Production code can be installed missing fixes that are committed in the project.
  • Broken with workspaces (ds300/patch-package#277).
  • Broken with install-strategy=linked (ds300/patch-package#595).
  • Unmaintained.

The headline outcome: reproducible, source-controlled dependency hotfixes that survive --ignore-scripts and work across every npm install strategy and across workspaces.

Relationship to #94

This RFC is a direct response to #94 (closed in 2020 as a footgun). The 2020 proposal was an ad-hoc npm install --patch foo.patch flag with no manifest record, no lockfile linkage, no transitive-dep support, and no failure-mode story; @isaacs's footgun objection was correct for that shape. This RFC is structured the opposite way — explicit manifest, lockfile-hashed, fail-loud-by-default, version-gated, publish-isolated. A row-by-row response is in the RFC's Prior Art → #94 section.

See the RFC for the full design, alternatives considered, implementation plan, tests, and unresolved questions.


Disclosure: Claude Code was used to draft this PR description and the initial version of this RFC and to iterate on it during review.

@manzoorwanijk manzoorwanijk requested a review from a team as a code owner May 3, 2026 14:44
@james-pre

james-pre commented May 11, 2026

Copy link
Copy Markdown

Hey @manzoorwanijk,

There are a few questions I have:

  1. How are multiple patches for a single package handled? Let's say I depend on duck-quack-js and want to apply a patch to add a new feature from a PR and another patch to fix a security issue.

  2. How are patches required for dependents handled? Take for example this dependency chain:

  • @ducks/quack patches duck-quack-js to add a feature PR that is required at runtime
  • @ducks/mallard depends on @ducks/quack. npm install @ducks/quack needs to result in the patch being applied otherwise we get runtime errors.
  1. Why not wrap everything under a single npm patch command (npm patch add, ... ls, ... rm, ... commit)?

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Thanks @james-pre - three good questions.

1. Multiple patches for the same package.

Punted in v1: see Unresolved Questions and Bikeshedding item 6 ("Stacking patches"). The RFC matches at most one patch per resolved node, and stacking is left as an additive extension (selector value becomes an array). The reason for deferring is that with fuzz=0 (also v1 default), stack order is load-bearing and composition rules need their own design pass - does order 1 → 2 produce the same tree as 2 → 1? what does npm patch-remove do with a stack? etc.

Practical workaround for now: merge the two diffs into a single .patch file (cat a.patch b.patch | <git-apply-style-merge>, or hand-merge). Not pretty, but unblocks the case until the stacking design lands.

2. Patches needed by dependants / transitive consumers.

This is intentionally not supported, and the constraint is by design rather than oversight. From the RFC:

  • patchedDependencies is honoured only in the root package.json of the consuming project.
  • npm publish / npm pack strip patchedDependencies from the published manifest and exclude the <patches-dir>/ from the tarball.

So in your example, if @ducks/quack publishes a patchedDependencies entry for duck-quack-js, it never travels through the registry, and consumers of @ducks/mallard will not have the patch applied. This is deliberate: a published package being able to silently mutate its consumers' transitive dependency trees is a supply-chain abuse vector we shouldn't introduce. (It would be a strictly bigger lever than postinstall scripts - applied silently, inside the install pipeline, with no --ignore-scripts-style escape hatch.)

The right tool for "I want consumers of @ducks/quack to get a patched duck-quack-js" is Alternative 2 in the RFC: @ducks/quack declares a normal dependency on a published @ducks/duck-quack-js fork (or uses an overrides entry that the consumer also opts into in their root). That makes the patched code a real registry artifact with a normal version, auditable in the lockfile.

3. Why not consolidate under npm patch <subcommand>?

Fair point - the current mix (npm patch, npm patch-commit, npm patch-remove, npm patch ls) is inconsistent, and npm patch add/ls/rm/commit would be more uniform and more idiomatic for npm (compare npm pkg get/set/delete/fix, npm cache add/clean/verify/ls, npm team create/destroy/add/rm/ls). I followed pnpm/yarn naming originally for prior-art familiarity, but you're right that npm's own convention is the better fit.

I'd propose:

  • npm patch <pkg> - shorthand for npm patch add <pkg> (the most common entry point, kept short).
  • npm patch add <pkg> - start an edit session.
  • npm patch commit <edit-dir> - finalise (no dash; aligns with npm pkg set, etc).
  • npm patch ls - list registered patches.
  • npm patch rm <pkg>[@<version>] - remove a patch.

Happy to update the RFC to use this shape if there's broader agreement.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

The rename is done — npm patch add / commit / ls / rm, with npm patch <pkg> kept as shorthand for npm patch add <pkg>. RFC and PR description updated.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

@owlstronaut, while this RFC is still in review, is it fine to create a draft PR of the implementation in the CLI repo for demonstration?

@owlstronaut

Copy link
Copy Markdown
Contributor

@owlstronaut, while this RFC is still in review, is it fine to create a draft PR of the implementation in the CLI repo for demonstration?

Absolutely. Love the idea

…atch-failure carve-out, lockfile-version pin upgrade)
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Here is the first PR (draft) npm/cli#9439. The PR is feature complete, and the only part left is to support patching for aliases via npm:, which I will do in a follow-up. Please let me know if I should mark the PR as ready for review.

@ehoogeveen-medweb

Copy link
Copy Markdown

Under this proposal, what would be the practical workflow for updating an existing patch that still applies to a new version?

With patch-package, it still attempts to apply patches to newer versions (but prints a warning), so you just run npx patch-package package-name to generate a new patch from the already modified files, then delete the old patch. Obviously things get more complicated if there are conflicts.

With the proposed native patching, I think you'd have to do something like this:

npm install <pkg>@new-version --allow-unused-patches

npm patch add <pkg>@new-version

pushd /path/printed/by/npm/patch/add
git apply --ignore-whitespace /path/to/project/patches/<pkg>@old-version.patch
popd

npm patch commit /path/printed/by/npm/patch/add

npm patch rm <pkg>@old-version

...and then commit the changes.

It would be great if there was a better way like an explicit npm patch update <pkg>.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Under this proposal, what would be the practical workflow for updating an existing patch that still applies to a new version?

npm patch update is a great idea. Thanks. Let me add that to the proposal.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Added npm patch update <pkg>[@<old-version>] [--to <new-version>] to the RFC: resolves the existing entry, extracts a fresh copy of the new version, attempts a 3-way git apply of the existing patch, and either re-commits cleanly (renaming the selector, deleting the old patch file subject to the shared-reference rule) or leaves the conflict markers in the edit dir for you to resolve and re-run npm patch commit. Single command for the happy path, same finalisation flow for the conflict path. Runnable without a successful prior install - designed so it can recover from a fuzz-drift-aborted install.

Comment thread accepted/0000-native-dependency-patching.md Outdated
Comment thread accepted/0000-native-dependency-patching.md Outdated
Comment thread accepted/0000-native-dependency-patching.md
Comment thread accepted/0000-native-dependency-patching.md
@owlstronaut owlstronaut merged commit 5dd695c into npm:main Jun 5, 2026
6 of 7 checks passed
@manzoorwanijk manzoorwanijk deleted the rfc-native-dependency-patching branch June 5, 2026 14:30
owlstronaut pushed a commit to npm/npm-packlist that referenced this pull request Jun 5, 2026
#291)

Part of native dependency patching
([npm/rfcs#862](npm/rfcs#862)). This
force-excludes the patch files declared in the root package's
`patchedDependencies` from the packed file list, even when they are
listed in `files`.

## Why

`patchedDependencies` maps a dependency selector to a project-local
patch file (e.g. `"abbrev@2.0.0": "patches/abbrev@2.0.0.patch"`). Those
patches are a property of the project, not something a consumer of the
published package applies. Without this, publishing a patched project
would ship the patch files — and, once pacote strips the
`patchedDependencies` field from the tarball's `package.json`, they
would be dangling, unreferenced files. Excluding them keeps the
published tarball clean.

## How

In `PackWalker.processPackage`, when the walker is the project root and
the manifest declares `patchedDependencies`, each declared patch file
path is pushed onto the strict (un-overridable) rule set, so it is
excluded even if `files` lists it. Design choices:

- **Exact files, not directories.** Only the declared patch files are
excluded — never their directory. A dedicated `patches/` dir becomes
empty and drops out naturally, but a patch that lives in a shared
directory (e.g. `src/foo.patch`) does not take the rest of `src/` down
with it.
- **`--patches-dir` honored for free.** The location is read straight
off the `patchedDependencies` values, which already encode wherever the
patches were written.
- **Root-only.** `patchedDependencies` is root-only state, so the block
is gated to the project root and never prunes a bundled dependency's
files.
- **Path safety.** Absolute paths and paths that escape the package root
are skipped (they are never packed anyway).
- **Warns** when a `files` entry pulled a patch file in (directly or via
its directory), so the override is not silent.

## References

Part of
- npm/rfcs#862

Related to
- npm/cli#9439
- npm/pacote#497
owlstronaut pushed a commit to npm/pacote that referenced this pull request Jun 5, 2026
Part of native dependency patching
([npm/rfcs#862](npm/rfcs#862)). When packing a
`directory` spec (the `npm publish` / `npm pack` path), this strips a
top-level `patchedDependencies` field from the `package.json` written
**into the tarball**.

## Why

`patchedDependencies` declares project-local patches against installed
dependencies. It is honored only in a root manifest, so it is
meaningless to consumers of a published package and should never travel
through the registry. The published *packument* manifest is already
stripped in `libnpmpublish`; this closes the other half — the
`package.json` inside the tarball itself — so `npm pack --dry-run` and
the published tarball no longer carry the field. It pairs with the
npm-packlist change that excludes the patch files themselves; together
they guarantee a patched project publishes clean.

## How

`DirFetcher` packs the raw on-disk files via `tar.c`, so the tarball's
`package.json` is the literal file on disk — there is no manifest seam
to edit. The new `#tarOptions()`:

1. Reads the on-disk `package.json` (after `prepare`) via
`@npmcli/package-json`. If it has no `patchedDependencies`, returns the
existing options unchanged — **non-patched packs are byte-for-byte
identical to before**.
2. Otherwise deletes the field and re-serializes preserving the original
indent, newline, and key order (the indent/newline symbols
`@npmcli/package-json` attaches; `JSON.stringify` ignores them), writes
the stripped copy to a temp dir, and removes the temp dir if the write
fails.
3. Sets node-tar's `onWriteEntry` to redirect **only** the top-level
`package.json` entry's `absolute` at the stripped copy and fix its
`stat.size`/`nlink`. `onWriteEntry` runs before the header and the
file's hardlink check, so the override is honored; every other file is
untouched.
4. The temp dir is removed once the tar source stream emits
`end`/`error`, so it outlives content consumption.

No behavior change for any package without `patchedDependencies`.

## References

Part of
- npm/rfcs#862

Related to
- npm/cli#9439
- npm/npm-packlist#291
owlstronaut pushed a commit to npm/cli that referenced this pull request Jun 18, 2026
…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#862
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.

5 participants