Skip to content

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

Open
manzoorwanijk wants to merge 28 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 28 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
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.

1 participant