From 0ed73be8b6453e1113c04d2ec147220ab7fa88f3 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:14:03 +0530 Subject: [PATCH 01/26] 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 40e6c1853287d..b5a1af22ec191 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 bab42b7c32ae696869f8fa3ea2df908050ddae0b Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:18:22 +0530 Subject: [PATCH 02/26] 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 57956a4f5171e..ca4cc478cd44b 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 f6dfe04f369d8..e8c508f9789d3 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -345,6 +345,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 @@ -930,6 +940,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 @@ -1515,6 +1535,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 @@ -2516,6 +2546,9 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", + "allow-unused-patches", + "ignore-patch-failures", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2681,6 +2714,9 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", + "allow-unused-patches", + "ignore-patch-failures", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2784,6 +2820,7 @@ Object { "allowScripts": Array [], "allowScriptsPending": false, "allowScriptsPin": true, + "allowUnusedPatches": false, "audit": true, "auditLevel": null, "authType": "web", @@ -2826,6 +2863,7 @@ Object { "heading": "npm", "httpsProxy": null, "ifPresent": false, + "ignorePatchFailures": false, "ignoreScripts": false, "includeAttestations": false, "includeStaged": false, @@ -2868,6 +2906,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 1fd88e9351953..be7dff82f90fb 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1724,6 +1724,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 208444ec9cc49c0989fe2ad28247e3e4648e2a25 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:40:01 +0530 Subject: [PATCH 03/26] 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 b5a1af22ec191..16a2ff1baebc3 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 43c078ce8eaf56d84cf85a7ba595c5a2f3abf508 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:40:22 +0530 Subject: [PATCH 04/26] 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 ca4cc478cd44b..2730f19c50656 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 e8c508f9789d3..02a0c08c97443 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", @@ -673,6 +674,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 @@ -940,6 +951,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 @@ -1132,6 +1153,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 @@ -1538,7 +1569,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\`. @@ -2549,6 +2580,9 @@ Array [ "patches-dir", "allow-unused-patches", "ignore-patch-failures", + "edit-dir", + "ignore-existing", + "keep-edit-dir", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2794,6 +2828,9 @@ Array [ "logs-max", "long", "node-options", + "edit-dir", + "ignore-existing", + "keep-edit-dir", "prefix", "timing", "update-notifier", @@ -2906,7 +2943,7 @@ Object { "packDestination": ".", "parseable": false, "password": null, - "patchesDir": "{CWD}/prefix/patches", + "patchesDir": "patches", "preferDedupe": false, "preferOffline": false, "preferOnline": false, @@ -5275,6 +5312,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 be7dff82f90fb..478726d9a3987 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1726,7 +1726,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\`. @@ -1752,6 +1752,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 2db57c791c4f15c0d05951b4565f299a0d8c02ff Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 11:48:06 +0530 Subject: [PATCH 05/26] 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 9fd197fba129d3e997b6034ded0cc8e8bc351eb4 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:16:08 +0530 Subject: [PATCH 06/26] 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 afdf47589c3c3cb34387733dcc82ddb4825aa282 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:16:20 +0530 Subject: [PATCH 07/26] 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 34af2e999015724288fc12edd20e42baf67f580e Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:35:40 +0530 Subject: [PATCH 08/26] 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 997d1eb0f00b3ada9973cc3f539640068efb163b Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 12:45:35 +0530 Subject: [PATCH 09/26] 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 e3dfdaa344f12ba4a667c66377211f960932a54c Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 13:50:08 +0530 Subject: [PATCH 10/26] 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 16a2ff1baebc3..c80239d50959c 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 d78e0748f8d6397c49d98c9cf3957b4e7b8f70a7 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:20:14 +0530 Subject: [PATCH 11/26] 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 c80239d50959c..2521511684789 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 010b03b25d6c1edc62ff58d7e66971af2047d66c Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:29:23 +0530 Subject: [PATCH 12/26] 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 2521511684789..1387703d3bcd3 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 20890ae1ffa6d061204334be9aa69e6ecdb0a78a Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:37:42 +0530 Subject: [PATCH 13/26] 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 1387703d3bcd3..4786da92c2b93 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 98e540e75b1d78e2e6cc3b1df24924a8f8cc0a06 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 20:52:09 +0530 Subject: [PATCH 14/26] 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 4786da92c2b93..c565364f4064b 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 e787adcbdf3e70c4c75e2e740e6cf4df00d5e8ff Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 21:17:05 +0530 Subject: [PATCH 15/26] 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 329233d7c8808c7f94bb0d304f0b921b2ca9979b Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 21:21:10 +0530 Subject: [PATCH 16/26] 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 d31e5701e45e192e4b70896b7e7d64e0bdbd198f Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 29 May 2026 21:29:34 +0530 Subject: [PATCH 17/26] 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 02a0c08c97443..f093ada1515c6 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -354,6 +354,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\` @@ -969,6 +972,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\` @@ -2749,8 +2755,6 @@ Array [ "pack-destination", "packages", "patches-dir", - "allow-unused-patches", - "ignore-patch-failures", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2828,6 +2832,8 @@ Array [ "logs-max", "long", "node-options", + "allow-unused-patches", + "ignore-patch-failures", "edit-dir", "ignore-existing", "keep-edit-dir", @@ -2857,7 +2863,6 @@ Object { "allowScripts": Array [], "allowScriptsPending": false, "allowScriptsPin": true, - "allowUnusedPatches": false, "audit": true, "auditLevel": null, "authType": "web", @@ -2900,7 +2905,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 e5639c3e04f8d..c6c34c91a5ff1 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -312,6 +312,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 478726d9a3987..a765b070f8af5 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1733,15 +1733,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, @@ -1749,8 +1752,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 184c35bdcd6bb53e079a27c0e22b4704f85e7160 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 08:14:24 +0530 Subject: [PATCH 18/26] 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 68893349cc75a..8c45d6da3877c 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 { @@ -46,6 +51,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, @@ -163,6 +169,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 c565364f4064b..4786da92c2b93 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 113bb98ba19de..b45372a2bbf77 100644 --- a/workspaces/arborist/lib/isolated-classes.js +++ b/workspaces/arborist/lib/isolated-classes.js @@ -22,6 +22,7 @@ class IsolatedNode { linksIn = new Set() meta = { loadedFromDisk: false } optional = false + patched = null parent = null root = null tops = new Set() @@ -49,6 +50,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 8d33d541f49d40166ec3c34853d501baa47bb73b Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 08:52:31 +0530 Subject: [PATCH 19/26] 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 39e3a599fc37e..3520f015dd0af 100644 --- a/lib/commands/audit.js +++ b/lib/commands/audit.js @@ -3,6 +3,7 @@ const ArboristWorkspaceCmd = require('../arborist-cmd.js') const auditError = require('../utils/audit-error.js') const { log, output } = require('proc-log') const reifyFinish = require('../utils/reify-finish.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const VerifySignatures = require('../utils/verify-signatures.js') class Audit extends ArboristWorkspaceCmd { @@ -60,6 +61,8 @@ class Audit extends ArboristWorkspaceCmd { const Arborist = require('@npmcli/arborist') 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 347031b60a78a..ff59162b50a62 100644 --- a/lib/commands/dedupe.js +++ b/lib/commands/dedupe.js @@ -1,4 +1,5 @@ const reifyFinish = require('../utils/reify-finish.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') // dedupe duplicated packages, or find them in the tree @@ -44,6 +45,7 @@ class Dedupe extends ArboristWorkspaceCmd { // In order to reduce potential confusion we set this to false. save: false, workspaces: this.workspaceNames, + ...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 ca656ad18f5ca..1eb4bf6eb998a 100644 --- a/lib/commands/link.js +++ b/lib/commands/link.js @@ -4,6 +4,7 @@ const npa = require('npm-package-arg') const pkgJson = require('@npmcli/package-json') const semver = require('semver') const reifyFinish = require('../utils/reify-finish.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Link extends ArboristWorkspaceCmd { @@ -69,6 +70,7 @@ class Link extends ArboristWorkspaceCmd { const Arborist = require('@npmcli/arborist') const globalOpts = { ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), Arborist, path: globalTop, global: true, @@ -117,6 +119,7 @@ class Link extends ArboristWorkspaceCmd { // reify all the pending names as symlinks there const localArb = new Arborist({ ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), prune: false, path: this.npm.prefix, save, @@ -141,6 +144,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 d91b003052443..bd85d6b7dcd9f 100644 --- a/lib/commands/prune.js +++ b/lib/commands/prune.js @@ -1,4 +1,5 @@ const reifyFinish = require('../utils/reify-finish.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Prune extends ArboristWorkspaceCmd { @@ -23,6 +24,7 @@ class Prune extends ArboristWorkspaceCmd { ...this.npm.flatOptions, path: where, workspaces: this.workspaceNames, + ...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 a369158d99c52..6ce0174bafb4b 100644 --- a/lib/commands/uninstall.js +++ b/lib/commands/uninstall.js @@ -2,6 +2,7 @@ const { resolve } = require('node:path') const pkgJson = require('@npmcli/package-json') const reifyFinish = require('../utils/reify-finish.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 { @@ -44,6 +45,7 @@ class Uninstall extends ArboristWorkspaceCmd { path, rm: args, workspaces: this.workspaceNames, + ...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 4786da92c2b93..6000bd5558e34 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 ab6a637211be42fdf790bfb0b36090e01f540490 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 09:05:25 +0530 Subject: [PATCH 20/26] 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 b1f0c7224f65c1533b48725c732894a76f512b20 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 10:48:35 +0530 Subject: [PATCH 21/26] 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 d3fe5fdb8c79573cf4bc9fbcf4e0abcc79466963 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 10:55:06 +0530 Subject: [PATCH 22/26] 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 c40b03b67dcee5b54005bcc0969a011d69b62cec Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 12:06:40 +0530 Subject: [PATCH 23/26] 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 a32d37f1e41010313f8af4058d7a815763d4ae73 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 12:06:40 +0530 Subject: [PATCH 24/26] 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 bab9f1eafc5b06a4e9cc3c0cc2b5b4a67a345dd0 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 12:21:27 +0530 Subject: [PATCH 25/26] 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 f28737a4dcb7ea00af33c6f7b15aad173510a457 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Sat, 30 May 2026 13:04:54 +0530 Subject: [PATCH 26/26] 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 451efdeaaf677..35bd4baf11ded 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