Skip to content

feat: native dependency patching (npm patch add/commit/update/ls/rm)#9439

Open
manzoorwanijk wants to merge 32 commits into
npm:latestfrom
manzoorwanijk:feat/native-dependency-patching
Open

feat: native dependency patching (npm patch add/commit/update/ls/rm)#9439
manzoorwanijk wants to merge 32 commits into
npm:latestfrom
manzoorwanijk:feat/native-dependency-patching

Conversation

@manzoorwanijk
Copy link
Copy Markdown
Contributor

@manzoorwanijk manzoorwanijk commented May 30, 2026

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

@manzoorwanijk manzoorwanijk force-pushed the feat/native-dependency-patching branch 2 times, most recently from f28737a to e4eaf2a Compare June 2, 2026 15:10
@manzoorwanijk manzoorwanijk marked this pull request as ready for review June 3, 2026 22:22
@manzoorwanijk manzoorwanijk requested review from a team as code owners June 3, 2026 22:22
…on skipped linked patches, and exclude store nodes from the registry check
@manzoorwanijk manzoorwanijk force-pushed the feat/native-dependency-patching branch from e4eaf2a to d745145 Compare June 4, 2026 17:22
@manzoorwanijk manzoorwanijk changed the title feat(patch): native dependency patching (npm patch add/commit/ls/rm) feat(patch): native dependency patching (npm patch add/commit/update/ls/rm) Jun 4, 2026
@manzoorwanijk manzoorwanijk force-pushed the feat/native-dependency-patching branch from d745145 to 26264da Compare June 4, 2026 17:29
@owlstronaut owlstronaut changed the title feat(patch): native dependency patching (npm patch add/commit/update/ls/rm) feat!: native dependency patching (npm patch add/commit/update/ls/rm) Jun 4, 2026
Comment thread lib/commands/patch.js Outdated
Comment thread lib/commands/patch.js Outdated
@owlstronaut owlstronaut changed the title feat!: native dependency patching (npm patch add/commit/update/ls/rm) feat: native dependency patching (npm patch add/commit/update/ls/rm) Jun 5, 2026
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
@manzoorwanijk manzoorwanijk requested a review from owlstronaut June 5, 2026 15:24
@owlstronaut
Copy link
Copy Markdown
Contributor

Awesome, thank you! Holding this for the deps update.

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.

2 participants