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/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/audit.js b/lib/commands/audit.js index a8d742cc73a6e..ddcaae6d0bb41 100644 --- a/lib/commands/audit.js +++ b/lib/commands/audit.js @@ -4,6 +4,7 @@ const auditError = require('../utils/audit-error.js') const { log, output } = require('proc-log') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const VerifySignatures = require('../utils/verify-signatures.js') class Audit extends ArboristWorkspaceCmd { @@ -62,6 +63,8 @@ class Audit extends ArboristWorkspaceCmd { const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) const opts = { ...this.npm.flatOptions, + // audit fix reifies, so honor the cli-only patch relax flags + ...patchRelaxOpts(this.npm.config), audit: true, path: this.npm.prefix, reporter, diff --git a/lib/commands/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/dedupe.js b/lib/commands/dedupe.js index e703e9fb3741a..0b3fee45bf256 100644 --- a/lib/commands/dedupe.js +++ b/lib/commands/dedupe.js @@ -1,5 +1,6 @@ const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') // dedupe duplicated packages, or find them in the tree @@ -47,6 +48,7 @@ class Dedupe extends ArboristWorkspaceCmd { save: false, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) await arb.dedupe(opts) diff --git a/lib/commands/install.js b/lib/commands/install.js index 4757cfbf02aa3..3565dde362d67 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 { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Install extends ArboristWorkspaceCmd { @@ -151,6 +152,8 @@ class Install extends ArboristWorkspaceCmd { add: args, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + // patch relax flags are honored only when passed on the command line + ...patchRelaxOpts(this.npm.config), } // Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before. diff --git a/lib/commands/link.js b/lib/commands/link.js index 160ba2b707efd..389a3b655e3d9 100644 --- a/lib/commands/link.js +++ b/lib/commands/link.js @@ -5,6 +5,7 @@ const pkgJson = require('@npmcli/package-json') const semver = require('semver') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Link extends ArboristWorkspaceCmd { @@ -70,6 +71,7 @@ class Link extends ArboristWorkspaceCmd { const Arborist = require('@npmcli/arborist') const globalOpts = { ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), Arborist, path: globalTop, global: true, @@ -119,6 +121,7 @@ class Link extends ArboristWorkspaceCmd { const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) const localArb = new Arborist({ ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), prune: false, path: this.npm.prefix, save, @@ -145,6 +148,7 @@ class Link extends ArboristWorkspaceCmd { const Arborist = require('@npmcli/arborist') const arb = new Arborist({ ...this.npm.flatOptions, + ...patchRelaxOpts(this.npm.config), Arborist, path: globalTop, global: true, diff --git a/lib/commands/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/lib/commands/patch.js b/lib/commands/patch.js new file mode 100644 index 0000000000000..3700a5a82c28b --- /dev/null +++ b/lib/commands/patch.js @@ -0,0 +1,359 @@ +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') +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 { patchRelaxOpts } = require('../utils/cli-only-flag.js') +const BaseCommand = require('../base-cmd.js') +const { diffDirs } = require('../utils/patch-diff.js') +const reifyFinish = require('../utils/reify-finish.js') + +const SUBCOMMANDS = ['add', 'commit', 'ls', 'rm'] + +// 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('/') + +// 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' + 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, + ...patchRelaxOpts(this.npm.config), + 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 this.#nonRegistryError(spec) + } + + 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) { + const nodes = installed.get(node.version) || [] + nodes.push(node) + installed.set(node.version, nodes) + } + } + + // 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) || [] + if (nodes.some(n => [...n.edgesIn].some(e => e.spec && !npa(e.spec).registry))) { + throw this.#nonRegistryError(`${name}@${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(exact) + return { name, version: exact } + } + const matches = [...installed.keys()].filter(v => semver.satisfies(v, parsed.fetchSpec)) + if (matches.length > 1) { + throw this.#ambiguousError(name, matches, installed) + } + if (matches.length === 1) { + ensureRegistry(matches[0]) + return { name, version: matches[0] } + } + // 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) { + throw this.#ambiguousError(name, [...installed.keys()], installed) + } + const [version] = [...installed.keys()] + ensureRegistry(version) + 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, 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, versions[0])}".`), + { code: 'EPATCHAMBIGUOUS' } + ) + } + + 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) + } 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)) + // refuse to write outside the project so the patch set stays in the repo + const relPatch = containedRelative(this.#root, absPatch) + if (!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) + + 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 + } + + // 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])) + // 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) { + continue + } + let winner = null + try { + winner = matchSelector(selectors, node) + } catch { + 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) { + counts.set(winner.key, counts.get(winner.key) + 1) + } + } + for (const key of keys) { + if (ambiguous.has(key)) { + 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'})`) + } + } + + 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 } = parseSelector(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)) { + const abs = resolve(this.#root, patchPath) + // never delete a path that escapes the project root + if (!containedRelative(this.#root, abs)) { + throw Object.assign( + new Error(`Refusing to delete patch outside the project root: ${patchPath}`), + { code: 'EPATCHUNSAFE' } + ) + } + await rm(abs, { 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/commands/prune.js b/lib/commands/prune.js index bc88a4e20de66..e1790e4094726 100644 --- a/lib/commands/prune.js +++ b/lib/commands/prune.js @@ -1,5 +1,6 @@ const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Prune extends ArboristWorkspaceCmd { @@ -26,6 +27,7 @@ class Prune extends ArboristWorkspaceCmd { path: where, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) await arb.prune(opts) diff --git a/lib/commands/uninstall.js b/lib/commands/uninstall.js index 60c5eb8e79170..eb595c26a45ba 100644 --- a/lib/commands/uninstall.js +++ b/lib/commands/uninstall.js @@ -3,6 +3,7 @@ const pkgJson = require('@npmcli/package-json') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const completion = require('../utils/installed-shallow.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Uninstall extends ArboristWorkspaceCmd { @@ -47,6 +48,7 @@ class Uninstall extends ArboristWorkspaceCmd { rm: args, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) await arb.reify(opts) diff --git a/lib/commands/update.js b/lib/commands/update.js index 22f77390b25a3..7c49fa63c9194 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -3,6 +3,7 @@ const { log } = require('proc-log') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') class Update extends ArboristWorkspaceCmd { @@ -63,6 +64,7 @@ class Update extends ArboristWorkspaceCmd { save, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) diff --git a/lib/utils/cli-only-flag.js b/lib/utils/cli-only-flag.js new file mode 100644 index 0000000000000..760c1eabaa95a --- /dev/null +++ b/lib/utils/cli-only-flag.js @@ -0,0 +1,13 @@ +// 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 + +// 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/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..de9fba49ced4d --- /dev/null +++ b/lib/utils/patch-diff.js @@ -0,0 +1,74 @@ +// 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) => { + const [origFiles, editFiles] = await Promise.all([ + listFiles(originalDir), + listFiles(editedDir), + ]) + const all = [...new Set([...origFiles, ...editFiles])].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/lib/utils/validate-lockfile.js b/lib/utils/validate-lockfile.js index 29161ec55bb79..cdab0ed0ea046 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -22,6 +22,13 @@ 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 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 patch recorded in the lock file`) + } } return errors } diff --git a/package-lock.json b/package-lock.json index 2e42b5d5f7818..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" @@ -14707,6 +14710,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/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/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 diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 57956a4f5171e..2730f19c50656 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -129,6 +129,12 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "package-lock-only": false, "pack-destination": ".", "packages": [], + "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, @@ -207,6 +213,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 @@ -239,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 @@ -262,6 +270,8 @@ globalconfig = "{CWD}/global/etc/npmrc" heading = "npm" https-proxy = null if-present = false +ignore-existing = false +ignore-patch-failures = false ignore-scripts = false include = [] include-attestations = false @@ -284,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 @@ -322,6 +333,7 @@ packages-all = false packages-and-scopes-permission = null parseable = false password = (protected) +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 f6dfe04f369d8..f093ada1515c6 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", @@ -345,6 +346,19 @@ 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. + +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\` * Default: true @@ -663,6 +677,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 @@ -930,6 +954,29 @@ 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 +* Type: Boolean + +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\` * Default: false @@ -1112,6 +1159,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 @@ -1515,6 +1572,16 @@ tokens, though it's generally safer to be prompted for it. +#### \`patches-dir\` + +* Default: "patches" +* Type: String + +The directory, relative to the project root, where \`npm patch commit\` writes +patch files for \`patchedDependencies\`. + + + #### \`prefer-dedupe\` * Default: false @@ -2516,6 +2583,12 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", + "allow-unused-patches", + "ignore-patch-failures", + "edit-dir", + "ignore-existing", + "keep-edit-dir", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2681,6 +2754,7 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2758,6 +2832,11 @@ Array [ "logs-max", "long", "node-options", + "allow-unused-patches", + "ignore-patch-failures", + "edit-dir", + "ignore-existing", + "keep-edit-dir", "prefix", "timing", "update-notifier", @@ -2868,6 +2947,7 @@ Object { "packDestination": ".", "parseable": false, "password": null, + "patchesDir": "patches", "preferDedupe": false, "preferOffline": false, "preferOnline": false, @@ -5236,6 +5316,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/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/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/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') + }) +}) diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js new file mode 100644 index 0000000000000..b1009112c1d89 --- /dev/null +++ b/test/lib/commands/patch.js @@ -0,0 +1,786 @@ +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('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 }, + 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('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 }, + 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: 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 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 }, + 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 }, + 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: 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 }, + 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 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(), /\(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, { + 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 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, { + 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') +}) + +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..a30d97bc450c9 --- /dev/null +++ b/test/lib/utils/cli-only-flag.js @@ -0,0 +1,33 @@ +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) => ({ + 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() +}) + +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/test/lib/utils/patch-diff.js b/test/lib/utils/patch-diff.js new file mode 100644 index 0000000000000..50d1742067468 --- /dev/null +++ b/test/lib/utils/patch-diff.js @@ -0,0 +1,119 @@ +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('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/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/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/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/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..6000bd5558e34 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,10 +749,59 @@ 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 + + // 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 }) + } 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 + 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) { - 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/diff.js b/workspaces/arborist/lib/diff.js index 12a27ed68157f..704dc7bafc42b 100644 --- a/workspaces/arborist/lib/diff.js +++ b/workspaces/arborist/lib/diff.js @@ -130,6 +130,16 @@ 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' + } + + // 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/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/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..51d3c1878600f --- /dev/null +++ b/workspaces/arborist/lib/patch.js @@ -0,0 +1,111 @@ +// Native dependency patching helpers shared across build-ideal-tree and reify. +// 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') +const { promises: fsp } = fs +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. +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 => 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 isAdd = isDevNull(filePatch.oldFileName) + const isDelete = isDevNull(filePatch.newFileName) + + if (isDelete) { + 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 file = stripPrefix(filePatch.newFileName) + const target = containedTarget(cwd, file) + + 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 + } + + 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) + 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 + } + 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 }) + } + } +} + +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..c814974b5471e --- /dev/null +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -0,0 +1,152 @@ +// Resolve the root patchedDependencies map against an ideal tree. +// 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') + +// 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. +// 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)) { + 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' + ) + } + } + 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 || {} + 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) + } + // 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(abs) + } 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()) { + // 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) { + continue + } + + const selector = matchSelector(selectors, node) + if (!selector) { + // 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 + } + + // 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.`, + 'EPATCHNONREGISTRY', + { node: node.name } + ) + } + + const integrity = await readPatch(selector.patchPath) + node.patched = { path: selector.patchPath, integrity } + usedKeys.add(selector.key) + } + + if (selectors.length && !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..c944cdad7803a 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 @@ -934,6 +946,12 @@ 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) { + log.warn('shrinkwrap', `patchedDependencies requires lockfileVersion ${patchedLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`) + 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", 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/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 {}, diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js new file mode 100644 index 0000000000000..4da3821cad5d7 --- /dev/null +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -0,0 +1,354 @@ +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') + // 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 => { + 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') +}) + +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) + 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('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 }, + }) + + 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('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. + 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) + 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, { + 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/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/patch.js b/workspaces/arborist/test/patch.js new file mode 100644 index 0000000000000..652d7252f00a1 --- /dev/null +++ b/workspaces/arborist/test/patch.js @@ -0,0 +1,143 @@ +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('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( + applyPatchToDir({ patch: filePatch('index.js', 'const v = 1\n', 'const v = 2\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +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 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( + 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('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')) + 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-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js new file mode 100644 index 0000000000000..c2cd830dff1ef --- /dev/null +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -0,0 +1,323 @@ +// 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('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({ + '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' }) +}) + +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' }) +}) diff --git a/workspaces/arborist/test/patched-dependencies.js b/workspaces/arborist/test/patched-dependencies.js new file mode 100644 index 0000000000000..ea7d95211cbe1 --- /dev/null +++ b/workspaces/arborist/test/patched-dependencies.js @@ -0,0 +1,52 @@ +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 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() +}) + +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() +}) 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), diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 1fd88e9351953..a765b070f8af5 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1724,6 +1724,63 @@ const definitions = { `, flatten, }), + 'patches-dir': new Definition('patches-dir', { + default: 'patches', + type: String, + description: ` + The directory, relative to the project root, where \`npm patch commit\` + writes patch files for \`patchedDependencies\`. + `, + flatten, + }), + // 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\`. + `, + }), + '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. + + This flag is only honored when passed on the command line; it is ignored + in \`.npmrc\` and environment variables, and rejected by \`npm ci\`. + `, + }), + '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 8a1f464cf4b92..0ec630b5a3d5a 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", @@ -170,6 +173,10 @@ Object { "dry-run": Array [ "boolean value (true or false)", ], + "edit-dir": Array [ + null, + "valid filesystem path", + ], "editor": Array [ Function String(), ], @@ -243,6 +250,12 @@ 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)", + ], "ignore-scripts": Array [ "boolean value (true or false)", ], @@ -318,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(), @@ -468,6 +484,9 @@ Object { null, Function String(), ], + "patches-dir": Array [ + Function String(), + ], "prefer-dedupe": Array [ "boolean value (true or false)", ], 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({