From 989562ed74130b6bf70d7ee60394805e6fedd6d9 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:14:03 +0530 Subject: [PATCH 01/32] feat(arborist): apply patchedDependencies during reify --- DEPENDENCIES.md | 1 + package-lock.json | 1 + .../arborist/lib/arborist/build-ideal-tree.js | 5 + .../arborist/lib/arborist/load-virtual.js | 1 + workspaces/arborist/lib/arborist/reify.js | 41 +++++ workspaces/arborist/lib/node.js | 3 + workspaces/arborist/lib/patch.js | 83 ++++++++++ .../arborist/lib/patched-dependencies.js | 150 ++++++++++++++++++ workspaces/arborist/lib/shrinkwrap.js | 21 +++ workspaces/arborist/package.json | 1 + 10 files changed, 307 insertions(+) create mode 100644 workspaces/arborist/lib/patch.js create mode 100644 workspaces/arborist/lib/patched-dependencies.js diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 8ad6d5654f5fb..6e742928c75bd 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -529,6 +529,7 @@ graph LR; npmcli-arborist-->bin-links; npmcli-arborist-->cacache; npmcli-arborist-->common-ancestor-path; + npmcli-arborist-->diff; npmcli-arborist-->gar-promise-retry["@gar/promise-retry"]; npmcli-arborist-->hosted-git-info; npmcli-arborist-->isaacs-string-locale-compare["@isaacs/string-locale-compare"]; diff --git a/package-lock.json b/package-lock.json index 2e42b5d5f7818..5ab8221ca47b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14707,6 +14707,7 @@ "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", + "diff": "^8.0.2", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 7fecd6759c041..90b5f49d74675 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -24,6 +24,7 @@ const PlaceDep = require('../place-dep.js') const debug = require('../debug.js') const fromPath = require('../from-path.js') const calcDepFlags = require('../calc-dep-flags.js') +const { resolvePatchedDependencies } = require('../patched-dependencies.js') const Shrinkwrap = require('../shrinkwrap.js') const { defaultLockfileVersion } = Shrinkwrap const Node = require('../node.js') @@ -179,6 +180,10 @@ module.exports = cls => class IdealTreeBuilder extends cls { await this.#fixDepFlags() await this.#pruneFailedOptional() await this.#checkEngineAndPlatform() + await resolvePatchedDependencies(this.idealTree, { + path: this.path, + allowUnusedPatches: this.options.allowUnusedPatches, + }) } finally { timeEnd() this.finishTracker('idealTree') diff --git a/workspaces/arborist/lib/arborist/load-virtual.js b/workspaces/arborist/lib/arborist/load-virtual.js index 36e57a011da5f..d10b198681d44 100644 --- a/workspaces/arborist/lib/arborist/load-virtual.js +++ b/workspaces/arborist/lib/arborist/load-virtual.js @@ -242,6 +242,7 @@ To fix: path, realpath: path, integrity: sw.integrity, + patched: sw.patched, resolved: consistentResolve(sw.resolved, this.path, path), pkg: sw, loadOverrides, diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index a3d80012e20f0..5de26fa524784 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -24,6 +24,8 @@ const debug = require('../debug.js') const onExit = require('../signal-handling.js') const optionalSet = require('../optional-set.js') const relpath = require('../relpath.js') +const { applyPatchToDir, patchIntegrity } = require('../patch.js') +const { readFile } = require('node:fs/promises') const retirePath = require('../retire-path.js') const treeCheck = require('../tree-check.js') const { defaultLockfileVersion } = require('../shrinkwrap.js') @@ -719,6 +721,7 @@ module.exports = cls => class Reifier extends cls { const { content: pkg } = await PackageJson.normalize(node.path) node.package.scripts = pkg.scripts } + await this.#applyPatch(node) return } @@ -746,6 +749,44 @@ module.exports = cls => class Reifier extends cls { return symlink(rel, node.path, 'junction') } + // apply a registered patch to a freshly extracted node, after extract and before rebuild + async #applyPatch (node) { + if (!node.patched) { + return + } + const { path: patchPath, integrity } = node.patched + const absPatch = resolve(this.path, patchPath) + + let contents + try { + contents = await readFile(absPatch) + } catch { + throw Object.assign( + new Error(`patch file not found: ${patchPath}`), + { code: 'EPATCHNOTFOUND', path: patchPath, node: node.name } + ) + } + + // detect drift between the recorded hash and the on-disk patch (npm ci safety) + const onDisk = patchIntegrity(contents) + if (integrity && onDisk !== integrity) { + throw Object.assign( + new Error(`patch file ${patchPath} does not match the integrity in the lockfile`), + { code: 'EPATCHINTEGRITY', path: patchPath, node: node.name } + ) + } + + try { + await applyPatchToDir({ patch: contents, cwd: node.path }) + } catch (er) { + if (this.options.ignorePatchFailures) { + log.warn('patch', `failed to apply ${patchPath} to ${node.name}: ${er.message}`) + return + } + throw er + } + } + // if the node is optional, then the failure of the promise is nonfatal // just add it and its optional set to the trash list. [_handleOptionalFailure] (node, p) { diff --git a/workspaces/arborist/lib/node.js b/workspaces/arborist/lib/node.js index 78b7f31e2c870..1e1d1bae298e7 100644 --- a/workspaces/arborist/lib/node.js +++ b/workspaces/arborist/lib/node.js @@ -94,6 +94,7 @@ class Node { optional = true, overrides, parent, + patched = null, path, peer = true, realpath, @@ -169,6 +170,8 @@ class Node { } } this.integrity = integrity || this.package._integrity || null + // Patch record { path, integrity } or null, set from patchedDependencies or the lockfile. + this.patched = patched || null this.installLinks = installLinks this.legacyPeerDeps = legacyPeerDeps diff --git a/workspaces/arborist/lib/patch.js b/workspaces/arborist/lib/patch.js new file mode 100644 index 0000000000000..b36881bfe8fa7 --- /dev/null +++ b/workspaces/arborist/lib/patch.js @@ -0,0 +1,83 @@ +// Native dependency patching helpers shared across build-ideal-tree and reify. +// Patches are plain unified diffs (git apply-compatible) and are applied with +// jsdiff using a fuzz factor of 0 so that any context drift fails loudly. +const { applyPatch, parsePatch } = require('diff') +const ssri = require('ssri') +const fs = require('node:fs') +const { promises: fsp } = fs +const { resolve, dirname } = require('node:path') + +// Compute the SSRI integrity of a patch file's contents. +// Accepts a string or Buffer and returns a sha512 SSRI string. +const patchIntegrity = data => + ssri.fromData(Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'), { + algorithms: ['sha512'], + }).toString() + +// Strip a leading git-style "a/" or "b/" prefix from a diff path. +const stripPrefix = file => { + if (!file || file === '/dev/null') { + return file + } + return file.replace(/^[ab]\//, '') +} + +// True when a diff path points at /dev/null, signalling a file add or delete. +const isDevNull = file => !file || file === '/dev/null' || /(^|\/)\.dev\/null$/.test(file) + +// Apply a single parsed file patch under cwd. +// Handles modified, added (--- /dev/null) and deleted (+++ /dev/null) files. +const applyFilePatch = async (filePatch, cwd) => { + const oldFile = stripPrefix(filePatch.oldFileName) + const newFile = stripPrefix(filePatch.newFileName) + const isAdd = isDevNull(filePatch.oldFileName) + const isDelete = isDevNull(filePatch.newFileName) + + if (isDelete) { + await fsp.rm(resolve(cwd, oldFile), { force: true }) + return + } + + const target = resolve(cwd, newFile) + + let source = '' + let mode + if (!isAdd) { + source = await fsp.readFile(target, 'utf8') + mode = (await fsp.stat(target)).mode + } + + // fuzzFactor 0: any context mismatch returns false and is treated as fatal. + const patched = applyPatch(source, filePatch, { fuzzFactor: 0 }) + if (patched === false) { + throw Object.assign( + new Error(`patch could not be applied to ${newFile}`), + { code: 'EPATCHFAILED', file: newFile } + ) + } + + await fsp.mkdir(dirname(target), { recursive: true }) + await fsp.writeFile(target, patched) + if (mode !== undefined) { + await fsp.chmod(target, mode) + } +} + +// Apply a unified diff to the package extracted at `cwd`. +// `patch` is the raw diff contents (string or Buffer). +// Throws with code EPATCHFAILED on any hunk or file that cannot be applied. +const applyPatchToDir = async ({ patch, cwd }) => { + const filePatches = parsePatch(patch.toString('utf8')) + for (const filePatch of filePatches) { + // jsdiff emits an empty trailing patch for some inputs; skip those. + if (!filePatch.hunks.length && isDevNull(filePatch.oldFileName) && isDevNull(filePatch.newFileName)) { + continue + } + await applyFilePatch(filePatch, cwd) + } +} + +module.exports = { + applyPatchToDir, + patchIntegrity, +} diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js new file mode 100644 index 0000000000000..b225e4c876c6d --- /dev/null +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -0,0 +1,150 @@ +// Resolve the root patchedDependencies map against an ideal tree. +// Attaches node.patched = { path, integrity } to each matched node and +// enforces the failure modes (workspace-member entry, missing file, unused +// patch, non-registry target, ambiguous selectors) as hard errors. +const semver = require('semver') +const { resolve } = require('node:path') +const { readFile } = require('node:fs/promises') +const { patchIntegrity } = require('./patch.js') + +// Split a selector key into { name, spec }. spec is null for a name-only key. +const parseSelector = key => { + const at = key.indexOf('@', 1) + return at === -1 + ? { name: key, spec: null } + : { name: key.slice(0, at), spec: key.slice(at + 1) } +} + +const err = (message, code, extra = {}) => + Object.assign(new Error(message), { code, ...extra }) + +// Pick the most specific range among several that all match a version. +// Returns the strict subset, or throws when ordering is ambiguous. +const pickRange = (ranges, name, version) => { + let best = ranges[0] + for (const r of ranges.slice(1)) { + if (semver.subset(r.spec, best.spec, { loose: true })) { + best = r + } else if (!semver.subset(best.spec, r.spec, { loose: true })) { + throw err( + `Ambiguous patch selectors for ${name}@${version}: ` + + `"${name}@${best.spec}" and "${name}@${r.spec}" overlap but neither ` + + `is a subset. Add an exact "${name}@${version}" entry to disambiguate.`, + 'EPATCHAMBIGUOUS' + ) + } + } + for (const r of ranges) { + if (r !== best && !semver.subset(best.spec, r.spec, { loose: true })) { + throw err( + `Ambiguous patch selectors for ${name}@${version}: ` + + `"${name}@${best.spec}" and "${name}@${r.spec}" overlap but neither ` + + `is a subset. Add an exact "${name}@${version}" entry to disambiguate.`, + 'EPATCHAMBIGUOUS' + ) + } + } + return best +} + +// Choose the winning selector for a node: exact > range subset > name-only. +const matchSelector = (selectors, node) => { + const { name, version } = node + const matches = selectors.filter(s => s.name === name) + if (!matches.length) { + return null + } + + const exact = matches.find(s => + s.spec && semver.valid(s.spec) && semver.eq(s.spec, version, { loose: true })) + if (exact) { + return exact + } + + const ranges = matches.filter(s => + s.spec && !semver.valid(s.spec) && semver.satisfies(version, s.spec, { loose: true })) + if (ranges.length) { + return pickRange(ranges, name, version) + } + + return matches.find(s => s.spec === null) || null +} + +const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => { + const patchedDependencies = tree.package?.patchedDependencies + if (!patchedDependencies || !Object.keys(patchedDependencies).length) { + return + } + + // patchedDependencies is honoured only in the root manifest + for (const node of tree.inventory.values()) { + const pkg = node.target?.package || node.package + if (node.isWorkspace && pkg?.patchedDependencies) { + throw err( + `patchedDependencies is only supported in the root package.json, ` + + `but was found in workspace "${node.name}". Move the entry to the root.`, + 'EPATCHWORKSPACE', + { workspace: node.name } + ) + } + } + + const selectors = Object.entries(patchedDependencies) + .map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath })) + + // cache patch file integrity by path so shared diffs are read once + const integrityCache = new Map() + const readPatch = async patchPath => { + if (integrityCache.has(patchPath)) { + return integrityCache.get(patchPath) + } + let contents + try { + contents = await readFile(resolve(path, patchPath)) + } catch { + throw err(`patch file not found: ${patchPath}`, 'EPATCHNOTFOUND', { path: patchPath }) + } + const integrity = patchIntegrity(contents) + integrityCache.set(patchPath, integrity) + return integrity + } + + const usedKeys = new Set() + for (const node of tree.inventory.values()) { + if (node.isProjectRoot || node.isWorkspace || node.isLink) { + continue + } + const selector = matchSelector(selectors, node) + if (!selector) { + continue + } + + if (!node.isRegistryDependency) { + throw err( + `Cannot patch non-registry dependency ${node.name}@${node.version} ` + + `(selector "${selector.key}"). Only registry dependencies can be patched.`, + 'EPATCHNONREGISTRY', + { node: node.name } + ) + } + + const integrity = await readPatch(selector.patchPath) + node.patched = { path: selector.patchPath, integrity } + usedKeys.add(selector.key) + } + + if (!allowUnusedPatches) { + const unused = selectors.filter(s => !usedKeys.has(s.key)) + if (unused.length) { + throw err( + `The following patches were registered but matched no installed ` + + `package:\n${unused.map(s => ` ${s.key} -> ${s.patchPath}`).join('\n')}\n` + + `Use --allow-unused-patches to install anyway.`, + 'EPATCHUNUSED', + { unused: unused.map(s => s.key) } + ) + } + } +} + +module.exports = { resolvePatchedDependencies, matchSelector, parseSelector } diff --git a/workspaces/arborist/lib/shrinkwrap.js b/workspaces/arborist/lib/shrinkwrap.js index ce2c58457098d..6cc52be4ec01f 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -10,6 +10,9 @@ const localeCompare = require('@isaacs/string-locale-compare')('en') const defaultLockfileVersion = 3 +// Bumped to 4 only when a node carries a patch record, so older clients abort. +const patchedLockfileVersion = 4 +const maxLockfileVersion = 4 // for comparing nodes to yarn.lock entries const mismatch = (a, b) => a && b && a !== b @@ -107,6 +110,7 @@ const nodeMetaKeys = [ 'integrity', 'inBundle', 'hasInstallScript', + 'patched', ] const metaFieldFromPkg = (pkg, key) => { @@ -458,6 +462,14 @@ class Shrinkwrap { this.ancientLockfile = false data = {} } + // refuse lockfiles newer than we understand so we never install unpatched + if (data.lockfileVersion > maxLockfileVersion) { + throw Object.assign( + new Error(`Unsupported lockfileVersion ${data.lockfileVersion}. ` + + `This npm only supports up to ${maxLockfileVersion}. Please upgrade npm.`), + { code: 'ELOCKFILEVERSION' } + ) + } // auto convert v1 lockfiles to v3 // leave v2 in place unless configured // v3 by default @@ -702,6 +714,10 @@ class Shrinkwrap { meta.integrity = lock.integrity } + if (lock.patched) { + meta.patched = lock.patched + } + if (lock.version && !lock.integrity) { // this is usually going to be a git url or symlink, but it could // also be a registry dependency that did not have integrity at @@ -934,6 +950,11 @@ class Shrinkwrap { if (!this.lockfileVersion) { this.lockfileVersion = defaultLockfileVersion } + // patched nodes force lockfileVersion 4 so older clients abort the install + const hasPatched = Object.values(this.data.packages || {}).some(p => p?.patched) + if (hasPatched && this.lockfileVersion < patchedLockfileVersion) { + this.lockfileVersion = patchedLockfileVersion + } this.data.lockfileVersion = this.lockfileVersion // hidden lockfiles don't include legacy metadata or a root entry diff --git a/workspaces/arborist/package.json b/workspaces/arborist/package.json index 12496f22b26ed..5b8c7a1db3668 100644 --- a/workspaces/arborist/package.json +++ b/workspaces/arborist/package.json @@ -18,6 +18,7 @@ "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", + "diff": "^8.0.2", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", From b15ca4eb577eab51f618a0d397572783b3fb99e8 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:18:22 +0530 Subject: [PATCH 02/32] feat(config): add patches-dir and patch relax flags --- .../test/lib/commands/config.js.test.cjs | 6 +++ tap-snapshots/test/lib/docs.js.test.cjs | 39 +++++++++++++++++++ .../config/lib/definitions/definitions.js | 28 +++++++++++++ .../test/type-description.js.test.cjs | 9 +++++ 4 files changed, 82 insertions(+) diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 26420c296f493..1a959d3dae6ab 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -129,6 +129,9 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "package-lock-only": false, "pack-destination": ".", "packages": [], + "patches-dir": "{CWD}/prefix/patches", + "allow-unused-patches": false, + "ignore-patch-failures": false, "parseable": false, "allow-scripts-pending": false, "allow-scripts-pin": true, @@ -207,6 +210,7 @@ allow-same-version = false allow-scripts = [""] allow-scripts-pending = false allow-scripts-pin = true +allow-unused-patches = false also = null audit = true audit-level = null @@ -262,6 +266,7 @@ globalconfig = "{CWD}/global/etc/npmrc" heading = "npm" https-proxy = null if-present = false +ignore-patch-failures = false ignore-scripts = false include = [] include-attestations = false @@ -322,6 +327,7 @@ packages-all = false packages-and-scopes-permission = null parseable = false password = (protected) +patches-dir = "{CWD}/prefix/patches" prefer-dedupe = false prefer-offline = false prefer-online = false diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index af94d8215d8bc..6a30596442af6 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -357,6 +357,16 @@ setting. +#### \`allow-unused-patches\` + +* Default: false +* Type: Boolean + +Install even when a registered patch in \`patchedDependencies\` matches no +installed package. Does not silence patch apply failures. + + + #### \`audit\` * Default: true @@ -942,6 +952,16 @@ CI setup. This value is not exported to the environment for child processes. +#### \`ignore-patch-failures\` + +* Default: false +* Type: Boolean + +Install even when a registered patch fails to apply, with a warning per +failure. Intended for incident response only. + + + #### \`ignore-scripts\` * Default: false @@ -1527,6 +1547,16 @@ tokens, though it's generally safer to be prompted for it. +#### \`patches-dir\` + +* Default: "patches" +* Type: Path + +The directory, relative to the project root, where \`npm patch commit\` writes +patch files for \`patchedDependencies\`. + + + #### \`prefer-dedupe\` * Default: false @@ -2529,6 +2559,9 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", + "allow-unused-patches", + "ignore-patch-failures", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2694,6 +2727,9 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", + "allow-unused-patches", + "ignore-patch-failures", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2797,6 +2833,7 @@ Object { "allowScripts": Array [], "allowScriptsPending": false, "allowScriptsPin": true, + "allowUnusedPatches": false, "audit": true, "auditLevel": null, "authType": "web", @@ -2839,6 +2876,7 @@ Object { "heading": "npm", "httpsProxy": null, "ifPresent": false, + "ignorePatchFailures": false, "ignoreScripts": false, "includeAttestations": false, "includeStaged": false, @@ -2881,6 +2919,7 @@ Object { "packDestination": ".", "parseable": false, "password": null, + "patchesDir": "{CWD}/prefix/patches", "preferDedupe": false, "preferOffline": false, "preferOnline": false, diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 25f7682073317..3aa3a61a76e73 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1737,6 +1737,34 @@ const definitions = { `, flatten, }), + 'patches-dir': new Definition('patches-dir', { + default: 'patches', + type: path, + description: ` + The directory, relative to the project root, where \`npm patch commit\` + writes patch files for \`patchedDependencies\`. + `, + flatten, + }), + // Intended to be CLI-only and rejected by `npm ci`; that restriction is not yet enforced. + 'allow-unused-patches': new Definition('allow-unused-patches', { + default: false, + type: Boolean, + description: ` + Install even when a registered patch in \`patchedDependencies\` matches no + installed package. Does not silence patch apply failures. + `, + flatten, + }), + 'ignore-patch-failures': new Definition('ignore-patch-failures', { + default: false, + type: Boolean, + description: ` + Install even when a registered patch fails to apply, with a warning per + failure. Intended for incident response only. + `, + flatten, + }), parseable: new Definition('parseable', { default: false, type: Boolean, diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index 8a1f464cf4b92..818ba537e2dbd 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -53,6 +53,9 @@ Object { "allow-scripts-pin": Array [ "boolean value (true or false)", ], + "allow-unused-patches": Array [ + "boolean value (true or false)", + ], "also": Array [ null, "dev", @@ -243,6 +246,9 @@ Object { "if-present": Array [ "boolean value (true or false)", ], + "ignore-patch-failures": Array [ + "boolean value (true or false)", + ], "ignore-scripts": Array [ "boolean value (true or false)", ], @@ -468,6 +474,9 @@ Object { null, Function String(), ], + "patches-dir": Array [ + "valid filesystem path", + ], "prefer-dedupe": Array [ "boolean value (true or false)", ], From 8cf21e597a40fc151a5578c9d6762d1241757121 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:40:01 +0530 Subject: [PATCH 03/32] feat(arborist): re-extract on patch change and validate patch hash in npm ci --- lib/utils/validate-lockfile.js | 8 + workspaces/arborist/lib/arborist/reify.js | 13 +- workspaces/arborist/lib/diff.js | 5 + .../tap-snapshots/test/node.js.test.cjs | 248 ++++++++++++++++++ 4 files changed, 263 insertions(+), 11 deletions(-) diff --git a/lib/utils/validate-lockfile.js b/lib/utils/validate-lockfile.js index 29161ec55bb79..1b75707eea41a 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -22,6 +22,14 @@ function validateLockfile (virtualTree, idealTree) { errors.push(`Invalid: lock file's ${lock.name}@${lock.version} does ` + `not satisfy ${entry.name}@${entry.version}`) } + + // a patch whose on-disk hash diverges from the lockfile is out of sync + const lockPatch = lock.patched?.integrity || null + const entryPatch = entry.patched?.integrity || null + if (lockPatch !== entryPatch) { + errors.push(`Invalid: patch for ${entry.name}@${entry.version} does not ` + + `match the integrity recorded in the lock file`) + } } return errors } diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 5de26fa524784..5a9eb6379bc50 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -24,7 +24,7 @@ const debug = require('../debug.js') const onExit = require('../signal-handling.js') const optionalSet = require('../optional-set.js') const relpath = require('../relpath.js') -const { applyPatchToDir, patchIntegrity } = require('../patch.js') +const { applyPatchToDir } = require('../patch.js') const { readFile } = require('node:fs/promises') const retirePath = require('../retire-path.js') const treeCheck = require('../tree-check.js') @@ -754,7 +754,7 @@ module.exports = cls => class Reifier extends cls { if (!node.patched) { return } - const { path: patchPath, integrity } = node.patched + const { path: patchPath } = node.patched const absPatch = resolve(this.path, patchPath) let contents @@ -767,15 +767,6 @@ module.exports = cls => class Reifier extends cls { ) } - // detect drift between the recorded hash and the on-disk patch (npm ci safety) - const onDisk = patchIntegrity(contents) - if (integrity && onDisk !== integrity) { - throw Object.assign( - new Error(`patch file ${patchPath} does not match the integrity in the lockfile`), - { code: 'EPATCHINTEGRITY', path: patchPath, node: node.name } - ) - } - try { await applyPatchToDir({ patch: contents, cwd: node.path }) } catch (er) { diff --git a/workspaces/arborist/lib/diff.js b/workspaces/arborist/lib/diff.js index 12a27ed68157f..259166bb9b3ca 100644 --- a/workspaces/arborist/lib/diff.js +++ b/workspaces/arborist/lib/diff.js @@ -130,6 +130,11 @@ const getAction = ({ actual, ideal }) => { return 'CHANGE' } + // a change in patch state requires re-extracting and re-applying + if ((ideal.patched?.integrity || null) !== (actual.patched?.integrity || null)) { + return 'CHANGE' + } + const binsExist = ideal.binPaths.every((path) => existsSync(path)) // top nodes, links, and git deps won't have integrity, but do have resolved diff --git a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs index 03af283d2fbc5..e1075bd0cbdd3 100644 --- a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs @@ -41,6 +41,7 @@ exports[`test/node.js TAP basic instantiation > just a lone root node 1`] = ` }, "parent": undefined, }, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -217,6 +218,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -244,6 +246,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -308,6 +311,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -319,6 +323,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -364,6 +369,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -375,6 +381,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -413,6 +420,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -461,6 +469,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -472,6 +481,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -500,6 +510,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -544,6 +555,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -555,6 +567,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -570,6 +583,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "", "name": "workspaces_root", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root", "peer": true, "queryContext": Object {}, @@ -618,6 +632,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -629,6 +644,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -674,6 +690,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -685,6 +702,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -724,6 +742,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -761,6 +780,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -813,6 +833,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -832,6 +853,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -871,6 +893,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -903,6 +926,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -935,6 +959,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -967,6 +992,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -995,6 +1021,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1048,6 +1075,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1059,6 +1087,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1127,6 +1156,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1164,6 +1194,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1216,6 +1247,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1235,6 +1267,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -1267,6 +1300,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1306,6 +1340,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -1338,6 +1373,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -1370,6 +1406,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -1402,6 +1439,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -1430,6 +1468,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1483,6 +1522,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1494,6 +1534,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1524,6 +1565,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1561,6 +1603,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1589,6 +1632,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1603,6 +1647,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -1634,6 +1679,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1682,6 +1728,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1734,6 +1781,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1753,6 +1801,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -1792,6 +1841,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -1824,6 +1874,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -1856,6 +1907,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -1888,6 +1940,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -1916,6 +1969,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1993,6 +2047,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2045,6 +2100,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2064,6 +2120,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2096,6 +2153,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2135,6 +2193,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2174,6 +2233,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2206,6 +2266,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2238,6 +2299,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2270,6 +2332,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2298,6 +2361,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2313,6 +2377,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -2344,6 +2409,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2404,6 +2470,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2423,6 +2490,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2462,6 +2530,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2494,6 +2563,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2526,6 +2596,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2558,6 +2629,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2586,6 +2658,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2628,6 +2701,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2717,6 +2791,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2736,6 +2811,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2768,6 +2844,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2807,6 +2884,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2839,6 +2917,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2871,6 +2950,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2903,6 +2983,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2931,6 +3012,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2973,6 +3055,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2988,6 +3071,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -3019,6 +3103,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3079,6 +3164,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3098,6 +3184,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3134,6 +3221,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3166,6 +3254,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -3198,6 +3287,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -3230,6 +3320,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -3258,6 +3349,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -3288,6 +3380,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3345,6 +3438,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3356,6 +3450,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -3445,6 +3540,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3464,6 +3560,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3496,6 +3593,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3532,6 +3630,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3564,6 +3663,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -3596,6 +3696,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -3628,6 +3729,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -3656,6 +3758,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -3684,6 +3787,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3713,6 +3817,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3770,6 +3875,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3781,6 +3887,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -3796,6 +3903,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -3827,6 +3935,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3887,6 +3996,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3906,6 +4016,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3942,6 +4053,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3974,6 +4086,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4006,6 +4119,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4038,6 +4152,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4066,6 +4181,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4096,6 +4212,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4153,6 +4270,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4164,6 +4282,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4253,6 +4372,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4272,6 +4392,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -4304,6 +4425,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4340,6 +4462,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -4372,6 +4495,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4404,6 +4528,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4436,6 +4561,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4464,6 +4590,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4492,6 +4619,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4521,6 +4649,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4578,6 +4707,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4589,6 +4719,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4604,6 +4735,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -4635,6 +4767,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4674,6 +4807,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4711,6 +4845,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4763,6 +4898,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4782,6 +4918,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -4821,6 +4958,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -4853,6 +4991,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4885,6 +5024,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4917,6 +5057,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4945,6 +5086,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4998,6 +5140,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5009,6 +5152,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5077,6 +5221,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5114,6 +5259,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5166,6 +5312,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5185,6 +5332,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -5217,6 +5365,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5256,6 +5405,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -5288,6 +5438,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -5320,6 +5471,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -5352,6 +5504,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -5380,6 +5533,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -5433,6 +5587,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5444,6 +5599,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5474,6 +5630,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5511,6 +5668,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5539,6 +5697,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5553,6 +5712,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -5584,6 +5744,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5632,6 +5793,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5684,6 +5846,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5703,6 +5866,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -5742,6 +5906,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -5774,6 +5939,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -5806,6 +5972,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -5838,6 +6005,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -5866,6 +6034,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -5943,6 +6112,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5995,6 +6165,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6014,6 +6185,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6046,6 +6218,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6085,6 +6258,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6124,6 +6298,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6156,6 +6331,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6188,6 +6364,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6220,6 +6397,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6248,6 +6426,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6263,6 +6442,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -6294,6 +6474,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6354,6 +6535,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6373,6 +6555,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6412,6 +6595,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6444,6 +6628,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6476,6 +6661,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6508,6 +6694,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6536,6 +6723,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6578,6 +6766,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6667,6 +6856,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6686,6 +6876,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6718,6 +6909,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6757,6 +6949,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6789,6 +6982,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6821,6 +7015,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6853,6 +7048,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6881,6 +7077,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6923,6 +7120,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6938,6 +7136,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -6969,6 +7168,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7029,6 +7229,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7048,6 +7249,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7084,6 +7286,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7116,6 +7319,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7148,6 +7352,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7180,6 +7385,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -7208,6 +7414,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -7238,6 +7445,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7295,6 +7503,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7306,6 +7515,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -7395,6 +7605,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7414,6 +7625,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7446,6 +7658,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7482,6 +7695,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7514,6 +7728,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7546,6 +7761,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7578,6 +7794,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -7606,6 +7823,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -7634,6 +7852,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7663,6 +7882,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7720,6 +7940,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7731,6 +7952,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -7746,6 +7968,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -7777,6 +8000,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7837,6 +8061,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7856,6 +8081,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7892,6 +8118,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7924,6 +8151,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7956,6 +8184,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7988,6 +8217,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -8016,6 +8246,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -8046,6 +8277,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8103,6 +8335,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8114,6 +8347,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -8203,6 +8437,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -8222,6 +8457,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -8254,6 +8490,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -8290,6 +8527,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -8322,6 +8560,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -8354,6 +8593,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -8386,6 +8626,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -8414,6 +8655,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -8442,6 +8684,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8471,6 +8714,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8528,6 +8772,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8539,6 +8784,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -8554,6 +8800,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -8585,6 +8832,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, From cea9cad131e961fb53d4867897647d1e4143bca0 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:40:22 +0530 Subject: [PATCH 04/32] feat(patch): add npm patch command (add/commit/ls/rm) --- docs/lib/content/commands/npm-patch.md | 69 +++++ docs/lib/content/nav.yml | 3 + lib/commands/patch.js | 291 ++++++++++++++++++ lib/utils/cmd-list.js | 1 + lib/utils/patch-diff.js | 77 +++++ package-lock.json | 3 + package.json | 2 + .../test/lib/commands/config.js.test.cjs | 10 +- tap-snapshots/test/lib/docs.js.test.cjs | 99 +++++- tap-snapshots/test/lib/npm.js.test.cjs | 92 +++--- .../config/lib/definitions/definitions.js | 26 +- .../test/type-description.js.test.cjs | 12 +- 12 files changed, 633 insertions(+), 52 deletions(-) create mode 100644 docs/lib/content/commands/npm-patch.md create mode 100644 lib/commands/patch.js create mode 100644 lib/utils/patch-diff.js diff --git a/docs/lib/content/commands/npm-patch.md b/docs/lib/content/commands/npm-patch.md new file mode 100644 index 0000000000000..030c414f4d6ac --- /dev/null +++ b/docs/lib/content/commands/npm-patch.md @@ -0,0 +1,69 @@ +--- +title: npm-patch +section: 1 +description: Apply local patches to installed dependencies +--- + +### Synopsis + + + +### Description + +`npm patch` lets you apply small, local modifications to an installed +dependency and have them re-applied automatically on every install. Patches +are declared in the `patchedDependencies` field of your root `package.json`, +stored as plain unified diffs under the `patches/` directory, and recorded with +a content hash in `package-lock.json`. + +Because patches are applied during the install itself, they work regardless of +`install-strategy`, apply to transitive dependencies, and are **not** disabled +by `--ignore-scripts`. + +The bare form `npm patch ` is shorthand for `npm patch add `. A +package literally named like a subcommand must use the explicit form, e.g. +`npm patch add add`. + +* `npm patch add [@]` + + Prepares a package for editing. npm extracts a clean copy of the resolved + package tarball into a temporary directory outside `node_modules` and prints + its path. Edit the files there, then run `npm patch commit`. + + If more than one version of `` is installed, re-run with an exact + selector such as `npm patch add lodash@4.17.21`. + +* `npm patch commit ` + + Diffs the edited directory against a clean copy of the original tarball, + writes the unified diff to `/@.patch`, adds the + entry to `patchedDependencies`, and updates `package-lock.json`. + +* `npm patch ls` + + Lists registered patches and how many installed nodes each one matches. + +* `npm patch rm [@]` + + Removes the matching entries from `patchedDependencies`, deletes the patch + file when no other entry references it, and updates `package-lock.json`. If + `` is omitted, all entries for `` are removed. + +### 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 flags relax this for one-off cases: `--allow-unused-patches` and +`--ignore-patch-failures`. + +### Configuration + + +## See Also + +* [npm install](/commands/npm-install) +* [npm ci](/commands/npm-ci) +* [package-lock.json](/configuring-npm/package-lock-json) +* [config](/commands/npm-config) diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index 96614ba6da7d2..7d148b43eab5f 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -120,6 +120,9 @@ - title: npm pack url: /commands/npm-pack description: Create a tarball from a package + - title: npm patch + url: /commands/npm-patch + description: Apply local patches to installed dependencies - title: npm ping url: /commands/npm-ping description: Ping npm registry diff --git a/lib/commands/patch.js b/lib/commands/patch.js new file mode 100644 index 0000000000000..7b595fe9767d3 --- /dev/null +++ b/lib/commands/patch.js @@ -0,0 +1,291 @@ +const { resolve, relative, join, dirname } = require('node:path') +const { tmpdir } = require('node:os') +const { mkdir, mkdtemp, rm, writeFile } = require('node:fs/promises') +const pacote = require('pacote') +const npa = require('npm-package-arg') +const semver = require('semver') +const PackageJson = require('@npmcli/package-json') +const { log, output } = require('proc-log') +const BaseCommand = require('../base-cmd.js') +const { diffDirs } = require('../utils/patch-diff.js') +const reifyFinish = require('../utils/reify-finish.js') + +const SUBCOMMANDS = ['add', 'commit', 'ls', 'rm'] + +// Build the selector key stored in patchedDependencies, e.g. lodash@4.17.21. +const selectorKey = (name, version) => `${name}@${version}` + +// Posix-relative path to a patch file inside patches-dir for name@version. +const patchFilePath = (patchesDir, name, version) => + `${patchesDir}/${name}@${version}.patch`.split('\\').join('/') + +class Patch extends BaseCommand { + static description = 'Apply local patches to installed dependencies' + static name = 'patch' + static params = [ + 'patches-dir', + 'allow-unused-patches', + 'ignore-patch-failures', + 'edit-dir', + 'ignore-existing', + 'keep-edit-dir', + 'registry', + ] + + static usage = [ + '[@]', + 'add [@] [--edit-dir ] [--ignore-existing]', + 'commit [--patches-dir ] [--keep-edit-dir]', + 'ls', + 'rm [@]', + ] + + static async completion (opts) { + if (opts.conf.argv.remain.length === 2) { + return SUBCOMMANDS + } + return [] + } + + async exec (args) { + const [sub, ...rest] = args + if (!sub) { + throw this.usageError() + } + // explicit subcommand, else treat the bare arg as `patch add ` + if (SUBCOMMANDS.includes(sub)) { + return this[sub](rest) + } + return this.add(args) + } + + get #root () { + return this.npm.localPrefix + } + + #newArborist (opts = {}) { + const Arborist = require('@npmcli/arborist') + return new Arborist({ ...this.npm.flatOptions, path: this.#root, ...opts }) + } + + async #loadActual () { + return this.#newArborist().loadActual() + } + + // Resolve a user spec to a concrete registry name@version to patch. + async #resolveTarget (spec) { + const parsed = npa(spec) + if (parsed.type && !parsed.registry) { + throw Object.assign( + new Error(`Cannot patch non-registry dependency "${spec}". ` + + `Only registry dependencies can be patched; edit the source directly.`), + { code: 'EPATCHNONREGISTRY' } + ) + } + + const { name } = parsed + const tree = await this.#loadActual() + const installed = new Map() + for (const node of tree.inventory.values()) { + if (node.name === name && !node.isProjectRoot && !node.isLink && node.version) { + if (!installed.has(node.version)) { + installed.set(node.version, node) + } + } + } + + // an explicit version/range is honored even when not present in the tree + if (parsed.rawSpec && parsed.rawSpec !== '*' && parsed.rawSpec !== 'latest') { + const exact = semver.valid(parsed.fetchSpec) + if (exact) { + return { name, version: exact } + } + const match = [...installed.keys()] + .filter(v => semver.satisfies(v, parsed.fetchSpec)) + .sort(semver.rcompare)[0] + if (match) { + return { name, version: match } + } + // resolve the range against the registry + const mani = await pacote.manifest(spec, this.npm.flatOptions) + return { name: mani.name, version: mani.version } + } + + if (installed.size === 0) { + throw Object.assign( + new Error(`No installed version of "${name}" found. ` + + `Run "npm install" first, or pass an explicit version.`), + { code: 'EPATCHNOTINSTALLED' } + ) + } + if (installed.size > 1) { + const lines = [...installed.entries()].map(([version, node]) => { + const dependant = [...node.edgesIn][0]?.from?.location || '(root)' + return ` ${selectorKey(name, version)} (via ${dependant})` + }) + throw Object.assign( + new Error(`Multiple versions of "${name}" are installed:\n${lines.join('\n')}\n` + + `Re-run with an exact selector, e.g. "npm patch add ${selectorKey(name, [...installed.keys()][0])}".`), + { code: 'EPATCHAMBIGUOUS' } + ) + } + return { name, version: [...installed.keys()][0] } + } + + async add (args) { + if (args.length !== 1) { + throw this.usageError() + } + const { name, version } = await this.#resolveTarget(args[0]) + + let editDir = this.npm.config.get('edit-dir') + if (!editDir) { + const base = join(tmpdir(), 'npm-patch') + await mkdir(base, { recursive: true }) + editDir = await mkdtemp(join(base, `${name.replace(/\//g, '+')}@${version}-`)) + } else { + editDir = resolve(editDir) + if (this.npm.config.get('ignore-existing')) { + await rm(editDir, { recursive: true, force: true }) + } + await mkdir(editDir, { recursive: true }) + } + + await pacote.extract(selectorKey(name, version), editDir, this.npm.flatOptions) + + output.standard(`You can now edit the following directory: ${editDir}`) + output.standard(`When done, run: npm patch commit ${editDir}`) + } + + async commit (args) { + if (args.length !== 1) { + throw this.usageError() + } + const editDir = resolve(args[0]) + const { content: edited } = await PackageJson.normalize(editDir).catch(() => { + throw Object.assign( + new Error(`No package.json found in edit directory: ${editDir}`), + { code: 'EPATCHNOEDITDIR' } + ) + }) + const { name, version } = edited + if (!name || !version) { + throw new Error(`Edit directory package.json is missing name or version: ${editDir}`) + } + + // extract a clean baseline to diff against + const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-')) + let diff + try { + await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions) + diff = await diffDirs(base, editDir, { exclude: [] }) + } finally { + await rm(base, { recursive: true, force: true }) + } + + if (!diff) { + log.warn('patch', `no changes detected in ${editDir}; nothing to commit`) + return + } + + const patchesDir = this.npm.config.get('patches-dir') + const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version)) + // always store a project-root-relative, posix-style path + const relPatch = relative(this.#root, absPatch).split('\\').join('/') + await mkdir(dirname(absPatch), { recursive: true }) + await writeFile(absPatch, diff) + + const pkgJson = await PackageJson.load(this.#root) + const patchedDependencies = { ...pkgJson.content.patchedDependencies } + patchedDependencies[selectorKey(name, version)] = relPatch + pkgJson.update({ patchedDependencies }) + await pkgJson.save() + + // reify to apply the patch and record its integrity in the lockfile + const arb = this.#newArborist() + await arb.reify(arb.options) + await reifyFinish(this.npm, arb) + + if (!this.npm.config.get('keep-edit-dir')) { + await rm(editDir, { recursive: true, force: true }) + } + + output.standard(`Patched ${selectorKey(name, version)} -> ${relPatch}`) + } + + async ls () { + const pkgJson = await PackageJson.normalize(this.#root).catch(() => ({ content: {} })) + const patched = pkgJson.content.patchedDependencies || {} + const keys = Object.keys(patched) + if (!keys.length) { + return + } + + const tree = await this.#loadActual() + for (const key of keys) { + const { name, spec } = this.#parseKey(key) + const matches = [...tree.inventory.values()].filter(node => + node.name === name && !node.isProjectRoot && !node.isLink && node.version && + (!spec || semver.valid(spec) + ? (!spec || node.version === spec) + : semver.satisfies(node.version, spec))) + output.standard(`${patched[key]}\t${key}\t(${matches.length} node${matches.length === 1 ? '' : 's'})`) + } + } + + #parseKey (key) { + const at = key.indexOf('@', 1) + return at === -1 + ? { name: key, spec: null } + : { name: key.slice(0, at), spec: key.slice(at + 1) } + } + + async rm (args) { + if (args.length !== 1) { + throw this.usageError() + } + const target = npa(args[0]) + const targetName = target.name + const targetVersion = target.rawSpec && target.rawSpec !== '*' ? target.fetchSpec : null + + const pkgJson = await PackageJson.load(this.#root) + const patched = { ...pkgJson.content.patchedDependencies } + const removed = [] + for (const key of Object.keys(patched)) { + const { name, spec } = this.#parseKey(key) + if (name === targetName && (!targetVersion || spec === targetVersion)) { + removed.push(key) + } + } + if (!removed.length) { + throw Object.assign( + new Error(`No registered patch found for "${args[0]}".`), + { code: 'EPATCHNOTFOUND' } + ) + } + + for (const key of removed) { + const patchPath = patched[key] + delete patched[key] + // only delete the file when no remaining selector references it + if (!Object.values(patched).includes(patchPath)) { + await rm(resolve(this.#root, patchPath), { force: true }) + } + } + + if (Object.keys(patched).length) { + pkgJson.update({ patchedDependencies: patched }) + } else { + delete pkgJson.content.patchedDependencies + } + await pkgJson.save() + + const arb = this.#newArborist() + await arb.reify(arb.options) + await reifyFinish(this.npm, arb) + + output.standard(`Removed patch${removed.length === 1 ? '' : 'es'}: ${removed.join(', ')}`) + } +} + +module.exports = Patch diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index 2093ff68c917b..709456913b491 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -40,6 +40,7 @@ const commands = [ 'outdated', 'owner', 'pack', + 'patch', 'ping', 'pkg', 'prefix', diff --git a/lib/utils/patch-diff.js b/lib/utils/patch-diff.js new file mode 100644 index 0000000000000..733f5ff8345a6 --- /dev/null +++ b/lib/utils/patch-diff.js @@ -0,0 +1,77 @@ +// Generate a git-compatible unified diff between two directories. +// Used by `npm patch commit` to capture edits against a clean tarball. +// The output is consumed by Arborist's apply step (jsdiff parsePatch). +const { createTwoFilesPatch } = require('diff') +const { readdir, readFile } = require('node:fs/promises') +const { join, sep } = require('node:path') + +const IGNORE = new Set(['node_modules', '.git']) + +// Recursively list file paths under dir, relative and posix-separated. +const listFiles = async dir => { + const out = [] + const walk = async sub => { + const entries = await readdir(join(dir, sub), { withFileTypes: true }) + for (const entry of entries) { + const rel = sub ? `${sub}/${entry.name}` : entry.name + if (entry.isDirectory()) { + if (!IGNORE.has(entry.name)) { + await walk(rel) + } + } else if (entry.isFile()) { + out.push(rel) + } + } + } + await walk('') + return out +} + +const readMaybe = async file => { + try { + return await readFile(file, 'utf8') + } catch { + return null + } +} + +// Diff originalDir against editedDir, returning a unified diff string. +// Added files use `--- /dev/null`, deleted files use `+++ /dev/null`. +const diffDirs = async (originalDir, editedDir, { exclude = [] } = {}) => { + const excluded = new Set(exclude) + const [origFiles, editFiles] = await Promise.all([ + listFiles(originalDir), + listFiles(editedDir), + ]) + const all = [...new Set([...origFiles, ...editFiles])] + .filter(f => !excluded.has(f)) + .sort() + + let result = '' + for (const file of all) { + const native = file.split('/').join(sep) + const [a, b] = await Promise.all([ + readMaybe(join(originalDir, native)), + readMaybe(join(editedDir, native)), + ]) + if (a === b) { + continue + } + + let patch = createTwoFilesPatch( + `a/${file}`, `b/${file}`, a || '', b || '', '', '' + ).replace('===================================================================\n', '') + + // mark adds and deletes with /dev/null so the apply step creates/removes files + if (a === null) { + patch = patch.replace(`--- a/${file}\t`, '--- /dev/null\t') + } + if (b === null) { + patch = patch.replace(`+++ b/${file}\t`, '+++ /dev/null\t') + } + result += patch + } + return result +} + +module.exports = { diffDirs } diff --git a/package-lock.json b/package-lock.json index 5ab8221ca47b1..14a9b4c50af0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "cacache", "chalk", "ci-info", + "diff", "fastest-levenshtein", "fs-minipass", "glob", @@ -99,6 +100,7 @@ "cacache": "^20.0.4", "chalk": "^5.6.2", "ci-info": "^4.4.0", + "diff": "^8.0.2", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^13.0.6", @@ -4449,6 +4451,7 @@ "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "inBundle": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" diff --git a/package.json b/package.json index 2cb0402575dc4..8e15bfbb3e6f3 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "cacache": "^20.0.4", "chalk": "^5.6.2", "ci-info": "^4.4.0", + "diff": "^8.0.2", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^13.0.6", @@ -130,6 +131,7 @@ "cacache", "chalk", "ci-info", + "diff", "fastest-levenshtein", "fs-minipass", "glob", diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 1a959d3dae6ab..74ce23f5852cd 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -129,9 +129,12 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "package-lock-only": false, "pack-destination": ".", "packages": [], - "patches-dir": "{CWD}/prefix/patches", + "patches-dir": "patches", "allow-unused-patches": false, "ignore-patch-failures": false, + "edit-dir": null, + "ignore-existing": false, + "keep-edit-dir": false, "parseable": false, "allow-scripts-pending": false, "allow-scripts-pin": true, @@ -243,6 +246,7 @@ diff-src-prefix = "a/" diff-text = false diff-unified = 3 dry-run = false +edit-dir = null editor = "{EDITOR}" engine-strict = false expect-result-count = null @@ -266,6 +270,7 @@ globalconfig = "{CWD}/global/etc/npmrc" heading = "npm" https-proxy = null if-present = false +ignore-existing = false ignore-patch-failures = false ignore-scripts = false include = [] @@ -289,6 +294,7 @@ init.version = "1.0.0" install-links = false install-strategy = "hoisted" json = false +keep-edit-dir = false key = null legacy-bundling = false legacy-peer-deps = false @@ -327,7 +333,7 @@ packages-all = false packages-and-scopes-permission = null parseable = false password = (protected) -patches-dir = "{CWD}/prefix/patches" +patches-dir = "patches" prefer-dedupe = false prefer-offline = false prefer-online = false diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 6a30596442af6..a630b71e25e33 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -133,6 +133,7 @@ Array [ "outdated", "owner", "pack", + "patch", "ping", "pkg", "prefix", @@ -685,6 +686,16 @@ Note: This is NOT honored by other network related commands, eg \`dist-tags\`, +#### \`edit-dir\` + +* Default: null +* Type: null or Path + +Override the temporary directory used by \`npm patch add\` to prepare a +package for editing. + + + #### \`editor\` * Default: The EDITOR or VISUAL environment variables, or @@ -952,6 +963,16 @@ CI setup. This value is not exported to the environment for child processes. +#### \`ignore-existing\` + +* Default: false +* Type: Boolean + +With \`npm patch add\`, discard a previous unfinished edit directory and start +fresh. + + + #### \`ignore-patch-failures\` * Default: false @@ -1144,6 +1165,16 @@ Not supported by all npm commands. +#### \`keep-edit-dir\` + +* Default: false +* Type: Boolean + +With \`npm patch commit\`, do not remove the edit directory after committing +the patch. + + + #### \`legacy-peer-deps\` * Default: false @@ -1550,7 +1581,7 @@ tokens, though it's generally safer to be prompted for it. #### \`patches-dir\` * Default: "patches" -* Type: Path +* Type: String The directory, relative to the project root, where \`npm patch commit\` writes patch files for \`patchedDependencies\`. @@ -2562,6 +2593,9 @@ Array [ "patches-dir", "allow-unused-patches", "ignore-patch-failures", + "edit-dir", + "ignore-existing", + "keep-edit-dir", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2807,6 +2841,9 @@ Array [ "logs-max", "long", "node-options", + "edit-dir", + "ignore-existing", + "keep-edit-dir", "prefix", "timing", "update-notifier", @@ -2919,7 +2956,7 @@ Object { "packDestination": ".", "parseable": false, "password": null, - "patchesDir": "{CWD}/prefix/patches", + "patchesDir": "patches", "preferDedupe": false, "preferOffline": false, "preferOnline": false, @@ -5288,6 +5325,64 @@ npm pack #### \`ignore-scripts\` ` +exports[`test/lib/docs.js TAP usage patch > must match snapshot 1`] = ` +Apply local patches to installed dependencies + +Usage: +npm patch [@] +npm patch add [@] [--edit-dir ] [--ignore-existing] +npm patch commit [--patches-dir ] [--keep-edit-dir] +npm patch ls +npm patch rm [@] + +Options: +[--patches-dir ] [--allow-unused-patches] [--ignore-patch-failures] +[--edit-dir ] [--ignore-existing] [--keep-edit-dir] +[--registry ] + + --patches-dir + The directory, relative to the project root, where \`npm patch commit\` + + --allow-unused-patches + Install even when a registered patch in \`patchedDependencies\` matches no + + --ignore-patch-failures + Install even when a registered patch fails to apply, with a warning per + + --edit-dir + Override the temporary directory used by \`npm patch add\` to prepare a + + --ignore-existing + With \`npm patch add\`, discard a previous unfinished edit directory and + + --keep-edit-dir + With \`npm patch commit\`, do not remove the edit directory after + + --registry + The base URL of the npm registry. + + +Run "npm help patch" for more info + +\`\`\`bash +npm patch [@] +npm patch add [@] [--edit-dir ] [--ignore-existing] +npm patch commit [--patches-dir ] [--keep-edit-dir] +npm patch ls +npm patch rm [@] +\`\`\` + +Note: This command is unaware of workspaces. + +#### \`patches-dir\` +#### \`allow-unused-patches\` +#### \`ignore-patch-failures\` +#### \`edit-dir\` +#### \`ignore-existing\` +#### \`keep-edit-dir\` +#### \`registry\` +` + exports[`test/lib/docs.js TAP usage ping > must match snapshot 1`] = ` Ping npm registry diff --git a/tap-snapshots/test/lib/npm.js.test.cjs b/tap-snapshots/test/lib/npm.js.test.cjs index 16d6d3689ee31..8066c41184f09 100644 --- a/tap-snapshots/test/lib/npm.js.test.cjs +++ b/tap-snapshots/test/lib/npm.js.test.cjs @@ -36,11 +36,11 @@ All commands: dist-tag, docs, doctor, edit, exec, explain, explore, find-dupes, fund, get, help, help-search, init, install, install-ci-test, install-test, link, ll, login, logout, ls, - org, outdated, owner, pack, ping, pkg, prefix, profile, - prune, publish, query, rebuild, repo, restart, root, run, - sbom, search, set, stage, start, stop, team, test, token, - trust, undeprecate, uninstall, unpublish, update, version, - view, whoami + org, outdated, owner, pack, patch, ping, pkg, prefix, + profile, prune, publish, query, rebuild, repo, restart, + root, run, sbom, search, set, stage, start, stop, team, + test, token, trust, undeprecate, uninstall, unpublish, + update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -84,11 +84,11 @@ All commands: install-test, link, ll, login, logout, ls, org, outdated, owner, pack, - ping, pkg, prefix, - profile, prune, publish, - query, rebuild, repo, - restart, root, run, - sbom, search, set, + patch, ping, pkg, + prefix, profile, prune, + publish, query, rebuild, + repo, restart, root, + run, sbom, search, set, stage, start, stop, team, test, token, trust, undeprecate, @@ -138,11 +138,11 @@ All commands: install-test, link, ll, login, logout, ls, org, outdated, owner, pack, - ping, pkg, prefix, - profile, prune, publish, - query, rebuild, repo, - restart, root, run, - sbom, search, set, + patch, ping, pkg, + prefix, profile, prune, + publish, query, rebuild, + repo, restart, root, + run, sbom, search, set, stage, start, stop, team, test, token, trust, undeprecate, @@ -181,11 +181,11 @@ All commands: dist-tag, docs, doctor, edit, exec, explain, explore, find-dupes, fund, get, help, help-search, init, install, install-ci-test, install-test, link, ll, login, logout, ls, - org, outdated, owner, pack, ping, pkg, prefix, profile, - prune, publish, query, rebuild, repo, restart, root, run, - sbom, search, set, stage, start, stop, team, test, token, - trust, undeprecate, uninstall, unpublish, update, version, - view, whoami + org, outdated, owner, pack, patch, ping, pkg, prefix, + profile, prune, publish, query, rebuild, repo, restart, + root, run, sbom, search, set, stage, start, stop, team, + test, token, trust, undeprecate, uninstall, unpublish, + update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -229,11 +229,11 @@ All commands: install-test, link, ll, login, logout, ls, org, outdated, owner, pack, - ping, pkg, prefix, - profile, prune, publish, - query, rebuild, repo, - restart, root, run, - sbom, search, set, + patch, ping, pkg, + prefix, profile, prune, + publish, query, rebuild, + repo, restart, root, + run, sbom, search, set, stage, start, stop, team, test, token, trust, undeprecate, @@ -283,11 +283,11 @@ All commands: install-test, link, ll, login, logout, ls, org, outdated, owner, pack, - ping, pkg, prefix, - profile, prune, publish, - query, rebuild, repo, - restart, root, run, - sbom, search, set, + patch, ping, pkg, + prefix, profile, prune, + publish, query, rebuild, + repo, restart, root, + run, sbom, search, set, stage, start, stop, team, test, token, trust, undeprecate, @@ -336,7 +336,7 @@ All commands: install-test, link, ll, login, logout, ls, org, outdated, owner, pack, - ping, pkg, prefix, + patch, ping, pkg, prefix, profile, prune, publish, query, rebuild, repo, restart, root, run, sbom, @@ -378,11 +378,11 @@ All commands: dist-tag, docs, doctor, edit, exec, explain, explore, find-dupes, fund, get, help, help-search, init, install, install-ci-test, install-test, link, ll, login, logout, ls, - org, outdated, owner, pack, ping, pkg, prefix, profile, - prune, publish, query, rebuild, repo, restart, root, run, - sbom, search, set, stage, start, stop, team, test, token, - trust, undeprecate, uninstall, unpublish, update, version, - view, whoami + org, outdated, owner, pack, patch, ping, pkg, prefix, + profile, prune, publish, query, rebuild, repo, restart, + root, run, sbom, search, set, stage, start, stop, team, + test, token, trust, undeprecate, uninstall, unpublish, + update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -415,11 +415,11 @@ All commands: dist-tag, docs, doctor, edit, exec, explain, explore, find-dupes, fund, get, help, help-search, init, install, install-ci-test, install-test, link, ll, login, logout, ls, - org, outdated, owner, pack, ping, pkg, prefix, profile, - prune, publish, query, rebuild, repo, restart, root, run, - sbom, search, set, stage, start, stop, team, test, token, - trust, undeprecate, uninstall, unpublish, update, version, - view, whoami + org, outdated, owner, pack, patch, ping, pkg, prefix, + profile, prune, publish, query, rebuild, repo, restart, + root, run, sbom, search, set, stage, start, stop, team, + test, token, trust, undeprecate, uninstall, unpublish, + update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} @@ -452,11 +452,11 @@ All commands: dist-tag, docs, doctor, edit, exec, explain, explore, find-dupes, fund, get, help, help-search, init, install, install-ci-test, install-test, link, ll, login, logout, ls, - org, outdated, owner, pack, ping, pkg, prefix, profile, - prune, publish, query, rebuild, repo, restart, root, run, - sbom, search, set, stage, start, stop, team, test, token, - trust, undeprecate, uninstall, unpublish, update, version, - view, whoami + org, outdated, owner, pack, patch, ping, pkg, prefix, + profile, prune, publish, query, rebuild, repo, restart, + root, run, sbom, search, set, stage, start, stop, team, + test, token, trust, undeprecate, uninstall, unpublish, + update, version, view, whoami Specify configs in the ini-formatted file: {USERCONFIG} diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 3aa3a61a76e73..9c5c131d4df7c 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1739,7 +1739,7 @@ const definitions = { }), 'patches-dir': new Definition('patches-dir', { default: 'patches', - type: path, + type: String, description: ` The directory, relative to the project root, where \`npm patch commit\` writes patch files for \`patchedDependencies\`. @@ -1765,6 +1765,30 @@ const definitions = { `, flatten, }), + 'edit-dir': new Definition('edit-dir', { + default: null, + type: [null, path], + description: ` + Override the temporary directory used by \`npm patch add\` to prepare a + package for editing. + `, + }), + 'ignore-existing': new Definition('ignore-existing', { + default: false, + type: Boolean, + description: ` + With \`npm patch add\`, discard a previous unfinished edit directory and + start fresh. + `, + }), + 'keep-edit-dir': new Definition('keep-edit-dir', { + default: false, + type: Boolean, + description: ` + With \`npm patch commit\`, do not remove the edit directory after + committing the patch. + `, + }), parseable: new Definition('parseable', { default: false, type: Boolean, diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index 818ba537e2dbd..0ec630b5a3d5a 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -173,6 +173,10 @@ Object { "dry-run": Array [ "boolean value (true or false)", ], + "edit-dir": Array [ + null, + "valid filesystem path", + ], "editor": Array [ Function String(), ], @@ -246,6 +250,9 @@ Object { "if-present": Array [ "boolean value (true or false)", ], + "ignore-existing": Array [ + "boolean value (true or false)", + ], "ignore-patch-failures": Array [ "boolean value (true or false)", ], @@ -324,6 +331,9 @@ Object { "json": Array [ "boolean value (true or false)", ], + "keep-edit-dir": Array [ + "boolean value (true or false)", + ], "key": Array [ null, Function String(), @@ -475,7 +485,7 @@ Object { Function String(), ], "patches-dir": Array [ - "valid filesystem path", + Function String(), ], "prefer-dedupe": Array [ "boolean value (true or false)", From 160592c00e28304e4aca8ad53f502c8b0ff4d322 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:48:06 +0530 Subject: [PATCH 05/32] test(arborist): unit tests for patch apply and selector matching --- workspaces/arborist/test/patch.js | 72 +++++++++++++++++++ .../arborist/test/patched-dependencies.js | 50 +++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 workspaces/arborist/test/patch.js create mode 100644 workspaces/arborist/test/patched-dependencies.js diff --git a/workspaces/arborist/test/patch.js b/workspaces/arborist/test/patch.js new file mode 100644 index 0000000000000..9404deec66419 --- /dev/null +++ b/workspaces/arborist/test/patch.js @@ -0,0 +1,72 @@ +const t = require('tap') +const { readFileSync, existsSync } = require('node:fs') +const { resolve } = require('node:path') +const { createTwoFilesPatch } = require('diff') +const { applyPatchToDir, patchIntegrity } = require('../lib/patch.js') + +// build a git-style unified diff for a single file change +const filePatch = (file, before, after) => { + let p = createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after, '', '') + .replace('===================================================================\n', '') + if (before === '') { + p = p.replace(`--- a/${file}\t`, '--- /dev/null\t') + } + if (after === '') { + p = p.replace(`+++ b/${file}\t`, '+++ /dev/null\t') + } + return p +} + +t.test('modifies an existing file', async t => { + const dir = t.testdir({ 'index.js': 'const v = 1\n' }) + await applyPatchToDir({ patch: filePatch('index.js', 'const v = 1\n', 'const v = 2\n'), cwd: dir }) + t.equal(readFileSync(resolve(dir, 'index.js'), 'utf8'), 'const v = 2\n') +}) + +t.test('creates a new file', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await applyPatchToDir({ patch: filePatch('added.js', '', 'new\n'), cwd: dir }) + t.equal(readFileSync(resolve(dir, 'added.js'), 'utf8'), 'new\n') +}) + +t.test('deletes a file', async t => { + const dir = t.testdir({ 'gone.js': 'bye\n' }) + await applyPatchToDir({ patch: filePatch('gone.js', 'bye\n', ''), cwd: dir }) + t.notOk(existsSync(resolve(dir, 'gone.js')), 'file removed') +}) + +t.test('creates nested directories for new files', async t => { + const dir = t.testdir({}) + await applyPatchToDir({ patch: filePatch('lib/deep/x.js', '', 'deep\n'), cwd: dir }) + t.equal(readFileSync(resolve(dir, 'lib/deep/x.js'), 'utf8'), 'deep\n') +}) + +t.test('throws on context drift (fuzz 0)', async t => { + const dir = t.testdir({ 'index.js': 'totally different content\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('index.js', 'const v = 1\n', 'const v = 2\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('patchIntegrity is stable and content-addressed', t => { + const a = patchIntegrity('hello') + const b = patchIntegrity(Buffer.from('hello')) + const c = patchIntegrity('world') + t.equal(a, b, 'string and buffer match') + t.match(a, /^sha512-/, 'is a sha512 SSRI') + t.not(a, c, 'different content -> different hash') + t.end() +}) + +t.test('round-trips a multi-file diff', async t => { + const dir = t.testdir({ 'a.js': 'aaa\n', 'del.js': 'd\n' }) + const patch = + filePatch('a.js', 'aaa\n', 'AAA\n') + + filePatch('b.js', '', 'bbb\n') + + filePatch('del.js', 'd\n', '') + await applyPatchToDir({ patch, cwd: dir }) + t.equal(readFileSync(resolve(dir, 'a.js'), 'utf8'), 'AAA\n') + t.equal(readFileSync(resolve(dir, 'b.js'), 'utf8'), 'bbb\n') + t.notOk(existsSync(resolve(dir, 'del.js'))) +}) diff --git a/workspaces/arborist/test/patched-dependencies.js b/workspaces/arborist/test/patched-dependencies.js new file mode 100644 index 0000000000000..bf8b6fd2fb308 --- /dev/null +++ b/workspaces/arborist/test/patched-dependencies.js @@ -0,0 +1,50 @@ +const t = require('tap') +const { parseSelector, matchSelector } = require('../lib/patched-dependencies.js') + +t.test('parseSelector', t => { + t.strictSame(parseSelector('lodash'), { name: 'lodash', spec: null }) + t.strictSame(parseSelector('lodash@4.17.21'), { name: 'lodash', spec: '4.17.21' }) + t.strictSame(parseSelector('lodash@^4.0.0'), { name: 'lodash', spec: '^4.0.0' }) + t.strictSame(parseSelector('@babel/core@7.23.0'), { name: '@babel/core', spec: '7.23.0' }) + t.strictSame(parseSelector('@babel/core'), { name: '@babel/core', spec: null }) + t.end() +}) + +const sel = (name, spec) => ({ name, spec, key: spec ? `${name}@${spec}` : name }) + +t.test('exact wins over range and name-only', t => { + const selectors = [sel('lodash', '4.17.21'), sel('lodash', '^4.0.0'), sel('lodash', null)] + t.equal(matchSelector(selectors, { name: 'lodash', version: '4.17.21' }).key, 'lodash@4.17.21') + t.end() +}) + +t.test('range wins over name-only', t => { + const selectors = [sel('lodash', '^4.0.0'), sel('lodash', null)] + t.equal(matchSelector(selectors, { name: 'lodash', version: '4.5.0' }).key, 'lodash@^4.0.0') + t.end() +}) + +t.test('name-only is the fallback', t => { + const selectors = [sel('lodash', null)] + t.equal(matchSelector(selectors, { name: 'lodash', version: '3.0.0' }).key, 'lodash') + t.end() +}) + +t.test('most specific (subset) range wins', t => { + const selectors = [sel('x', '>=1.0.0 <3.0.0'), sel('x', '>=1.5.0 <2.0.0')] + t.equal(matchSelector(selectors, { name: 'x', version: '1.7.0' }).key, 'x@>=1.5.0 <2.0.0') + t.end() +}) + +t.test('ambiguous overlapping ranges throw', t => { + const selectors = [sel('x', '>=1.0.0 <2.0.0'), sel('x', '>=1.5.0 <3.0.0')] + t.throws(() => matchSelector(selectors, { name: 'x', version: '1.7.0' }), { code: 'EPATCHAMBIGUOUS' }) + t.end() +}) + +t.test('no match returns null', t => { + const selectors = [sel('lodash', '4.17.21')] + t.equal(matchSelector(selectors, { name: 'lodash', version: '5.0.0' }), null) + t.equal(matchSelector(selectors, { name: 'other', version: '1.0.0' }), null) + t.end() +}) From c4980414a7dd0cc4dbc42f759e6d9bb78e9e88fd Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:16:08 +0530 Subject: [PATCH 06/32] fix(arborist): clear stale patch records when a selector is removed --- workspaces/arborist/lib/patch.js | 3 +- .../arborist/lib/patched-dependencies.js | 56 ++++++++----------- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/workspaces/arborist/lib/patch.js b/workspaces/arborist/lib/patch.js index b36881bfe8fa7..c0c30481b7349 100644 --- a/workspaces/arborist/lib/patch.js +++ b/workspaces/arborist/lib/patch.js @@ -1,6 +1,5 @@ // Native dependency patching helpers shared across build-ideal-tree and reify. -// Patches are plain unified diffs (git apply-compatible) and are applied with -// jsdiff using a fuzz factor of 0 so that any context drift fails loudly. +// Patches are plain unified diffs (git apply-compatible) applied with jsdiff using a fuzz factor of 0 so that any context drift fails loudly. const { applyPatch, parsePatch } = require('diff') const ssri = require('ssri') const fs = require('node:fs') diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js index b225e4c876c6d..18532eaee1ec1 100644 --- a/workspaces/arborist/lib/patched-dependencies.js +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -1,7 +1,6 @@ // Resolve the root patchedDependencies map against an ideal tree. -// Attaches node.patched = { path, integrity } to each matched node and -// enforces the failure modes (workspace-member entry, missing file, unused -// patch, non-registry target, ambiguous selectors) as hard errors. +// Attaches node.patched = { path, integrity } to each matched node. +// Enforces the failure modes (workspace-member entry, missing file, unused patch, non-registry target, ambiguous selectors) as hard errors. const semver = require('semver') const { resolve } = require('node:path') const { readFile } = require('node:fs/promises') @@ -20,6 +19,7 @@ const err = (message, code, extra = {}) => // Pick the most specific range among several that all match a version. // Returns the strict subset, or throws when ordering is ambiguous. +// semver.subset is transitive, so the running minimum is a subset of every range it did not throw on. const pickRange = (ranges, name, version) => { let best = ranges[0] for (const r of ranges.slice(1)) { @@ -34,16 +34,6 @@ const pickRange = (ranges, name, version) => { ) } } - for (const r of ranges) { - if (r !== best && !semver.subset(best.spec, r.spec, { loose: true })) { - throw err( - `Ambiguous patch selectors for ${name}@${version}: ` + - `"${name}@${best.spec}" and "${name}@${r.spec}" overlap but neither ` + - `is a subset. Add an exact "${name}@${version}" entry to disambiguate.`, - 'EPATCHAMBIGUOUS' - ) - } - } return best } @@ -71,24 +61,7 @@ const matchSelector = (selectors, node) => { } const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => { - const patchedDependencies = tree.package?.patchedDependencies - if (!patchedDependencies || !Object.keys(patchedDependencies).length) { - return - } - - // patchedDependencies is honoured only in the root manifest - for (const node of tree.inventory.values()) { - const pkg = node.target?.package || node.package - if (node.isWorkspace && pkg?.patchedDependencies) { - throw err( - `patchedDependencies is only supported in the root package.json, ` + - `but was found in workspace "${node.name}". Move the entry to the root.`, - 'EPATCHWORKSPACE', - { workspace: node.name } - ) - } - } - + const patchedDependencies = tree.package?.patchedDependencies || {} const selectors = Object.entries(patchedDependencies) .map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath })) @@ -111,11 +84,28 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => const usedKeys = new Set() for (const node of tree.inventory.values()) { - if (node.isProjectRoot || node.isWorkspace || node.isLink) { + // patchedDependencies is honoured only in the root manifest + if (node.isWorkspace) { + // Link.package already delegates to its target's package + const pkg = node.package + if (pkg?.patchedDependencies && Object.keys(pkg.patchedDependencies).length) { + throw err( + `patchedDependencies is only supported in the root package.json, ` + + `but was found in workspace "${node.name}". Move the entry to the root.`, + 'EPATCHWORKSPACE', + { workspace: node.name } + ) + } + continue + } + if (node.isProjectRoot || node.isLink) { continue } + const selector = matchSelector(selectors, node) if (!selector) { + // clear any stale patch record inherited from the lockfile + node.patched = null continue } @@ -133,7 +123,7 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => usedKeys.add(selector.key) } - if (!allowUnusedPatches) { + if (selectors.length && !allowUnusedPatches) { const unused = selectors.filter(s => !usedKeys.has(s.key)) if (unused.length) { throw err( From ceaa9d4205c367471762edb9c585f40b1f973404 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:16:20 +0530 Subject: [PATCH 07/32] test(patch): integration tests for command, reify apply, and selectors --- test/lib/commands/patch.js | 535 ++++++++++++++++++ test/lib/utils/patch-diff.js | 131 +++++ .../arborist/test/arborist/reify-patch.js | 179 ++++++ workspaces/arborist/test/patch.js | 6 + .../test/patched-dependencies-resolve.js | 265 +++++++++ .../arborist/test/patched-dependencies.js | 8 +- 6 files changed, 1121 insertions(+), 3 deletions(-) create mode 100644 test/lib/commands/patch.js create mode 100644 test/lib/utils/patch-diff.js create mode 100644 workspaces/arborist/test/arborist/reify-patch.js create mode 100644 workspaces/arborist/test/patched-dependencies-resolve.js diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js new file mode 100644 index 0000000000000..233d67ef8e0e8 --- /dev/null +++ b/test/lib/commands/patch.js @@ -0,0 +1,535 @@ +const fs = require('node:fs') +const path = require('node:path') +const t = require('tap') +const Arborist = require('@npmcli/arborist') +const pacote = require('pacote') + +const { loadNpmWithRegistry: loadMockNpm } = require('../../fixtures/mock-npm') +const Patch = require('../../../lib/commands/patch.js') + +// Tiny dependency served by the mock registry so pacote can extract it. +const DEP_NAME = 'patch-me' +const DEP_VERSION = '1.0.0' +const DEP_SRC = 'module.exports = function () { return "original" }\n' + +// On-disk tarball contents for the dependency. +const depTarball = { + 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }), + 'index.js': DEP_SRC, +} + +// Root project package.json depending on the patchable dep. +const rootPackageJson = { + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: `^${DEP_VERSION}` }, +} + +// Lockfile pre-resolving the dep so installs/reifies are deterministic. +const rootPackageLock = { + name: 'root-project', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: `^${DEP_VERSION}` }, + }, + [`node_modules/${DEP_NAME}`]: { + version: DEP_VERSION, + resolved: `https://registry.npmjs.org/${DEP_NAME}/-/${DEP_NAME}-${DEP_VERSION}.tgz`, + }, + }, +} + +// Persist the manifest and tarball so the many extract and reify passes (add, commit baseline, reify, rm reify, install) all find a tarball without having to count requests precisely. +const setupDep = async (npm, registry) => { + const manifest = registry.manifest({ name: DEP_NAME, versions: [DEP_VERSION] }) + const dist = new URL(manifest.versions[DEP_VERSION].dist.tarball) + const tar = await pacote.tarball(path.join(npm.prefix, 'dep-tarball'), { Arborist }) + registry.nock.get(`/${DEP_NAME}`).reply(200, manifest).persist() + registry.nock.get(dist.pathname).reply(200, tar).persist() + return manifest +} + +const basePrefix = () => ({ + 'dep-tarball': depTarball, + 'package.json': JSON.stringify(rootPackageJson), + 'package-lock.json': JSON.stringify(rootPackageLock), +}) + +const readJson = file => JSON.parse(fs.readFileSync(file, 'utf8')) + +t.test('no args rejects with EUSAGE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: basePrefix(), + }) + await t.rejects(npm.exec('patch', []), { code: 'EUSAGE' }, 'bare npm patch is a usage error') +}) + +t.test('add with no pkg rejects with EUSAGE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: basePrefix(), + }) + await t.rejects(npm.exec('patch', ['add']), { code: 'EUSAGE' }) +}) + +t.test('add rejects non-registry spec with EPATCHNONREGISTRY', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: basePrefix(), + }) + await t.rejects( + npm.exec('patch', ['add', 'file:./dep-tarball']), + { code: 'EPATCHNONREGISTRY' }, + 'file: spec is rejected' + ) +}) + +t.test('full round-trip: install, add, edit, commit, ls, rm', async t => { + const { npm, joinedOutput, registry, outputs } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + + // install the dep so it is present on disk + await npm.exec('install', []) + const installedIndex = path.join(npm.prefix, 'node_modules', DEP_NAME, 'index.js') + t.equal(fs.readFileSync(installedIndex, 'utf8'), DEP_SRC, 'installed clean') + + // npm patch add prints the edit dir and commit hint + outputs.length = 0 + await npm.exec('patch', ['add', DEP_NAME]) + const addOut = joinedOutput() + t.match(addOut, /You can now edit the following directory: /, 'prints edit dir line') + t.match(addOut, /When done, run: npm patch commit /, 'prints commit hint line') + + const editDirMatch = addOut.match(/You can now edit the following directory: (.+)/) + const editDir = editDirMatch[1].trim() + t.ok(fs.existsSync(path.join(editDir, 'package.json')), 'extracted package.json to edit dir') + + // edit a file in the printed edit dir + const edited = 'module.exports = function () { return "patched" }\n' + fs.writeFileSync(path.join(editDir, 'index.js'), edited) + + // npm patch commit + outputs.length = 0 + await npm.exec('patch', ['commit', editDir]) + + // patches/@.patch exists + const patchFile = path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`) + t.ok(fs.existsSync(patchFile), 'patch file written under patches/') + t.match(fs.readFileSync(patchFile, 'utf8'), /patched/, 'patch file contains the edit') + + // package.json has the relative patchedDependencies entry + const pkg = readJson(path.join(npm.prefix, 'package.json')) + t.same( + pkg.patchedDependencies, + { [`${DEP_NAME}@${DEP_VERSION}`]: `patches/${DEP_NAME}@${DEP_VERSION}.patch` }, + 'patchedDependencies has the relative posix entry' + ) + + // package-lock.json: lockfileVersion 4 and packages[node_modules/].patched + const lock = readJson(path.join(npm.prefix, 'package-lock.json')) + t.equal(lock.lockfileVersion, 4, 'lockfile bumped to v4') + const lockNode = lock.packages[`node_modules/${DEP_NAME}`] + t.ok(lockNode.patched, 'lockfile node has patched block') + t.equal(lockNode.patched.path, `patches/${DEP_NAME}@${DEP_VERSION}.patch`, 'patched.path set') + t.match(lockNode.patched.integrity, /^sha512-/, 'patched.integrity is an SSRI') + + // the installed file on disk contains the edit + t.equal(fs.readFileSync(installedIndex, 'utf8'), edited, 'installed file is patched on disk') + + // edit dir removed by default + t.notOk(fs.existsSync(editDir), 'edit dir removed when keep-edit-dir not set') + + // npm patch ls lists the entry + outputs.length = 0 + await npm.exec('patch', ['ls']) + const lsOut = joinedOutput() + t.match(lsOut, new RegExp(`patches/${DEP_NAME}@${DEP_VERSION}\\.patch`), 'ls shows patch path') + t.match(lsOut, new RegExp(`${DEP_NAME}@${DEP_VERSION}`), 'ls shows selector') + t.match(lsOut, /\(1 node\)/, 'ls shows node count') + + // npm patch rm removes the entry from package.json and deletes the file + outputs.length = 0 + await npm.exec('patch', ['rm', DEP_NAME]) + const pkgAfter = readJson(path.join(npm.prefix, 'package.json')) + t.notOk(pkgAfter.patchedDependencies, 'patchedDependencies removed from package.json') + t.notOk(fs.existsSync(patchFile), 'patch file deleted') + + // rm clears the patch record from the lockfile and reverts the installed file + const lockAfter = readJson(path.join(npm.prefix, 'package-lock.json')) + t.notOk( + lockAfter.packages[`node_modules/${DEP_NAME}`].patched, + 'lockfile patched block removed' + ) + t.equal( + fs.readFileSync(installedIndex, 'utf8'), + DEP_SRC, + 'installed file reverted to original' + ) +}) + +t.test('bare form routes to add', async t => { + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + // npm patch behaves like npm patch add + await npm.exec('patch', [DEP_NAME]) + t.match(joinedOutput(), /You can now edit the following directory: /, 'bare form extracts like add') +}) + +t.test('rm with no registered patch rejects with EPATCHNOTFOUND', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: basePrefix(), + }) + await t.rejects( + npm.exec('patch', ['rm', DEP_NAME]), + { code: 'EPATCHNOTFOUND' }, + 'rm errors when nothing matches' + ) +}) + +t.test('ls with no patches prints nothing', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: basePrefix(), + }) + await npm.exec('patch', ['ls']) + t.equal(joinedOutput(), '', 'no output when no patchedDependencies') +}) + +t.test('ls with no package.json prints nothing', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: {}, + }) + await npm.exec('patch', ['ls']) + t.equal(joinedOutput(), '', 'no output and no crash without a package.json') +}) + +t.test('add with edit-dir config uses that directory', async t => { + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + const customDir = path.join(npm.prefix, 'my-edit-dir') + npm.config.set('edit-dir', customDir) + await npm.exec('patch', ['add', DEP_NAME]) + t.match(joinedOutput(), new RegExp('my-edit-dir'), 'uses configured edit dir') + t.ok(fs.existsSync(path.join(customDir, 'package.json')), 'extracted into configured dir') +}) + +t.test('add: not-installed bare name rejects with EPATCHNOTINSTALLED', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + }, + }) + await t.rejects( + npm.exec('patch', ['add', DEP_NAME]), + { code: 'EPATCHNOTINSTALLED' }, + 'errors when no installed version and no explicit version' + ) +}) + +t.test('add: ambiguous when multiple versions installed', async t => { + // root-direct 1.0.0 plus two nested 2.0.0 copies, so the dedup guard and the + // root-dependant label are both exercised while listing the ambiguity + const nestedDep = v => ({ + node_modules: { [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: v }) } }, + }) + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: '1.0.0', b: '1.0.0', c: '1.0.0' }, + }), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '1.0.0' }) }, + b: { + 'package.json': JSON.stringify({ name: 'b', version: '1.0.0', dependencies: { [DEP_NAME]: '2.0.0' } }), + ...nestedDep('2.0.0'), + }, + c: { + 'package.json': JSON.stringify({ name: 'c', version: '1.0.0', dependencies: { [DEP_NAME]: '2.0.0' } }), + ...nestedDep('2.0.0'), + }, + }, + }, + }) + await t.rejects( + npm.exec('patch', ['add', DEP_NAME]), + { code: 'EPATCHAMBIGUOUS' }, + 'errors when multiple versions are installed for a bare name' + ) +}) + +t.test('add: explicit exact version is honored without install', async t => { + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + // no install; explicit exact version path returns { name, version } directly + await npm.exec('patch', ['add', `${DEP_NAME}@${DEP_VERSION}`]) + t.match(joinedOutput(), /You can now edit the following directory: /, 'extracts the exact version') +}) + +t.test('commit: no edit dir arg rejects with EUSAGE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: basePrefix(), + }) + await t.rejects(npm.exec('patch', ['commit']), { code: 'EUSAGE' }) +}) + +t.test('commit: missing package.json in edit dir rejects with EPATCHNOEDITDIR', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { 'package.json': JSON.stringify(rootPackageJson), 'empty-dir': {} }, + }) + await t.rejects( + npm.exec('patch', ['commit', path.join(npm.prefix, 'empty-dir')]), + { code: 'EPATCHNOEDITDIR' } + ) +}) + +t.test('commit: no changes logs a warning and does not write a patch', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + // add then commit without editing anything + await npm.exec('patch', ['add', DEP_NAME]) + // the edit dir is a tmp path; re-extract a fresh clean copy to a known dir + const editDir = path.join(npm.prefix, 'clean-edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + + await npm.exec('patch', ['commit', editDir]) + t.notOk( + fs.existsSync(path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`)), + 'no patch file written when there are no changes' + ) + const pkg = readJson(path.join(npm.prefix, 'package.json')) + t.notOk(pkg.patchedDependencies, 'no patchedDependencies added when nothing changed') +}) + +t.test('rm: no pkg arg rejects with EUSAGE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: basePrefix(), + }) + await t.rejects(npm.exec('patch', ['rm']), { code: 'EUSAGE' }) +}) + +t.test('completion lists subcommands at the right depth', async t => { + t.same( + await Patch.completion({ conf: { argv: { remain: ['npm', 'patch'] } } }), + ['add', 'commit', 'ls', 'rm'] + ) + t.same(await Patch.completion({ conf: { argv: { remain: ['npm', 'patch', 'add', 'x'] } } }), []) +}) + +t.test('add: ignore-existing wipes a pre-existing edit dir', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + const customDir = path.join(npm.prefix, 'reuse-edit') + fs.mkdirSync(customDir, { recursive: true }) + fs.writeFileSync(path.join(customDir, 'stale.txt'), 'old') + npm.config.set('edit-dir', customDir) + npm.config.set('ignore-existing', true) + await npm.exec('patch', ['add', DEP_NAME]) + t.notOk(fs.existsSync(path.join(customDir, 'stale.txt')), 'stale file removed') + t.ok(fs.existsSync(path.join(customDir, 'package.json')), 'fresh extract present') +}) + +t.test('add: range matching an installed version resolves to it', async t => { + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + await npm.exec('patch', ['add', `${DEP_NAME}@^${DEP_VERSION}`]) + t.match(joinedOutput(), /You can now edit the following directory: /, 'range matched the installed version') +}) + +t.test('add: range not installed resolves against the registry', async t => { + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: { + 'dep-tarball': { 'package.json': JSON.stringify({ name: DEP_NAME, version: '2.0.0' }), 'index.js': DEP_SRC }, + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + }, + }) + const manifest = registry.manifest({ name: DEP_NAME, versions: ['2.0.0'] }) + const dist = new URL(manifest.versions['2.0.0'].dist.tarball) + const tar = await pacote.tarball(path.join(npm.prefix, 'dep-tarball'), { Arborist }) + registry.nock.get(`/${DEP_NAME}`).reply(200, manifest).persist() + registry.nock.get(dist.pathname).reply(200, tar).persist() + + await npm.exec('patch', ['add', `${DEP_NAME}@^2.0.0`]) + t.match(joinedOutput(), /You can now edit the following directory: /, 'resolved the range via the registry') +}) + +t.test('commit: edit dir package.json missing version rejects', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify(rootPackageJson), + 'bad-edit': { 'package.json': JSON.stringify({ name: 'no-version' }) }, + }, + }) + await t.rejects( + npm.exec('patch', ['commit', path.join(npm.prefix, 'bad-edit')]), + /missing name or version/ + ) +}) + +t.test('commit: keep-edit-dir leaves the edit directory in place', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false, 'keep-edit-dir': true }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + const editDir = path.join(npm.prefix, 'kept-edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = () => "patched"\n') + await npm.exec('patch', ['commit', editDir]) + t.ok(fs.existsSync(editDir), 'edit dir kept when keep-edit-dir is set') +}) + +t.test('ls counts nodes for a range selector', async t => { + // offline fixture: ls reads the installed tree from disk, no registry needed + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + ...rootPackageJson, + patchedDependencies: { [`${DEP_NAME}@^1.0.0`]: `patches/${DEP_NAME}.patch` }, + }), + 'package-lock.json': JSON.stringify(rootPackageLock), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + }, + }, + }) + await npm.exec('patch', ['ls']) + t.match(joinedOutput(), /\(1 node\)/, 'range selector matches the installed version') +}) + +t.test('ls reports plural node counts for a name-only selector', async t => { + // offline fixture with two installed copies so the match count is plural + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: '1.0.0', b: '1.0.0' }, + patchedDependencies: { [DEP_NAME]: `patches/${DEP_NAME}.patch` }, + }), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '1.0.0' }) }, + b: { + 'package.json': JSON.stringify({ name: 'b', version: '1.0.0', dependencies: { [DEP_NAME]: '2.0.0' } }), + node_modules: { [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '2.0.0' }) } }, + }, + }, + }, + }) + await npm.exec('patch', ['ls']) + t.match(joinedOutput(), /\(2 nodes\)/, 'name-only selector matches both installed copies') +}) + +t.test('rm removes every selector for a bare name', async t => { + // offline: the dep is already installed and unpatched, so rm reifies without the registry + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + ...rootPackageJson, + patchedDependencies: { + [`${DEP_NAME}@1.0.0`]: 'patches/one.patch', + [`${DEP_NAME}@2.0.0`]: 'patches/two.patch', + }, + }), + 'package-lock.json': JSON.stringify(rootPackageLock), + patches: { 'one.patch': '', 'two.patch': '' }, + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + }, + }, + }) + await npm.exec('patch', ['rm', DEP_NAME]) + t.match(joinedOutput(), /Removed patches:/, 'reports plural removal') + t.notOk(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, 'all selectors removed') +}) + +t.test('rm keeps a patch file still referenced by another selector', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + // create a real patch via the normal flow + await npm.exec('patch', ['add', DEP_NAME]) + const editDir = path.join(npm.prefix, 'edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = () => "patched"\n') + await npm.exec('patch', ['commit', editDir]) + + // add a second name-only selector pointing at the same patch file + const pkgPath = path.join(npm.prefix, 'package.json') + const pkg = readJson(pkgPath) + const patchPath = pkg.patchedDependencies[`${DEP_NAME}@${DEP_VERSION}`] + pkg.patchedDependencies[DEP_NAME] = patchPath + fs.writeFileSync(pkgPath, JSON.stringify(pkg)) + + // removing the exact selector leaves the name-only one, so the file stays + await npm.exec('patch', ['rm', `${DEP_NAME}@${DEP_VERSION}`]) + t.ok(fs.existsSync(path.join(npm.prefix, patchPath)), 'shared patch file retained') + const after = readJson(pkgPath) + t.ok(after.patchedDependencies[DEP_NAME], 'name-only selector kept') + t.notOk(after.patchedDependencies[`${DEP_NAME}@${DEP_VERSION}`], 'exact selector removed') +}) diff --git a/test/lib/utils/patch-diff.js b/test/lib/utils/patch-diff.js new file mode 100644 index 0000000000000..0ed96ae70c96a --- /dev/null +++ b/test/lib/utils/patch-diff.js @@ -0,0 +1,131 @@ +const t = require('tap') +const { resolve } = require('node:path') +const { readFileSync, existsSync, symlinkSync } = require('node:fs') +const { diffDirs } = require('../../../lib/utils/patch-diff.js') +const { applyPatchToDir } = require('@npmcli/arborist/lib/patch.js') + +// Helper to read a file from a dir as utf8. +const read = (...p) => readFileSync(resolve(...p), 'utf8') + +t.test('modified file produces a unified diff', async t => { + const dir = t.testdir({ + orig: { 'index.js': 'hello\n' }, + edit: { 'index.js': 'world\n' }, + }) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.match(diff, '--- a/index.js', 'has old header') + t.match(diff, '+++ b/index.js', 'has new header') + t.match(diff, '-hello', 'removes old line') + t.match(diff, '+world', 'adds new line') + t.notMatch(diff, '====', 'index separator is stripped') +}) + +t.test('added file uses --- /dev/null', async t => { + const dir = t.testdir({ + orig: { 'keep.js': 'same\n' }, + edit: { 'keep.js': 'same\n', 'added.js': 'brand new\n' }, + }) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.match(diff, '--- /dev/null', 'old side is /dev/null') + t.match(diff, '+++ b/added.js', 'new side names the added file') + t.match(diff, '+brand new', 'includes added content') + t.notMatch(diff, 'keep.js', 'identical file is not in the diff') +}) + +t.test('deleted file uses +++ /dev/null', async t => { + const dir = t.testdir({ + orig: { 'gone.js': 'remove me\n' }, + edit: {}, + }) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.match(diff, '--- a/gone.js', 'old side names the deleted file') + t.match(diff, '+++ /dev/null', 'new side is /dev/null') + t.match(diff, '-remove me', 'includes removed content') +}) + +t.test('nested file path is posix-separated in the diff', async t => { + const dir = t.testdir({ + orig: { lib: { deep: { 'x.js': 'a\n' } } }, + edit: { lib: { deep: { 'x.js': 'b\n' } } }, + }) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.match(diff, '--- a/lib/deep/x.js', 'old header uses posix separators') + t.match(diff, '+++ b/lib/deep/x.js', 'new header uses posix separators') +}) + +t.test('identical files produce no diff', async t => { + const dir = t.testdir({ + orig: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } }, + edit: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } }, + }) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.equal(diff, '', 'empty diff for identical trees') +}) + +t.test('node_modules and .git are ignored', async t => { + const dir = t.testdir({ + orig: { + 'index.js': 'v1\n', + node_modules: { dep: { 'index.js': 'old\n' } }, + '.git': { HEAD: 'ref: refs/heads/main\n' }, + }, + edit: { + 'index.js': 'v2\n', + node_modules: { dep: { 'index.js': 'changed\n' } }, + '.git': { HEAD: 'ref: refs/heads/other\n' }, + }, + }) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.match(diff, 'index.js', 'top-level change is captured') + t.notMatch(diff, 'node_modules', 'node_modules contents are excluded') + t.notMatch(diff, 'HEAD', '.git contents are excluded') +}) + +t.test('exclude option skips a path', async t => { + const dir = t.testdir({ + orig: { 'keep.js': 'a\n', 'skip.js': 'a\n' }, + edit: { 'keep.js': 'b\n', 'skip.js': 'b\n' }, + }) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'), { + exclude: ['skip.js'], + }) + t.match(diff, 'keep.js', 'non-excluded file is diffed') + t.notMatch(diff, 'skip.js', 'excluded file is skipped') +}) + +t.test('non-file entries like symlinks are skipped', async t => { + const dir = t.testdir({ + orig: { 'real.js': 'a\n' }, + edit: { 'real.js': 'b\n' }, + }) + // A symlink is neither a directory nor a regular file so it is ignored. + symlinkSync(resolve(dir, 'orig', 'real.js'), resolve(dir, 'edit', 'link.js')) + const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.match(diff, 'real.js', 'regular file is diffed') + t.notMatch(diff, 'link.js', 'symlink entry is skipped') +}) + +t.test('round-trip: applying the diff reproduces the edited tree', async t => { + const dir = t.testdir({ + orig: { + 'mod.js': 'original line\n', + 'del.js': 'doomed\n', + lib: { deep: { 'x.js': 'before\n' } }, + }, + edit: { + 'mod.js': 'patched line\n', + 'add.js': 'fresh content\n', + lib: { deep: { 'x.js': 'after\n' } }, + }, + }) + const orig = resolve(dir, 'orig') + const diff = await diffDirs(orig, resolve(dir, 'edit')) + + // Apply the diff back onto a copy of the original and check the result. + await applyPatchToDir({ patch: diff, cwd: orig }) + + t.equal(read(orig, 'mod.js'), 'patched line\n', 'modified file matches edit') + t.equal(read(orig, 'add.js'), 'fresh content\n', 'added file was created') + t.equal(read(orig, 'lib', 'deep', 'x.js'), 'after\n', 'nested file matches edit') + t.notOk(existsSync(resolve(orig, 'del.js')), 'deleted file was removed') +}) diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js new file mode 100644 index 0000000000000..46a3b15d7a88a --- /dev/null +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -0,0 +1,179 @@ +const t = require('tap') +const fs = require('node:fs') +const { resolve } = require('node:path') +const { createTwoFilesPatch } = require('diff') +const MockRegistry = require('@npmcli/mock-registry') +const Arborist = require('../../lib/index.js') + +// build a git-style unified diff for a single file change +const filePatch = (file, before, after) => { + let p = createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after, '', '') + .replace('===================================================================\n', '') + if (before === '') { + p = p.replace(`--- a/${file}\t`, '--- /dev/null\t') + } + if (after === '') { + p = p.replace(`+++ b/${file}\t`, '+++ /dev/null\t') + } + return p +} + +const createRegistry = (t) => new MockRegistry({ + strict: false, + tap: t, + registry: 'https://registry.npmjs.org', +}) + +const newArb = (opt) => new Arborist({ + audit: false, + cache: opt.path, + registry: 'https://registry.npmjs.org', + timeout: 30 * 60 * 1000, + ...opt, +}) + +// the registry package source we patch in these tests +const PKG_NAME = 'patch-me' +const PKG_VERSION = '1.0.0' +const ORIGINAL = 'module.exports = "original"\n' +const PATCHED = 'module.exports = "patched"\n' + +// register the package manifest + tarball on the mock registry. +// manifestTimes controls how many packument GETs are served, tarballTimes how many tarball GETs. +// nock consumes one mock per request and teardown asserts every registered mock is used, so counts must match the requests a test makes. +const mockPackage = async (t, registry, { manifestTimes = 1, tarballTimes = 1 } = {}) => { + const src = t.testdir({ + 'package.json': JSON.stringify({ name: PKG_NAME, version: PKG_VERSION }), + 'index.js': ORIGINAL, + }) + const manifest = registry.manifest({ + name: PKG_NAME, + packuments: [{ version: PKG_VERSION }], + }) + registry.nock = registry.nock + .get(registry.fullPath(`/${PKG_NAME}`)).times(manifestTimes).reply(200, manifest) + for (let i = 0; i < tarballTimes; i++) { + await registry.tarball({ manifest: manifest.versions[PKG_VERSION], tarball: src }) + } + return manifest +} + +// write a project root + on-disk patch file, return its path +const makeProject = (t, { patch, patchedDependencies, extra = {} }) => { + const tree = { + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { [PKG_NAME]: `^${PKG_VERSION}` }, + ...(patchedDependencies ? { patchedDependencies } : {}), + }), + ...extra, + } + if (patch !== undefined) { + tree.patches = { [`${PKG_NAME}@${PKG_VERSION}.patch`]: patch } + } + return t.testdir(tree) +} + +const installedFile = (path) => + resolve(path, 'node_modules', PKG_NAME, 'index.js') + +t.test('registry dep with patch is applied and recorded in lockfile', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + + const patch = filePatch('index.js', ORIGINAL, PATCHED) + const path = makeProject(t, { + patch, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await newArb({ path }).reify() + + t.equal(fs.readFileSync(installedFile(path), 'utf8'), PATCHED, + 'extracted package was patched') + + const lock = JSON.parse(fs.readFileSync(resolve(path, 'package-lock.json'), 'utf8')) + t.equal(lock.lockfileVersion, 4, 'lockfile bumped to version 4') + const pkgEntry = lock.packages[`node_modules/${PKG_NAME}`] + t.ok(pkgEntry.patched, 'lockfile records patched') + t.equal(pkgEntry.patched.path, `patches/${PKG_NAME}@${PKG_VERSION}.patch`, + 'patched.path is the relative patch path') + t.match(pkgEntry.patched.integrity, /^sha512-/, 'patched.integrity is an SSRI') +}) + +t.test('patch is re-applied on a patch-change reify even with ignoreScripts', async t => { + const registry = createRegistry(t) + // two reifys: the second re-extracts the node due to the patch change. + // the second reify resolves the dep from the lockfile, so only one manifest GET. + await mockPackage(t, registry, { manifestTimes: 1, tarballTimes: 2 }) + + // first reify with no patch registered + const path = makeProject(t, {}) + await newArb({ path }).reify() + t.equal(fs.readFileSync(installedFile(path), 'utf8'), ORIGINAL, + 'first install is unpatched') + + // now add a patch + patchedDependencies and reify again with ignoreScripts + const patch = filePatch('index.js', ORIGINAL, PATCHED) + fs.mkdirSync(resolve(path, 'patches'), { recursive: true }) + fs.writeFileSync(resolve(path, 'patches', `${PKG_NAME}@${PKG_VERSION}.patch`), patch) + const rootPkg = JSON.parse(fs.readFileSync(resolve(path, 'package.json'), 'utf8')) + rootPkg.patchedDependencies = { + [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch`, + } + fs.writeFileSync(resolve(path, 'package.json'), JSON.stringify(rootPkg)) + + await newArb({ path, ignoreScripts: true }).reify() + + t.equal(fs.readFileSync(installedFile(path), 'utf8'), PATCHED, + 'patch applied on patch-change reify under ignoreScripts') + const lock = JSON.parse(fs.readFileSync(resolve(path, 'package-lock.json'), 'utf8')) + t.equal(lock.lockfileVersion, 4, 'lockfile bumped to version 4 after patch added') +}) + +t.test('patch that fails to apply throws EPATCHFAILED', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + + // a patch whose context does not match the extracted file + const patch = filePatch('index.js', 'totally different\n', 'something else\n') + const path = makeProject(t, { + patch, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await t.rejects(newArb({ path }).reify(), { code: 'EPATCHFAILED' }, + 'hunk that does not apply hard-errors') +}) + +t.test('ignorePatchFailures downgrades EPATCHFAILED to a warning', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + + const patch = filePatch('index.js', 'totally different\n', 'something else\n') + const path = makeProject(t, { + patch, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await t.resolves(newArb({ path, ignorePatchFailures: true }).reify(), + 'failure is downgraded and reify continues') + // file remains as extracted since the patch was skipped + t.equal(fs.readFileSync(installedFile(path), 'utf8'), ORIGINAL, + 'package left unpatched after skipped failure') +}) + +t.test('missing patch file throws EPATCHNOTFOUND', async t => { + const registry = createRegistry(t) + // resolvePatchedDependencies fails before extract, so the tarball is never fetched + await mockPackage(t, registry, { tarballTimes: 0 }) + + // register patchedDependencies but do NOT write the patch file + const path = makeProject(t, { + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await t.rejects(newArb({ path }).reify(), { code: 'EPATCHNOTFOUND' }, + 'missing patch file on disk hard-errors') +}) diff --git a/workspaces/arborist/test/patch.js b/workspaces/arborist/test/patch.js index 9404deec66419..07e85035764cf 100644 --- a/workspaces/arborist/test/patch.js +++ b/workspaces/arborist/test/patch.js @@ -41,6 +41,12 @@ t.test('creates nested directories for new files', async t => { t.equal(readFileSync(resolve(dir, 'lib/deep/x.js'), 'utf8'), 'deep\n') }) +t.test('empty patch content is a no-op', async t => { + const dir = t.testdir({ 'index.js': 'unchanged\n' }) + await applyPatchToDir({ patch: '', cwd: dir }) + t.equal(readFileSync(resolve(dir, 'index.js'), 'utf8'), 'unchanged\n') +}) + t.test('throws on context drift (fuzz 0)', async t => { const dir = t.testdir({ 'index.js': 'totally different content\n' }) await t.rejects( diff --git a/workspaces/arborist/test/patched-dependencies-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js new file mode 100644 index 0000000000000..af9c8b6e6a6b3 --- /dev/null +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -0,0 +1,265 @@ +// Exercises resolvePatchedDependencies, which is not exported, so it must be driven through Arborist. +// We build a real ideal tree against a t.testdir fixture and assert that node.patched is set on matches and that the documented error codes throw. +const t = require('tap') +const Arborist = require('../lib/arborist') + +// a trivial but valid unified diff used as the on-disk patch contents +const PATCH = '--- a/index.js\n+++ b/index.js\n@@ -1 +1 @@\n-old\n+new\n' + +// build a lockfileVersion 3 entry for a registry dependency +const lockEntry = (name, version) => ({ + version, + resolved: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, + integrity: 'sha512-deadbeef', +}) + +// build an offline ideal tree for a fixture directory, so registry deps need no network +const buildIdeal = (path, opts = {}) => + new Arborist({ path, offline: true, ...opts }).buildIdealTree() + +t.test('attaches node.patched on an exact match', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const dep = tree.inventory.query('name', 'dep').values().next().value + t.ok(dep, 'dep node exists') + t.ok(dep.patched, 'node.patched is set') + t.equal(dep.patched.path, 'fix.patch', 'records the relative patch path') + t.match(dep.patched.integrity, /^sha512-/, 'records the sha512 integrity') +}) + +t.test('no patchedDependencies is a no-op', async t => { + // empty patchedDependencies hits the early return guard + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + patchedDependencies: {}, + }), + }) + const tree = await buildIdeal(path) + for (const node of tree.inventory.values()) { + t.notOk(node.patched, `${node.name} is not patched`) + } +}) + +t.test('shares integrity cache across selectors pointing at one file', async t => { + // two selectors reference the same patch path, so the file is read once and both matched nodes get the identical integrity value + const path = t.testdir({ + 'shared.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { a: '^1.0.0', b: '^1.0.0' }, + patchedDependencies: { 'a@1.0.0': 'shared.patch', 'b@1.0.0': 'shared.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { a: '^1.0.0', b: '^1.0.0' } }, + 'node_modules/a': lockEntry('a', '1.0.0'), + 'node_modules/b': lockEntry('b', '1.0.0'), + }, + }), + node_modules: { + a: { 'package.json': JSON.stringify({ name: 'a', version: '1.0.0' }) }, + b: { 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const a = tree.inventory.query('name', 'a').values().next().value + const b = tree.inventory.query('name', 'b').values().next().value + t.ok(a.patched && b.patched, 'both nodes are patched') + t.equal(a.patched.integrity, b.patched.integrity, 'integrity is shared from the cache') + t.equal(a.patched.path, 'shared.patch') + t.equal(b.patched.path, 'shared.patch') +}) + +t.test('EPATCHWORKSPACE when a workspace member declares patchedDependencies', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['workspace-a'], + // a root entry is needed so the function does not early-return + patchedDependencies: { 'x@1.0.0': 'fix.patch' }, + }), + 'workspace-a': { + 'package.json': JSON.stringify({ + name: 'workspace-a', + version: '1.0.0', + patchedDependencies: { 'x@1.0.0': 'fix.patch' }, + }), + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHWORKSPACE', workspace: 'workspace-a' }) +}) + +t.test('skips a clean workspace member and patches a root dep', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['workspace-a'], + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, + }), + 'workspace-a': { + 'package.json': JSON.stringify({ name: 'workspace-a', version: '1.0.0' }), + }, + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' }, workspaces: ['workspace-a'] }, + 'workspace-a': { name: 'workspace-a', version: '1.0.0' }, + 'node_modules/workspace-a': { link: true, resolved: 'workspace-a' }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + 'workspace-a': t.fixture('symlink', '../workspace-a'), + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const dep = tree.inventory.query('name', 'dep').values().next().value + t.ok(dep.patched, 'root dep is patched even though a workspace member exists') +}) + +t.test('EPATCHNONREGISTRY when the matched node is not a registry dependency', async t => { + // a file: dependency resolves to a Link/non-registry node and cannot be patched + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: 'file:./localdep' }, + patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, + }), + localdep: { + 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }), + }, + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHNONREGISTRY', node: 'dep' }) +}) + +t.test('EPATCHUNUSED when a registered patch matches no node', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + // ghost has no installed node so it is unused + patchedDependencies: { 'ghost@1.0.0': 'fix.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHUNUSED', unused: ['ghost@1.0.0'] }) +}) + +t.test('allowUnusedPatches:true suppresses EPATCHUNUSED', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'ghost@1.0.0': 'fix.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path, { allowUnusedPatches: true }) + for (const node of tree.inventory.values()) { + t.notOk(node.patched, `${node.name} is not patched`) + } +}) + +t.test('EPATCHNOTFOUND when the patch file is missing on disk', async t => { + // selector matches an installed node but the referenced patch file is absent + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': 'missing.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHNOTFOUND', path: 'missing.patch' }) +}) diff --git a/workspaces/arborist/test/patched-dependencies.js b/workspaces/arborist/test/patched-dependencies.js index bf8b6fd2fb308..ea7d95211cbe1 100644 --- a/workspaces/arborist/test/patched-dependencies.js +++ b/workspaces/arborist/test/patched-dependencies.js @@ -30,9 +30,11 @@ t.test('name-only is the fallback', t => { t.end() }) -t.test('most specific (subset) range wins', t => { - const selectors = [sel('x', '>=1.0.0 <3.0.0'), sel('x', '>=1.5.0 <2.0.0')] - t.equal(matchSelector(selectors, { name: 'x', version: '1.7.0' }).key, 'x@>=1.5.0 <2.0.0') +t.test('most specific (subset) range wins regardless of order', t => { + const wideFirst = [sel('x', '>=1.0.0 <3.0.0'), sel('x', '>=1.5.0 <2.0.0')] + t.equal(matchSelector(wideFirst, { name: 'x', version: '1.7.0' }).key, 'x@>=1.5.0 <2.0.0') + const narrowFirst = [sel('x', '>=1.5.0 <2.0.0'), sel('x', '>=1.0.0 <3.0.0')] + t.equal(matchSelector(narrowFirst, { name: 'x', version: '1.7.0' }).key, 'x@>=1.5.0 <2.0.0') t.end() }) From 8105b4aed134be6a2cc0e3b83080303ec5191a64 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:35:40 +0530 Subject: [PATCH 08/32] fix(patch): harden apply pipeline and tighten selector handling --- lib/commands/patch.js | 100 +++++++++++------- lib/utils/validate-lockfile.js | 9 +- .../lib/utils/validate-lockfile.js.test.cjs | 8 ++ test/lib/commands/patch.js | 68 ++++++++++++ test/lib/utils/validate-lockfile.js | 18 ++++ workspaces/arborist/lib/patch.js | 79 +++++++++----- .../arborist/lib/patched-dependencies.js | 15 ++- workspaces/arborist/test/patch.js | 48 +++++++++ .../test/patched-dependencies-resolve.js | 26 +++++ 9 files changed, 297 insertions(+), 74 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 7b595fe9767d3..e46741dab50fc 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -6,6 +6,7 @@ const npa = require('npm-package-arg') const semver = require('semver') const PackageJson = require('@npmcli/package-json') const { log, output } = require('proc-log') +const { matchSelector, parseSelector } = require('@npmcli/arborist/lib/patched-dependencies.js') const BaseCommand = require('../base-cmd.js') const { diffDirs } = require('../utils/patch-diff.js') const reifyFinish = require('../utils/reify-finish.js') @@ -76,35 +77,42 @@ class Patch extends BaseCommand { async #resolveTarget (spec) { const parsed = npa(spec) if (parsed.type && !parsed.registry) { - throw Object.assign( - new Error(`Cannot patch non-registry dependency "${spec}". ` + - `Only registry dependencies can be patched; edit the source directly.`), - { code: 'EPATCHNONREGISTRY' } - ) + throw this.#nonRegistryError(spec) } const { name } = parsed const tree = await this.#loadActual() const installed = new Map() for (const node of tree.inventory.values()) { - if (node.name === name && !node.isProjectRoot && !node.isLink && node.version) { + if (node.name === name && !node.isProjectRoot && node.version) { if (!installed.has(node.version)) { installed.set(node.version, node) } } } + // a node that is not a registry dependency cannot be patched + const ensureRegistry = node => { + if (node && (node.isLink || !node.isRegistryDependency)) { + throw this.#nonRegistryError(`${name}@${node.version}`) + } + } + // an explicit version/range is honored even when not present in the tree if (parsed.rawSpec && parsed.rawSpec !== '*' && parsed.rawSpec !== 'latest') { const exact = semver.valid(parsed.fetchSpec) if (exact) { + ensureRegistry(installed.get(exact)) return { name, version: exact } } - const match = [...installed.keys()] - .filter(v => semver.satisfies(v, parsed.fetchSpec)) - .sort(semver.rcompare)[0] - if (match) { - return { name, version: match } + const matches = [...installed.entries()] + .filter(([v]) => semver.satisfies(v, parsed.fetchSpec)) + if (matches.length > 1) { + throw this.#ambiguousError(name, matches) + } + if (matches.length === 1) { + ensureRegistry(matches[0][1]) + return { name, version: matches[0][0] } } // resolve the range against the registry const mani = await pacote.manifest(spec, this.npm.flatOptions) @@ -119,17 +127,31 @@ class Patch extends BaseCommand { ) } if (installed.size > 1) { - const lines = [...installed.entries()].map(([version, node]) => { - const dependant = [...node.edgesIn][0]?.from?.location || '(root)' - return ` ${selectorKey(name, version)} (via ${dependant})` - }) - throw Object.assign( - new Error(`Multiple versions of "${name}" are installed:\n${lines.join('\n')}\n` + - `Re-run with an exact selector, e.g. "npm patch add ${selectorKey(name, [...installed.keys()][0])}".`), - { code: 'EPATCHAMBIGUOUS' } - ) + throw this.#ambiguousError(name, [...installed.entries()]) } - return { name, version: [...installed.keys()][0] } + const [version, node] = [...installed.entries()][0] + ensureRegistry(node) + return { name, version } + } + + #nonRegistryError (label) { + return Object.assign( + new Error(`Cannot patch non-registry dependency "${label}". ` + + `Only registry dependencies can be patched; edit the source directly.`), + { code: 'EPATCHNONREGISTRY' } + ) + } + + #ambiguousError (name, entries) { + const lines = entries.map(([version, node]) => { + const dependant = [...node.edgesIn][0]?.from?.location || '(root)' + return ` ${selectorKey(name, version)} (via ${dependant})` + }) + return Object.assign( + new Error(`Multiple versions of "${name}" are installed:\n${lines.join('\n')}\n` + + `Re-run with an exact selector, e.g. "npm patch add ${selectorKey(name, entries[0][0])}".`), + { code: 'EPATCHAMBIGUOUS' } + ) } async add (args) { @@ -221,25 +243,31 @@ class Patch extends BaseCommand { return } + // count nodes per patch using the same precedence Arborist applies at install const tree = await this.#loadActual() + const selectors = keys.map(key => ({ ...parseSelector(key), key, patchPath: patched[key] })) + const counts = new Map(keys.map(key => [key, 0])) + for (const node of tree.inventory.values()) { + if (node.isProjectRoot || node.isLink || !node.version) { + continue + } + let winner = null + try { + winner = matchSelector(selectors, node) + } catch { + // ambiguous overlapping ranges surface at install time, skip here + continue + } + if (winner) { + counts.set(winner.key, counts.get(winner.key) + 1) + } + } for (const key of keys) { - const { name, spec } = this.#parseKey(key) - const matches = [...tree.inventory.values()].filter(node => - node.name === name && !node.isProjectRoot && !node.isLink && node.version && - (!spec || semver.valid(spec) - ? (!spec || node.version === spec) - : semver.satisfies(node.version, spec))) - output.standard(`${patched[key]}\t${key}\t(${matches.length} node${matches.length === 1 ? '' : 's'})`) + const n = counts.get(key) + output.standard(`${patched[key]}\t${key}\t(${n} node${n === 1 ? '' : 's'})`) } } - #parseKey (key) { - const at = key.indexOf('@', 1) - return at === -1 - ? { name: key, spec: null } - : { name: key.slice(0, at), spec: key.slice(at + 1) } - } - async rm (args) { if (args.length !== 1) { throw this.usageError() @@ -252,7 +280,7 @@ class Patch extends BaseCommand { const patched = { ...pkgJson.content.patchedDependencies } const removed = [] for (const key of Object.keys(patched)) { - const { name, spec } = this.#parseKey(key) + const { name, spec } = parseSelector(key) if (name === targetName && (!targetVersion || spec === targetVersion)) { removed.push(key) } diff --git a/lib/utils/validate-lockfile.js b/lib/utils/validate-lockfile.js index 1b75707eea41a..cdab0ed0ea046 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -23,12 +23,11 @@ function validateLockfile (virtualTree, idealTree) { `not satisfy ${entry.name}@${entry.version}`) } - // a patch whose on-disk hash diverges from the lockfile is out of sync - const lockPatch = lock.patched?.integrity || null - const entryPatch = entry.patched?.integrity || null - if (lockPatch !== entryPatch) { + // a patch whose on-disk hash or path diverges from the lockfile is out of sync + if ((lock.patched?.integrity || null) !== (entry.patched?.integrity || null) || + (lock.patched?.path || null) !== (entry.patched?.path || null)) { errors.push(`Invalid: patch for ${entry.name}@${entry.version} does not ` + - `match the integrity recorded in the lock file`) + `match the patch recorded in the lock file`) } } return errors diff --git a/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs b/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs index 98a51267b1f4e..bafd54bd2ed61 100644 --- a/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs +++ b/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs @@ -19,6 +19,14 @@ exports[`test/lib/utils/validate-lockfile.js TAP identical inventory for both id Array [] ` +exports[`test/lib/utils/validate-lockfile.js TAP mismatching patch integrity or path > should error on integrity drift, path drift, and a newly added patch 1`] = ` +Array [ + "Invalid: patch for foo@1.0.0 does not match the patch recorded in the lock file", + "Invalid: patch for bar@2.0.0 does not match the patch recorded in the lock file", + "Invalid: patch for baz@3.0.0 does not match the patch recorded in the lock file", +] +` + exports[`test/lib/utils/validate-lockfile.js TAP mismatching versions on inventory > should have errors for each mismatching version 1`] = ` Array [ "Invalid: lock file's foo@1.0.0 does not satisfy foo@2.0.0", diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index 233d67ef8e0e8..ac8e5963977fc 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -285,6 +285,51 @@ t.test('add: ambiguous when multiple versions installed', async t => { ) }) +t.test('add: an installed file: dependency is rejected as non-registry', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', version: '1.0.0', dependencies: { [DEP_NAME]: 'file:./local' }, + }), + local: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + }, + }, + }) + await t.rejects( + npm.exec('patch', ['add', DEP_NAME]), + { code: 'EPATCHNONREGISTRY' }, + 'cannot patch a file: dependency that is already installed' + ) +}) + +t.test('add: a range matching multiple installed versions is ambiguous', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: '1.0.0', b: '1.0.0' }, + }), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '1.0.0' }) }, + b: { + 'package.json': JSON.stringify({ name: 'b', version: '1.0.0', dependencies: { [DEP_NAME]: '2.0.0' } }), + node_modules: { [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '2.0.0' }) } }, + }, + }, + }, + }) + await t.rejects( + npm.exec('patch', ['add', `${DEP_NAME}@>=1.0.0`]), + { code: 'EPATCHAMBIGUOUS' }, + 'a range matching two installed versions errors' + ) +}) + t.test('add: explicit exact version is honored without install', async t => { const { npm, joinedOutput, registry } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, @@ -455,6 +500,29 @@ t.test('ls counts nodes for a range selector', async t => { t.match(joinedOutput(), /\(1 node\)/, 'range selector matches the installed version') }) +t.test('ls tolerates ambiguous overlapping range selectors', async t => { + // two overlapping non-subset ranges make matchSelector throw; ls must not crash + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: '1.5.0' }, + patchedDependencies: { + [`${DEP_NAME}@>=1.0.0 <2.0.0`]: 'patches/a.patch', + [`${DEP_NAME}@>=1.4.0 <3.0.0`]: 'patches/b.patch', + }, + }), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '1.5.0' }) }, + }, + }, + }) + await npm.exec('patch', ['ls']) + t.match(joinedOutput(), /\(0 nodes\)/, 'ambiguous node is skipped, ls still prints') +}) + t.test('ls reports plural node counts for a name-only selector', async t => { // offline fixture with two installed copies so the match count is plural const { npm, joinedOutput } = await loadMockNpm(t, { diff --git a/test/lib/utils/validate-lockfile.js b/test/lib/utils/validate-lockfile.js index 25939c5f89cda..a3942a6903658 100644 --- a/test/lib/utils/validate-lockfile.js +++ b/test/lib/utils/validate-lockfile.js @@ -67,6 +67,24 @@ t.test('mismatching versions on inventory', async t => { ) }) +t.test('mismatching patch integrity or path', async t => { + t.matchSnapshot( + validateLockfile( + new Map([ + ['foo', { name: 'foo', version: '1.0.0', patched: { path: 'patches/foo.patch', integrity: 'sha512-aaa' } }], + ['bar', { name: 'bar', version: '2.0.0', patched: { path: 'patches/bar.patch', integrity: 'sha512-bbb' } }], + ['baz', { name: 'baz', version: '3.0.0' }], + ]), + new Map([ + ['foo', { name: 'foo', version: '1.0.0', patched: { path: 'patches/foo.patch', integrity: 'sha512-CHANGED' } }], + ['bar', { name: 'bar', version: '2.0.0', patched: { path: 'patches/moved.patch', integrity: 'sha512-bbb' } }], + ['baz', { name: 'baz', version: '3.0.0', patched: { path: 'patches/baz.patch', integrity: 'sha512-ccc' } }], + ]) + ), + 'should error on integrity drift, path drift, and a newly added patch' + ) +}) + t.test('missing virtualTree inventory', async t => { t.matchSnapshot( validateLockfile( diff --git a/workspaces/arborist/lib/patch.js b/workspaces/arborist/lib/patch.js index c0c30481b7349..09ec74ea2cbdd 100644 --- a/workspaces/arborist/lib/patch.js +++ b/workspaces/arborist/lib/patch.js @@ -4,7 +4,7 @@ const { applyPatch, parsePatch } = require('diff') const ssri = require('ssri') const fs = require('node:fs') const { promises: fsp } = fs -const { resolve, dirname } = require('node:path') +const { resolve, relative, dirname, isAbsolute } = require('node:path') // Compute the SSRI integrity of a patch file's contents. // Accepts a string or Buffer and returns a sha512 SSRI string. @@ -14,52 +14,73 @@ const patchIntegrity = data => }).toString() // Strip a leading git-style "a/" or "b/" prefix from a diff path. -const stripPrefix = file => { - if (!file || file === '/dev/null') { - return file - } - return file.replace(/^[ab]\//, '') -} +const stripPrefix = file => file.replace(/^[ab]\//, '') // True when a diff path points at /dev/null, signalling a file add or delete. const isDevNull = file => !file || file === '/dev/null' || /(^|\/)\.dev\/null$/.test(file) +const patchError = (message, code, file) => + Object.assign(new Error(message), { code, file }) + +// Resolve a diff path under cwd and refuse anything that escapes the package directory. +const containedTarget = (cwd, file) => { + const target = resolve(cwd, file) + const rel = relative(cwd, target) + if (!rel || rel.startsWith('..') || isAbsolute(rel)) { + throw patchError(`patch path escapes the package directory: ${file}`, 'EPATCHUNSAFE', file) + } + return target +} + +// Run a parsed file patch against a source string with fuzz 0. +// Returns the patched text, or throws EPATCHFAILED on any context mismatch. +const strictApply = (source, filePatch, file) => { + const patched = applyPatch(source, filePatch, { fuzzFactor: 0 }) + if (patched === false) { + throw patchError(`patch could not be applied to ${file}`, 'EPATCHFAILED', file) + } + return patched +} + // Apply a single parsed file patch under cwd. // Handles modified, added (--- /dev/null) and deleted (+++ /dev/null) files. const applyFilePatch = async (filePatch, cwd) => { - const oldFile = stripPrefix(filePatch.oldFileName) - const newFile = stripPrefix(filePatch.newFileName) const isAdd = isDevNull(filePatch.oldFileName) const isDelete = isDevNull(filePatch.newFileName) if (isDelete) { - await fsp.rm(resolve(cwd, oldFile), { force: true }) + const file = stripPrefix(filePatch.oldFileName) + const target = containedTarget(cwd, file) + // verify the file still matches the diff before removing it + const source = await fsp.readFile(target, 'utf8').catch(() => { + throw patchError(`patch target to delete is missing: ${file}`, 'EPATCHFAILED', file) + }) + strictApply(source, filePatch, file) + await fsp.rm(target, { force: true }) return } - const target = resolve(cwd, newFile) + const file = stripPrefix(filePatch.newFileName) + const target = containedTarget(cwd, file) - let source = '' - let mode - if (!isAdd) { - source = await fsp.readFile(target, 'utf8') - mode = (await fsp.stat(target)).mode - } - - // fuzzFactor 0: any context mismatch returns false and is treated as fatal. - const patched = applyPatch(source, filePatch, { fuzzFactor: 0 }) - if (patched === false) { - throw Object.assign( - new Error(`patch could not be applied to ${newFile}`), - { code: 'EPATCHFAILED', file: newFile } - ) + if (isAdd) { + // a new file must not already exist, otherwise the tarball drifted + if (fs.existsSync(target)) { + throw patchError(`patch adds a file that already exists: ${file}`, 'EPATCHFAILED', file) + } + const created = strictApply('', filePatch, file) + await fsp.mkdir(dirname(target), { recursive: true }) + await fsp.writeFile(target, created) + return } - await fsp.mkdir(dirname(target), { recursive: true }) + const source = await fsp.readFile(target, 'utf8').catch(() => { + throw patchError(`patch target is missing: ${file}`, 'EPATCHFAILED', file) + }) + const mode = (await fsp.stat(target)).mode + const patched = strictApply(source, filePatch, file) await fsp.writeFile(target, patched) - if (mode !== undefined) { - await fsp.chmod(target, mode) - } + await fsp.chmod(target, mode) } // Apply a unified diff to the package extracted at `cwd`. diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js index 18532eaee1ec1..be7af4bcc3bd5 100644 --- a/workspaces/arborist/lib/patched-dependencies.js +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -2,7 +2,7 @@ // Attaches node.patched = { path, integrity } to each matched node. // Enforces the failure modes (workspace-member entry, missing file, unused patch, non-registry target, ambiguous selectors) as hard errors. const semver = require('semver') -const { resolve } = require('node:path') +const { resolve, relative, isAbsolute } = require('node:path') const { readFile } = require('node:fs/promises') const { patchIntegrity } = require('./patch.js') @@ -71,9 +71,15 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => if (integrityCache.has(patchPath)) { return integrityCache.get(patchPath) } + // patch files must live inside the project so the patch set stays auditable + const abs = resolve(path, patchPath) + const rel = relative(path, abs) + if (!rel || rel.startsWith('..') || isAbsolute(rel)) { + throw err(`patch path escapes the project: ${patchPath}`, 'EPATCHUNSAFE', { path: patchPath }) + } let contents try { - contents = await readFile(resolve(path, patchPath)) + contents = await readFile(abs) } catch { throw err(`patch file not found: ${patchPath}`, 'EPATCHNOTFOUND', { path: patchPath }) } @@ -98,7 +104,7 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => } continue } - if (node.isProjectRoot || node.isLink) { + if (node.isProjectRoot) { continue } @@ -109,7 +115,8 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => continue } - if (!node.isRegistryDependency) { + // links and other non-registry resolutions cannot be patched + if (node.isLink || !node.isRegistryDependency) { throw err( `Cannot patch non-registry dependency ${node.name}@${node.version} ` + `(selector "${selector.key}"). Only registry dependencies can be patched.`, diff --git a/workspaces/arborist/test/patch.js b/workspaces/arborist/test/patch.js index 07e85035764cf..c823f2f83679d 100644 --- a/workspaces/arborist/test/patch.js +++ b/workspaces/arborist/test/patch.js @@ -55,6 +55,54 @@ t.test('throws on context drift (fuzz 0)', async t => { ) }) +t.test('refuses to write outside the package directory', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('../escape.js', '', 'pwned\n'), cwd: dir }), + { code: 'EPATCHUNSAFE' } + ) +}) + +t.test('refuses to delete outside the package directory', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('../escape.js', 'secret\n', ''), cwd: dir }), + { code: 'EPATCHUNSAFE' } + ) +}) + +t.test('delete fails when the file drifted from the diff', async t => { + const dir = t.testdir({ 'gone.js': 'different content\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('gone.js', 'original\n', ''), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('delete fails when the target is missing', async t => { + const dir = t.testdir({}) + await t.rejects( + applyPatchToDir({ patch: filePatch('gone.js', 'original\n', ''), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('add fails when the file already exists', async t => { + const dir = t.testdir({ 'added.js': 'already here\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('added.js', '', 'new\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('modify fails when the target is missing', async t => { + const dir = t.testdir({}) + await t.rejects( + applyPatchToDir({ patch: filePatch('index.js', 'a\n', 'b\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + t.test('patchIntegrity is stable and content-addressed', t => { const a = patchIntegrity('hello') const b = patchIntegrity(Buffer.from('hello')) diff --git a/workspaces/arborist/test/patched-dependencies-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js index af9c8b6e6a6b3..9219dfded32b2 100644 --- a/workspaces/arborist/test/patched-dependencies-resolve.js +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -263,3 +263,29 @@ t.test('EPATCHNOTFOUND when the patch file is missing on disk', async t => { await t.rejects(buildIdeal(path), { code: 'EPATCHNOTFOUND', path: 'missing.patch' }) }) + +t.test('EPATCHUNSAFE when the patch path escapes the project', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': '../outside.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHUNSAFE' }) +}) From 84366f4403a82cdeb1ee3f54c76cddfbbb8b122e Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:45:35 +0530 Subject: [PATCH 09/32] fix(patch): contain patches-dir writes, reject non-registry version matches, surface ls ambiguity --- lib/commands/patch.js | 58 ++++++++++++++++++++----------- test/lib/commands/patch.js | 53 +++++++++++++++++++++++++++- workspaces/arborist/test/patch.js | 8 +++++ 3 files changed, 97 insertions(+), 22 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index e46741dab50fc..71792e1b65dc1 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -1,4 +1,4 @@ -const { resolve, relative, join, dirname } = require('node:path') +const { resolve, relative, join, dirname, isAbsolute } = require('node:path') const { tmpdir } = require('node:os') const { mkdir, mkdtemp, rm, writeFile } = require('node:fs/promises') const pacote = require('pacote') @@ -82,19 +82,21 @@ class Patch extends BaseCommand { const { name } = parsed const tree = await this.#loadActual() + // group every installed node by version so mixed-source duplicates are seen const installed = new Map() for (const node of tree.inventory.values()) { if (node.name === name && !node.isProjectRoot && node.version) { - if (!installed.has(node.version)) { - installed.set(node.version, node) - } + const nodes = installed.get(node.version) || [] + nodes.push(node) + installed.set(node.version, nodes) } } - // a node that is not a registry dependency cannot be patched - const ensureRegistry = node => { - if (node && (node.isLink || !node.isRegistryDependency)) { - throw this.#nonRegistryError(`${name}@${node.version}`) + // a version cannot be patched if any of its installed nodes is non-registry + const ensureRegistry = version => { + const nodes = installed.get(version) || [] + if (nodes.some(n => n.isLink || !n.isRegistryDependency)) { + throw this.#nonRegistryError(`${name}@${version}`) } } @@ -102,17 +104,16 @@ class Patch extends BaseCommand { if (parsed.rawSpec && parsed.rawSpec !== '*' && parsed.rawSpec !== 'latest') { const exact = semver.valid(parsed.fetchSpec) if (exact) { - ensureRegistry(installed.get(exact)) + ensureRegistry(exact) return { name, version: exact } } - const matches = [...installed.entries()] - .filter(([v]) => semver.satisfies(v, parsed.fetchSpec)) + const matches = [...installed.keys()].filter(v => semver.satisfies(v, parsed.fetchSpec)) if (matches.length > 1) { - throw this.#ambiguousError(name, matches) + throw this.#ambiguousError(name, matches, installed) } if (matches.length === 1) { - ensureRegistry(matches[0][1]) - return { name, version: matches[0][0] } + ensureRegistry(matches[0]) + return { name, version: matches[0] } } // resolve the range against the registry const mani = await pacote.manifest(spec, this.npm.flatOptions) @@ -127,10 +128,10 @@ class Patch extends BaseCommand { ) } if (installed.size > 1) { - throw this.#ambiguousError(name, [...installed.entries()]) + throw this.#ambiguousError(name, [...installed.keys()], installed) } - const [version, node] = [...installed.entries()][0] - ensureRegistry(node) + const [version] = [...installed.keys()] + ensureRegistry(version) return { name, version } } @@ -142,14 +143,15 @@ class Patch extends BaseCommand { ) } - #ambiguousError (name, entries) { - const lines = entries.map(([version, node]) => { + #ambiguousError (name, versions, installed) { + const lines = versions.map(version => { + const node = installed.get(version)[0] const dependant = [...node.edgesIn][0]?.from?.location || '(root)' return ` ${selectorKey(name, version)} (via ${dependant})` }) return Object.assign( new Error(`Multiple versions of "${name}" are installed:\n${lines.join('\n')}\n` + - `Re-run with an exact selector, e.g. "npm patch add ${selectorKey(name, entries[0][0])}".`), + `Re-run with an exact selector, e.g. "npm patch add ${selectorKey(name, versions[0])}".`), { code: 'EPATCHAMBIGUOUS' } ) } @@ -214,6 +216,13 @@ class Patch extends BaseCommand { const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version)) // always store a project-root-relative, posix-style path const relPatch = relative(this.#root, absPatch).split('\\').join('/') + // refuse to write outside the project so the patch set stays in the repo + if (!relPatch || relPatch.startsWith('..') || isAbsolute(relPatch)) { + throw Object.assign( + new Error(`patches-dir "${patchesDir}" resolves outside the project root.`), + { code: 'EPATCHUNSAFE' } + ) + } await mkdir(dirname(absPatch), { recursive: true }) await writeFile(absPatch, diff) @@ -247,6 +256,8 @@ class Patch extends BaseCommand { const tree = await this.#loadActual() const selectors = keys.map(key => ({ ...parseSelector(key), key, patchPath: patched[key] })) const counts = new Map(keys.map(key => [key, 0])) + // names with overlapping ranges that install would reject as ambiguous + const ambiguous = new Set() for (const node of tree.inventory.values()) { if (node.isProjectRoot || node.isLink || !node.version) { continue @@ -255,7 +266,7 @@ class Patch extends BaseCommand { try { winner = matchSelector(selectors, node) } catch { - // ambiguous overlapping ranges surface at install time, skip here + ambiguous.add(node.name) continue } if (winner) { @@ -263,6 +274,11 @@ class Patch extends BaseCommand { } } for (const key of keys) { + const { name } = parseSelector(key) + if (ambiguous.has(name)) { + output.standard(`${patched[key]}\t${key}\t(error: ambiguous selectors)`) + continue + } const n = counts.get(key) output.standard(`${patched[key]}\t${key}\t(${n} node${n === 1 ? '' : 's'})`) } diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index ac8e5963977fc..52f10c378e2b8 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -191,6 +191,38 @@ t.test('bare form routes to add', async t => { t.match(joinedOutput(), /You can now edit the following directory: /, 'bare form extracts like add') }) +t.test('npm ci rejects patch path drift from the lockfile', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + // commit a real patch so the lockfile records patched.path + const editDir = path.join(npm.prefix, 'edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = () => "patched"\n') + await npm.exec('patch', ['commit', editDir]) + + // move the patch file and repoint package.json without updating the lockfile + const pkgPath = path.join(npm.prefix, 'package.json') + const pkg = readJson(pkgPath) + const key = `${DEP_NAME}@${DEP_VERSION}` + const oldPath = path.join(npm.prefix, pkg.patchedDependencies[key]) + const newRel = 'patches/renamed.patch' + fs.renameSync(oldPath, path.join(npm.prefix, newRel)) + pkg.patchedDependencies[key] = newRel + fs.writeFileSync(pkgPath, JSON.stringify(pkg)) + + await t.rejects( + npm.exec('ci', []), + /package-lock\.json are in sync/, + 'npm ci refuses when the patch path diverges from the lockfile' + ) +}) + t.test('rm with no registered patch rejects with EPATCHNOTFOUND', async t => { const { npm } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, @@ -451,6 +483,25 @@ t.test('add: range not installed resolves against the registry', async t => { t.match(joinedOutput(), /You can now edit the following directory: /, 'resolved the range via the registry') }) +t.test('commit: a patches-dir outside the project is rejected', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false, 'patches-dir': '../outside' }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + const editDir = path.join(npm.prefix, 'edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = () => "patched"\n') + await t.rejects( + npm.exec('patch', ['commit', editDir]), + { code: 'EPATCHUNSAFE' }, + 'commit refuses to write the patch outside the project root' + ) +}) + t.test('commit: edit dir package.json missing version rejects', async t => { const { npm } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, @@ -520,7 +571,7 @@ t.test('ls tolerates ambiguous overlapping range selectors', async t => { }, }) await npm.exec('patch', ['ls']) - t.match(joinedOutput(), /\(0 nodes\)/, 'ambiguous node is skipped, ls still prints') + t.match(joinedOutput(), /\(error: ambiguous selectors\)/, 'ls surfaces the ambiguity') }) t.test('ls reports plural node counts for a name-only selector', async t => { diff --git a/workspaces/arborist/test/patch.js b/workspaces/arborist/test/patch.js index c823f2f83679d..8c3becc52e8f5 100644 --- a/workspaces/arborist/test/patch.js +++ b/workspaces/arborist/test/patch.js @@ -63,6 +63,14 @@ t.test('refuses to write outside the package directory', async t => { ) }) +t.test('refuses an absolute-path target', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('/tmp/escape.js', '', 'pwned\n'), cwd: dir }), + { code: 'EPATCHUNSAFE' } + ) +}) + t.test('refuses to delete outside the package directory', async t => { const dir = t.testdir({ 'index.js': 'x\n' }) await t.rejects( From d277ccbdfc9c554ce18790981fa2c005a51fc460 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 13:50:08 +0530 Subject: [PATCH 10/32] fix(patch): clear node.patched on ignored failure, contain rm deletes, scope ls ambiguity to ranges --- lib/commands/patch.js | 23 +++++++-- test/lib/commands/patch.js | 49 +++++++++++++++++++ workspaces/arborist/lib/arborist/reify.js | 2 + .../arborist/test/arborist/reify-patch.js | 4 ++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 71792e1b65dc1..d4d0e0facab98 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -256,7 +256,7 @@ class Patch extends BaseCommand { const tree = await this.#loadActual() const selectors = keys.map(key => ({ ...parseSelector(key), key, patchPath: patched[key] })) const counts = new Map(keys.map(key => [key, 0])) - // names with overlapping ranges that install would reject as ambiguous + // only the overlapping range selectors that actually conflict on a node const ambiguous = new Set() for (const node of tree.inventory.values()) { if (node.isProjectRoot || node.isLink || !node.version) { @@ -266,7 +266,12 @@ class Patch extends BaseCommand { try { winner = matchSelector(selectors, node) } catch { - ambiguous.add(node.name) + for (const s of selectors) { + if (s.name === node.name && s.spec && !semver.valid(s.spec) && + semver.satisfies(node.version, s.spec)) { + ambiguous.add(s.key) + } + } continue } if (winner) { @@ -274,8 +279,7 @@ class Patch extends BaseCommand { } } for (const key of keys) { - const { name } = parseSelector(key) - if (ambiguous.has(name)) { + if (ambiguous.has(key)) { output.standard(`${patched[key]}\t${key}\t(error: ambiguous selectors)`) continue } @@ -313,7 +317,16 @@ class Patch extends BaseCommand { delete patched[key] // only delete the file when no remaining selector references it if (!Object.values(patched).includes(patchPath)) { - await rm(resolve(this.#root, patchPath), { force: true }) + const abs = resolve(this.#root, patchPath) + const rel = relative(this.#root, abs) + // never delete a path that escapes the project root + if (!rel || rel.startsWith('..') || isAbsolute(rel)) { + throw Object.assign( + new Error(`Refusing to delete patch outside the project root: ${patchPath}`), + { code: 'EPATCHUNSAFE' } + ) + } + await rm(abs, { force: true }) } } diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index 52f10c378e2b8..3992b68936b84 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -574,6 +574,37 @@ t.test('ls tolerates ambiguous overlapping range selectors', async t => { t.match(joinedOutput(), /\(error: ambiguous selectors\)/, 'ls surfaces the ambiguity') }) +t.test('ls flags only the conflicting range selectors, not an exact one', async t => { + // an exact selector for the same name must not be reported as ambiguous + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: '1.0.0', b: '1.0.0' }, + patchedDependencies: { + [`${DEP_NAME}@1.0.0`]: 'patches/exact.patch', + [`${DEP_NAME}@>=2.0.0 <4.0.0`]: 'patches/a.patch', + [`${DEP_NAME}@>=3.0.0 <5.0.0`]: 'patches/b.patch', + }, + }), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '1.0.0' }) }, + b: { + 'package.json': JSON.stringify({ name: 'b', version: '1.0.0', dependencies: { [DEP_NAME]: '3.5.0' } }), + node_modules: { [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: '3.5.0' }) } }, + }, + }, + }, + }) + await npm.exec('patch', ['ls']) + const out = joinedOutput() + t.match(out, new RegExp(`patches/exact\\.patch\\t${DEP_NAME}@1\\.0\\.0\\t\\(1 node\\)`), 'exact selector counts its node') + t.match(out, /patches\/a\.patch\t.*\(error: ambiguous selectors\)/, 'first overlapping range flagged') + t.match(out, /patches\/b\.patch\t.*\(error: ambiguous selectors\)/, 'second overlapping range flagged') +}) + t.test('ls reports plural node counts for a name-only selector', async t => { // offline fixture with two installed copies so the match count is plural const { npm, joinedOutput } = await loadMockNpm(t, { @@ -598,6 +629,24 @@ t.test('ls reports plural node counts for a name-only selector', async t => { t.match(joinedOutput(), /\(2 nodes\)/, 'name-only selector matches both installed copies') }) +t.test('rm refuses to delete a patch file outside the project root', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + patchedDependencies: { [`${DEP_NAME}@1.0.0`]: '../escape.patch' }, + }), + }, + }) + await t.rejects( + npm.exec('patch', ['rm', DEP_NAME]), + { code: 'EPATCHUNSAFE' }, + 'a crafted escaping patch path is not deleted' + ) +}) + t.test('rm removes every selector for a bare name', async t => { // offline: the dep is already installed and unpatched, so rm reifies without the registry const { npm, joinedOutput } = await loadMockNpm(t, { diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 5a9eb6379bc50..ca49c919998d2 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -772,6 +772,8 @@ module.exports = cls => class Reifier extends cls { } catch (er) { if (this.options.ignorePatchFailures) { log.warn('patch', `failed to apply ${patchPath} to ${node.name}: ${er.message}`) + // the patch was not applied, so do not record it in the lockfile + node.patched = null return } throw er diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index 46a3b15d7a88a..187f422adec97 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -162,6 +162,10 @@ t.test('ignorePatchFailures downgrades EPATCHFAILED to a warning', async t => { // file remains as extracted since the patch was skipped t.equal(fs.readFileSync(installedFile(path), 'utf8'), ORIGINAL, 'package left unpatched after skipped failure') + // the skipped patch must not be recorded in the lockfile + const lock = JSON.parse(fs.readFileSync(resolve(path, 'package-lock.json'), 'utf8')) + t.notOk(lock.packages[`node_modules/${PKG_NAME}`].patched, + 'unapplied patch is not written to the lockfile') }) t.test('missing patch file throws EPATCHNOTFOUND', async t => { From e756a492ac52c02fcce6a2528c071d54443dc574 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:20:14 +0530 Subject: [PATCH 11/32] test(patch): make full arborist suite pass at 100% coverage --- test/lib/commands/patch.js | 3 +- workspaces/arborist/lib/arborist/reify.js | 13 ++------- workspaces/arborist/lib/shrinkwrap.js | 6 +--- .../tap-snapshots/test/link.js.test.cjs | 4 +++ .../arborist/test/arborist/reify-patch.js | 29 +++++++++++++++++++ workspaces/arborist/test/shrinkwrap.js | 14 +++++++++ 6 files changed, 51 insertions(+), 18 deletions(-) diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index 3992b68936b84..7f2818327975a 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -284,8 +284,7 @@ t.test('add: not-installed bare name rejects with EPATCHNOTINSTALLED', async t = }) t.test('add: ambiguous when multiple versions installed', async t => { - // root-direct 1.0.0 plus two nested 2.0.0 copies, so the dedup guard and the - // root-dependant label are both exercised while listing the ambiguity + // root-direct 1.0.0 plus two nested 2.0.0 copies, so the dedup guard and the root-dependant label are both exercised while listing the ambiguity const nestedDep = v => ({ node_modules: { [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: v }) } }, }) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index ca49c919998d2..c68654b2816e7 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -755,17 +755,8 @@ module.exports = cls => class Reifier extends cls { return } const { path: patchPath } = node.patched - const absPatch = resolve(this.path, patchPath) - - let contents - try { - contents = await readFile(absPatch) - } catch { - throw Object.assign( - new Error(`patch file not found: ${patchPath}`), - { code: 'EPATCHNOTFOUND', path: patchPath, node: node.name } - ) - } + // existence and integrity were already validated by resolvePatchedDependencies in build-ideal-tree + const contents = await readFile(resolve(this.path, patchPath)) try { await applyPatchToDir({ patch: contents, cwd: node.path }) diff --git a/workspaces/arborist/lib/shrinkwrap.js b/workspaces/arborist/lib/shrinkwrap.js index 6cc52be4ec01f..55b0977a9604c 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -714,10 +714,6 @@ class Shrinkwrap { meta.integrity = lock.integrity } - if (lock.patched) { - meta.patched = lock.patched - } - if (lock.version && !lock.integrity) { // this is usually going to be a git url or symlink, but it could // also be a registry dependency that did not have integrity at @@ -951,7 +947,7 @@ class Shrinkwrap { this.lockfileVersion = defaultLockfileVersion } // patched nodes force lockfileVersion 4 so older clients abort the install - const hasPatched = Object.values(this.data.packages || {}).some(p => p?.patched) + const hasPatched = Object.values(this.data.packages).some(p => p.patched) if (hasPatched && this.lockfileVersion < patchedLockfileVersion) { this.lockfileVersion = patchedLockfileVersion } diff --git a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs index 4147de62640d7..aa2afbd6ccdcf 100644 --- a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs @@ -26,6 +26,7 @@ Link { "location": "../../../../../some/other/path", "name": "path", "optional": true, + "patched": null, "path": "/home/user/some/other/path", "peer": true, "queryContext": Object {}, @@ -72,6 +73,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "", "name": "path", "optional": true, + "patched": null, "path": "/home/user/projects/some/kind/of/path", "peer": true, "queryContext": Object {}, @@ -88,6 +90,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "../../../../../some/other/path", "name": "path", "optional": true, + "patched": null, "path": "/home/user/some/other/path", "peer": true, "queryContext": Object {}, @@ -116,6 +119,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "", "name": "path", "optional": true, + "patched": null, "path": "/home/user/projects/some/kind/of/path", "peer": true, "queryContext": Object {}, diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index 187f422adec97..b598a780b3781 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -181,3 +181,32 @@ t.test('missing patch file throws EPATCHNOTFOUND', async t => { await t.rejects(newArb({ path }).reify(), { code: 'EPATCHNOTFOUND' }, 'missing patch file on disk hard-errors') }) + +t.test('restores node.patched from an existing v4 lockfile', async t => { + const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` + const path = makeProject(t, { + patch: filePatch('index.js', ORIGINAL, PATCHED), + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: patchRel }, + extra: { + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 4, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { [PKG_NAME]: `^${PKG_VERSION}` } }, + [`node_modules/${PKG_NAME}`]: { + version: PKG_VERSION, + resolved: `https://registry.npmjs.org/${PKG_NAME}/-/${PKG_NAME}-${PKG_VERSION}.tgz`, + integrity: 'sha512-deadbeef', + patched: { path: patchRel, integrity: 'sha512-abc' }, + }, + }, + }), + }, + }) + const tree = await newArb({ path }).loadVirtual() + const dep = [...tree.inventory.values()].find(n => n.name === PKG_NAME) + t.strictSame(dep.patched, { path: patchRel, integrity: 'sha512-abc' }, + 'node.patched is read back from the lockfile packages entry') +}) diff --git a/workspaces/arborist/test/shrinkwrap.js b/workspaces/arborist/test/shrinkwrap.js index 79389a862caa0..d4f9756e8c7c3 100644 --- a/workspaces/arborist/test/shrinkwrap.js +++ b/workspaces/arborist/test/shrinkwrap.js @@ -78,6 +78,20 @@ t.test('starting out with a reset lockfile is an empty lockfile', async t => { t.equal(sw.filename, resolve(fixture, 'package-lock.json')) }) +t.test('errors on a lockfileVersion newer than supported', async t => { + const dir = t.testdir({ + 'package-lock.json': JSON.stringify({ + name: 'x', + version: '1.0.0', + lockfileVersion: 5, + requires: true, + packages: {}, + }), + }) + await t.rejects(Shrinkwrap.load({ path: dir }), { code: 'ELOCKFILEVERSION' }, + 'a lockfile newer than the supported version is refused') +}) + t.test('reset in a bad dir gets an empty lockfile with no lockfile version', async t => { const nullLockDir = t.testdir({ 'package-lock.json': JSON.stringify(null), From af41b8a5a31c6409d14d963084be2db814d12aa8 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:29:23 +0530 Subject: [PATCH 12/32] fix(arborist): revalidate patch file existence and integrity in reify --- workspaces/arborist/lib/arborist/reify.js | 23 +++++++++--- .../arborist/test/arborist/reify-patch.js | 35 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index c68654b2816e7..50c30bd06d4b7 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -24,7 +24,7 @@ const debug = require('../debug.js') const onExit = require('../signal-handling.js') const optionalSet = require('../optional-set.js') const relpath = require('../relpath.js') -const { applyPatchToDir } = require('../patch.js') +const { applyPatchToDir, patchIntegrity } = require('../patch.js') const { readFile } = require('node:fs/promises') const retirePath = require('../retire-path.js') const treeCheck = require('../tree-check.js') @@ -754,9 +754,24 @@ module.exports = cls => class Reifier extends cls { if (!node.patched) { return } - const { path: patchPath } = node.patched - // existence and integrity were already validated by resolvePatchedDependencies in build-ideal-tree - const contents = await readFile(resolve(this.path, patchPath)) + const { path: patchPath, integrity } = node.patched + + // validate the patch file here too, since reify can run on an ideal tree that skipped resolvePatchedDependencies + let contents + try { + contents = await readFile(resolve(this.path, patchPath)) + } catch { + throw Object.assign( + new Error(`patch file not found: ${patchPath}`), + { code: 'EPATCHNOTFOUND', path: patchPath, node: node.name } + ) + } + if (patchIntegrity(contents) !== integrity) { + throw Object.assign( + new Error(`patch file ${patchPath} does not match the recorded integrity`), + { code: 'EPATCHINTEGRITY', path: patchPath, node: node.name } + ) + } try { await applyPatchToDir({ patch: contents, cwd: node.path }) diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index b598a780b3781..5161e82b3f183 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -182,6 +182,41 @@ t.test('missing patch file throws EPATCHNOTFOUND', async t => { 'missing patch file on disk hard-errors') }) +t.test('reify revalidates the patch file when build-ideal-tree was already run', async t => { + // build-ideal-tree validates first, but reify must still guard against a file removed afterwards + const registry = createRegistry(t) + await mockPackage(t, registry) + const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` + const path = makeProject(t, { + patch: filePatch('index.js', ORIGINAL, PATCHED), + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: patchRel }, + }) + + const arb = newArb({ path }) + await arb.buildIdealTree() + // delete the validated patch file; reify reuses the cached ideal tree and re-checks + fs.rmSync(resolve(path, patchRel)) + await t.rejects(arb.reify(), { code: 'EPATCHNOTFOUND' }, + 'reify re-checks the patch file even on a prebuilt ideal tree') +}) + +t.test('reify rejects a patch whose contents changed after build-ideal-tree', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` + const path = makeProject(t, { + patch: filePatch('index.js', ORIGINAL, PATCHED), + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: patchRel }, + }) + + const arb = newArb({ path }) + await arb.buildIdealTree() + // change the patch contents after validation so the integrity no longer matches + fs.writeFileSync(resolve(path, patchRel), filePatch('index.js', ORIGINAL, 'module.exports = "other"\n')) + await t.rejects(arb.reify(), { code: 'EPATCHINTEGRITY' }, + 'reify rejects an integrity mismatch introduced after build-ideal-tree') +}) + t.test('restores node.patched from an existing v4 lockfile', async t => { const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` const path = makeProject(t, { From bd1700f65f485dadfea522f253b3e90fdb01efda Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:37:42 +0530 Subject: [PATCH 13/32] fix(arborist): fail loudly on optional patch errors and reject patches under install-strategy=linked --- .../arborist/lib/arborist/build-ideal-tree.js | 1 + workspaces/arborist/lib/arborist/reify.js | 6 ++++- .../arborist/lib/patched-dependencies.js | 11 +++++++- .../arborist/test/arborist/reify-patch.js | 22 +++++++++++++++ .../test/patched-dependencies-resolve.js | 27 +++++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 90b5f49d74675..7114f886b7ced 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -183,6 +183,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { await resolvePatchedDependencies(this.idealTree, { path: this.path, allowUnusedPatches: this.options.allowUnusedPatches, + installStrategy: this.options.installStrategy, }) } finally { timeEnd() diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 50c30bd06d4b7..92251f76d00c9 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -789,7 +789,11 @@ module.exports = cls => class Reifier extends cls { // if the node is optional, then the failure of the promise is nonfatal // just add it and its optional set to the trash list. [_handleOptionalFailure] (node, p) { - return (node.optional ? p.catch(() => { + return (node.optional ? p.catch((er) => { + // a declared patch must apply or fail loudly, even on an optional dep + if (typeof er?.code === 'string' && er.code.startsWith('EPATCH')) { + throw er + } const set = optionalSet(node) for (const node of set) { log.verbose('reify', 'failed optional dependency', node.path) diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js index be7af4bcc3bd5..516d6911ea425 100644 --- a/workspaces/arborist/lib/patched-dependencies.js +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -60,11 +60,20 @@ const matchSelector = (selectors, node) => { return matches.find(s => s.spec === null) || null } -const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => { +const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches, installStrategy }) => { const patchedDependencies = tree.package?.patchedDependencies || {} const selectors = Object.entries(patchedDependencies) .map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath })) + // linked installs store packages in a content-addressed side-store that this slice does not patch yet, so fail loudly instead of silently installing unpatched code + if (selectors.length && installStrategy === 'linked') { + throw err( + `patchedDependencies is not yet supported with install-strategy=linked.`, + 'EPATCHUNSUPPORTED', + { installStrategy } + ) + } + // cache patch file integrity by path so shared diffs are read once const integrityCache = new Map() const readPatch = async patchPath => { diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index 5161e82b3f183..6267f5df1d224 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -217,6 +217,28 @@ t.test('reify rejects a patch whose contents changed after build-ideal-tree', as 'reify rejects an integrity mismatch introduced after build-ideal-tree') }) +t.test('a patched optional dependency still fails loudly on patch problems', async t => { + // optional installs tolerate platform/env failures, but a declared patch must not be silently skipped + const registry = createRegistry(t) + await mockPackage(t, registry) + const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + optionalDependencies: { [PKG_NAME]: `^${PKG_VERSION}` }, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: patchRel }, + }), + patches: { [`${PKG_NAME}@${PKG_VERSION}.patch`]: filePatch('index.js', ORIGINAL, PATCHED) }, + }) + + const arb = newArb({ path }) + await arb.buildIdealTree() + fs.rmSync(resolve(path, patchRel)) + await t.rejects(arb.reify(), { code: 'EPATCHNOTFOUND' }, + 'optional patch failure is not swallowed by optional handling') +}) + t.test('restores node.patched from an existing v4 lockfile', async t => { const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` const path = makeProject(t, { diff --git a/workspaces/arborist/test/patched-dependencies-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js index 9219dfded32b2..2a4e60debedb8 100644 --- a/workspaces/arborist/test/patched-dependencies-resolve.js +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -49,6 +49,33 @@ t.test('attaches node.patched on an exact match', async t => { t.match(dep.patched.integrity, /^sha512-/, 'records the sha512 integrity') }) +t.test('EPATCHUNSUPPORTED with install-strategy=linked', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + // patching is not yet wired into the linked side-store, so it must fail loudly + await t.rejects(buildIdeal(path, { installStrategy: 'linked' }), { code: 'EPATCHUNSUPPORTED' }) +}) + t.test('no patchedDependencies is a no-op', async t => { // empty patchedDependencies hits the early return guard const path = t.testdir({ From de2ca7efd2534b61f6c7ef68af3d45a993a7cc60 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:52:09 +0530 Subject: [PATCH 14/32] fix(arborist): seal linked-strategy patch guard at reify and re-code raw patch FS errors --- .../arborist/lib/arborist/build-ideal-tree.js | 1 - workspaces/arborist/lib/arborist/reify.js | 9 ++++++ workspaces/arborist/lib/patch.js | 10 ++++++- .../arborist/lib/patched-dependencies.js | 11 +------- .../arborist/test/arborist/reify-patch.js | 28 +++++++++++++++++++ workspaces/arborist/test/patch.js | 9 ++++++ .../test/patched-dependencies-resolve.js | 27 ------------------ 7 files changed, 56 insertions(+), 39 deletions(-) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 7114f886b7ced..90b5f49d74675 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -183,7 +183,6 @@ module.exports = cls => class IdealTreeBuilder extends cls { await resolvePatchedDependencies(this.idealTree, { path: this.path, allowUnusedPatches: this.options.allowUnusedPatches, - installStrategy: this.options.installStrategy, }) } finally { timeEnd() diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 92251f76d00c9..e1a36ef2ad4a8 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -113,6 +113,15 @@ module.exports = cls => class Reifier extends cls { const oldTree = this.idealTree if (linked) { + // patching is not yet wired into the linked side-store, so fail loudly instead of silently installing unpatched code + for (const node of this.idealTree.inventory.values()) { + if (node.patched) { + throw Object.assign( + new Error('patchedDependencies is not yet supported with install-strategy=linked.'), + { code: 'EPATCHUNSUPPORTED', node: node.name } + ) + } + } // swap out the tree with the isolated tree // this is currently technical debt which will be resolved in a refactor // of Node/Link trees diff --git a/workspaces/arborist/lib/patch.js b/workspaces/arborist/lib/patch.js index 09ec74ea2cbdd..51d3c1878600f 100644 --- a/workspaces/arborist/lib/patch.js +++ b/workspaces/arborist/lib/patch.js @@ -93,7 +93,15 @@ const applyPatchToDir = async ({ patch, cwd }) => { if (!filePatch.hunks.length && isDevNull(filePatch.oldFileName) && isDevNull(filePatch.newFileName)) { continue } - await applyFilePatch(filePatch, cwd) + try { + await applyFilePatch(filePatch, cwd) + } catch (er) { + // re-code raw filesystem errors so a patch failure is never mistaken for an optional-install skip + if (typeof er?.code === 'string' && er.code.startsWith('EPATCH')) { + throw er + } + throw Object.assign(new Error(`failed to apply patch: ${er.message}`), { code: 'EPATCHFAILED', cause: er }) + } } } diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js index 516d6911ea425..be7af4bcc3bd5 100644 --- a/workspaces/arborist/lib/patched-dependencies.js +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -60,20 +60,11 @@ const matchSelector = (selectors, node) => { return matches.find(s => s.spec === null) || null } -const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches, installStrategy }) => { +const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => { const patchedDependencies = tree.package?.patchedDependencies || {} const selectors = Object.entries(patchedDependencies) .map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath })) - // linked installs store packages in a content-addressed side-store that this slice does not patch yet, so fail loudly instead of silently installing unpatched code - if (selectors.length && installStrategy === 'linked') { - throw err( - `patchedDependencies is not yet supported with install-strategy=linked.`, - 'EPATCHUNSUPPORTED', - { installStrategy } - ) - } - // cache patch file integrity by path so shared diffs are read once const integrityCache = new Map() const readPatch = async patchPath => { diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index 6267f5df1d224..7a8c6b627bab5 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -217,6 +217,34 @@ t.test('reify rejects a patch whose contents changed after build-ideal-tree', as 'reify rejects an integrity mismatch introduced after build-ideal-tree') }) +t.test('reify rejects patches under install-strategy=linked', async t => { + // patching is not wired into the linked side-store yet, so it must fail loudly, not silently skip + const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` + const path = makeProject(t, { + patch: filePatch('index.js', ORIGINAL, PATCHED), + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: patchRel }, + extra: { + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 4, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { [PKG_NAME]: `^${PKG_VERSION}` } }, + [`node_modules/${PKG_NAME}`]: { + version: PKG_VERSION, + resolved: `https://registry.npmjs.org/${PKG_NAME}/-/${PKG_NAME}-${PKG_VERSION}.tgz`, + integrity: 'sha512-deadbeef', + }, + }, + }), + }, + }) + // offline + lockfile means the guard fires before any extraction is attempted + await t.rejects(newArb({ path, installStrategy: 'linked', offline: true }).reify(), + { code: 'EPATCHUNSUPPORTED' }, 'linked install with a declared patch is rejected') +}) + t.test('a patched optional dependency still fails loudly on patch problems', async t => { // optional installs tolerate platform/env failures, but a declared patch must not be silently skipped const registry = createRegistry(t) diff --git a/workspaces/arborist/test/patch.js b/workspaces/arborist/test/patch.js index 8c3becc52e8f5..652d7252f00a1 100644 --- a/workspaces/arborist/test/patch.js +++ b/workspaces/arborist/test/patch.js @@ -111,6 +111,15 @@ t.test('modify fails when the target is missing', async t => { ) }) +t.test('re-codes a raw filesystem error as EPATCHFAILED', async t => { + // "foo" exists as a file, so creating "foo/bar.js" makes mkdir throw a raw FS error + const dir = t.testdir({ foo: 'i am a file, not a directory\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('foo/bar.js', '', 'new\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + t.test('patchIntegrity is stable and content-addressed', t => { const a = patchIntegrity('hello') const b = patchIntegrity(Buffer.from('hello')) diff --git a/workspaces/arborist/test/patched-dependencies-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js index 2a4e60debedb8..9219dfded32b2 100644 --- a/workspaces/arborist/test/patched-dependencies-resolve.js +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -49,33 +49,6 @@ t.test('attaches node.patched on an exact match', async t => { t.match(dep.patched.integrity, /^sha512-/, 'records the sha512 integrity') }) -t.test('EPATCHUNSUPPORTED with install-strategy=linked', async t => { - const path = t.testdir({ - 'fix.patch': PATCH, - 'package.json': JSON.stringify({ - name: 'root', - version: '1.0.0', - dependencies: { dep: '^1.0.0' }, - patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, - }), - 'package-lock.json': JSON.stringify({ - name: 'root', - version: '1.0.0', - lockfileVersion: 3, - requires: true, - packages: { - '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, - 'node_modules/dep': lockEntry('dep', '1.0.0'), - }, - }), - node_modules: { - dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, - }, - }) - // patching is not yet wired into the linked side-store, so it must fail loudly - await t.rejects(buildIdeal(path, { installStrategy: 'linked' }), { code: 'EPATCHUNSUPPORTED' }) -}) - t.test('no patchedDependencies is a no-op', async t => { // empty patchedDependencies hits the early return guard const path = t.testdir({ From 8398bccc5223d96e93ed7207500b8eab3f0a5e7d Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 21:17:05 +0530 Subject: [PATCH 15/32] feat(publish): strip patchedDependencies from the published registry manifest --- workspaces/libnpmpublish/lib/publish.js | 2 + workspaces/libnpmpublish/test/publish.js | 54 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 414e07b78bf72..eae4de7ae9c2d 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -87,6 +87,8 @@ const patchManifest = async (_manifest, opts) => { ) } manifest.version = version + // patchedDependencies is consumer-side state and must never be published + delete manifest.patchedDependencies return manifest } diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index 389c2a8fe98b3..fa2b688f427db 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -75,6 +75,60 @@ t.test('basic publish - no npmVersion', async t => { t.ok(ret, 'publish succeeded') }) +t.test('publish strips patchedDependencies from the registry manifest', async t => { + const { publish } = t.mock('..') + const registry = new MockRegistry({ + tap: t, + registry: opts.registry, + authorization: token, + }) + const manifest = { + name: 'libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + patchedDependencies: { 'lodash@4.17.21': 'patches/lodash@4.17.21.patch' }, + } + const spec = npa(manifest.name) + // patchedDependencies must not appear in the published version metadata + const { patchedDependencies, ...clean } = manifest + + const packument = { + _id: manifest.name, + name: manifest.name, + description: manifest.description, + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: `${manifest.name}@${manifest.version}`, + _nodeVersion: process.versions.node, + ...clean, + dist: { + shasum, + integrity: integrity.sha512[0].toString(), + tarball: 'http://mock.reg/libnpmpublish-test/-/libnpmpublish-test-1.0.0.tgz', + }, + }, + }, + access: null, + _attachments: { + 'libnpmpublish-test-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + } + + registry.nock.put(`/${spec.escapedName}`, packument).reply(201, {}) + const ret = await publish(manifest, tarData, { + ...opts, + npmVersion: null, + }) + t.ok(ret, 'publish succeeded with patchedDependencies stripped') +}) + t.test('scoped publish', async t => { const { publish } = t.mock('..') const registry = new MockRegistry({ From 2ab1bd821c19fcf4a827fdc7f7fc5957654277a8 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 21:21:10 +0530 Subject: [PATCH 16/32] feat(ls): annotate patched dependencies in npm ls output --- lib/commands/ls.js | 9 +++++++++ test/lib/commands/ls.js | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/commands/ls.js b/lib/commands/ls.js index 5dacd3919882e..1e46443d32c58 100644 --- a/lib/commands/ls.js +++ b/lib/commands/ls.js @@ -333,6 +333,11 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => { ? ' ' + chalk.dim('overridden') : '' ) + + ( + node.patched + ? ' ' + chalk.cyan(`[patched: ${node.patched.path}]`) + : '' + ) + (isGitNode(node) ? ` (${node.resolved})` : '') + (node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') + (long ? `\n${node.package.description || ''}` : '') @@ -389,6 +394,10 @@ const getJsonOutputItem = (node, { global, long }) => { item.invalid = node[_invalid] } + if (node.patched) { + item.patched = node.patched.path + } + if (node[_missing] && !isOptional(node)) { item.required = node[_required] item.missing = true diff --git a/test/lib/commands/ls.js b/test/lib/commands/ls.js index ab98773bc68e5..878ffb3f38c53 100644 --- a/test/lib/commands/ls.js +++ b/test/lib/commands/ls.js @@ -5405,3 +5405,47 @@ t.test('ls --install-strategy=linked', async t => { 'should report declared workspace as UNMET DEPENDENCY') }) }) + +t.test('patched dependency annotation', async t => { + const patchedLock = { + name: 'test-npm-ls', + version: '1.0.0', + lockfileVersion: 4, + requires: true, + packages: { + '': { name: 'test-npm-ls', version: '1.0.0', dependencies: { foo: '^1.0.0' } }, + 'node_modules/foo': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz', + integrity: 'sha512-deadbeef', + patched: { path: 'patches/foo@1.0.0.patch', integrity: 'sha512-abc' }, + }, + }, + } + const prefixDir = { + 'package.json': JSON.stringify({ + name: 'test-npm-ls', + version: '1.0.0', + dependencies: { foo: '^1.0.0' }, + patchedDependencies: { 'foo@1.0.0': 'patches/foo@1.0.0.patch' }, + }), + node_modules: { + '.package-lock.json': JSON.stringify(patchedLock), + foo: { 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }) }, + }, + } + + t.test('human output annotates the patched dependency', async t => { + const { npm, result, ls } = await mockLs(t, { config: {}, prefixDir }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + t.match(result(), /foo@1\.0\.0 \[patched: patches\/foo@1\.0\.0\.patch\]/) + }) + + t.test('json output records the patch path', async t => { + const { npm, result, ls } = await mockLs(t, { config: { json: true }, prefixDir }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + t.equal(JSON.parse(result()).dependencies.foo.patched, 'patches/foo@1.0.0.patch') + }) +}) From ab167cf11f152f0375f89c882bb5c25e9dd24a1e Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 21:29:34 +0530 Subject: [PATCH 17/32] feat(patch): enforce allow-unused-patches and ignore-patch-failures as cli-only and reject them in npm ci --- lib/commands/ci.js | 9 +++++ lib/commands/install.js | 4 +++ lib/utils/cli-only-flag.js | 6 ++++ tap-snapshots/test/lib/docs.js.test.cjs | 12 ++++--- test/lib/commands/ci.js | 11 +++++++ test/lib/commands/patch.js | 33 +++++++++++++++++++ test/lib/utils/cli-only-flag.js | 20 +++++++++++ .../config/lib/definitions/definitions.js | 11 +++++-- 8 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 lib/utils/cli-only-flag.js create mode 100644 test/lib/utils/cli-only-flag.js diff --git a/lib/commands/ci.js b/lib/commands/ci.js index bb8f525dd2479..ef5ce206aff6f 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -44,6 +44,15 @@ class CI extends ArboristWorkspaceCmd { }) } + // npm ci is always strict about patches; the relax flags are not accepted + for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) { + if (this.npm.config.find(flag) === 'cli') { + throw Object.assign(new Error(`The --${flag} flag is not allowed with \`npm ci\`.`), { + code: 'ECIPATCHFLAG', + }) + } + } + const dryRun = this.npm.config.get('dry-run') const ignoreScripts = this.npm.config.get('ignore-scripts') const where = this.npm.prefix diff --git a/lib/commands/install.js b/lib/commands/install.js index 4757cfbf02aa3..b8e452ab09467 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -7,6 +7,7 @@ const checks = require('npm-install-checks') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const cliOnlyFlag = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Install extends ArboristWorkspaceCmd { @@ -151,6 +152,9 @@ class Install extends ArboristWorkspaceCmd { add: args, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + // patch relax flags are honored only when passed on the command line + allowUnusedPatches: cliOnlyFlag(this.npm.config, 'allow-unused-patches'), + ignorePatchFailures: cliOnlyFlag(this.npm.config, 'ignore-patch-failures'), } // Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before. diff --git a/lib/utils/cli-only-flag.js b/lib/utils/cli-only-flag.js new file mode 100644 index 0000000000000..9557a16717671 --- /dev/null +++ b/lib/utils/cli-only-flag.js @@ -0,0 +1,6 @@ +// Read a config value only when it was passed on the command line. +// Values from .npmrc, env, or defaults resolve to undefined, so the flag cannot be set as project policy. +const cliOnlyFlag = (config, key) => + config.find(key) === 'cli' ? config.get(key) : undefined + +module.exports = cliOnlyFlag diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index a630b71e25e33..d6affbff718f3 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -366,6 +366,9 @@ setting. Install even when a registered patch in \`patchedDependencies\` matches no installed package. Does not silence patch apply failures. +This flag is only honored when passed on the command line; it is ignored in +\`.npmrc\` and environment variables, and rejected by \`npm ci\`. + #### \`audit\` @@ -981,6 +984,9 @@ fresh. Install even when a registered patch fails to apply, with a warning per failure. Intended for incident response only. +This flag is only honored when passed on the command line; it is ignored in +\`.npmrc\` and environment variables, and rejected by \`npm ci\`. + #### \`ignore-scripts\` @@ -2762,8 +2768,6 @@ Array [ "pack-destination", "packages", "patches-dir", - "allow-unused-patches", - "ignore-patch-failures", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2841,6 +2845,8 @@ Array [ "logs-max", "long", "node-options", + "allow-unused-patches", + "ignore-patch-failures", "edit-dir", "ignore-existing", "keep-edit-dir", @@ -2870,7 +2876,6 @@ Object { "allowScripts": Array [], "allowScriptsPending": false, "allowScriptsPin": true, - "allowUnusedPatches": false, "audit": true, "auditLevel": null, "authType": "web", @@ -2913,7 +2918,6 @@ Object { "heading": "npm", "httpsProxy": null, "ifPresent": false, - "ignorePatchFailures": false, "ignoreScripts": false, "includeAttestations": false, "includeStaged": false, diff --git a/test/lib/commands/ci.js b/test/lib/commands/ci.js index 15559dc688b09..e8b2a69264674 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -343,6 +343,17 @@ t.test('should throw ECIGLOBAL', async t => { await t.rejects(npm.exec('ci', []), { code: 'ECIGLOBAL' }) }) +t.test('rejects the patch relax flags', async t => { + for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) { + t.test(flag, async t => { + const { npm } = await loadMockNpm(t, { + config: { [flag]: true }, + }) + await t.rejects(npm.exec('ci', []), { code: 'ECIPATCHFLAG' }) + }) + } +}) + t.test('should throw error when ideal inventory mismatches virtual', async t => { const { npm, registry } = await loadMockNpm(t, { prefixDir: { diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index 7f2818327975a..da2b92fc079b5 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -700,3 +700,36 @@ t.test('rm keeps a patch file still referenced by another selector', async t => t.ok(after.patchedDependencies[DEP_NAME], 'name-only selector kept') t.notOk(after.patchedDependencies[`${DEP_NAME}@${DEP_VERSION}`], 'exact selector removed') }) + +t.test('install honors --allow-unused-patches only from the cli', async t => { + // an empty project with a ghost patch entry triggers EPATCHUNUSED entirely offline + const prefixDir = { + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + patchedDependencies: { 'ghost@1.0.0': 'patches/ghost.patch' }, + }), + patches: { 'ghost.patch': '--- a/x\n+++ b/x\n' }, + } + + t.test('unused patch is a hard error by default', async t => { + const { npm } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, prefixDir }) + await t.rejects(npm.exec('install', []), { code: 'EPATCHUNUSED' }) + }) + + t.test('the cli flag suppresses the error', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false, 'allow-unused-patches': true }, + prefixDir, + }) + await t.resolves(npm.exec('install', [])) + }) + + t.test('the same flag in .npmrc is ignored', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { ...prefixDir, '.npmrc': 'allow-unused-patches=true' }, + }) + await t.rejects(npm.exec('install', []), { code: 'EPATCHUNUSED' }) + }) +}) diff --git a/test/lib/utils/cli-only-flag.js b/test/lib/utils/cli-only-flag.js new file mode 100644 index 0000000000000..3fa3dbc0a8686 --- /dev/null +++ b/test/lib/utils/cli-only-flag.js @@ -0,0 +1,20 @@ +const t = require('tap') +const cliOnlyFlag = require('../../../lib/utils/cli-only-flag.js') + +// minimal config stub: `where` is the layer find() would resolve the key from +const mockConfig = (where, value) => ({ + find: () => where, + get: () => value, +}) + +t.test('returns the value when set on the cli layer', t => { + t.equal(cliOnlyFlag(mockConfig('cli', true), 'x'), true) + t.end() +}) + +t.test('returns undefined when resolved from any non-cli layer', t => { + for (const where of ['env', 'project', 'user', 'global', 'default']) { + t.equal(cliOnlyFlag(mockConfig(where, true), 'x'), undefined, `${where} is ignored`) + } + t.end() +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 9c5c131d4df7c..43ab539a0a4d7 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1746,15 +1746,18 @@ const definitions = { `, flatten, }), - // Intended to be CLI-only and rejected by `npm ci`; that restriction is not yet enforced. + // CLI-only: deliberately no flatten, so a value in .npmrc/env never reaches the install pipeline. + // npm install reads it from the cli layer only, and npm ci rejects it. 'allow-unused-patches': new Definition('allow-unused-patches', { default: false, type: Boolean, description: ` Install even when a registered patch in \`patchedDependencies\` matches no installed package. Does not silence patch apply failures. + + This flag is only honored when passed on the command line; it is ignored + in \`.npmrc\` and environment variables, and rejected by \`npm ci\`. `, - flatten, }), 'ignore-patch-failures': new Definition('ignore-patch-failures', { default: false, @@ -1762,8 +1765,10 @@ const definitions = { description: ` Install even when a registered patch fails to apply, with a warning per failure. Intended for incident response only. + + This flag is only honored when passed on the command line; it is ignored + in \`.npmrc\` and environment variables, and rejected by \`npm ci\`. `, - flatten, }), 'edit-dir': new Definition('edit-dir', { default: null, From 76bcd933809c3b3e95a63b73aa93b720aea8f50e Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 08:14:24 +0530 Subject: [PATCH 18/32] feat(arborist): apply patches under install-strategy=linked via a content-addressed side-store --- lib/commands/patch.js | 6 ++- .../arborist/lib/arborist/isolated-reifier.js | 11 +++++- workspaces/arborist/lib/arborist/reify.js | 9 ----- workspaces/arborist/lib/isolated-classes.js | 4 ++ .../arborist/test/arborist/reify-patch.js | 39 +++++++++---------- 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index d4d0e0facab98..c7855ba4a5c85 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -92,10 +92,12 @@ class Patch extends BaseCommand { } } - // a version cannot be patched if any of its installed nodes is non-registry + // a version is patchable when any of its nodes resolves from the registry. + // under install-strategy=linked a registry dep is a symlink to a store entry, so checking for a registry resolution avoids rejecting it. + // an explicit version with nothing installed is allowed; the spec was already verified as a registry spec above. const ensureRegistry = version => { const nodes = installed.get(version) || [] - if (nodes.some(n => n.isLink || !n.isRegistryDependency)) { + if (nodes.length && !nodes.some(n => n.isRegistryDependency)) { throw this.#nonRegistryError(`${name}@${version}`) } } diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index e782d6125e9fa..f24b25b763bed 100644 --- a/workspaces/arborist/lib/arborist/isolated-reifier.js +++ b/workspaces/arborist/lib/arborist/isolated-reifier.js @@ -13,7 +13,10 @@ const getKey = (startNode) => { getChildren: node => node.dependencies, visit: node => { branch.push(`${node.packageName}@${node.version}`) - deps.push(`${branch.join('->')}::${node.resolved}`) + // a patch changes the materialized contents, so it must change the store key. + // the patch segment is only appended when present, so unpatched keys are unchanged. + const patch = node.patched ? `::patch:${node.patched.integrity}` : '' + deps.push(`${branch.join('->')}::${node.resolved}${patch}`) }, leave: () => { branch.pop() @@ -28,7 +31,9 @@ const getKey = (startNode) => { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/m, '') - return `${startNode.packageName}@${startNode.version}-${hash}` + // a patched entry gets a distinct, identifiable side-store key so unpatched consumers keep sharing the original + const patchSuffix = startNode.patched ? '+patch' : '' + return `${startNode.packageName}@${startNode.version}-${hash}${patchSuffix}` } module.exports = cls => class IsolatedReifier extends cls { @@ -47,6 +52,7 @@ module.exports = cls => class IsolatedReifier extends cls { optional: node.optional, package: pkg, parent: root, + patched: node.patched, path: join(this.idealGraph.localPath, location), resolved: node.resolved, root, @@ -164,6 +170,7 @@ module.exports = cls => class IsolatedReifier extends cls { result.name = result.isWorkspace ? (node.packageName || node.name) : node.name // strip any path traversal from package.json name fields before they hit path.join below result.packageName = nameFromFolder(node.packageName || node.path) + result.patched = node.patched result.package = { ...node.package } result.package.bundleDependencies = undefined diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index e1a36ef2ad4a8..92251f76d00c9 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -113,15 +113,6 @@ module.exports = cls => class Reifier extends cls { const oldTree = this.idealTree if (linked) { - // patching is not yet wired into the linked side-store, so fail loudly instead of silently installing unpatched code - for (const node of this.idealTree.inventory.values()) { - if (node.patched) { - throw Object.assign( - new Error('patchedDependencies is not yet supported with install-strategy=linked.'), - { code: 'EPATCHUNSUPPORTED', node: node.name } - ) - } - } // swap out the tree with the isolated tree // this is currently technical debt which will be resolved in a refactor // of Node/Link trees diff --git a/workspaces/arborist/lib/isolated-classes.js b/workspaces/arborist/lib/isolated-classes.js index 32bf19972d150..5a94cb81f0618 100644 --- a/workspaces/arborist/lib/isolated-classes.js +++ b/workspaces/arborist/lib/isolated-classes.js @@ -23,6 +23,7 @@ class IsolatedNode { linksIn = new Set() meta = { loadedFromDisk: false } optional = false + patched = null parent = null root = null tops = new Set() @@ -53,6 +54,9 @@ class IsolatedNode { if (options.optional) { this.optional = true } + if (options.patched) { + this.patched = options.patched + } } get isRoot () { diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index 7a8c6b627bab5..9ed227c97b9ea 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -217,32 +217,29 @@ t.test('reify rejects a patch whose contents changed after build-ideal-tree', as 'reify rejects an integrity mismatch introduced after build-ideal-tree') }) -t.test('reify rejects patches under install-strategy=linked', async t => { - // patching is not wired into the linked side-store yet, so it must fail loudly, not silently skip +t.test('applies a patch under install-strategy=linked via the side-store', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` const path = makeProject(t, { patch: filePatch('index.js', ORIGINAL, PATCHED), patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: patchRel }, - extra: { - 'package-lock.json': JSON.stringify({ - name: 'root', - version: '1.0.0', - lockfileVersion: 4, - requires: true, - packages: { - '': { name: 'root', version: '1.0.0', dependencies: { [PKG_NAME]: `^${PKG_VERSION}` } }, - [`node_modules/${PKG_NAME}`]: { - version: PKG_VERSION, - resolved: `https://registry.npmjs.org/${PKG_NAME}/-/${PKG_NAME}-${PKG_VERSION}.tgz`, - integrity: 'sha512-deadbeef', - }, - }, - }), - }, }) - // offline + lockfile means the guard fires before any extraction is attempted - await t.rejects(newArb({ path, installStrategy: 'linked', offline: true }).reify(), - { code: 'EPATCHUNSUPPORTED' }, 'linked install with a declared patch is rejected') + + await newArb({ path, installStrategy: 'linked' }).reify() + + // the consumer symlink resolves to the patched contents + t.equal(fs.readFileSync(installedFile(path), 'utf8'), PATCHED, 'linked consumer sees the patch') + + // the patched package lives in a distinct +patch side-store entry + const store = fs.readdirSync(resolve(path, 'node_modules', '.store')) + const entry = store.find(e => e.startsWith(`${PKG_NAME}@${PKG_VERSION}-`) && e.endsWith('+patch')) + t.ok(entry, 'side-store key carries the +patch suffix') + t.equal( + fs.readFileSync(resolve(path, 'node_modules', '.store', entry, 'node_modules', PKG_NAME, 'index.js'), 'utf8'), + PATCHED, + 'the patch is applied in the side-store entry' + ) }) t.test('a patched optional dependency still fails loudly on patch problems', async t => { From 105dbe1d422e830ff2611fc3eac793b3afde77f5 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 08:52:31 +0530 Subject: [PATCH 19/32] fix(patch): honor relax flags across all reify commands, fail loudly on skipped linked patches, and exclude store nodes from the registry check --- lib/commands/audit.js | 3 +++ lib/commands/dedupe.js | 2 ++ lib/commands/install.js | 5 ++--- lib/commands/link.js | 4 ++++ lib/commands/patch.js | 17 ++++++++++++----- lib/commands/prune.js | 2 ++ lib/commands/uninstall.js | 2 ++ lib/commands/update.js | 2 ++ lib/utils/cli-only-flag.js | 7 +++++++ test/lib/utils/cli-only-flag.js | 13 +++++++++++++ workspaces/arborist/lib/arborist/reify.js | 8 ++++++++ .../arborist/test/arborist/reify-patch.js | 18 ++++++++++++++++++ 12 files changed, 75 insertions(+), 8 deletions(-) diff --git a/lib/commands/audit.js b/lib/commands/audit.js index a8d742cc73a6e..ddcaae6d0bb41 100644 --- a/lib/commands/audit.js +++ b/lib/commands/audit.js @@ -4,6 +4,7 @@ const auditError = require('../utils/audit-error.js') const { log, output } = require('proc-log') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const VerifySignatures = require('../utils/verify-signatures.js') class Audit extends ArboristWorkspaceCmd { @@ -62,6 +63,8 @@ class Audit extends ArboristWorkspaceCmd { const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) const opts = { ...this.npm.flatOptions, + // audit fix reifies, so honor the cli-only patch relax flags + ...patchRelaxOpts(this.npm.config), audit: true, path: this.npm.prefix, reporter, diff --git a/lib/commands/dedupe.js b/lib/commands/dedupe.js index e703e9fb3741a..0b3fee45bf256 100644 --- a/lib/commands/dedupe.js +++ b/lib/commands/dedupe.js @@ -1,5 +1,6 @@ const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') // dedupe duplicated packages, or find them in the tree @@ -47,6 +48,7 @@ class Dedupe extends ArboristWorkspaceCmd { save: false, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) await arb.dedupe(opts) diff --git a/lib/commands/install.js b/lib/commands/install.js index b8e452ab09467..3565dde362d67 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -7,7 +7,7 @@ const checks = require('npm-install-checks') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') -const cliOnlyFlag = require('../utils/cli-only-flag.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Install extends ArboristWorkspaceCmd { @@ -153,8 +153,7 @@ class Install extends ArboristWorkspaceCmd { workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, // patch relax flags are honored only when passed on the command line - allowUnusedPatches: cliOnlyFlag(this.npm.config, 'allow-unused-patches'), - ignorePatchFailures: cliOnlyFlag(this.npm.config, 'ignore-patch-failures'), + ...patchRelaxOpts(this.npm.config), } // Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before. diff --git a/lib/commands/link.js b/lib/commands/link.js index 160ba2b707efd..389a3b655e3d9 100644 --- a/lib/commands/link.js +++ b/lib/commands/link.js @@ -5,6 +5,7 @@ const pkgJson = require('@npmcli/package-json') const semver = require('semver') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Link extends ArboristWorkspaceCmd { @@ -70,6 +71,7 @@ class Link extends ArboristWorkspaceCmd { const Arborist = require('@npmcli/arborist') const globalOpts = { ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), Arborist, path: globalTop, global: true, @@ -119,6 +121,7 @@ class Link extends ArboristWorkspaceCmd { const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) const localArb = new Arborist({ ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), prune: false, path: this.npm.prefix, save, @@ -145,6 +148,7 @@ class Link extends ArboristWorkspaceCmd { const Arborist = require('@npmcli/arborist') const arb = new Arborist({ ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), Arborist, path: globalTop, global: true, diff --git a/lib/commands/patch.js b/lib/commands/patch.js index c7855ba4a5c85..faca1b4ebc97e 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -7,6 +7,7 @@ const semver = require('semver') const PackageJson = require('@npmcli/package-json') const { log, output } = require('proc-log') const { matchSelector, parseSelector } = require('@npmcli/arborist/lib/patched-dependencies.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const BaseCommand = require('../base-cmd.js') const { diffDirs } = require('../utils/patch-diff.js') const reifyFinish = require('../utils/reify-finish.js') @@ -66,7 +67,12 @@ class Patch extends BaseCommand { #newArborist (opts = {}) { const Arborist = require('@npmcli/arborist') - return new Arborist({ ...this.npm.flatOptions, path: this.#root, ...opts }) + return new Arborist({ + ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), + path: this.#root, + ...opts, + }) } async #loadActual () { @@ -92,12 +98,13 @@ class Patch extends BaseCommand { } } - // a version is patchable when any of its nodes resolves from the registry. - // under install-strategy=linked a registry dep is a symlink to a store entry, so checking for a registry resolution avoids rejecting it. + // a version is patchable only when every consumer-facing node resolves from the registry. + // store entries under .store are content artifacts with no edges, so they are excluded from the check. // an explicit version with nothing installed is allowed; the spec was already verified as a registry spec above. const ensureRegistry = version => { - const nodes = installed.get(version) || [] - if (nodes.length && !nodes.some(n => n.isRegistryDependency)) { + const nodes = (installed.get(version) || []) + .filter(n => !n.location?.includes('node_modules/.store/')) + if (nodes.length && !nodes.every(n => n.isRegistryDependency)) { throw this.#nonRegistryError(`${name}@${version}`) } } diff --git a/lib/commands/prune.js b/lib/commands/prune.js index bc88a4e20de66..e1790e4094726 100644 --- a/lib/commands/prune.js +++ b/lib/commands/prune.js @@ -1,5 +1,6 @@ const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Prune extends ArboristWorkspaceCmd { @@ -26,6 +27,7 @@ class Prune extends ArboristWorkspaceCmd { path: where, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) await arb.prune(opts) diff --git a/lib/commands/uninstall.js b/lib/commands/uninstall.js index 60c5eb8e79170..eb595c26a45ba 100644 --- a/lib/commands/uninstall.js +++ b/lib/commands/uninstall.js @@ -3,6 +3,7 @@ const pkgJson = require('@npmcli/package-json') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const completion = require('../utils/installed-shallow.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Uninstall extends ArboristWorkspaceCmd { @@ -47,6 +48,7 @@ class Uninstall extends ArboristWorkspaceCmd { rm: args, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) await arb.reify(opts) diff --git a/lib/commands/update.js b/lib/commands/update.js index 22f77390b25a3..7c49fa63c9194 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -3,6 +3,7 @@ const { log } = require('proc-log') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Update extends ArboristWorkspaceCmd { @@ -63,6 +64,7 @@ class Update extends ArboristWorkspaceCmd { save, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) diff --git a/lib/utils/cli-only-flag.js b/lib/utils/cli-only-flag.js index 9557a16717671..760c1eabaa95a 100644 --- a/lib/utils/cli-only-flag.js +++ b/lib/utils/cli-only-flag.js @@ -3,4 +3,11 @@ const cliOnlyFlag = (config, key) => config.find(key) === 'cli' ? config.get(key) : undefined +// The patch relax flags, honored only from the command line, as Arborist options. +const patchRelaxOpts = config => ({ + allowUnusedPatches: cliOnlyFlag(config, 'allow-unused-patches'), + ignorePatchFailures: cliOnlyFlag(config, 'ignore-patch-failures'), +}) + module.exports = cliOnlyFlag +module.exports.patchRelaxOpts = patchRelaxOpts diff --git a/test/lib/utils/cli-only-flag.js b/test/lib/utils/cli-only-flag.js index 3fa3dbc0a8686..a30d97bc450c9 100644 --- a/test/lib/utils/cli-only-flag.js +++ b/test/lib/utils/cli-only-flag.js @@ -1,5 +1,6 @@ const t = require('tap') const cliOnlyFlag = require('../../../lib/utils/cli-only-flag.js') +const { patchRelaxOpts } = require('../../../lib/utils/cli-only-flag.js') // minimal config stub: `where` is the layer find() would resolve the key from const mockConfig = (where, value) => ({ @@ -18,3 +19,15 @@ t.test('returns undefined when resolved from any non-cli layer', t => { } t.end() }) + +t.test('patchRelaxOpts maps the cli-only patch flags to arborist options', t => { + const config = { + find: key => (key === 'allow-unused-patches' ? 'cli' : 'project'), + get: () => true, + } + t.strictSame(patchRelaxOpts(config), { + allowUnusedPatches: true, + ignorePatchFailures: undefined, + }) + t.end() +}) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 92251f76d00c9..b49887edb732f 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -777,6 +777,14 @@ module.exports = cls => class Reifier extends cls { await applyPatchToDir({ patch: contents, cwd: node.path }) } catch (er) { if (this.options.ignorePatchFailures) { + // the linked side-store keys a package by its patch, so an unpatched package cannot be represented at a patched key and would be trusted on later installs + if (node.isInStore) { + throw Object.assign( + new Error(`Cannot skip the failed patch for ${node.name} under install-strategy=linked. ` + + `Fix the patch or install with a different strategy.`), + { code: 'EPATCHFAILED', path: patchPath, node: node.name } + ) + } log.warn('patch', `failed to apply ${patchPath} to ${node.name}: ${er.message}`) // the patch was not applied, so do not record it in the lockfile node.patched = null diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index 9ed227c97b9ea..0cf977883663e 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -242,6 +242,24 @@ t.test('applies a patch under install-strategy=linked via the side-store', async ) }) +t.test('linked ignorePatchFailures cannot skip a failed patch', async t => { + // the content-addressed side-store cannot represent an unpatched package at a patched key, + // so a failed patch must error rather than silently leave unpatched contents that later installs trust. + const registry = createRegistry(t) + await mockPackage(t, registry) + const patch = filePatch('index.js', 'totally different\n', 'something else\n') + const path = makeProject(t, { + patch, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await t.rejects( + newArb({ path, installStrategy: 'linked', ignorePatchFailures: true }).reify(), + { code: 'EPATCHFAILED', message: /install-strategy=linked/ }, + 'a failed patch cannot be skipped under linked mode' + ) +}) + t.test('a patched optional dependency still fails loudly on patch problems', async t => { // optional installs tolerate platform/env failures, but a declared patch must not be silently skipped const registry = createRegistry(t) From 55db1e89ab54507670a4e4e9d815626f77a8c409 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 09:05:25 +0530 Subject: [PATCH 20/32] refactor(patch): drop unused diffDirs exclude option and dedupe the project-root containment check --- lib/commands/patch.js | 16 ++++++++++------ lib/utils/patch-diff.js | 7 ++----- test/lib/utils/patch-diff.js | 12 ------------ 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index faca1b4ebc97e..ea1ee7ae31120 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -21,6 +21,12 @@ const selectorKey = (name, version) => `${name}@${version}` const patchFilePath = (patchesDir, name, version) => `${patchesDir}/${name}@${version}.patch`.split('\\').join('/') +// The project-root-relative posix path for abs, or null if abs escapes the root. +const containedRelative = (root, abs) => { + const rel = relative(root, abs).split('\\').join('/') + return (!rel || rel.startsWith('..') || isAbsolute(rel)) ? null : rel +} + class Patch extends BaseCommand { static description = 'Apply local patches to installed dependencies' static name = 'patch' @@ -211,7 +217,7 @@ class Patch extends BaseCommand { let diff try { await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions) - diff = await diffDirs(base, editDir, { exclude: [] }) + diff = await diffDirs(base, editDir) } finally { await rm(base, { recursive: true, force: true }) } @@ -223,10 +229,9 @@ class Patch extends BaseCommand { const patchesDir = this.npm.config.get('patches-dir') const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version)) - // always store a project-root-relative, posix-style path - const relPatch = relative(this.#root, absPatch).split('\\').join('/') // refuse to write outside the project so the patch set stays in the repo - if (!relPatch || relPatch.startsWith('..') || isAbsolute(relPatch)) { + const relPatch = containedRelative(this.#root, absPatch) + if (!relPatch) { throw Object.assign( new Error(`patches-dir "${patchesDir}" resolves outside the project root.`), { code: 'EPATCHUNSAFE' } @@ -327,9 +332,8 @@ class Patch extends BaseCommand { // only delete the file when no remaining selector references it if (!Object.values(patched).includes(patchPath)) { const abs = resolve(this.#root, patchPath) - const rel = relative(this.#root, abs) // never delete a path that escapes the project root - if (!rel || rel.startsWith('..') || isAbsolute(rel)) { + if (!containedRelative(this.#root, abs)) { throw Object.assign( new Error(`Refusing to delete patch outside the project root: ${patchPath}`), { code: 'EPATCHUNSAFE' } diff --git a/lib/utils/patch-diff.js b/lib/utils/patch-diff.js index 733f5ff8345a6..de9fba49ced4d 100644 --- a/lib/utils/patch-diff.js +++ b/lib/utils/patch-diff.js @@ -37,15 +37,12 @@ const readMaybe = async file => { // Diff originalDir against editedDir, returning a unified diff string. // Added files use `--- /dev/null`, deleted files use `+++ /dev/null`. -const diffDirs = async (originalDir, editedDir, { exclude = [] } = {}) => { - const excluded = new Set(exclude) +const diffDirs = async (originalDir, editedDir) => { const [origFiles, editFiles] = await Promise.all([ listFiles(originalDir), listFiles(editedDir), ]) - const all = [...new Set([...origFiles, ...editFiles])] - .filter(f => !excluded.has(f)) - .sort() + const all = [...new Set([...origFiles, ...editFiles])].sort() let result = '' for (const file of all) { diff --git a/test/lib/utils/patch-diff.js b/test/lib/utils/patch-diff.js index 0ed96ae70c96a..50d1742067468 100644 --- a/test/lib/utils/patch-diff.js +++ b/test/lib/utils/patch-diff.js @@ -81,18 +81,6 @@ t.test('node_modules and .git are ignored', async t => { t.notMatch(diff, 'HEAD', '.git contents are excluded') }) -t.test('exclude option skips a path', async t => { - const dir = t.testdir({ - orig: { 'keep.js': 'a\n', 'skip.js': 'a\n' }, - edit: { 'keep.js': 'b\n', 'skip.js': 'b\n' }, - }) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'), { - exclude: ['skip.js'], - }) - t.match(diff, 'keep.js', 'non-excluded file is diffed') - t.notMatch(diff, 'skip.js', 'excluded file is skipped') -}) - t.test('non-file entries like symlinks are skipped', async t => { const dir = t.testdir({ orig: { 'real.js': 'a\n' }, From e48da1d2008cea953cbf0be58557e2b1dd4828e9 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 10:48:35 +0530 Subject: [PATCH 21/32] fix(arborist): re-extract a dependency when its patch is removed so the patched files are reverted --- workspaces/arborist/lib/diff.js | 5 +++ .../arborist/lib/patched-dependencies.js | 5 ++- workspaces/arborist/test/diff.js | 24 ++++++++++++++ .../test/patched-dependencies-resolve.js | 32 +++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/workspaces/arborist/lib/diff.js b/workspaces/arborist/lib/diff.js index 259166bb9b3ca..704dc7bafc42b 100644 --- a/workspaces/arborist/lib/diff.js +++ b/workspaces/arborist/lib/diff.js @@ -135,6 +135,11 @@ const getAction = ({ actual, ideal }) => { return 'CHANGE' } + // a node whose patch was just removed must be re-extracted to revert the patched files + if (ideal.patchRemoved) { + return 'CHANGE' + } + const binsExist = ideal.binPaths.every((path) => existsSync(path)) // top nodes, links, and git deps won't have integrity, but do have resolved diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js index be7af4bcc3bd5..a95f0ca2e60ac 100644 --- a/workspaces/arborist/lib/patched-dependencies.js +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -110,7 +110,10 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => const selector = matchSelector(selectors, node) if (!selector) { - // clear any stale patch record inherited from the lockfile + // a node that was patched but no longer matches a selector must be re-extracted to revert its files + if (node.patched) { + node.patchRemoved = true + } node.patched = null continue } diff --git a/workspaces/arborist/test/diff.js b/workspaces/arborist/test/diff.js index 353085321f142..c0e6dff538ee5 100644 --- a/workspaces/arborist/test/diff.js +++ b/workspaces/arborist/test/diff.js @@ -439,6 +439,30 @@ t.test('extraneous pruning in workspaces', async t => { t.matchSnapshot(pruneWsB, 'prune in workspace B') }) +t.test('a removed patch forces a CHANGE even when other metadata matches', t => { + const integrity = 'sha512-iWml6OqIudarD/AngxZbQoeX0QoPywHRJ2rJbCcB0l9BfL1c5+Tl433R3V+AU404jppRHZGBofm97m48yKTRiA==' + const resolved = 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz' + const build = () => new Node({ + path: '/some/path', + pkg: { dependencies: { foo: '' } }, + children: [ + { name: 'foo', resolved, integrity, pkg: { name: 'foo', version: '1.0.0' } }, + ], + }) + const actual = build() + + // identical trees produce no diff entry for foo + t.equal(Diff.calculate({ actual, ideal: build() }).children.length, 0) + + // but a node marked patchRemoved must be re-extracted to revert its files + const ideal = build() + ideal.children.get('foo').patchRemoved = true + t.match(Diff.calculate({ actual, ideal }).children, [ + { ideal: ideal.children.get('foo'), action: 'CHANGE' }, + ]) + t.end() +}) + t.test('check versions (even if all other metadata is missing)', t => { const actual = new Node({ path: '/some/path', diff --git a/workspaces/arborist/test/patched-dependencies-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js index 9219dfded32b2..c2cd830dff1ef 100644 --- a/workspaces/arborist/test/patched-dependencies-resolve.js +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -64,6 +64,38 @@ t.test('no patchedDependencies is a no-op', async t => { } }) +t.test('marks patchRemoved when a lockfile-patched node loses its selector', async t => { + // the lockfile records a patch but package.json declares none, so the node must be re-extracted + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 4, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': { + ...lockEntry('dep', '1.0.0'), + patched: { path: 'patches/dep@1.0.0.patch', integrity: 'sha512-old' }, + }, + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const dep = tree.inventory.query('name', 'dep').values().next().value + t.notOk(dep.patched, 'the stale patch record is cleared') + t.ok(dep.patchRemoved, 'the node is marked for re-extraction') +}) + t.test('shares integrity cache across selectors pointing at one file', async t => { // two selectors reference the same patch path, so the file is read once and both matched nodes get the identical integrity value const path = t.testdir({ From 5545c3966d2e6c45829d226de140eae2f2c08bc2 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 10:55:06 +0530 Subject: [PATCH 22/32] test(arborist): cover patch removal under install-strategy=linked --- .../arborist/test/arborist/reify-patch.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index 0cf977883663e..cdf0c949da503 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -242,6 +242,31 @@ t.test('applies a patch under install-strategy=linked via the side-store', async ) }) +t.test('removing a patch under install-strategy=linked reverts via the side-store', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry, { manifestTimes: 1, tarballTimes: 2 }) + const patchRel = `patches/${PKG_NAME}@${PKG_VERSION}.patch` + const path = makeProject(t, { + patch: filePatch('index.js', ORIGINAL, PATCHED), + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: patchRel }, + }) + + // first install materializes the patched +patch side-store entry + await newArb({ path, installStrategy: 'linked' }).reify() + t.equal(fs.readFileSync(installedFile(path), 'utf8'), PATCHED, 'patched before removal') + + // remove the patch declaration and its file, then reinstall + const pkg = JSON.parse(fs.readFileSync(resolve(path, 'package.json'), 'utf8')) + delete pkg.patchedDependencies + fs.writeFileSync(resolve(path, 'package.json'), JSON.stringify(pkg)) + fs.rmSync(resolve(path, patchRel)) + + await newArb({ path, installStrategy: 'linked' }).reify() + t.equal(fs.readFileSync(installedFile(path), 'utf8'), ORIGINAL, 'consumer reverted to unpatched contents') + const store = fs.readdirSync(resolve(path, 'node_modules', '.store')) + t.notOk(store.some(e => e.endsWith('+patch')), 'the +patch side-store entry was pruned') +}) + t.test('linked ignorePatchFailures cannot skip a failed patch', async t => { // the content-addressed side-store cannot represent an unpatched package at a patched key, // so a failed patch must error rather than silently leave unpatched contents that later installs trust. From 947ef572eb32b2466ca52d529ebbd0ce240049c2 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 12:06:40 +0530 Subject: [PATCH 23/32] fix(patch): patch a registry dep even when a consumer node is edgeless (linked store, extraneous) --- lib/commands/patch.js | 10 ++++------ test/lib/commands/patch.js | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index ea1ee7ae31120..3860918a96609 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -104,13 +104,11 @@ class Patch extends BaseCommand { } } - // a version is patchable only when every consumer-facing node resolves from the registry. - // store entries under .store are content artifacts with no edges, so they are excluded from the check. - // an explicit version with nothing installed is allowed; the spec was already verified as a registry spec above. + // a version cannot be patched if a consumer depends on it through a non-registry spec (file:, git:, link:). + // checking the edges (not isRegistryDependency) avoids rejecting edgeless store nodes and linked symlinks, which are registry deps. const ensureRegistry = version => { - const nodes = (installed.get(version) || []) - .filter(n => !n.location?.includes('node_modules/.store/')) - if (nodes.length && !nodes.every(n => n.isRegistryDependency)) { + const nodes = installed.get(version) || [] + if (nodes.some(n => [...n.edgesIn].some(e => e.spec && !npa(e.spec).registry))) { throw this.#nonRegistryError(`${name}@${version}`) } } diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index da2b92fc079b5..4ed913488c676 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -90,6 +90,25 @@ t.test('add rejects non-registry spec with EPATCHNONREGISTRY', async t => { ) }) +t.test('add accepts an edgeless installed node (extraneous / linked store)', async t => { + // an installed-but-undeclared dep has no edges, so isRegistryDependency is false; + // it must not be misread as non-registry the way a linked store node or extraneous install would be + const { npm, joinedOutput, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: { + 'dep-tarball': depTarball, + 'package.json': JSON.stringify({ name: 'root-project', version: '1.0.0' }), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + }, + }, + }) + await setupDep(npm, registry) + await npm.exec('patch', ['add', DEP_NAME]) + t.match(joinedOutput(), /You can now edit the following directory: /, 'edgeless node is patchable') +}) + t.test('full round-trip: install, add, edit, commit, ls, rm', async t => { const { npm, joinedOutput, registry, outputs } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, From 329048e05d590e6027973636b0614d760ff92daf Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 12:06:40 +0530 Subject: [PATCH 24/32] feat(arborist): warn when patchedDependencies upgrades the lockfile to version 4 --- workspaces/arborist/lib/shrinkwrap.js | 1 + .../arborist/test/arborist/reify-patch.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/workspaces/arborist/lib/shrinkwrap.js b/workspaces/arborist/lib/shrinkwrap.js index 55b0977a9604c..c944cdad7803a 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -949,6 +949,7 @@ class Shrinkwrap { // patched nodes force lockfileVersion 4 so older clients abort the install const hasPatched = Object.values(this.data.packages).some(p => p.patched) if (hasPatched && this.lockfileVersion < patchedLockfileVersion) { + log.warn('shrinkwrap', `patchedDependencies requires lockfileVersion ${patchedLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`) this.lockfileVersion = patchedLockfileVersion } this.data.lockfileVersion = this.lockfileVersion diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index cdf0c949da503..4da3821cad5d7 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -182,6 +182,23 @@ t.test('missing patch file throws EPATCHNOTFOUND', async t => { 'missing patch file on disk hard-errors') }) +t.test('warns when a patch upgrades the lockfile version', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + const path = makeProject(t, { + patch: filePatch('index.js', ORIGINAL, PATCHED), + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + const warnings = [] + const onLog = (level, prefix, msg) => level === 'warn' && warnings.push(`${prefix} ${msg}`) + process.on('log', onLog) + t.teardown(() => process.removeListener('log', onLog)) + + await newArb({ path }).reify() + t.match(warnings.join('\n'), /requires lockfileVersion 4/, 'warns that the lockfile was upgraded') +}) + t.test('reify revalidates the patch file when build-ideal-tree was already run', async t => { // build-ideal-tree validates first, but reify must still guard against a file removed afterwards const registry = createRegistry(t) From 9b67b2285f0ad2d311a369ced756318f5568c5b4 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 12:21:27 +0530 Subject: [PATCH 25/32] fix(arborist): use the edge-based registry check on the install path too and cover the mixed registry/file: case --- lib/commands/patch.js | 2 +- test/lib/commands/patch.js | 32 +++++++++++++++++++ .../arborist/lib/patched-dependencies.js | 6 ++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 3860918a96609..3700a5a82c28b 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -104,7 +104,7 @@ class Patch extends BaseCommand { } } - // a version cannot be patched if a consumer depends on it through a non-registry spec (file:, git:, link:). + // a version cannot be patched if a consumer depends on it through a non-registry spec (file:, git:, http(s)); npm: aliases stay registry. // checking the edges (not isRegistryDependency) avoids rejecting edgeless store nodes and linked symlinks, which are registry deps. const ensureRegistry = version => { const nodes = installed.get(version) || [] diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index 4ed913488c676..b1009112c1d89 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -355,6 +355,38 @@ t.test('add: an installed file: dependency is rejected as non-registry', async t ) }) +t.test('add: a version installed as both registry and file: is rejected', async t => { + // one consumer pulls the registry copy, another pulls a file: copy of the same version; + // the file: edge must still cause a rejection even though a registry edge also exists + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [DEP_NAME]: '1.0.0', b: '1.0.0' }, + }), + local: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + b: { + 'package.json': JSON.stringify({ + name: 'b', version: '1.0.0', dependencies: { [DEP_NAME]: 'file:../../local' }, + }), + node_modules: { + [DEP_NAME]: { 'package.json': JSON.stringify({ name: DEP_NAME, version: DEP_VERSION }) }, + }, + }, + }, + }, + }) + await t.rejects( + npm.exec('patch', ['add', DEP_NAME]), + { code: 'EPATCHNONREGISTRY' }, + 'a version with any file: consumer cannot be patched' + ) +}) + t.test('add: a range matching multiple installed versions is ambiguous', async t => { const { npm } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js index a95f0ca2e60ac..c814974b5471e 100644 --- a/workspaces/arborist/lib/patched-dependencies.js +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -2,6 +2,7 @@ // Attaches node.patched = { path, integrity } to each matched node. // Enforces the failure modes (workspace-member entry, missing file, unused patch, non-registry target, ambiguous selectors) as hard errors. const semver = require('semver') +const npa = require('npm-package-arg') const { resolve, relative, isAbsolute } = require('node:path') const { readFile } = require('node:fs/promises') const { patchIntegrity } = require('./patch.js') @@ -118,8 +119,9 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => continue } - // links and other non-registry resolutions cannot be patched - if (node.isLink || !node.isRegistryDependency) { + // a non-registry consumer edge (file:, git:, http(s)) means there is no registry tarball to patch; npm: aliases stay registry. + // checking edges (not isRegistryDependency) avoids rejecting an edgeless node, which is still a registry dep. + if ([...node.edgesIn].some(e => e.spec && !npa(e.spec).registry)) { throw err( `Cannot patch non-registry dependency ${node.name}@${node.version} ` + `(selector "${selector.key}"). Only registry dependencies can be patched.`, From 4ccd1ad9165e3f158e30d848a9d4ac38d54001f0 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 13:04:54 +0530 Subject: [PATCH 26/32] test(smoke): add patch to the no-args command-list snapshot --- smoke-tests/tap-snapshots/test/index.js.test.cjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/smoke-tests/tap-snapshots/test/index.js.test.cjs b/smoke-tests/tap-snapshots/test/index.js.test.cjs index 94ade88222232..96ab2230e1af5 100644 --- a/smoke-tests/tap-snapshots/test/index.js.test.cjs +++ b/smoke-tests/tap-snapshots/test/index.js.test.cjs @@ -26,11 +26,11 @@ All commands: dist-tag, docs, doctor, edit, exec, explain, explore, find-dupes, fund, get, help, help-search, init, install, install-ci-test, install-test, link, ll, login, logout, ls, - org, outdated, owner, pack, ping, pkg, prefix, profile, - prune, publish, query, rebuild, repo, restart, root, run, - sbom, search, set, stage, start, stop, team, test, token, - trust, undeprecate, uninstall, unpublish, update, version, - view, whoami + org, outdated, owner, pack, patch, ping, pkg, prefix, + profile, prune, publish, query, rebuild, repo, restart, + root, run, sbom, search, set, stage, start, stop, team, + test, token, trust, undeprecate, uninstall, unpublish, + update, version, view, whoami Specify configs in the ini-formatted file: {NPM}/{TESTDIR}/home/.npmrc From 53f2c9523b5e846938ef392b09d3815f66888699 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 4 Jun 2026 21:06:50 +0530 Subject: [PATCH 27/32] feat(patch): exclude package.json from the diff and warn when an edit only touches it --- lib/commands/patch.js | 13 ++++++-- lib/utils/patch-diff.js | 12 ++++++-- test/lib/commands/patch.js | 58 ++++++++++++++++++++++++++++++++++++ test/lib/utils/patch-diff.js | 46 +++++++++++++++++++++++----- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 3700a5a82c28b..47acf1c1423ef 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -212,18 +212,25 @@ class Patch extends BaseCommand { // extract a clean baseline to diff against const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-')) - let diff + let diff, packageJsonChanged try { await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions) - diff = await diffDirs(base, editDir) + ;({ diff, packageJsonChanged } = await diffDirs(base, editDir)) } finally { await rm(base, { recursive: true, force: true }) } if (!diff) { - log.warn('patch', `no changes detected in ${editDir}; nothing to commit`) + // package.json is excluded from patches, so an edit limited to it captures nothing + const reason = packageJsonChanged + ? `only package.json changed in ${editDir}, which is not patchable; nothing to commit` + : `no changes detected in ${editDir}; nothing to commit` + log.warn('patch', reason) return } + if (packageJsonChanged) { + log.warn('patch', 'changes to package.json are not included in patches and were ignored') + } const patchesDir = this.npm.config.get('patches-dir') const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version)) diff --git a/lib/utils/patch-diff.js b/lib/utils/patch-diff.js index de9fba49ced4d..2f7fd7bddf939 100644 --- a/lib/utils/patch-diff.js +++ b/lib/utils/patch-diff.js @@ -35,8 +35,9 @@ const readMaybe = async file => { } } -// Diff originalDir against editedDir, returning a unified diff string. +// Diff originalDir against editedDir, returning { diff, packageJsonChanged }. // Added files use `--- /dev/null`, deleted files use `+++ /dev/null`. +// The root package.json is excluded: Arborist resolves the pre-patch manifest, so a patched manifest would apply to disk without being honored. const diffDirs = async (originalDir, editedDir) => { const [origFiles, editFiles] = await Promise.all([ listFiles(originalDir), @@ -45,6 +46,7 @@ const diffDirs = async (originalDir, editedDir) => { const all = [...new Set([...origFiles, ...editFiles])].sort() let result = '' + let packageJsonChanged = false for (const file of all) { const native = file.split('/').join(sep) const [a, b] = await Promise.all([ @@ -55,6 +57,12 @@ const diffDirs = async (originalDir, editedDir) => { continue } + // the root package.json is never patchable; flag the change so commit can warn + if (file === 'package.json') { + packageJsonChanged = true + continue + } + let patch = createTwoFilesPatch( `a/${file}`, `b/${file}`, a || '', b || '', '', '' ).replace('===================================================================\n', '') @@ -68,7 +76,7 @@ const diffDirs = async (originalDir, editedDir) => { } result += patch } - return result + return { diff: result, packageJsonChanged } } module.exports = { diffDirs } diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index b1009112c1d89..1a4f99faa324f 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -467,6 +467,64 @@ t.test('commit: no changes logs a warning and does not write a patch', async t = t.notOk(pkg.patchedDependencies, 'no patchedDependencies added when nothing changed') }) +t.test('commit: only package.json changed warns and writes no patch', async t => { + const { npm, logs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + await npm.exec('patch', ['add', DEP_NAME]) + const editDir = path.join(npm.prefix, 'clean-edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + + // edit only package.json, which is excluded from patches + const pkgPath = path.join(editDir, 'package.json') + const edited = readJson(pkgPath) + edited.description = 'edited' + fs.writeFileSync(pkgPath, JSON.stringify(edited)) + + await npm.exec('patch', ['commit', editDir]) + t.notOk( + fs.existsSync(path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`)), + 'no patch file written when only package.json changed' + ) + t.match(logs.warn.join('\n'), /only package.json changed/, 'warns package.json is not patchable') +}) + +t.test('commit: package.json change alongside code is dropped with a warning', async t => { + const { npm, logs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + await npm.exec('patch', ['add', DEP_NAME]) + const editDir = path.join(npm.prefix, 'clean-edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + + // edit both package.json and a real file + const pkgPath = path.join(editDir, 'package.json') + const edited = readJson(pkgPath) + edited.description = 'edited' + fs.writeFileSync(pkgPath, JSON.stringify(edited)) + fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = () => "patched"\n') + + await npm.exec('patch', ['commit', editDir]) + const patchPath = path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`) + t.ok(fs.existsSync(patchPath), 'patch written for the code change') + t.notMatch(fs.readFileSync(patchPath, 'utf8'), 'package.json', 'patch excludes package.json') + t.match( + logs.warn.join('\n'), + /changes to package.json are not included/, + 'warns the package.json edit was ignored' + ) +}) + t.test('rm: no pkg arg rejects with EUSAGE', async t => { const { npm } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, diff --git a/test/lib/utils/patch-diff.js b/test/lib/utils/patch-diff.js index 50d1742067468..dd571651be8ec 100644 --- a/test/lib/utils/patch-diff.js +++ b/test/lib/utils/patch-diff.js @@ -12,7 +12,7 @@ t.test('modified file produces a unified diff', async t => { orig: { 'index.js': 'hello\n' }, edit: { 'index.js': 'world\n' }, }) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) t.match(diff, '--- a/index.js', 'has old header') t.match(diff, '+++ b/index.js', 'has new header') t.match(diff, '-hello', 'removes old line') @@ -25,7 +25,7 @@ t.test('added file uses --- /dev/null', async t => { orig: { 'keep.js': 'same\n' }, edit: { 'keep.js': 'same\n', 'added.js': 'brand new\n' }, }) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) t.match(diff, '--- /dev/null', 'old side is /dev/null') t.match(diff, '+++ b/added.js', 'new side names the added file') t.match(diff, '+brand new', 'includes added content') @@ -37,7 +37,7 @@ t.test('deleted file uses +++ /dev/null', async t => { orig: { 'gone.js': 'remove me\n' }, edit: {}, }) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) t.match(diff, '--- a/gone.js', 'old side names the deleted file') t.match(diff, '+++ /dev/null', 'new side is /dev/null') t.match(diff, '-remove me', 'includes removed content') @@ -48,7 +48,7 @@ t.test('nested file path is posix-separated in the diff', async t => { orig: { lib: { deep: { 'x.js': 'a\n' } } }, edit: { lib: { deep: { 'x.js': 'b\n' } } }, }) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) t.match(diff, '--- a/lib/deep/x.js', 'old header uses posix separators') t.match(diff, '+++ b/lib/deep/x.js', 'new header uses posix separators') }) @@ -58,7 +58,7 @@ t.test('identical files produce no diff', async t => { orig: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } }, edit: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } }, }) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) t.equal(diff, '', 'empty diff for identical trees') }) @@ -75,12 +75,42 @@ t.test('node_modules and .git are ignored', async t => { '.git': { HEAD: 'ref: refs/heads/other\n' }, }, }) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) t.match(diff, 'index.js', 'top-level change is captured') t.notMatch(diff, 'node_modules', 'node_modules contents are excluded') t.notMatch(diff, 'HEAD', '.git contents are excluded') }) +t.test('root package.json is excluded and flagged, nested is kept', async t => { + const dir = t.testdir({ + orig: { + 'package.json': '{ "version": "1.0.0" }\n', + 'index.js': 'a\n', + sub: { 'package.json': '{ "private": true }\n' }, + }, + edit: { + 'package.json': '{ "version": "2.0.0" }\n', + 'index.js': 'b\n', + sub: { 'package.json': '{ "private": false }\n' }, + }, + }) + const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.equal(packageJsonChanged, true, 'root package.json change is flagged') + t.notMatch(diff, 'a/package.json\t', 'root package.json is not in the diff') + t.match(diff, 'a/sub/package.json', 'nested package.json is still diffed') + t.match(diff, 'a/index.js', 'other files are still diffed') +}) + +t.test('packageJsonChanged is false when only other files change', async t => { + const dir = t.testdir({ + orig: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'a\n' }, + edit: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'b\n' }, + }) + const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.equal(packageJsonChanged, false, 'unchanged package.json is not flagged') + t.match(diff, 'a/index.js', 'the real change is captured') +}) + t.test('non-file entries like symlinks are skipped', async t => { const dir = t.testdir({ orig: { 'real.js': 'a\n' }, @@ -88,7 +118,7 @@ t.test('non-file entries like symlinks are skipped', async t => { }) // A symlink is neither a directory nor a regular file so it is ignored. symlinkSync(resolve(dir, 'orig', 'real.js'), resolve(dir, 'edit', 'link.js')) - const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) t.match(diff, 'real.js', 'regular file is diffed') t.notMatch(diff, 'link.js', 'symlink entry is skipped') }) @@ -107,7 +137,7 @@ t.test('round-trip: applying the diff reproduces the edited tree', async t => { }, }) const orig = resolve(dir, 'orig') - const diff = await diffDirs(orig, resolve(dir, 'edit')) + const { diff } = await diffDirs(orig, resolve(dir, 'edit')) // Apply the diff back onto a copy of the original and check the result. await applyPatchToDir({ patch: diff, cwd: orig }) From 26264dad0f8fbfb7c2ea3fcb55d54e6f484e0975 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 4 Jun 2026 22:30:10 +0530 Subject: [PATCH 28/32] feat(patch): add npm patch update to rebase a patch onto a new version --- lib/commands/patch.js | 393 +++++++++++++++++- package-lock.json | 3 +- package.json | 3 +- .../test/lib/commands/config.js.test.cjs | 2 + tap-snapshots/test/lib/docs.js.test.cjs | 21 +- test/lib/commands/patch.js | 388 ++++++++++++++++- .../config/lib/definitions/definitions.js | 10 + .../test/type-description.js.test.cjs | 4 + 8 files changed, 800 insertions(+), 24 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 47acf1c1423ef..d4d14fafeff49 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -1,18 +1,26 @@ -const { resolve, relative, join, dirname, isAbsolute } = require('node:path') +const { resolve, relative, join, dirname, basename, isAbsolute, sep } = require('node:path') const { tmpdir } = require('node:os') -const { mkdir, mkdtemp, rm, writeFile } = require('node:fs/promises') +const { cp, mkdir, mkdtemp, readFile, rm, writeFile } = require('node:fs/promises') const pacote = require('pacote') const npa = require('npm-package-arg') const semver = require('semver') +const git = require('@npmcli/git') const PackageJson = require('@npmcli/package-json') const { log, output } = require('proc-log') const { matchSelector, parseSelector } = require('@npmcli/arborist/lib/patched-dependencies.js') +const { applyPatchToDir } = require('@npmcli/arborist/lib/patch.js') const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const BaseCommand = require('../base-cmd.js') const { diffDirs } = require('../utils/patch-diff.js') const reifyFinish = require('../utils/reify-finish.js') -const SUBCOMMANDS = ['add', 'commit', 'ls', 'rm'] +const SUBCOMMANDS = ['add', 'commit', 'update', 'ls', 'rm'] + +// Marker left in a conflicted update edit dir so the finalizing commit drops the old exact selector. +const UPDATE_MARKER = '.npm-patch-update.json' + +// fs.cp filter that skips a git working directory. +const notGitDir = src => !src.split(sep).includes('.git') // Build the selector key stored in patchedDependencies, e.g. lodash@4.17.21. const selectorKey = (name, version) => `${name}@${version}` @@ -21,6 +29,16 @@ const selectorKey = (name, version) => `${name}@${version}` const patchFilePath = (patchesDir, name, version) => `${patchesDir}/${name}@${version}.patch`.split('\\').join('/') +// Recover the exact version a patch file was authored against from its name. +// commit always writes @.patch, so the baseline is encoded there. +const patchFileVersion = patchPath => { + const { spec } = parseSelector(basename(patchPath).replace(/\.patch$/, '')) + return semver.valid(spec) +} + +const patchErr = (message, code, extra = {}) => + Object.assign(new Error(message), { code, ...extra }) + // The project-root-relative posix path for abs, or null if abs escapes the root. const containedRelative = (root, abs) => { const rel = relative(root, abs).split('\\').join('/') @@ -37,6 +55,7 @@ class Patch extends BaseCommand { 'edit-dir', 'ignore-existing', 'keep-edit-dir', + 'to', 'registry', ] @@ -44,6 +63,7 @@ class Patch extends BaseCommand { '[@]', 'add [@] [--edit-dir ] [--ignore-existing]', 'commit [--patches-dir ] [--keep-edit-dir]', + 'update [@] [--to ] [--patches-dir ]', 'ls', 'rm [@]', ] @@ -169,24 +189,28 @@ class Patch extends BaseCommand { ) } - async add (args) { - if (args.length !== 1) { - throw this.usageError() - } - const { name, version } = await this.#resolveTarget(args[0]) - + // Create (or reuse, with --edit-dir) the directory a package is extracted into for editing. + async #makeEditDir (name, version) { let editDir = this.npm.config.get('edit-dir') if (!editDir) { const base = join(tmpdir(), 'npm-patch') await mkdir(base, { recursive: true }) - editDir = await mkdtemp(join(base, `${name.replace(/\//g, '+')}@${version}-`)) - } else { - editDir = resolve(editDir) - if (this.npm.config.get('ignore-existing')) { - await rm(editDir, { recursive: true, force: true }) - } - await mkdir(editDir, { recursive: true }) + return mkdtemp(join(base, `${name.replace(/\//g, '+')}@${version}-`)) + } + editDir = resolve(editDir) + if (this.npm.config.get('ignore-existing')) { + await rm(editDir, { recursive: true, force: true }) + } + await mkdir(editDir, { recursive: true }) + return editDir + } + + async add (args) { + if (args.length !== 1) { + throw this.usageError() } + const { name, version } = await this.#resolveTarget(args[0]) + const editDir = await this.#makeEditDir(name, version) await pacote.extract(selectorKey(name, version), editDir, this.npm.flatOptions) @@ -210,6 +234,20 @@ class Patch extends BaseCommand { throw new Error(`Edit directory package.json is missing name or version: ${editDir}`) } + // a conflicted `patch update` leaves a marker so this commit drops the renamed-from selector + const markerPath = join(editDir, UPDATE_MARKER) + const markerRaw = await readFile(markerPath, 'utf8').catch(() => null) + let marker = null + if (markerRaw !== null) { + // always remove the marker so it never lands in the generated diff, even if it is malformed + await rm(markerPath, { force: true }) + try { + marker = JSON.parse(markerRaw) + } catch { + throw patchErr(`invalid update marker in ${editDir}`, 'EPATCHBADMARKER') + } + } + // extract a clean baseline to diff against const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-')) let diff, packageJsonChanged @@ -247,20 +285,335 @@ class Patch extends BaseCommand { const pkgJson = await PackageJson.load(this.#root) const patchedDependencies = { ...pkgJson.content.patchedDependencies } - patchedDependencies[selectorKey(name, version)] = relPatch + const newKey = selectorKey(name, version) + patchedDependencies[newKey] = relPatch + // only treat the marker as an update finalize when it renames a real, same-package selector, + // so a stray or forged marker file can never hijack a normal commit or drop an unrelated entry + const updateFinalize = !!marker?.removeKey && + marker.removeKey !== newKey && + patchedDependencies[marker.removeKey] !== undefined && + parseSelector(marker.removeKey).name === name + let orphan = null + if (updateFinalize) { + orphan = this.#dropSelector(patchedDependencies, marker.removeKey) + } pkgJson.update({ patchedDependencies }) await pkgJson.save() - // reify to apply the patch and record its integrity in the lockfile - const arb = this.#newArborist() + // finishing a conflicted update is metadata-only (like update itself): the new version may not be installed yet + const arb = updateFinalize + ? this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false }) + : this.#newArborist() await arb.reify(arb.options) await reifyFinish(this.npm, arb) + // remove the renamed-from patch file only after the lockfile is durable + await this.#removePatchFile(orphan) + if (!this.npm.config.get('keep-edit-dir')) { await rm(editDir, { recursive: true, force: true }) } - output.standard(`Patched ${selectorKey(name, version)} -> ${relPatch}`) + output.standard(`Patched ${newKey} -> ${relPatch}`) + } + + // Remove a selector from the map; return its patch file path if nothing else references it. + #dropSelector (patched, key) { + const patchPath = patched[key] + delete patched[key] + /* istanbul ignore next - the shared-file branch only fires when two selectors point at one patch */ + return (patchPath && !Object.values(patched).includes(patchPath)) ? patchPath : null + } + + // Delete an orphaned patch file, but never one that escapes the project root. + async #removePatchFile (patchPath) { + if (!patchPath) { + return + } + const abs = resolve(this.#root, patchPath) + /* istanbul ignore else - defensive: orphaned paths come from the manifest and stay in-project */ + if (containedRelative(this.#root, abs)) { + await rm(abs, { force: true }) + } + } + + async update (args) { + if (args.length !== 1) { + throw this.usageError() + } + + const pkgJson = await PackageJson.load(this.#root) + const patched = { ...pkgJson.content.patchedDependencies } + const entry = this.#resolveUpdateEntry(args[0], patched) + + const baseVersion = patchFileVersion(entry.patchPath) + if (!baseVersion) { + throw patchErr( + `cannot determine the version "${entry.patchPath}" was authored against; rebase by hand`, + 'EPATCHBASE' + ) + } + + const installedVersions = await this.#installedVersions(entry.name) + const newVersion = this.#resolveNewVersion(entry, installedVersions) + if (newVersion === baseVersion) { + throw patchErr(`nothing to update: the patch already targets ${selectorKey(entry.name, newVersion)}`, 'EPATCHNOOP') + } + const newKey = selectorKey(entry.name, newVersion) + if (patched[newKey] && newKey !== entry.key) { + throw patchErr( + `an entry already exists for ${newKey}; use "npm patch rm" first or rebase manually`, + 'EPATCHEXISTS' + ) + } + + const rebase = await this.#rebasePatch({ + name: entry.name, + baseVersion, + newVersion, + patchAbs: resolve(this.#root, entry.patchPath), + }) + try { + if (rebase.conflicted) { + const editDir = await this.#makeEditDir(entry.name, newVersion) + await cp(rebase.repo, editDir, { recursive: true, filter: notGitDir }) + // an exact selector is renamed, so tell the finalizing commit to drop the old key + if (entry.spec && semver.valid(entry.spec)) { + await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ removeKey: entry.key }) + '\n') + } + output.standard(`Patch did not apply cleanly to ${newKey}.`) + output.standard(`Resolve the conflicts in: ${editDir}`) + output.standard(`When done, run: npm patch commit ${editDir}`) + return + } + + const { diff } = await diffDirs(rebase.newDir, rebase.repo) + if (!diff) { + throw patchErr( + `the patch no longer changes ${newKey}; remove it with "npm patch rm" instead`, + 'EPATCHEMPTY' + ) + } + await this.#finalizeUpdate({ + pkgJson, patched, entry, name: entry.name, newVersion, newKey, diff, installedVersions, + }) + } finally { + await rm(rebase.work, { recursive: true, force: true }) + } + } + + // Identify the single patchedDependencies entry the update targets. + #resolveUpdateEntry (spec, patched) { + const target = npa(spec) + const name = target.name + const oldSpec = target.rawSpec && target.rawSpec !== '*' ? target.fetchSpec : null + const keys = Object.keys(patched) + + if (oldSpec) { + const key = selectorKey(name, oldSpec) + if (!patched[key]) { + throw patchErr(`no patch registered for ${key}`, 'EPATCHNOTFOUND') + } + return { key, name, spec: oldSpec, patchPath: patched[key] } + } + + const matches = keys.filter(k => parseSelector(k).name === name) + if (!matches.length) { + throw patchErr(`no patch to update for "${name}"`, 'EPATCHNOTFOUND') + } + if (matches.length > 1) { + throw patchErr( + `multiple patches match "${name}":\n${matches.map(k => ` ${k}`).join('\n')}\n` + + `Re-run with an explicit selector, e.g. "npm patch update ${matches[0]}".`, + 'EPATCHAMBIGUOUS' + ) + } + const key = matches[0] + return { key, name, spec: parseSelector(key).spec, patchPath: patched[key] } + } + + // Determine the version to rebase onto: --to, else the installed version matching the selector. + #resolveNewVersion (entry, installedVersions) { + const to = this.npm.config.get('to') + if (to) { + const valid = semver.valid(to) + if (!valid) { + throw patchErr(`--to "${to}" is not a valid version`, 'EPATCHBADTO') + } + return valid + } + + const stale = patchErr( + `lockfile is stale or missing for "${entry.name}" — pass --to or run "npm install" first`, + 'EPATCHSTALE' + ) + if (installedVersions === null) { + throw stale + } + const { spec } = entry + let matching + if (spec && semver.valid(spec)) { + matching = installedVersions.filter(v => semver.eq(v, spec)) + } else if (spec) { + matching = installedVersions.filter(v => semver.satisfies(v, spec)) + } else { + matching = installedVersions + } + if (!matching.length) { + throw stale + } + return matching.sort(semver.rcompare)[0] + } + + // Distinct registry-installed versions of a package from the lockfile, or null when it cannot be loaded. + // Uses loadVirtual so the lockfile's own validity gates staleness and links/workspaces are excluded. + async #installedVersions (name) { + let tree + try { + tree = await this.#newArborist().loadVirtual() + } catch { + return null + } + const versions = new Set() + for (const node of tree.inventory.values()) { + if (node.isProjectRoot || node.isWorkspace || node.isLink || !node.version) { + continue + } + /* istanbul ignore else - other-named nodes are skipped; only exercised with multi-package trees */ + if (node.name === name) { + versions.add(node.version) + } + } + return [...versions] + } + + // 3-way merge the existing patch onto the new version via a throwaway git repo. + // Returns { work, repo, newDir, conflicted }; the caller removes work on success. + async #rebasePatch ({ name, baseVersion, newVersion, patchAbs }) { + const work = await mkdtemp(join(tmpdir(), 'npm-patch-rebase-')) + try { + const baseDir = join(work, 'base') + const newDir = join(work, 'new') + const repo = join(work, 'repo') + await pacote.extract(selectorKey(name, baseVersion), baseDir, this.npm.flatOptions) + await pacote.extract(selectorKey(name, newVersion), newDir, this.npm.flatOptions) + await mkdir(repo, { recursive: true }) + + const id = ['-c', 'user.name=npm', '-c', 'user.email=npm@npmjs.com', '-c', 'commit.gpgsign=false'] + await this.#git(['init', '-q', '-b', 'base'], repo) + // base = the version the patch was authored against + await cp(baseDir, repo, { recursive: true }) + await this.#git(['add', '-Af'], repo) + await this.#git([...id, 'commit', '-qm', 'base', '--allow-empty'], repo) + // theirs = base + the existing patch, applied with the same jsdiff helper installs use + await this.#git(['checkout', '-q', '-b', 'theirs'], repo) + try { + await applyPatchToDir({ patch: await readFile(patchAbs), cwd: repo }) + } catch (er) { + throw patchErr( + `the existing patch no longer applies to its baseline ${selectorKey(name, baseVersion)}; rebase by hand`, + 'EPATCHBASE', + { cause: er } + ) + } + await this.#git(['add', '-Af'], repo) + await this.#git([...id, 'commit', '-qm', 'theirs', '--allow-empty'], repo) + // ours = the new version, branched from base + await this.#git(['checkout', '-q', '-b', 'ours', 'base'], repo) + await this.#git(['rm', '-rqf', '.'], repo) + await cp(newDir, repo, { recursive: true }) + await this.#git(['add', '-Af'], repo) + await this.#git([...id, 'commit', '-qm', 'ours', '--allow-empty'], repo) + // replay the patch's intent onto the new version + let conflicted = false + try { + await this.#git([...id, 'merge', '--no-edit', 'theirs'], repo) + } catch (er) { + // a genuine conflict leaves unmerged paths; any other non-zero exit is a real git failure + const unmerged = await this.#git(['ls-files', '--unmerged'], repo) + /* istanbul ignore else - a non-conflict merge failure is a git/environment fault */ + if (String(unmerged.stdout).trim()) { + conflicted = true + } else { + throw er + } + } + return { work, repo, newDir, conflicted } + } catch (er) { + await rm(work, { recursive: true, force: true }) + throw er + } + } + + #git (args, cwd) { + return git.spawn(args, { + cwd, + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }) + } + + // Apply the selector rules, write the new patch, and sync the lockfile without touching node_modules. + async #finalizeUpdate (opts) { + const { pkgJson, patched, entry, name, newVersion, newKey, diff, installedVersions } = opts + const patchesDir = this.npm.config.get('patches-dir') + const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, newVersion)) + const relPatch = containedRelative(this.#root, absPatch) + if (!relPatch) { + throw patchErr(`patches-dir "${patchesDir}" resolves outside the project root.`, 'EPATCHUNSAFE') + } + // write the new patch first so no selector points at a missing file + await mkdir(dirname(absPatch), { recursive: true }) + await writeFile(absPatch, diff) + + patched[newKey] = relPatch + // drop the old selector only when it no longer wins any installed node, so a still-needed range survives + let orphan = null + if (entry.key !== newKey && this.#selectorUnused(patched, entry.key, name, installedVersions)) { + orphan = this.#dropSelector(patched, entry.key) + } + + pkgJson.update({ patchedDependencies: patched }) + await pkgJson.save() + + // record the new patch integrity in the lockfile only; update never touches node_modules + const arb = this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false }) + await arb.reify(arb.options) + await reifyFinish(this.npm, arb) + + // remove the now-orphaned old patch file only after the lockfile is durable + await this.#removePatchFile(orphan) + + output.standard(`Updated ${entry.key} -> ${newKey} (${relPatch})`) + } + + // Whether `key` should be dropped once the new selector is added. + // An exact selector is a straight rename. A range/name-only selector is dropped only when it no longer + // wins any installed node under the installer's exact > range > name-only precedence, so a kept one is still used. + #selectorUnused (patched, key, name, installedVersions) { + const { spec } = parseSelector(key) + if (spec && semver.valid(spec)) { + return true + } + if (installedVersions === null) { + return false + } + const selectors = Object.keys(patched) + .filter(k => parseSelector(k).name === name) + .map(k => ({ ...parseSelector(k), key: k })) + for (const version of installedVersions) { + let winner + try { + winner = matchSelector(selectors, { name, version }) + } catch { + /* istanbul ignore next - ambiguous overlapping ranges: keep the selector to be safe */ + return false + } + /* istanbul ignore if - a still-winning selector is kept; only exercised with multiple installed versions */ + if (winner?.key === key) { + return false + } + } + return true } async ls () { diff --git a/package-lock.json b/package-lock.json index 14a9b4c50af0a..cae618557c5a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@npmcli/arborist", "@npmcli/config", "@npmcli/fs", + "@npmcli/git", "@npmcli/map-workspaces", "@npmcli/metavuln-calculator", "@npmcli/package-json", @@ -88,6 +89,7 @@ "@npmcli/arborist": "^10.0.0-pre.0", "@npmcli/config": "^11.0.0-pre.0", "@npmcli/fs": "^5.0.0", + "@npmcli/git": "^7.0.2", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", "@npmcli/package-json": "^7.0.5", @@ -158,7 +160,6 @@ "devDependencies": { "@npmcli/docs": "^1.0.0", "@npmcli/eslint-config": "^5.1.0", - "@npmcli/git": "^7.0.2", "@npmcli/mock-globals": "^1.0.0", "@npmcli/mock-registry": "^1.0.0", "@npmcli/template-oss": "4.29.0", diff --git a/package.json b/package.json index 8e15bfbb3e6f3..319d10eaa6a25 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@npmcli/arborist": "^10.0.0-pre.0", "@npmcli/config": "^11.0.0-pre.0", "@npmcli/fs": "^5.0.0", + "@npmcli/git": "^7.0.2", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", "@npmcli/package-json": "^7.0.5", @@ -119,6 +120,7 @@ "@npmcli/arborist", "@npmcli/config", "@npmcli/fs", + "@npmcli/git", "@npmcli/map-workspaces", "@npmcli/metavuln-calculator", "@npmcli/package-json", @@ -185,7 +187,6 @@ "devDependencies": { "@npmcli/docs": "^1.0.0", "@npmcli/eslint-config": "^5.1.0", - "@npmcli/git": "^7.0.2", "@npmcli/mock-globals": "^1.0.0", "@npmcli/mock-registry": "^1.0.0", "@npmcli/template-oss": "4.29.0", diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 74ce23f5852cd..1011f88609722 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -181,6 +181,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "strict-ssl": true, "tag-version-prefix": "v", "timing": false, + "to": null, "umask": 0, "unicode": false, "update-notifier": true, @@ -374,6 +375,7 @@ strict-ssl = true ; tag = "latest" ; overridden by project tag-version-prefix = "v" timing = false +to = null token-description = null umask = 0 unicode = false diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index d6affbff718f3..317d70054cb03 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -2074,6 +2074,17 @@ while still writing the timing file, use \`--silent\`. +#### \`to\` + +* Default: null +* Type: null or String + +Used by \`npm patch update\` to set the version to rebase a patch onto when it +cannot be read from \`package-lock.json\` — for example an exact-version +selector, or a version that has not been installed yet. + + + #### \`token-description\` * Default: null @@ -2650,6 +2661,7 @@ Array [ "tag", "tag-version-prefix", "timing", + "to", "umask", "unicode", "update-notifier", @@ -2852,6 +2864,7 @@ Array [ "keep-edit-dir", "prefix", "timing", + "to", "update-notifier", "usage", "userconfig", @@ -5336,12 +5349,13 @@ Usage: npm patch [@] npm patch add [@] [--edit-dir ] [--ignore-existing] npm patch commit [--patches-dir ] [--keep-edit-dir] +npm patch update [@] [--to ] [--patches-dir ] npm patch ls npm patch rm [@] Options: [--patches-dir ] [--allow-unused-patches] [--ignore-patch-failures] -[--edit-dir ] [--ignore-existing] [--keep-edit-dir] +[--edit-dir ] [--ignore-existing] [--keep-edit-dir] [--to ] [--registry ] --patches-dir @@ -5362,6 +5376,9 @@ Options: --keep-edit-dir With \`npm patch commit\`, do not remove the edit directory after + --to + Used by \`npm patch update\` to set the version to rebase a patch onto + --registry The base URL of the npm registry. @@ -5372,6 +5389,7 @@ Run "npm help patch" for more info npm patch [@] npm patch add [@] [--edit-dir ] [--ignore-existing] npm patch commit [--patches-dir ] [--keep-edit-dir] +npm patch update [@] [--to ] [--patches-dir ] npm patch ls npm patch rm [@] \`\`\` @@ -5384,6 +5402,7 @@ Note: This command is unaware of workspaces. #### \`edit-dir\` #### \`ignore-existing\` #### \`keep-edit-dir\` +#### \`to\` #### \`registry\` ` diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index 1a4f99faa324f..d081918dbfe26 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -525,6 +525,392 @@ t.test('commit: package.json change alongside code is dropped with a warning', a ) }) +// Serve several versions of a package, each with its own index.js source. +const setupVersions = async (npm, registry, name, sources) => { + const versions = Object.keys(sources) + const manifest = registry.manifest({ name, versions }) + for (const version of versions) { + const dir = path.join(npm.prefix, `pkg-${name}-${version}`) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version })) + fs.writeFileSync(path.join(dir, 'index.js'), sources[version]) + const tar = await pacote.tarball(dir, { Arborist }) + const { pathname } = new URL(manifest.versions[version].dist.tarball) + registry.nock.get(pathname).reply(200, tar).persist() + } + registry.nock.get(`/${name}`).reply(200, manifest).persist() + return manifest +} + +const rootWith = dep => ({ + 'package.json': JSON.stringify({ + name: 'root-project', version: '1.0.0', dependencies: dep, + }), +}) + +const updatePrefix = patchedDependencies => ({ + 'package.json': JSON.stringify({ + name: 'root-project', version: '1.0.0', patchedDependencies, + }), +}) + +t.test('update --to rebases an exact patch onto a new version', async t => { + const name = 'upd-exact' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + // v2 differs from v1 only on the last line; the patch edits the first line -> clean 3-way merge + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nb\nCC\n' }) + await npm.exec('install', []) + + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n') + await npm.exec('patch', ['commit', editDir]) + + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', name]) + + const pkg = readJson(path.join(npm.prefix, 'package.json')) + t.same(pkg.patchedDependencies, { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` }, + 'selector renamed to the new version') + t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed') + t.match(fs.readFileSync(path.join(npm.prefix, 'patches', `${name}@2.0.0.patch`), 'utf8'), /\+AA/, + 'rebased patch keeps the edit') +}) + +t.test('update auto-detects the new version and drops a fully-shadowed range', async t => { + const name = 'upd-range' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'x\n', '1.1.0': 'x\n' }) + await npm.exec('install', []) + // a patch that adds a file applies to any version, so the dep can float + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'EXTRA.txt'), 'extra\n') + await npm.exec('patch', ['commit', editDir]) + + // turn the exact selector into a range and float the lockfile to 1.1.0 + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.dependencies[name] = '^1.0.0' + pkg.patchedDependencies = { [`${name}@^1.0.0`]: pkg.patchedDependencies[`${name}@1.0.0`] } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + // clear the resolved tree so a fresh install floats the range up to 1.1.0 + fs.rmSync(path.join(npm.prefix, 'package-lock.json')) + fs.rmSync(path.join(npm.prefix, 'node_modules'), { recursive: true, force: true }) + await npm.exec('install', []) + + await npm.exec('patch', ['update', name]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${name}@1.1.0`]: `patches/${name}@1.1.0.patch` }, 'shadowed range dropped, new exact entry added') +}) + +t.test('update conflict leaves an edit dir; commit finalizes the rename', async t => { + const name = 'upd-conflict' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + // v2 changes the same line the patch edits -> conflict + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', addDir]) + + npm.config.set('to', '2.0.0') + outputs.length = 0 + await npm.exec('patch', ['update', name]) + const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() + t.ok(fs.existsSync(path.join(editDir, '.npm-patch-update.json')), 'cleanup marker written') + t.match(fs.readFileSync(path.join(editDir, 'index.js'), 'utf8'), /<<<<<<>>>>>>[^\n]*\n/, '$1') + fs.writeFileSync(path.join(editDir, 'index.js'), src) + await npm.exec('patch', ['commit', editDir]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` }, 'renamed after the resolving commit') + t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed') +}) + +t.test('update conflict on a range selector writes no rename marker', async t => { + const name = 'upd-rconflict' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', addDir]) + // turn it into a name-only selector so the conflict path takes the non-exact branch + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.patchedDependencies = { [name]: pkg.patchedDependencies[`${name}@1.0.0`] } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + + npm.config.set('to', '2.0.0') + outputs.length = 0 + await npm.exec('patch', ['update', name]) + const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() + t.notOk(fs.existsSync(path.join(editDir, '.npm-patch-update.json')), + 'no rename marker for a non-exact selector') +}) + +t.test('update: no registered patch rejects with EPATCHNOTFOUND', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { 'package.json': JSON.stringify({ name: 'r', version: '1.0.0' }) }, + }) + await t.rejects(npm.exec('patch', ['update', 'nope']), { code: 'EPATCHNOTFOUND' }) +}) + +t.test('update: an unknown explicit selector rejects with EPATCHNOTFOUND', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo@9.9.9']), { code: 'EPATCHNOTFOUND' }) +}) + +t.test('update: multiple entries for a bare name reject with EPATCHAMBIGUOUS', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch', 'foo@2.0.0': 'patches/foo@2.0.0.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo']), { code: 'EPATCHAMBIGUOUS' }) +}) + +t.test('update: an unparseable patch filename rejects with EPATCHBASE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/custom.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHBASE' }) +}) + +t.test('update: --to equal to the baseline rejects with EPATCHNOOP', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch' }), + }) + npm.config.set('to', '1.0.0') + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHNOOP' }) +}) + +t.test('update: an invalid --to rejects with EPATCHBADTO', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch' }), + }) + npm.config.set('to', 'not-a-version') + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHBADTO' }) +}) + +t.test('update: an existing target entry rejects with EPATCHEXISTS', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch', 'foo@2.0.0': 'patches/foo@2.0.0.patch' }), + }) + npm.config.set('to', '2.0.0') + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHEXISTS' }) +}) + +t.test('update: a missing lockfile with no --to rejects with EPATCHSTALE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@^1.0.0': 'patches/foo@1.0.0.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo']), { code: 'EPATCHSTALE' }) +}) + +t.test('update: wrong arg count rejects with EUSAGE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { 'package.json': JSON.stringify({ name: 'r', version: '1.0.0' }) }, + }) + await t.rejects(npm.exec('patch', ['update']), { code: 'EUSAGE' }) +}) + +// install a single version of `name` and commit a patch, then hand-edit the selector to `selectorKey`. +const installAndPatch = async (t, name, { src = 'x\n', addFile, selectorKey } = {}) => { + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': src }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + if (addFile) { + fs.writeFileSync(path.join(editDir, addFile), 'extra\n') + } else { + fs.writeFileSync(path.join(editDir, 'index.js'), 'A\n') + } + await npm.exec('patch', ['commit', editDir]) + if (selectorKey) { + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.patchedDependencies = { [selectorKey]: pkg.patchedDependencies[`${name}@1.0.0`] } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + } + return { npm, joinedOutput, outputs } +} + +t.test('update: exact selector with no --to is a no-op', async t => { + const { npm } = await installAndPatch(t, 'upd-noop') + await t.rejects(npm.exec('patch', ['update', 'upd-noop']), { code: 'EPATCHNOOP' }) +}) + +t.test('update: a name-only selector resolves the installed version', async t => { + const { npm } = await installAndPatch(t, 'upd-nameonly', { selectorKey: 'upd-nameonly' }) + // only 1.0.0 installed, so the name-only selector resolves to it -> no-op + await t.rejects(npm.exec('patch', ['update', 'upd-nameonly']), { code: 'EPATCHNOOP' }) +}) + +t.test('update: a range matching no installed version rejects with EPATCHSTALE', async t => { + const { npm } = await installAndPatch(t, 'upd-norange', { selectorKey: 'upd-norange@^5.0.0' }) + await t.rejects(npm.exec('patch', ['update', 'upd-norange']), { code: 'EPATCHSTALE' }) +}) + +t.test('update: a patch that no longer applies to its baseline rejects with EPATCHBASE', async t => { + const name = 'upd-drift' + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'real\n', '2.0.0': 'real2\n' }) + await npm.exec('install', []) + // a patch whose context does not exist in the baseline tarball cannot be re-applied during rebase + fs.mkdirSync(path.join(npm.prefix, 'patches'), { recursive: true }) + fs.writeFileSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`), + '--- a/index.js\t\n+++ b/index.js\t\n@@ -1,1 +1,1 @@\n-NOT-THE-REAL-LINE\n+changed\n') + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.patchedDependencies = { [`${name}@1.0.0`]: `patches/${name}@1.0.0.patch` } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + npm.config.set('to', '2.0.0') + await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHBASE' }) +}) + +t.test('update: when the new version already contains the patch, reports EPATCHEMPTY', async t => { + const name = 'upd-empty' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + // v2 already has the value the patch sets, so the rebase yields nothing + await setupVersions(npm, registry, name, { '1.0.0': 'old\n', '2.0.0': 'new\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'new\n') + await npm.exec('patch', ['commit', editDir]) + npm.config.set('to', '2.0.0') + await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHEMPTY' }) +}) + +t.test('update: a patches-dir outside the project is rejected', async t => { + const name = 'upd-unsafe' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nb\nCC\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n') + await npm.exec('patch', ['commit', editDir]) + npm.config.set('to', '2.0.0') + npm.config.set('patches-dir', '../outside') + await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHUNSAFE' }) +}) + +t.test('update --to keeps a range selector when the lockfile is unknown', async t => { + const name = 'upd-keep' + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [name]: '*' }, + patchedDependencies: { [`${name}@^1.0.0`]: `patches/${name}@1.0.0.patch` }, + }), + patches: { [`${name}@1.0.0.patch`]: '--- /dev/null\t\n+++ b/EXTRA.txt\t\n@@ -0,0 +1 @@\n+extra\n' }, + }, + }) + await setupVersions(npm, registry, name, { '1.0.0': 'x\n', '2.0.0': 'x\n' }) + // no install -> no lockfile -> installed versions unknown; --to drives the target + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', name]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, { + [`${name}@^1.0.0`]: `patches/${name}@1.0.0.patch`, + [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch`, + }, 'range kept, new exact entry added') +}) + +t.test('commit: a foreign update marker does not hijack a normal commit', async t => { + const name = 'upd-foreign' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'patched\n') + // a valid marker naming a different package must be ignored, not acted on + fs.writeFileSync(path.join(editDir, '.npm-patch-update.json'), JSON.stringify({ removeKey: 'other-pkg@9.9.9' })) + await npm.exec('patch', ['commit', editDir]) + + const pkg = readJson(path.join(npm.prefix, 'package.json')) + t.ok(pkg.patchedDependencies[`${name}@1.0.0`], 'normal commit recorded its own selector') + // a normal commit does a full reify, so node_modules is patched (not the metadata-only update path) + t.equal(fs.readFileSync(path.join(npm.prefix, 'node_modules', name, 'index.js'), 'utf8'), 'patched\n', + 'node_modules is patched despite the foreign marker') +}) + +t.test('commit: an invalid update marker rejects with EPATCHBADMARKER', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { 'package.json': JSON.stringify({ name: 'r', version: '1.0.0' }) }, + }) + const editDir = path.join(npm.prefix, 'ed') + fs.mkdirSync(editDir, { recursive: true }) + fs.writeFileSync(path.join(editDir, 'package.json'), JSON.stringify({ name: 'foo', version: '1.0.0' })) + fs.writeFileSync(path.join(editDir, '.npm-patch-update.json'), 'not json') + await t.rejects(npm.exec('patch', ['commit', editDir]), { code: 'EPATCHBADMARKER' }) +}) + t.test('rm: no pkg arg rejects with EUSAGE', async t => { const { npm } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, @@ -536,7 +922,7 @@ t.test('rm: no pkg arg rejects with EUSAGE', async t => { t.test('completion lists subcommands at the right depth', async t => { t.same( await Patch.completion({ conf: { argv: { remain: ['npm', 'patch'] } } }), - ['add', 'commit', 'ls', 'rm'] + ['add', 'commit', 'update', 'ls', 'rm'] ) t.same(await Patch.completion({ conf: { argv: { remain: ['npm', 'patch', 'add', 'x'] } } }), []) }) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 43ab539a0a4d7..42cef1cad6dc6 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -2460,6 +2460,16 @@ const definitions = { while still writing the timing file, use \`--silent\`. `, }), + to: new Definition('to', { + default: null, + hint: '', + type: [null, String], + description: ` + Used by \`npm patch update\` to set the version to rebase a patch onto + when it cannot be read from \`package-lock.json\` — for example an + exact-version selector, or a version that has not been installed yet. + `, + }), umask: new Definition('umask', { default: 0, type: Umask, diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index 0ec630b5a3d5a..ffdb343d87c2b 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -619,6 +619,10 @@ Object { "timing": Array [ "boolean value (true or false)", ], + "to": Array [ + null, + Function String(), + ], "token-description": Array [ null, Function String(), From f862332f34de067ff515c65ded331e82fd0a0c7e Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 5 Jun 2026 12:04:04 +0530 Subject: [PATCH 29/32] fix(patch): discard the temp edit dir if a conflicted update's copy or marker write fails --- lib/commands/patch.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index d4d14fafeff49..81dc6616fb3e0 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -376,10 +376,20 @@ class Patch extends BaseCommand { try { if (rebase.conflicted) { const editDir = await this.#makeEditDir(entry.name, newVersion) - await cp(rebase.repo, editDir, { recursive: true, filter: notGitDir }) - // an exact selector is renamed, so tell the finalizing commit to drop the old key - if (entry.spec && semver.valid(entry.spec)) { - await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ removeKey: entry.key }) + '\n') + try { + await cp(rebase.repo, editDir, { recursive: true, filter: notGitDir }) + // an exact selector is renamed, so tell the finalizing commit to drop the old key + if (entry.spec && semver.valid(entry.spec)) { + await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ removeKey: entry.key }) + '\n') + } + } catch (er) { + // discard a temp edit dir we created if the copy or marker write fails; never a user-supplied --edit-dir + /* istanbul ignore next - a failing copy/marker write is not deterministically reproducible */ + if (!this.npm.config.get('edit-dir')) { + await rm(editDir, { recursive: true, force: true }) + } + /* istanbul ignore next */ + throw er } output.standard(`Patch did not apply cleanly to ${newKey}.`) output.standard(`Resolve the conflicts in: ${editDir}`) From 1f134b7faf602b177840ee7fd8a5b4273bb8dcc0 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 5 Jun 2026 20:42:34 +0530 Subject: [PATCH 30/32] fix(patch): finalize a conflicted range/name-only update without EPATCHUNUSED --- lib/commands/patch.js | 22 +++++++++++----------- test/lib/commands/patch.js | 22 +++++++++++++++++----- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 81dc6616fb3e0..798a2e0cc24b9 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -287,14 +287,14 @@ class Patch extends BaseCommand { const patchedDependencies = { ...pkgJson.content.patchedDependencies } const newKey = selectorKey(name, version) patchedDependencies[newKey] = relPatch - // only treat the marker as an update finalize when it renames a real, same-package selector, - // so a stray or forged marker file can never hijack a normal commit or drop an unrelated entry - const updateFinalize = !!marker?.removeKey && - marker.removeKey !== newKey && - patchedDependencies[marker.removeKey] !== undefined && - parseSelector(marker.removeKey).name === name + // a marker left by a conflicted `patch update` for this package means a metadata-only finalize. + // its name is checked so a stray or forged marker can never hijack a normal commit. + const updateFinalize = !!marker && marker.name === name let orphan = null - if (updateFinalize) { + // an exact rename drops the renamed-from selector; a fork carries removeKey null and keeps the old entry. + if (updateFinalize && marker.removeKey && marker.removeKey !== newKey && + patchedDependencies[marker.removeKey] !== undefined && + parseSelector(marker.removeKey).name === name) { orphan = this.#dropSelector(patchedDependencies, marker.removeKey) } pkgJson.update({ patchedDependencies }) @@ -378,10 +378,10 @@ class Patch extends BaseCommand { const editDir = await this.#makeEditDir(entry.name, newVersion) try { await cp(rebase.repo, editDir, { recursive: true, filter: notGitDir }) - // an exact selector is renamed, so tell the finalizing commit to drop the old key - if (entry.spec && semver.valid(entry.spec)) { - await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ removeKey: entry.key }) + '\n') - } + // mark the conflict so the finalizing commit knows this is an update finalize. + // an exact selector is a rename (drop the old key); a range/name-only is a fork (keep it). + const removeKey = entry.spec && semver.valid(entry.spec) ? entry.key : null + await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ name: entry.name, removeKey }) + '\n') } catch (er) { // discard a temp edit dir we created if the copy or marker write fails; never a user-supplied --edit-dir /* istanbul ignore next - a failing copy/marker write is not deterministically reproducible */ diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index d081918dbfe26..a02aeb00ed140 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -648,7 +648,7 @@ t.test('update conflict leaves an edit dir; commit finalizes the rename', async t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed') }) -t.test('update conflict on a range selector writes no rename marker', async t => { +t.test('update conflict on a name-only selector forks and commits without EPATCHUNUSED', async t => { const name = 'upd-rconflict' const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { config: { 'ignore-scripts': true, audit: false }, @@ -662,17 +662,28 @@ t.test('update conflict on a range selector writes no rename marker', async t => const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') await npm.exec('patch', ['commit', addDir]) - // turn it into a name-only selector so the conflict path takes the non-exact branch + // turn it into a name-only selector so the conflict path takes the fork (non-exact) branch const pkg = readJson(path.join(npm.prefix, 'package.json')) pkg.patchedDependencies = { [name]: pkg.patchedDependencies[`${name}@1.0.0`] } fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + // --to 2.0.0 is not installed and conflicts; the fork must still leave a marker npm.config.set('to', '2.0.0') outputs.length = 0 await npm.exec('patch', ['update', name]) const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() - t.notOk(fs.existsSync(path.join(editDir, '.npm-patch-update.json')), - 'no rename marker for a non-exact selector') + t.same(readJson(path.join(editDir, '.npm-patch-update.json')), { name, removeKey: null }, + 'a fork still writes a marker, with removeKey null') + + // resolve and commit: must finalize metadata-only, not fail with EPATCHUNUSED on the uninstalled 2.0.0 + let src = fs.readFileSync(path.join(editDir, 'index.js'), 'utf8') + src = src.replace(/<<<<<<<[^\n]*\n[\s\S]*?=======\n([\s\S]*?)>>>>>>>[^\n]*\n/, '$1') + fs.writeFileSync(path.join(editDir, 'index.js'), src) + await npm.exec('patch', ['commit', editDir]) + + const after = readJson(path.join(npm.prefix, 'package.json')).patchedDependencies + t.ok(after[name], 'the name-only selector is kept') + t.ok(after[`${name}@2.0.0`], 'the new exact selector is added') }) t.test('update: no registered patch rejects with EPATCHNOTFOUND', async t => { @@ -889,7 +900,8 @@ t.test('commit: a foreign update marker does not hijack a normal commit', async const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() fs.writeFileSync(path.join(editDir, 'index.js'), 'patched\n') // a valid marker naming a different package must be ignored, not acted on - fs.writeFileSync(path.join(editDir, '.npm-patch-update.json'), JSON.stringify({ removeKey: 'other-pkg@9.9.9' })) + fs.writeFileSync(path.join(editDir, '.npm-patch-update.json'), + JSON.stringify({ name: 'other-pkg', removeKey: 'other-pkg@9.9.9' })) await npm.exec('patch', ['commit', editDir]) const pkg = readJson(path.join(npm.prefix, 'package.json')) From ab8c90857656e09fcad208cc388b0061e866ad46 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 5 Jun 2026 20:44:10 +0530 Subject: [PATCH 31/32] fix(patch): clearer stale errors distinguishing an unreadable lockfile from no matching version --- lib/commands/patch.js | 16 ++++++++++------ test/lib/commands/patch.js | 6 ++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 798a2e0cc24b9..1c5e485866133 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -453,12 +453,12 @@ class Patch extends BaseCommand { return valid } - const stale = patchErr( - `lockfile is stale or missing for "${entry.name}" — pass --to or run "npm install" first`, - 'EPATCHSTALE' - ) if (installedVersions === null) { - throw stale + throw patchErr( + `could not read the lockfile for "${entry.name}"; ` + + `run "npm install" first or pass --to `, + 'EPATCHSTALE' + ) } const { spec } = entry let matching @@ -470,7 +470,11 @@ class Patch extends BaseCommand { matching = installedVersions } if (!matching.length) { - throw stale + throw patchErr( + `no installed version matches the patch selector "${entry.key}"; ` + + `pass --to to rebase onto a specific version`, + 'EPATCHSTALE' + ) } return matching.sort(semver.rcompare)[0] } diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index a02aeb00ed140..cbf8493972eb5 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -750,7 +750,8 @@ t.test('update: a missing lockfile with no --to rejects with EPATCHSTALE', async config: { 'ignore-scripts': true, audit: false }, prefixDir: updatePrefix({ 'foo@^1.0.0': 'patches/foo@1.0.0.patch' }), }) - await t.rejects(npm.exec('patch', ['update', 'foo']), { code: 'EPATCHSTALE' }) + await t.rejects(npm.exec('patch', ['update', 'foo']), + { code: 'EPATCHSTALE', message: /could not read the lockfile/ }) }) t.test('update: wrong arg count rejects with EUSAGE', async t => { @@ -800,7 +801,8 @@ t.test('update: a name-only selector resolves the installed version', async t => t.test('update: a range matching no installed version rejects with EPATCHSTALE', async t => { const { npm } = await installAndPatch(t, 'upd-norange', { selectorKey: 'upd-norange@^5.0.0' }) - await t.rejects(npm.exec('patch', ['update', 'upd-norange']), { code: 'EPATCHSTALE' }) + await t.rejects(npm.exec('patch', ['update', 'upd-norange']), + { code: 'EPATCHSTALE', message: /no installed version matches the patch selector "upd-norange@\^5.0.0"/ }) }) t.test('update: a patch that no longer applies to its baseline rejects with EPATCHBASE', async t => { From cf66bfaa90302af14178f1dc55a0bd100d88bb48 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 5 Jun 2026 20:51:41 +0530 Subject: [PATCH 32/32] fix(patch): reject a malformed removeKey in the update marker --- lib/commands/patch.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 1c5e485866133..b012649cbc185 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -292,7 +292,8 @@ class Patch extends BaseCommand { const updateFinalize = !!marker && marker.name === name let orphan = null // an exact rename drops the renamed-from selector; a fork carries removeKey null and keeps the old entry. - if (updateFinalize && marker.removeKey && marker.removeKey !== newKey && + // removeKey must be a real, same-package selector string, so a malformed marker can neither crash nor drop the wrong entry. + if (updateFinalize && typeof marker.removeKey === 'string' && marker.removeKey !== newKey && patchedDependencies[marker.removeKey] !== undefined && parseSelector(marker.removeKey).name === name) { orphan = this.#dropSelector(patchedDependencies, marker.removeKey)