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
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.

4 participants