diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index bbe20d942aeb9..7e64150920914 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -184,6 +184,7 @@ graph LR; npmcli-arborist-->proggy; npmcli-arborist-->semver; npmcli-arborist-->ssri; + npmcli-arborist-->validate-npm-package-name; npmcli-config-->ini; npmcli-config-->nopt; npmcli-config-->npmcli-eslint-config["@npmcli/eslint-config"]; @@ -540,6 +541,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"]; @@ -578,6 +580,7 @@ graph LR; npmcli-arborist-->tar-stream; npmcli-arborist-->tcompare; npmcli-arborist-->treeverse; + npmcli-arborist-->validate-npm-package-name; npmcli-arborist-->walk-up-path; npmcli-config-->ci-info; npmcli-config-->ini; 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/configuring-npm/package-json.md b/docs/lib/content/configuring-npm/package-json.md index 8bdf3c3e74f2a..56724afe699ab 100644 --- a/docs/lib/content/configuring-npm/package-json.md +++ b/docs/lib/content/configuring-npm/package-json.md @@ -1033,6 +1033,66 @@ For example, to replace a transitive dependency with a fork: } ``` +### packageExtensions + +`packageExtensions` lets a project apply small, declarative repairs to the manifests of third-party dependencies before npm resolves the dependency tree. +Use it to add a missing `dependencies`, `optionalDependencies`, or `peerDependencies` entry, or to correct `peerDependencies` and `peerDependenciesMeta`, while you wait for the upstream package to publish a fix. + +This is especially useful with [`install-strategy=linked`](/using-npm/config#install-strategy), where dependencies are fully isolated and a package only sees what it actually declared. +A package that worked under a hoisted layout because a dependency happened to be hoisted above it can fail under `linked`; `packageExtensions` records the missing edge as explicit, reviewable, root-owned policy. + +`packageExtensions` complements [`overrides`](#overrides): `overrides` changes what an existing dependency edge resolves to, while `packageExtensions` adds or corrects the dependency metadata that creates the edge in the first place. +For changing the resolved version of a dependency that is already declared, use `overrides`. + +Like `overrides`, `packageExtensions` is only honored in the root `package.json` of a project (the workspace root in a workspace). +The field in installed dependencies and in non-root workspace packages is ignored. +Because it is root-only project policy, npm refuses to publish a non-private package that contains `packageExtensions`; it remains available to private packages and unpublished local projects. + +Each key is a package selector: a package name with an optional semver range. + +```json +{ + "packageExtensions": { + "broken-package@1": { + "dependencies": { + "missing-runtime-dep": "^2.0.0" + } + }, + "typescript-plugin@4.3.0": { + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "@scope/uses-types@2": { + "dependencies": { + "@types/node": "^22.0.0" + } + } + } +} +``` + +- `"foo"` matches all versions of `foo`. +- `"foo@1"` matches versions satisfying `1`. +- `"@scope/foo@^2.3.0"` matches versions satisfying `^2.3.0`. + +Selectors match a candidate package's own `name` and `version`. They do not accept dist-tags, git, file, directory, URL, or `npm:` alias specs. For aliases, the selector matches the underlying package name. At most one selector may match a given package; overlapping selectors that both match the same package fail the install. + +Only `dependencies`, `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta` may be extended. The merge rules are: + +- `dependencies` and `optionalDependencies` entries add a missing dependency only. Adding a name that the package already declares in either field is an error; use `overrides` to change a version. +- `peerDependencies` entries are merged by name, replacing an existing range. +- `peerDependenciesMeta` entries are merged by name and then by key, so you can add `optional: true` without dropping other metadata. Every `peerDependenciesMeta` entry must correspond to a `peerDependencies` entry. + +Deletion is not supported; a `null`, `false`, or `"-"` value is an error. + +`packageExtensions` does not rewrite the installed package's `package.json` on disk and does not modify `bundleDependencies`. Affected packages are recorded in `package-lock.json` and surfaced by [`npm explain`](/commands/npm-explain) and [`npm ls`](/commands/npm-ls), so each repair is easy to audit and to remove once upstream is fixed. + ### engines You can specify the version of node that your stuff works on: 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 6a55af4313584..bc2480c520fe9 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..941270e0bad22 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -6,6 +6,7 @@ const fs = require('node:fs/promises') const path = require('node:path') const { log, time } = require('proc-log') const validateLockfile = require('../utils/validate-lockfile.js') +const { validatePackageExtensions } = require('../utils/validate-lockfile.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') const getWorkspaces = require('../utils/get-workspaces.js') @@ -44,6 +45,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 @@ -80,6 +90,8 @@ class CI extends ArboristWorkspaceCmd { // Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file). const errors = validateLockfile(virtualInventory, arb.idealTree.inventory) + // Verifies that the root packageExtensions state matches the lockfile and is still consistent with the locked tree. + errors.push(...validatePackageExtensions(virtualArb.virtualTree, arb.idealTree)) if (errors.length) { throw this.usageError( '`npm ci` can only install packages when your package.json and package-lock.json are in sync. ' + 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 2e922d0a1f3ab..2fd9bc8d5cd7a 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 { @@ -152,6 +153,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..738052c5a9c05 100644 --- a/lib/commands/ls.js +++ b/lib/commands/ls.js @@ -278,6 +278,21 @@ const augmentItemWithIncludeMetadata = (node, item) => { return item } +// Render a node's packageExtensions provenance as a short "field.name" list, empty when none. +const formatPackageExtensions = (applied) => { + if (!applied) { + return '' + } + const fields = ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta'] + const parts = [] + for (const field of fields) { + for (const name of applied[field] || []) { + parts.push(`${field}.${name}`) + } + } + return parts.join(', ') +} + const getHumanOutputItem = (node, { args, chalk, global, long }) => { const { pkgid, path } = node const workspacePkgId = chalk.blueBright(pkgid) @@ -333,6 +348,16 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => { ? ' ' + chalk.dim('overridden') : '' ) + + ( + node.patched + ? ' ' + chalk.cyan(`[patched: ${node.patched.path}]`) + : '' + ) + + ( + formatPackageExtensions(node.packageExtensionsApplied) + ? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`) + : '' + ) + (isGitNode(node) ? ` (${node.resolved})` : '') + (node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') + (long ? `\n${node.package.description || ''}` : '') @@ -357,6 +382,10 @@ const getJsonOutputItem = (node, { global, long }) => { item.overridden = node.overridden } + if (node.packageExtensionsApplied) { + item.packageExtensionsApplied = node.packageExtensionsApplied + } + item[_name] = node.name // special formatting for top-level package name @@ -389,6 +418,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..67b8bc4555572 --- /dev/null +++ b/lib/commands/patch.js @@ -0,0 +1,740 @@ +const { resolve, relative, join, dirname, basename, isAbsolute, sep } = require('node:path') +const { tmpdir } = require('node:os') +const { cp, mkdir, mkdtemp, readFile, rm, writeFile } = require('node:fs/promises') +const pacote = require('pacote') +const npa = require('npm-package-arg') +const semver = require('semver') +const git = require('@npmcli/git') +const PackageJson = require('@npmcli/package-json') +const { log, output } = require('proc-log') +const { matchSelector, parseSelector } = require('@npmcli/arborist/lib/patched-dependencies.js') +const { applyPatchToDir } = require('@npmcli/arborist/lib/patch.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') +const BaseCommand = require('../base-cmd.js') +const { diffDirs } = require('../utils/patch-diff.js') +const reifyFinish = require('../utils/reify-finish.js') + +const SUBCOMMANDS = ['add', 'commit', 'update', 'ls', 'rm'] + +// Marker left in a conflicted update edit dir so the finalizing commit drops the old exact selector. +const UPDATE_MARKER = '.npm-patch-update.json' + +// fs.cp filter that skips a git working directory. +const notGitDir = src => !src.split(sep).includes('.git') + +// Build the selector key stored in patchedDependencies, e.g. lodash@4.17.21. +const selectorKey = (name, version) => `${name}@${version}` + +// Posix-relative path to a patch file inside patches-dir for name@version. +const patchFilePath = (patchesDir, name, version) => + `${patchesDir}/${name}@${version}.patch`.split('\\').join('/') + +// Recover the exact version a patch file was authored against from its name. +// commit always writes @.patch, so the baseline is encoded there. +const patchFileVersion = patchPath => { + const { spec } = parseSelector(basename(patchPath).replace(/\.patch$/, '')) + return semver.valid(spec) +} + +const patchErr = (message, code, extra = {}) => + Object.assign(new Error(message), { code, ...extra }) + +// The project-root-relative posix path for abs, or null if abs escapes the root. +const containedRelative = (root, abs) => { + const rel = relative(root, abs).split('\\').join('/') + 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', + 'to', + 'registry', + ] + + static usage = [ + '[@]', + 'add [@] [--edit-dir ] [--ignore-existing]', + 'commit [--patches-dir ] [--keep-edit-dir]', + 'update [@] [--to ] [--patches-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' } + ) + } + + // Create (or reuse, with --edit-dir) the directory a package is extracted into for editing. + async #makeEditDir (name, version) { + let editDir = this.npm.config.get('edit-dir') + if (!editDir) { + const base = join(tmpdir(), 'npm-patch') + await mkdir(base, { recursive: true }) + return mkdtemp(join(base, `${name.replace(/\//g, '+')}@${version}-`)) + } + editDir = resolve(editDir) + if (this.npm.config.get('ignore-existing')) { + await rm(editDir, { recursive: true, force: true }) + } + await mkdir(editDir, { recursive: true }) + return editDir + } + + async add (args) { + if (args.length !== 1) { + throw this.usageError() + } + const { name, version } = await this.#resolveTarget(args[0]) + const editDir = await this.#makeEditDir(name, version) + + await pacote.extract(selectorKey(name, version), editDir, this.npm.flatOptions) + + 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}`) + } + + // a conflicted `patch update` leaves a marker so this commit drops the renamed-from selector + // it is kept on disk (and excluded from the diff) so a re-run after a no-op resolution still finalizes the update + const markerPath = join(editDir, UPDATE_MARKER) + const markerRaw = await readFile(markerPath, 'utf8').catch(() => null) + let marker = null + if (markerRaw !== null) { + try { + marker = JSON.parse(markerRaw) + } catch { + throw patchErr(`invalid update marker in ${editDir}`, 'EPATCHBADMARKER') + } + } + + // extract a clean baseline to diff against + const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-')) + let diff, packageJsonChanged + try { + await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions) + ;({ diff, packageJsonChanged } = await diffDirs(base, editDir, new Set([UPDATE_MARKER]))) + } finally { + await rm(base, { recursive: true, force: true }) + } + + if (!diff) { + // package.json is excluded from patches, so an edit limited to it captures nothing + const reason = packageJsonChanged + ? `only package.json changed in ${editDir}, which is not patchable; nothing to commit` + : `no changes detected in ${editDir}; nothing to commit` + log.warn('patch', reason) + return + } + if (packageJsonChanged) { + log.warn('patch', 'changes to package.json are not included in patches and were ignored') + } + + const patchesDir = this.npm.config.get('patches-dir') + const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version)) + // 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 } + const newKey = selectorKey(name, version) + patchedDependencies[newKey] = relPatch + // a marker left by a conflicted `patch update` for this package means a metadata-only finalize. + // its name is checked so a stray or forged marker can never hijack a normal commit. + const updateFinalize = !!marker && marker.name === name + let orphan = null + // an exact rename drops the renamed-from selector; a fork carries removeKey null and keeps the old entry. + // removeKey must be a real, same-package selector string, so a malformed marker can neither crash nor drop the wrong entry. + if (updateFinalize && typeof marker.removeKey === 'string' && marker.removeKey !== newKey && + patchedDependencies[marker.removeKey] !== undefined && + parseSelector(marker.removeKey).name === name) { + orphan = this.#dropSelector(patchedDependencies, marker.removeKey) + } + pkgJson.update({ patchedDependencies }) + await pkgJson.save() + + // finishing a conflicted update is metadata-only (like update itself): the new version may not be installed yet + const arb = updateFinalize + ? this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false }) + : this.#newArborist() + await arb.reify(arb.options) + await reifyFinish(this.npm, arb) + + // remove the renamed-from patch file only after the lockfile is durable + await this.#removePatchFile(orphan) + + if (!this.npm.config.get('keep-edit-dir')) { + await rm(editDir, { recursive: true, force: true }) + } + + output.standard(`Patched ${newKey} -> ${relPatch}`) + } + + // Remove a selector from the map; return its patch file path if nothing else references it. + #dropSelector (patched, key) { + const patchPath = patched[key] + delete patched[key] + /* istanbul ignore next - the shared-file branch only fires when two selectors point at one patch */ + return (patchPath && !Object.values(patched).includes(patchPath)) ? patchPath : null + } + + // Delete an orphaned patch file, but never one that escapes the project root. + async #removePatchFile (patchPath) { + if (!patchPath) { + return + } + const abs = resolve(this.#root, patchPath) + /* istanbul ignore else - defensive: orphaned paths come from the manifest and stay in-project */ + if (containedRelative(this.#root, abs)) { + await rm(abs, { force: true }) + } + } + + async update (args) { + if (args.length !== 1) { + throw this.usageError() + } + + const pkgJson = await PackageJson.load(this.#root) + const patched = { ...pkgJson.content.patchedDependencies } + const entry = this.#resolveUpdateEntry(args[0], patched) + + const baseVersion = patchFileVersion(entry.patchPath) + if (!baseVersion) { + throw patchErr( + `cannot determine the version "${entry.patchPath}" was authored against; rebase by hand`, + 'EPATCHBASE' + ) + } + + const installedVersions = await this.#installedVersions(entry.name) + const newVersion = this.#resolveNewVersion(entry, installedVersions) + if (newVersion === baseVersion) { + throw patchErr(`nothing to update: the patch already targets ${selectorKey(entry.name, newVersion)}`, 'EPATCHNOOP') + } + const newKey = selectorKey(entry.name, newVersion) + if (patched[newKey] && newKey !== entry.key) { + throw patchErr( + `an entry already exists for ${newKey}; use "npm patch rm" first or rebase manually`, + 'EPATCHEXISTS' + ) + } + + const rebase = await this.#rebasePatch({ + name: entry.name, + baseVersion, + newVersion, + patchAbs: resolve(this.#root, entry.patchPath), + }) + try { + if (rebase.conflicted) { + const editDir = await this.#makeEditDir(entry.name, newVersion) + try { + await cp(rebase.repo, editDir, { recursive: true, filter: notGitDir }) + // mark the conflict so the finalizing commit knows this is an update finalize. + // an exact selector is a rename (drop the old key); a range/name-only is a fork (keep it). + const removeKey = entry.spec && semver.valid(entry.spec) ? entry.key : null + await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ name: entry.name, removeKey }) + '\n') + } catch (er) { + // discard a temp edit dir we created if the copy or marker write fails; never a user-supplied --edit-dir + /* istanbul ignore next - a failing copy/marker write is not deterministically reproducible */ + if (!this.npm.config.get('edit-dir')) { + await rm(editDir, { recursive: true, force: true }) + } + /* istanbul ignore next */ + throw er + } + output.standard(`Patch did not apply cleanly to ${newKey}.`) + output.standard(`Resolve the conflicts in: ${editDir}`) + output.standard(`When done, run: npm patch commit ${editDir}`) + return + } + + const { diff } = await diffDirs(rebase.newDir, rebase.repo) + if (!diff) { + throw patchErr( + `the patch no longer changes ${newKey}; remove it with "npm patch rm" instead`, + 'EPATCHEMPTY' + ) + } + await this.#finalizeUpdate({ + pkgJson, patched, entry, name: entry.name, newVersion, newKey, diff, installedVersions, + }) + } finally { + await rm(rebase.work, { recursive: true, force: true }) + } + } + + // Identify the single patchedDependencies entry the update targets. + #resolveUpdateEntry (spec, patched) { + const target = npa(spec) + const name = target.name + const oldSpec = target.rawSpec && target.rawSpec !== '*' ? target.fetchSpec : null + const keys = Object.keys(patched) + + if (oldSpec) { + const key = selectorKey(name, oldSpec) + if (!patched[key]) { + throw patchErr(`no patch registered for ${key}`, 'EPATCHNOTFOUND') + } + return { key, name, spec: oldSpec, patchPath: patched[key] } + } + + const matches = keys.filter(k => parseSelector(k).name === name) + if (!matches.length) { + throw patchErr(`no patch to update for "${name}"`, 'EPATCHNOTFOUND') + } + if (matches.length > 1) { + throw patchErr( + `multiple patches match "${name}":\n${matches.map(k => ` ${k}`).join('\n')}\n` + + `Re-run with an explicit selector, e.g. "npm patch update ${matches[0]}".`, + 'EPATCHAMBIGUOUS' + ) + } + const key = matches[0] + return { key, name, spec: parseSelector(key).spec, patchPath: patched[key] } + } + + // Determine the version to rebase onto: --to, else the installed version matching the selector. + #resolveNewVersion (entry, installedVersions) { + const to = this.npm.config.get('to') + if (to) { + const valid = semver.valid(to) + if (!valid) { + throw patchErr(`--to "${to}" is not a valid version`, 'EPATCHBADTO') + } + // update finalizes with allowUnusedPatches, so a --to absent from the tree records an unused patch silently. + // The next plain install then rejects with EPATCHUNUSED, so warn now to surface the mismatch early. + if (installedVersions !== null && !installedVersions.includes(valid)) { + log.warn('patch', + `${selectorKey(entry.name, valid)} is not installed; ` + + `bump the dependency and reinstall, or the next "npm install" will fail with EPATCHUNUSED.`) + } + return valid + } + + if (installedVersions === null) { + throw patchErr( + `could not read the lockfile for "${entry.name}"; ` + + `run "npm install" first or pass --to `, + 'EPATCHSTALE' + ) + } + const { spec } = entry + let matching + if (spec && semver.valid(spec)) { + matching = installedVersions.filter(v => semver.eq(v, spec)) + } else if (spec) { + matching = installedVersions.filter(v => semver.satisfies(v, spec)) + } else { + matching = installedVersions + } + if (!matching.length) { + throw patchErr( + `no installed version matches the patch selector "${entry.key}"; ` + + `pass --to to rebase onto a specific version`, + 'EPATCHSTALE' + ) + } + return matching.sort(semver.rcompare)[0] + } + + // Distinct registry-installed versions of a package from the lockfile, or null when it cannot be loaded. + // Uses loadVirtual so the lockfile's own validity gates staleness and links/workspaces are excluded. + async #installedVersions (name) { + let tree + try { + tree = await this.#newArborist().loadVirtual() + } catch { + return null + } + const versions = new Set() + for (const node of tree.inventory.values()) { + if (node.isProjectRoot || node.isWorkspace || node.isLink || !node.version) { + continue + } + /* istanbul ignore else - other-named nodes are skipped; only exercised with multi-package trees */ + if (node.name === name) { + versions.add(node.version) + } + } + return [...versions] + } + + // 3-way merge the existing patch onto the new version via a throwaway git repo. + // Returns { work, repo, newDir, conflicted }; the caller removes work on success. + async #rebasePatch ({ name, baseVersion, newVersion, patchAbs }) { + const work = await mkdtemp(join(tmpdir(), 'npm-patch-rebase-')) + try { + const baseDir = join(work, 'base') + const newDir = join(work, 'new') + const repo = join(work, 'repo') + await pacote.extract(selectorKey(name, baseVersion), baseDir, this.npm.flatOptions) + await pacote.extract(selectorKey(name, newVersion), newDir, this.npm.flatOptions) + await mkdir(repo, { recursive: true }) + + const id = ['-c', 'user.name=npm', '-c', 'user.email=npm@npmjs.com', '-c', 'commit.gpgsign=false'] + await this.#git(['init', '-q', '-b', 'base'], repo) + // base = the version the patch was authored against + await cp(baseDir, repo, { recursive: true }) + await this.#git(['add', '-Af'], repo) + await this.#git([...id, 'commit', '-qm', 'base', '--allow-empty'], repo) + // theirs = base + the existing patch, applied with the same jsdiff helper installs use + await this.#git(['checkout', '-q', '-b', 'theirs'], repo) + try { + await applyPatchToDir({ patch: await readFile(patchAbs), cwd: repo }) + } catch (er) { + throw patchErr( + `the existing patch no longer applies to its baseline ${selectorKey(name, baseVersion)}; rebase by hand`, + 'EPATCHBASE', + { cause: er } + ) + } + await this.#git(['add', '-Af'], repo) + await this.#git([...id, 'commit', '-qm', 'theirs', '--allow-empty'], repo) + // ours = the new version, branched from base + await this.#git(['checkout', '-q', '-b', 'ours', 'base'], repo) + await this.#git(['rm', '-rqf', '.'], repo) + await cp(newDir, repo, { recursive: true }) + await this.#git(['add', '-Af'], repo) + await this.#git([...id, 'commit', '-qm', 'ours', '--allow-empty'], repo) + // replay the patch's intent onto the new version + let conflicted = false + try { + await this.#git([...id, 'merge', '--no-edit', 'theirs'], repo) + } catch (er) { + // a genuine conflict leaves unmerged paths; any other non-zero exit is a real git failure + const unmerged = await this.#git(['ls-files', '--unmerged'], repo) + /* istanbul ignore else - a non-conflict merge failure is a git/environment fault */ + if (String(unmerged.stdout).trim()) { + conflicted = true + } else { + throw er + } + } + return { work, repo, newDir, conflicted } + } catch (er) { + await rm(work, { recursive: true, force: true }) + throw er + } + } + + #git (args, cwd) { + return git.spawn(args, { + cwd, + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }) + } + + // Apply the selector rules, write the new patch, and sync the lockfile without touching node_modules. + async #finalizeUpdate (opts) { + const { pkgJson, patched, entry, name, newVersion, newKey, diff, installedVersions } = opts + const patchesDir = this.npm.config.get('patches-dir') + const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, newVersion)) + const relPatch = containedRelative(this.#root, absPatch) + if (!relPatch) { + throw patchErr(`patches-dir "${patchesDir}" resolves outside the project root.`, 'EPATCHUNSAFE') + } + // write the new patch first so no selector points at a missing file + await mkdir(dirname(absPatch), { recursive: true }) + await writeFile(absPatch, diff) + + patched[newKey] = relPatch + // drop the old selector only when it no longer wins any installed node, so a still-needed range survives + let orphan = null + if (entry.key !== newKey && this.#selectorUnused(patched, entry.key, name, installedVersions)) { + orphan = this.#dropSelector(patched, entry.key) + } + + pkgJson.update({ patchedDependencies: patched }) + await pkgJson.save() + + // record the new patch integrity in the lockfile only; update never touches node_modules + const arb = this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false }) + await arb.reify(arb.options) + await reifyFinish(this.npm, arb) + + // remove the now-orphaned old patch file only after the lockfile is durable + await this.#removePatchFile(orphan) + + output.standard(`Updated ${entry.key} -> ${newKey} (${relPatch})`) + } + + // Whether `key` should be dropped once the new selector is added. + // An exact selector is a straight rename. A range/name-only selector is dropped only when it no longer + // wins any installed node under the installer's exact > range > name-only precedence, so a kept one is still used. + #selectorUnused (patched, key, name, installedVersions) { + const { spec } = parseSelector(key) + if (spec && semver.valid(spec)) { + return true + } + if (installedVersions === null) { + return false + } + const selectors = Object.keys(patched) + .filter(k => parseSelector(k).name === name) + .map(k => ({ ...parseSelector(k), key: k })) + for (const version of installedVersions) { + let winner + try { + winner = matchSelector(selectors, { name, version }) + } catch { + /* istanbul ignore next - ambiguous overlapping ranges: keep the selector to be safe */ + return false + } + /* istanbul ignore if - a still-winning selector is kept; only exercised with multiple installed versions */ + if (winner?.key === key) { + return false + } + } + return true + } + + async ls () { + 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/publish.js b/lib/commands/publish.js index 015e4312f6113..f13bc026f3295 100644 --- a/lib/commands/publish.js +++ b/lib/commands/publish.js @@ -96,6 +96,9 @@ class Publish extends BaseCommand { const spec = npa(args[0]) let manifest = await this.#getManifest(spec, opts) + // packageExtensions is root-only project policy and must never be published; fail fast so dry-run reports it too + this.#assertNoPackageExtensions(manifest) + // only run scripts for directory type publishes if (spec.type === 'directory' && !ignoreScripts) { await runScript({ @@ -122,6 +125,8 @@ class Publish extends BaseCommand { // The purpose of re-reading the manifest is in case it changed, so that we send the latest and greatest thing to the registry note that publishConfig might have changed as well! manifest = await this.#getManifest(spec, opts, true) + // re-check the authoritative manifest in case a lifecycle script introduced packageExtensions + this.#assertNoPackageExtensions(manifest) const force = this.npm.config.get('force') const isDefaultTag = this.npm.config.isDefault('tag') && !manifest.publishConfig?.tag @@ -273,6 +278,16 @@ class Publish extends BaseCommand { } } + // packageExtensions is root-only project policy and must never reach the registry; private packages may keep it for local use + #assertNoPackageExtensions (manifest) { + if (!manifest.private && manifest.packageExtensions !== undefined) { + throw Object.assign( + new Error('packageExtensions is only honored at the project root and must not be published.'), + { code: 'EPACKAGEEXTENSIONS' } + ) + } + } + // if it's a directory, read it from the file system // otherwise, get the full metadata from whatever it is // XXX can't pacote read the manifest from a directory? 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 38f87c4a3e218..64c2c5128bb04 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 { @@ -64,6 +65,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/explain-dep.js b/lib/utils/explain-dep.js index 6c84aa4ebbc39..75af3fbcbc5e9 100644 --- a/lib/utils/explain-dep.js +++ b/lib/utils/explain-dep.js @@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => { } const explainEdge = ( - { name, type, bundled, from, spec, rawSpec, overridden }, + { name, type, bundled, from, spec, rawSpec, overridden, packageExtensions }, depth, chalk, seen = new Set() ) => { let dep = type === 'workspace' @@ -88,9 +88,14 @@ const explainEdge = ( const fromMsg = ` from ${explainFrom(from, depth, chalk, seen)}` + // note an edge created by a root packageExtensions repair + const extMsg = packageExtensions + ? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`) + : '' + return (type === 'prod' ? '' : `${colorType(type, chalk)} `) + (bundled ? `${colorType('bundled', chalk)} ` : '') + - `${dep}${fromMsg}` + `${dep}${fromMsg}${extMsg}` } const explainFrom = (from, depth, chalk, seen) => { diff --git a/lib/utils/patch-diff.js b/lib/utils/patch-diff.js new file mode 100644 index 0000000000000..b0ab9a3aa0bff --- /dev/null +++ b/lib/utils/patch-diff.js @@ -0,0 +1,88 @@ +// 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 { diff, packageJsonChanged }. +// Added files use `--- /dev/null`, deleted files use `+++ /dev/null`. +// The root package.json is excluded: Arborist resolves the pre-patch manifest, so a patched manifest would apply to disk without being honored. +// ignore holds extra root-relative filenames the caller keeps out of the diff, e.g. the patch-update marker. +const diffDirs = async (originalDir, editedDir, ignore = new Set()) => { + const [origFiles, editFiles] = await Promise.all([ + listFiles(originalDir), + listFiles(editedDir), + ]) + const all = [...new Set([...origFiles, ...editFiles])].sort() + + let result = '' + let packageJsonChanged = false + for (const file of all) { + const native = file.split('/').join(sep) + const [a, b] = await Promise.all([ + readMaybe(join(originalDir, native)), + readMaybe(join(editedDir, native)), + ]) + if (a === b) { + continue + } + + // the root package.json is never patchable; flag the change so commit can warn + if (file === 'package.json') { + packageJsonChanged = true + continue + } + + // caller-owned control files (e.g. the patch-update marker) never belong in the diff + if (ignore.has(file)) { + 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 { diff: result, packageJsonChanged } +} + +module.exports = { diffDirs } diff --git a/lib/utils/validate-lockfile.js b/lib/utils/validate-lockfile.js index 29161ec55bb79..f55c983a4f905 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -22,8 +22,81 @@ 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)) { + if (entry.patched && !lock.patched) { + // package.json declares a patch the lockfile lacks: newly added, or skipped via --ignore-patch-failures + errors.push(`Invalid: package.json declares a patch for ${entry.name}@${entry.version} ` + + `that the lock file does not record (it may have been skipped with --ignore-patch-failures). ` + + `Fix the patch and reinstall, or remove its patchedDependencies entry`) + } else if (lock.patched && !entry.patched) { + // describe the lock file's own version, which can differ from the ideal tree's when the version also drifted + errors.push(`Invalid: lock file records a patch for ${lock.name}@${lock.version} ` + + `that package.json no longer declares`) + } else { + errors.push(`Invalid: patch for ${entry.name}@${entry.version} does not ` + + `match the patch recorded in the lock file`) + } + } + } + return errors +} + +// validates that the root packageExtensions state matches what the lockfile recorded, and that the locked tree is still consistent with the rule set. +// Returns an array of human-readable error strings, empty when valid. +function validatePackageExtensions (virtualTree, idealTree) { + const errors = [] + const lockHash = virtualTree.meta?.packageExtensionsHash || null + const idealHash = idealTree.meta?.packageExtensionsHash || null + + if (idealHash !== lockHash) { + if (idealHash && !lockHash) { + errors.push('Missing: packageExtensions state from lock file') + } else if (!idealHash && lockHash) { + errors.push('Invalid: lock file records packageExtensions state but package.json has none') + } else { + errors.push('Invalid: packageExtensions in package.json do not match the lock file') + } + // once the canonical hashes diverge, the deeper per-node checks are moot + return errors + } + + // the hashes match, so validate the locked tree's own consistency against the rules + const { PackageExtensions } = require('@npmcli/arborist') + const root = idealTree.target || idealTree + let pe + try { + pe = new PackageExtensions(root.package?.packageExtensions) + } catch (err) { + return [`Invalid: ${err.message}`] + } + + for (const node of virtualTree.inventory.values()) { + if (node.isProjectRoot || node.isWorkspace) { + continue + } + // selectors match the underlying package identity, which is the alias target for aliased installs + const name = node.packageName || node.name + // a locked package identity must not match more than one selector + try { + pe.match(name, node.version) + } catch (err) { + errors.push(`Invalid: ${err.message}`) + } + // recorded provenance must still correspond to a selector that matches the node + const applied = node.packageExtensionsApplied + if (applied) { + const sel = pe.selectors.find(s => s.key === applied.selector) + if (!sel || !pe.wouldMatch(name, node.version)) { + errors.push( + `Invalid: stale packageExtensions provenance for ${node.name}@${node.version} (selector "${applied.selector}")`) + } + } } return errors } module.exports = validateLockfile +module.exports.validatePackageExtensions = validatePackageExtensions diff --git a/package-lock.json b/package-lock.json index 05d5476423dfb..7f2f76e4da60e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@npmcli/arborist", "@npmcli/config", "@npmcli/fs", + "@npmcli/git", "@npmcli/map-workspaces", "@npmcli/metavuln-calculator", "@npmcli/package-json", @@ -25,6 +26,7 @@ "cacache", "chalk", "ci-info", + "diff", "fastest-levenshtein", "fs-minipass", "glob", @@ -88,6 +90,7 @@ "@npmcli/arborist": "^10.0.0-pre.0", "@npmcli/config": "^11.0.0-pre.0", "@npmcli/fs": "^6.0.0", + "@npmcli/git": "^8.0.0", "@npmcli/map-workspaces": "^6.0.0", "@npmcli/metavuln-calculator": "^10.0.0", "@npmcli/package-json": "^8.0.0", @@ -101,6 +104,7 @@ "cacache": "^21.0.1", "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", @@ -158,7 +162,6 @@ "devDependencies": { "@npmcli/docs": "^1.0.0", "@npmcli/eslint-config": "^5.1.0", - "@npmcli/git": "^8.0.0", "@npmcli/installed-package-contents": "^5.0.0", "@npmcli/mock-globals": "^1.0.0", "@npmcli/mock-registry": "^1.0.0", @@ -6265,6 +6268,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" @@ -16892,6 +16896,7 @@ "bin-links": "^7.0.0", "cacache": "^21.0.1", "common-ancestor-path": "^2.0.0", + "diff": "^8.0.2", "hosted-git-info": "^10.1.1", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", @@ -16910,6 +16915,7 @@ "semver": "^7.3.7", "ssri": "^14.0.0", "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", "walk-up-path": "^4.0.0" }, "bin": { @@ -16930,6 +16936,15 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "workspaces/arborist/node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "workspaces/config": { "name": "@npmcli/config", "version": "11.0.0-pre.0", diff --git a/package.json b/package.json index f1bc45ab9a375..ddc178d28586f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@npmcli/arborist": "^10.0.0-pre.0", "@npmcli/config": "^11.0.0-pre.0", "@npmcli/fs": "^6.0.0", + "@npmcli/git": "^8.0.0", "@npmcli/map-workspaces": "^6.0.0", "@npmcli/metavuln-calculator": "^10.0.0", "@npmcli/package-json": "^8.0.0", @@ -64,6 +65,7 @@ "cacache": "^21.0.1", "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", @@ -119,6 +121,7 @@ "@npmcli/arborist", "@npmcli/config", "@npmcli/fs", + "@npmcli/git", "@npmcli/map-workspaces", "@npmcli/metavuln-calculator", "@npmcli/package-json", @@ -132,6 +135,7 @@ "cacache", "chalk", "ci-info", + "diff", "fastest-levenshtein", "fs-minipass", "glob", @@ -185,7 +189,6 @@ "devDependencies": { "@npmcli/docs": "^1.0.0", "@npmcli/eslint-config": "^5.1.0", - "@npmcli/git": "^8.0.0", "@npmcli/installed-package-contents": "^5.0.0", "@npmcli/mock-globals": "^1.0.0", "@npmcli/mock-registry": "^1.0.0", diff --git a/smoke-tests/tap-snapshots/test/index.js.test.cjs b/smoke-tests/tap-snapshots/test/index.js.test.cjs index 079dd7d8fcd74..fae7823fc47ee 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 @@ -190,7 +190,6 @@ Wrote to {NPM}/{TESTDIR}/project/package.json: "test": "echo /"Error: no test specified/" && exit 1" }, "keywords": [], - "license": "ISC", "type": "commonjs" } ` @@ -207,7 +206,6 @@ Object { "devDependencies": Object { "promise-all-reject-late": "^5.0.0", }, - "license": "ISC", "name": "project", "version": "1.0.0", }, @@ -239,7 +237,6 @@ Object { "promise-all-reject-late": "^5.0.0", }, "keywords": Array [], - "license": "ISC", "main": "index.js", "name": "project", "scripts": Object { @@ -270,7 +267,6 @@ Object { "dependencies": Object { "abbrev": "^1.0.4", }, - "license": "ISC", "name": "project", "version": "1.0.0", }, @@ -291,7 +287,6 @@ Object { }, "description": "", "keywords": Array [], - "license": "ISC", "main": "index.js", "name": "project", "scripts": Object { @@ -320,7 +315,6 @@ Object { }, "description": "", "keywords": Array [], - "license": "ISC", "main": "index.js", "name": "project", "scripts": Object { @@ -339,7 +333,6 @@ Object { }, "description": "", "keywords": Array [], - "license": "ISC", "main": "index.js", "name": "project", "scripts": Object { @@ -361,7 +354,7 @@ exports[`test/index.js TAP basic npm pkg > should have expected pkg delete outpu ` exports[`test/index.js TAP basic npm pkg > should have expected pkg get output 1`] = ` -ISC + ` exports[`test/index.js TAP basic npm pkg > should have expected pkg set output 1`] = ` @@ -378,7 +371,6 @@ scripts = { hello: 'echo Hello' } keywords = [] -license = 'ISC' type = 'commonjs' dependencies = { abbrev: '^1.0.4' } tap = { 'test-env': [ 'LC_ALL=sk' ] } @@ -394,7 +386,6 @@ Object { "promise-all-reject-late": "^5.0.0", }, "keywords": Array [], - "license": "ISC", "main": "index.js", "name": "project", "scripts": Object { @@ -427,7 +418,6 @@ Object { "dependencies": Object { "abbrev": "^1.0.4", }, - "license": "ISC", "name": "project", "version": "1.0.0", }, @@ -448,7 +438,6 @@ Object { }, "description": "", "keywords": Array [], - "license": "ISC", "main": "index.js", "name": "project", "scripts": Object { @@ -476,7 +465,6 @@ Object { "devDependencies": Object { "promise-all-reject-late": "^5.0.0", }, - "license": "ISC", "name": "project", "version": "1.0.0", }, @@ -508,7 +496,6 @@ Object { "promise-all-reject-late": "^5.0.0", }, "keywords": Array [], - "license": "ISC", "main": "index.js", "name": "project", "scripts": Object { diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 914556f8f2761..5efaa3f4136a0 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -90,7 +90,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "init.author.email": "", "init.author.name": "", "init.author.url": "", - "init.license": "ISC", + "init.license": "", "init.module": "{CWD}/home/.npm-init.js", "init.version": "1.0.0", "install-links": false, @@ -130,6 +130,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, @@ -176,6 +182,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "strict-ssl": true, "tag-version-prefix": "v", "timing": false, + "to": null, "umask": 0, "unicode": false, "update-notifier": true, @@ -208,6 +215,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 @@ -240,6 +248,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 @@ -263,6 +272,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 @@ -271,7 +282,7 @@ include-workspace-root = false init-author-email = "" ; init-author-name = "" ; overridden by user init-author-url = "" -; init-license = "ISC" ; overridden by global +; init-license = "" ; overridden by global init-module = "{CWD}/home/.npm-init.js" init-private = false init-type = "commonjs" @@ -279,12 +290,13 @@ init-version = "1.0.0" init.author.email = "" init.author.name = "" init.author.url = "" -init.license = "ISC" +init.license = "" init.module = "{CWD}/home/.npm-init.js" 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 @@ -324,6 +336,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 @@ -364,6 +377,7 @@ strict-ssl = true ; tag = "latest" ; overridden by project tag-version-prefix = "v" timing = false +to = null token-description = null umask = 0 unicode = false diff --git a/tap-snapshots/test/lib/commands/ls.js.test.cjs b/tap-snapshots/test/lib/commands/ls.js.test.cjs index fc7fbdf8a906f..6a4918a32d539 100644 --- a/tap-snapshots/test/lib/commands/ls.js.test.cjs +++ b/tap-snapshots/test/lib/commands/ls.js.test.cjs @@ -556,6 +556,12 @@ exports[`test/lib/commands/ls.js TAP ls overridden dep w/ color > should contain  ` +exports[`test/lib/commands/ls.js TAP ls packageExtensions dep > human output annotates the extended node 1`] = ` +test-package-extensions@1.0.0 {CWD}/prefix +\`-- foo@1.0.0 packageExtensions: dependencies.bar + \`-- bar@1.0.0 +` + exports[`test/lib/commands/ls.js TAP ls print deduped symlinks > should output tree containing linked deps 1`] = ` print-deduped-symlinks@1.0.0 {CWD}/prefix +-- a@1.0.0 diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 19aacebfa44a3..4633ecb73afe7 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", @@ -357,6 +358,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 @@ -682,6 +696,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 @@ -949,6 +973,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 @@ -1044,10 +1091,11 @@ homepage. #### \`init-license\` -* Default: "ISC" +* Default: "" * Type: String -The value \`npm init\` should use by default for the package license. +The value \`npm init\` should use by default for the package license. If not +set, the license field will be omitted from new packages. @@ -1131,6 +1179,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 @@ -1571,6 +1629,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 @@ -2051,6 +2119,17 @@ while still writing the timing file, use \`--silent\`. +#### \`to\` + +* Default: null +* Type: null or String + +Used by \`npm patch update\` to set the version to rebase a patch onto when it +cannot be read from \`package-lock.json\` — for example an exact-version +selector, or a version that has not been installed yet. + + + #### \`token-description\` * Default: null @@ -2356,7 +2435,7 @@ Alias for \`--init-author-url\` #### \`init.license\` -* Default: "ISC" +* Default: "" * Type: String * DEPRECATED: Use \`--init-license\` instead. @@ -2574,6 +2653,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", @@ -2622,6 +2707,7 @@ Array [ "tag", "tag-version-prefix", "timing", + "to", "umask", "unicode", "update-notifier", @@ -2740,6 +2826,7 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2817,8 +2904,14 @@ Array [ "logs-max", "long", "node-options", + "allow-unused-patches", + "ignore-patch-failures", + "edit-dir", + "ignore-existing", + "keep-edit-dir", "prefix", "timing", + "to", "update-notifier", "usage", "userconfig", @@ -2928,6 +3021,7 @@ Object { "packDestination": ".", "parseable": false, "password": null, + "patchesDir": "patches", "preferDedupe": false, "preferOffline": false, "preferOnline": false, @@ -5313,6 +5407,70 @@ 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 update [@] [--to ] [--patches-dir ] +npm patch ls +npm patch rm [@] + +Options: +[--patches-dir ] [--allow-unused-patches] [--ignore-patch-failures] +[--edit-dir ] [--ignore-existing] [--keep-edit-dir] [--to ] +[--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 + + --to + Used by \`npm patch update\` to set the version to rebase a patch onto + + --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 update [@] [--to ] [--patches-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\` +#### \`to\` +#### \`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/explain-dep.js.test.cjs b/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs index 60fca466bb43f..d432d6fc9776e 100644 --- a/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs +++ b/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs @@ -205,6 +205,30 @@ overridden-root@1.0.0 overridden node_modules/overridden-root ` +exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > explain color deep 1`] = ` +bar@1.2.3 +node_modules/bar + bar@"^1.0.0" from foo@1.0.0 + node_modules/foo (added by packageExtensions["foo@1"].dependencies.bar) +` + +exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > explain nocolor shallow 1`] = ` +bar@1.2.3 +node_modules/bar + bar@"^1.0.0" from foo@1.0.0 + node_modules/foo (added by packageExtensions["foo@1"].dependencies.bar) +` + +exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > print color 1`] = ` +bar@1.2.3 +node_modules/bar +` + +exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > print nocolor 1`] = ` +bar@1.2.3 +node_modules/bar +` + exports[`test/lib/utils/explain-dep.js TAP basic peer > explain color deep 1`] = ` peer@1.0.0 peer node_modules/peer 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..5335beb359996 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,20 @@ exports[`test/lib/utils/validate-lockfile.js TAP identical inventory for both id Array [] ` +exports[`test/lib/utils/validate-lockfile.js TAP lock file records a patch package.json no longer declares > should report a stray lock file patch 1`] = ` +Array [ + "Invalid: lock file records a patch for foo@1.0.0 that package.json no longer declares", +] +` + +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: package.json declares a patch for baz@3.0.0 that the lock file does not record (it may have been skipped with --ignore-patch-failures). Fix the patch and reinstall, or remove its patchedDependencies entry", +] +` + 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 15559dc688b09..618560d064a46 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -122,6 +122,23 @@ t.test('reifies, audits, removes node_modules on repeat run', async t => { t.equal(fs.existsSync(nmAbbrev), true, 'installs abbrev') }) +t.test('fails when packageExtensions are out of sync with the lock file', async t => { + const { npm } = await loadMockNpm(t, { + config: { audit: false }, + prefixDir: { + abbrev, + // packageExtensions present in package.json but the lock file records no hash + 'package.json': JSON.stringify({ ...packageJson, packageExtensions: {} }), + 'package-lock.json': JSON.stringify(packageLock), + }, + }) + await t.rejects( + npm.exec('ci', []), + /packageExtensions state from lock file/, + 'ci refuses to install with stale packageExtensions state' + ) +}) + t.test('--no-audit and --ignore-scripts', async t => { const { npm, joinedOutput, registry } = await loadMockNpm(t, { config: { @@ -343,6 +360,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/init.js b/test/lib/commands/init.js index 1b59cc418c678..7e79098a55848 100644 --- a/test/lib/commands/init.js +++ b/test/lib/commands/init.js @@ -43,7 +43,7 @@ t.test('classic npm init -y', async t => { const pkg = require(resolve(prefix, 'package.json')) t.equal(pkg.version, '1.0.0') - t.equal(pkg.license, 'ISC') + t.equal(pkg.license, undefined, 'license is omitted by default') }) t.test('classic interactive npm init', async t => { @@ -273,7 +273,7 @@ t.test('workspaces', async t => { const pkg = require(resolve(prefix, 'a/package.json')) t.equal(pkg.name, 'a') t.equal(pkg.version, '1.0.0') - t.equal(pkg.license, 'ISC') + t.equal(pkg.license, undefined, 'license is omitted by default') t.matchSnapshot(joinedOutput(), 'should print helper info') @@ -306,7 +306,7 @@ t.test('workspaces', async t => { const pkg = require(resolve(prefix, 'packages/a/package.json')) t.equal(pkg.name, 'a') t.equal(pkg.version, '2.0.0') - t.equal(pkg.license, 'ISC') + t.equal(pkg.license, undefined, 'license is omitted by default') }) await t.test('fail parsing top-level package.json to set workspace', async t => { @@ -422,12 +422,12 @@ t.test('workspaces', async t => { const pkg = require(resolve(npm.localPrefix, 'package.json')) t.equal(pkg.version, '1.0.0') - t.equal(pkg.license, 'ISC') + t.equal(pkg.license, undefined, 'license is omitted by default') t.strictSame(pkg.workspaces, ['packages/a']) const ws = require(resolve(npm.localPrefix, 'packages/a/package.json')) t.equal(ws.version, '1.0.0') - t.equal(ws.license, 'ISC') + t.equal(ws.license, undefined, 'license is omitted by default') }) t.test('init pkg - installed workspace package', async t => { const { npm } = await mockNpm(t, { diff --git a/test/lib/commands/ls.js b/test/lib/commands/ls.js index ab98773bc68e5..f63787eec10d2 100644 --- a/test/lib/commands/ls.js +++ b/test/lib/commands/ls.js @@ -308,6 +308,49 @@ t.test('ls', async t => { t.matchSnapshot(cleanCwd(result()), 'should contain overridden output') }) + const packageExtensionsPrefix = { + 'package.json': JSON.stringify({ + name: 'test-package-extensions', + version: '1.0.0', + dependencies: { foo: '^1.0.0' }, + packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } }, + }), + node_modules: { + '.package-lock.json': JSON.stringify({ + packages: { + 'node_modules/foo': { + version: '1.0.0', + dependencies: { bar: '^1.0.0' }, + packageExtensionsApplied: { selector: 'foo@1', dependencies: ['bar'] }, + }, + 'node_modules/bar': { version: '1.0.0' }, + }, + }), + foo: { + 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0', dependencies: { bar: '^1.0.0' } }), + }, + bar: { 'package.json': JSON.stringify({ name: 'bar', version: '1.0.0' }) }, + }, + } + + t.test('packageExtensions dep', async t => { + const { npm, result, ls } = await mockLs(t, { config: {}, prefixDir: packageExtensionsPrefix }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + t.matchSnapshot(cleanCwd(result()), 'human output annotates the extended node') + }) + + t.test('packageExtensions dep --json', async t => { + const { npm, result, ls } = await mockLs(t, { + config: { json: true }, + prefixDir: packageExtensionsPrefix, + }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + const applied = JSON.parse(result()).dependencies.foo.packageExtensionsApplied + t.match(applied, { selector: 'foo@1', dependencies: ['bar'] }, 'json output includes provenance') + }) + t.test('with filter arg', async t => { const config = { color: 'always', @@ -5405,3 +5448,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..ac409bf2d175a --- /dev/null +++ b/test/lib/commands/patch.js @@ -0,0 +1,1328 @@ +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('commit: only package.json changed warns and writes no patch', async t => { + const { npm, logs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + await npm.exec('patch', ['add', DEP_NAME]) + const editDir = path.join(npm.prefix, 'clean-edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + + // edit only package.json, which is excluded from patches + const pkgPath = path.join(editDir, 'package.json') + const edited = readJson(pkgPath) + edited.description = 'edited' + fs.writeFileSync(pkgPath, JSON.stringify(edited)) + + await npm.exec('patch', ['commit', editDir]) + t.notOk( + fs.existsSync(path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`)), + 'no patch file written when only package.json changed' + ) + t.match(logs.warn.join('\n'), /only package.json changed/, 'warns package.json is not patchable') +}) + +t.test('commit: package.json change alongside code is dropped with a warning', async t => { + const { npm, logs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: basePrefix(), + }) + await setupDep(npm, registry) + await npm.exec('install', []) + + await npm.exec('patch', ['add', DEP_NAME]) + const editDir = path.join(npm.prefix, 'clean-edit') + await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions) + + // edit both package.json and a real file + const pkgPath = path.join(editDir, 'package.json') + const edited = readJson(pkgPath) + edited.description = 'edited' + fs.writeFileSync(pkgPath, JSON.stringify(edited)) + fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = () => "patched"\n') + + await npm.exec('patch', ['commit', editDir]) + const patchPath = path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`) + t.ok(fs.existsSync(patchPath), 'patch written for the code change') + t.notMatch(fs.readFileSync(patchPath, 'utf8'), 'package.json', 'patch excludes package.json') + t.match( + logs.warn.join('\n'), + /changes to package.json are not included/, + 'warns the package.json edit was ignored' + ) +}) + +// Serve several versions of a package, each with its own index.js source. +const setupVersions = async (npm, registry, name, sources) => { + const versions = Object.keys(sources) + const manifest = registry.manifest({ name, versions }) + for (const version of versions) { + const dir = path.join(npm.prefix, `pkg-${name}-${version}`) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version })) + fs.writeFileSync(path.join(dir, 'index.js'), sources[version]) + const tar = await pacote.tarball(dir, { Arborist }) + const { pathname } = new URL(manifest.versions[version].dist.tarball) + registry.nock.get(pathname).reply(200, tar).persist() + } + registry.nock.get(`/${name}`).reply(200, manifest).persist() + return manifest +} + +const rootWith = dep => ({ + 'package.json': JSON.stringify({ + name: 'root-project', version: '1.0.0', dependencies: dep, + }), +}) + +const updatePrefix = patchedDependencies => ({ + 'package.json': JSON.stringify({ + name: 'root-project', version: '1.0.0', patchedDependencies, + }), +}) + +t.test('update --to rebases an exact patch onto a new version', async t => { + const name = 'upd-exact' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + // v2 differs from v1 only on the last line; the patch edits the first line -> clean 3-way merge + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nb\nCC\n' }) + await npm.exec('install', []) + + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n') + await npm.exec('patch', ['commit', editDir]) + + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', name]) + + const pkg = readJson(path.join(npm.prefix, 'package.json')) + t.same(pkg.patchedDependencies, { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` }, + 'selector renamed to the new version') + t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed') + t.match(fs.readFileSync(path.join(npm.prefix, 'patches', `${name}@2.0.0.patch`), 'utf8'), /\+AA/, + 'rebased patch keeps the edit') +}) + +t.test('update --to warns when the target version is not installed', async t => { + const name = 'upd-uninstalled' + const { npm, joinedOutput, outputs, registry, logs } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nb\nCC\n' }) + await npm.exec('install', []) + + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n') + await npm.exec('patch', ['commit', editDir]) + + // dependency is pinned to 1.0.0, so rebasing onto 2.0.0 targets an uninstalled version + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', name]) + + t.match(logs.warn.byTitle('patch'), + [new RegExp(`${name}@2\\.0\\.0 is not installed.*EPATCHUNUSED`)], + 'warns that the target version is not installed') +}) + +t.test('update --to is silent when the target version is installed', async t => { + const name = 'upd-installed' + const { npm, registry, logs } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^2.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'x\n', '2.0.0': 'x\n' }) + // 2.0.0 is the installed version; hand-author a patch against 1.0.0 that adds a file (applies to any version) + await npm.exec('install', []) + fs.mkdirSync(path.join(npm.prefix, 'patches'), { recursive: true }) + fs.writeFileSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`), + '--- /dev/null\t\n+++ b/EXTRA.txt\t\n@@ -0,0 +1 @@\n+extra\n') + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.patchedDependencies = { [`${name}@1.0.0`]: `patches/${name}@1.0.0.patch` } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + + // rebasing onto 2.0.0, which is installed, must not warn + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', name]) + t.strictSame(logs.warn.byTitle('patch'), [], 'no warning when --to matches the installed version') +}) + +t.test('update auto-detects the new version and drops a fully-shadowed range', async t => { + const name = 'upd-range' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'x\n', '1.1.0': 'x\n' }) + await npm.exec('install', []) + // a patch that adds a file applies to any version, so the dep can float + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'EXTRA.txt'), 'extra\n') + await npm.exec('patch', ['commit', editDir]) + + // turn the exact selector into a range and float the lockfile to 1.1.0 + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.dependencies[name] = '^1.0.0' + pkg.patchedDependencies = { [`${name}@^1.0.0`]: pkg.patchedDependencies[`${name}@1.0.0`] } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + // clear the resolved tree so a fresh install floats the range up to 1.1.0 + fs.rmSync(path.join(npm.prefix, 'package-lock.json')) + fs.rmSync(path.join(npm.prefix, 'node_modules'), { recursive: true, force: true }) + await npm.exec('install', []) + + await npm.exec('patch', ['update', name]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${name}@1.1.0`]: `patches/${name}@1.1.0.patch` }, 'shadowed range dropped, new exact entry added') +}) + +t.test('update conflict leaves an edit dir; commit finalizes the rename', async t => { + const name = 'upd-conflict' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + // v2 changes the same line the patch edits -> conflict + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', addDir]) + + npm.config.set('to', '2.0.0') + outputs.length = 0 + await npm.exec('patch', ['update', name]) + const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() + t.ok(fs.existsSync(path.join(editDir, '.npm-patch-update.json')), 'cleanup marker written') + t.match(fs.readFileSync(path.join(editDir, 'index.js'), 'utf8'), /<<<<<<>>>>>>[^\n]*\n/, '$1') + fs.writeFileSync(path.join(editDir, 'index.js'), src) + await npm.exec('patch', ['commit', editDir]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` }, 'renamed after the resolving commit') + t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed') +}) + +t.test('a no-op resolving commit keeps the marker so a corrected retry still finalizes', async t => { + const name = 'upd-noop-retry' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', addDir]) + + npm.config.set('to', '2.0.0') + outputs.length = 0 + await npm.exec('patch', ['update', name]) + const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() + const markerPath = path.join(editDir, '.npm-patch-update.json') + t.ok(fs.existsSync(markerPath), 'marker written on conflict') + + // resolve to the new version verbatim (no net change) and commit: a no-op + let src = fs.readFileSync(path.join(editDir, 'index.js'), 'utf8') + src = src.replace(/<<<<<<<[^\n]*\n([\s\S]*?)=======\n[\s\S]*?>>>>>>>[^\n]*\n/, '$1') + fs.writeFileSync(path.join(editDir, 'index.js'), src) + await npm.exec('patch', ['commit', editDir]) + t.ok(fs.existsSync(markerPath), 'marker survives a no-op commit so the update context is not lost') + + // now resolve properly and commit again: must finalize the rename, not throw EPATCHUNUSED on the uninstalled 2.0.0 + fs.writeFileSync(path.join(editDir, 'index.js'), src.replace('BB', 'MINE')) + await npm.exec('patch', ['commit', editDir]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` }, 'finalized: old selector dropped, new one added') +}) + +t.test('update conflict on a name-only selector forks and commits without EPATCHUNUSED', async t => { + const name = 'upd-rconflict' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', addDir]) + // turn it into a name-only selector so the conflict path takes the fork (non-exact) branch + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.patchedDependencies = { [name]: pkg.patchedDependencies[`${name}@1.0.0`] } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + + // --to 2.0.0 is not installed and conflicts; the fork must still leave a marker + npm.config.set('to', '2.0.0') + outputs.length = 0 + await npm.exec('patch', ['update', name]) + const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() + t.same(readJson(path.join(editDir, '.npm-patch-update.json')), { name, removeKey: null }, + 'a fork still writes a marker, with removeKey null') + + // resolve and commit: must finalize metadata-only, not fail with EPATCHUNUSED on the uninstalled 2.0.0 + let src = fs.readFileSync(path.join(editDir, 'index.js'), 'utf8') + src = src.replace(/<<<<<<<[^\n]*\n[\s\S]*?=======\n([\s\S]*?)>>>>>>>[^\n]*\n/, '$1') + fs.writeFileSync(path.join(editDir, 'index.js'), src) + await npm.exec('patch', ['commit', editDir]) + + const after = readJson(path.join(npm.prefix, 'package.json')).patchedDependencies + t.ok(after[name], 'the name-only selector is kept') + t.ok(after[`${name}@2.0.0`], 'the new exact selector is added') +}) + +t.test('update: no registered patch rejects with EPATCHNOTFOUND', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { 'package.json': JSON.stringify({ name: 'r', version: '1.0.0' }) }, + }) + await t.rejects(npm.exec('patch', ['update', 'nope']), { code: 'EPATCHNOTFOUND' }) +}) + +t.test('update: an unknown explicit selector rejects with EPATCHNOTFOUND', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo@9.9.9']), { code: 'EPATCHNOTFOUND' }) +}) + +t.test('update: multiple entries for a bare name reject with EPATCHAMBIGUOUS', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch', 'foo@2.0.0': 'patches/foo@2.0.0.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo']), { code: 'EPATCHAMBIGUOUS' }) +}) + +t.test('update: an unparseable patch filename rejects with EPATCHBASE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/custom.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHBASE' }) +}) + +t.test('update: --to equal to the baseline rejects with EPATCHNOOP', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch' }), + }) + npm.config.set('to', '1.0.0') + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHNOOP' }) +}) + +t.test('update: an invalid --to rejects with EPATCHBADTO', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch' }), + }) + npm.config.set('to', 'not-a-version') + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHBADTO' }) +}) + +t.test('update: an existing target entry rejects with EPATCHEXISTS', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@1.0.0': 'patches/foo@1.0.0.patch', 'foo@2.0.0': 'patches/foo@2.0.0.patch' }), + }) + npm.config.set('to', '2.0.0') + await t.rejects(npm.exec('patch', ['update', 'foo@1.0.0']), { code: 'EPATCHEXISTS' }) +}) + +t.test('update: a missing lockfile with no --to rejects with EPATCHSTALE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: updatePrefix({ 'foo@^1.0.0': 'patches/foo@1.0.0.patch' }), + }) + await t.rejects(npm.exec('patch', ['update', 'foo']), + { code: 'EPATCHSTALE', message: /could not read the lockfile/ }) +}) + +t.test('update: wrong arg count rejects with EUSAGE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { 'package.json': JSON.stringify({ name: 'r', version: '1.0.0' }) }, + }) + await t.rejects(npm.exec('patch', ['update']), { code: 'EUSAGE' }) +}) + +// install a single version of `name` and commit a patch, then hand-edit the selector to `selectorKey`. +const installAndPatch = async (t, name, { src = 'x\n', addFile, selectorKey } = {}) => { + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': src }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + if (addFile) { + fs.writeFileSync(path.join(editDir, addFile), 'extra\n') + } else { + fs.writeFileSync(path.join(editDir, 'index.js'), 'A\n') + } + await npm.exec('patch', ['commit', editDir]) + if (selectorKey) { + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.patchedDependencies = { [selectorKey]: pkg.patchedDependencies[`${name}@1.0.0`] } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + } + return { npm, joinedOutput, outputs } +} + +t.test('update: exact selector with no --to is a no-op', async t => { + const { npm } = await installAndPatch(t, 'upd-noop') + await t.rejects(npm.exec('patch', ['update', 'upd-noop']), { code: 'EPATCHNOOP' }) +}) + +t.test('update: a name-only selector resolves the installed version', async t => { + const { npm } = await installAndPatch(t, 'upd-nameonly', { selectorKey: 'upd-nameonly' }) + // only 1.0.0 installed, so the name-only selector resolves to it -> no-op + await t.rejects(npm.exec('patch', ['update', 'upd-nameonly']), { code: 'EPATCHNOOP' }) +}) + +t.test('update: a range matching no installed version rejects with EPATCHSTALE', async t => { + const { npm } = await installAndPatch(t, 'upd-norange', { selectorKey: 'upd-norange@^5.0.0' }) + await t.rejects(npm.exec('patch', ['update', 'upd-norange']), + { code: 'EPATCHSTALE', message: /no installed version matches the patch selector "upd-norange@\^5.0.0"/ }) +}) + +t.test('update: a patch that no longer applies to its baseline rejects with EPATCHBASE', async t => { + const name = 'upd-drift' + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'real\n', '2.0.0': 'real2\n' }) + await npm.exec('install', []) + // a patch whose context does not exist in the baseline tarball cannot be re-applied during rebase + fs.mkdirSync(path.join(npm.prefix, 'patches'), { recursive: true }) + fs.writeFileSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`), + '--- a/index.js\t\n+++ b/index.js\t\n@@ -1,1 +1,1 @@\n-NOT-THE-REAL-LINE\n+changed\n') + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.patchedDependencies = { [`${name}@1.0.0`]: `patches/${name}@1.0.0.patch` } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + npm.config.set('to', '2.0.0') + await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHBASE' }) +}) + +t.test('update: when the new version already contains the patch, reports EPATCHEMPTY', async t => { + const name = 'upd-empty' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + // v2 already has the value the patch sets, so the rebase yields nothing + await setupVersions(npm, registry, name, { '1.0.0': 'old\n', '2.0.0': 'new\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'new\n') + await npm.exec('patch', ['commit', editDir]) + npm.config.set('to', '2.0.0') + await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHEMPTY' }) +}) + +t.test('update: a patches-dir outside the project is rejected', async t => { + const name = 'upd-unsafe' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nb\nCC\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n') + await npm.exec('patch', ['commit', editDir]) + npm.config.set('to', '2.0.0') + npm.config.set('patches-dir', '../outside') + await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHUNSAFE' }) +}) + +t.test('update --to keeps a range selector when the lockfile is unknown', async t => { + const name = 'upd-keep' + const { npm, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'root-project', + version: '1.0.0', + dependencies: { [name]: '*' }, + patchedDependencies: { [`${name}@^1.0.0`]: `patches/${name}@1.0.0.patch` }, + }), + patches: { [`${name}@1.0.0.patch`]: '--- /dev/null\t\n+++ b/EXTRA.txt\t\n@@ -0,0 +1 @@\n+extra\n' }, + }, + }) + await setupVersions(npm, registry, name, { '1.0.0': 'x\n', '2.0.0': 'x\n' }) + // no install -> no lockfile -> installed versions unknown; --to drives the target + npm.config.set('to', '2.0.0') + await npm.exec('patch', ['update', name]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, { + [`${name}@^1.0.0`]: `patches/${name}@1.0.0.patch`, + [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch`, + }, 'range kept, new exact entry added') +}) + +t.test('commit: a foreign update marker does not hijack a normal commit', async t => { + const name = 'upd-foreign' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'a\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'index.js'), 'patched\n') + // a valid marker naming a different package must be ignored, not acted on + fs.writeFileSync(path.join(editDir, '.npm-patch-update.json'), + JSON.stringify({ name: 'other-pkg', removeKey: 'other-pkg@9.9.9' })) + await npm.exec('patch', ['commit', editDir]) + + const pkg = readJson(path.join(npm.prefix, 'package.json')) + t.ok(pkg.patchedDependencies[`${name}@1.0.0`], 'normal commit recorded its own selector') + // a normal commit does a full reify, so node_modules is patched (not the metadata-only update path) + t.equal(fs.readFileSync(path.join(npm.prefix, 'node_modules', name, 'index.js'), 'utf8'), 'patched\n', + 'node_modules is patched despite the foreign marker') +}) + +t.test('commit: an invalid update marker rejects with EPATCHBADMARKER', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + prefixDir: { 'package.json': JSON.stringify({ name: 'r', version: '1.0.0' }) }, + }) + const editDir = path.join(npm.prefix, 'ed') + fs.mkdirSync(editDir, { recursive: true }) + fs.writeFileSync(path.join(editDir, 'package.json'), JSON.stringify({ name: 'foo', version: '1.0.0' })) + fs.writeFileSync(path.join(editDir, '.npm-patch-update.json'), 'not json') + await t.rejects(npm.exec('patch', ['commit', editDir]), { code: 'EPATCHBADMARKER' }) +}) + +t.test('rm: no pkg arg rejects with EUSAGE', async t => { + const { npm } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + 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', 'update', '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/commands/publish.js b/test/lib/commands/publish.js index 5b2780dbf4c7e..fe286ff46b748 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -214,6 +214,67 @@ t.test('shows usage with wrong set of arguments', async t => { await t.rejects(publish.exec(['a', 'b', 'c']), publish.usage) }) +t.test('fails for a non-private package containing packageExtensions', async t => { + const { npm } = await loadNpmWithRegistry(t, { + config: { ...auth }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } }, + }, null, 2), + }, + authorization: token, + }) + await t.rejects( + npm.exec('publish', []), + { code: 'EPACKAGEEXTENSIONS', message: /must not be published/ }, + 'refuses to publish' + ) +}) + +t.test('fails on --dry-run for a package containing packageExtensions', async t => { + const { npm } = await loadNpmWithRegistry(t, { + config: { 'dry-run': true, ...auth }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } }, + }, null, 2), + }, + authorization: token, + }) + await t.rejects( + npm.exec('publish', []), + { code: 'EPACKAGEEXTENSIONS' }, + 'dry-run also reports the failure' + ) +}) + +t.test('fails when a lifecycle script injects packageExtensions before the re-read', async t => { + const { npm } = await loadNpmWithRegistry(t, { + config: { ...auth }, + prefixDir: { + 'package.json': JSON.stringify({ + ...pkgJson, + scripts: { prepublishOnly: 'node inject.js' }, + }, null, 2), + // the first manifest read is clean; this hook adds packageExtensions before the authoritative re-read + 'inject.js': [ + "const fs = require('fs')", + "const p = JSON.parse(fs.readFileSync('package.json'))", + "p.packageExtensions = { 'foo@1': { dependencies: { bar: '^1.0.0' } } }", + "fs.writeFileSync('package.json', JSON.stringify(p))", + ].join('\n'), + }, + authorization: token, + }) + await t.rejects( + npm.exec('publish', []), + { code: 'EPACKAGEEXTENSIONS' }, + 'the post-script manifest re-read catches the injected field' + ) +}) + t.test('throws when invalid tag is semver', async t => { const { npm } = await loadNpmWithRegistry(t, { config: { 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/explain-dep.js b/test/lib/utils/explain-dep.js index 2a9a93f2b529e..d847c4106e1a7 100644 --- a/test/lib/utils/explain-dep.js +++ b/test/lib/utils/explain-dep.js @@ -140,6 +140,23 @@ const getCases = (testdir) => { }, }], }, + + packageExtensions: { + name: 'bar', + version: '1.2.3', + location: 'node_modules/bar', + dependents: [{ + type: 'prod', + name: 'bar', + spec: '^1.0.0', + packageExtensions: { selector: 'foo@1', field: 'dependencies' }, + from: { + name: 'foo', + version: '1.0.0', + location: 'node_modules/foo', + }, + }], + }, } cases.manyDeps = { diff --git a/test/lib/utils/patch-diff.js b/test/lib/utils/patch-diff.js new file mode 100644 index 0000000000000..dd571651be8ec --- /dev/null +++ b/test/lib/utils/patch-diff.js @@ -0,0 +1,149 @@ +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('root package.json is excluded and flagged, nested is kept', async t => { + const dir = t.testdir({ + orig: { + 'package.json': '{ "version": "1.0.0" }\n', + 'index.js': 'a\n', + sub: { 'package.json': '{ "private": true }\n' }, + }, + edit: { + 'package.json': '{ "version": "2.0.0" }\n', + 'index.js': 'b\n', + sub: { 'package.json': '{ "private": false }\n' }, + }, + }) + const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.equal(packageJsonChanged, true, 'root package.json change is flagged') + t.notMatch(diff, 'a/package.json\t', 'root package.json is not in the diff') + t.match(diff, 'a/sub/package.json', 'nested package.json is still diffed') + t.match(diff, 'a/index.js', 'other files are still diffed') +}) + +t.test('packageJsonChanged is false when only other files change', async t => { + const dir = t.testdir({ + orig: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'a\n' }, + edit: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'b\n' }, + }) + const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit')) + t.equal(packageJsonChanged, false, 'unchanged package.json is not flagged') + t.match(diff, 'a/index.js', 'the real change is captured') +}) + +t.test('non-file entries like symlinks are skipped', async t => { + const dir = t.testdir({ + orig: { 'real.js': 'a\n' }, + 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..8beb5e892fad4 100644 --- a/test/lib/utils/validate-lockfile.js +++ b/test/lib/utils/validate-lockfile.js @@ -1,5 +1,102 @@ const t = require('tap') const validateLockfile = require('../../../lib/utils/validate-lockfile.js') +const { validatePackageExtensions } = require('../../../lib/utils/validate-lockfile.js') + +// build mock virtual/ideal trees for validatePackageExtensions +const tree = ({ hash = null, packageExtensions, nodes = [] }) => ({ + meta: { packageExtensionsHash: hash }, + target: { package: packageExtensions === undefined ? {} : { packageExtensions } }, + inventory: { values: () => nodes }, +}) + +t.test('packageExtensions: matching hashes and clean tree', async t => { + const errors = validatePackageExtensions( + tree({ hash: 'sha512-abc' }), + tree({ hash: 'sha512-abc' }) + ) + t.strictSame(errors, [], 'no errors when hashes match and no provenance') +}) + +t.test('packageExtensions: both absent', async t => { + t.strictSame(validatePackageExtensions(tree({}), tree({})), [], 'no errors when neither has state') +}) + +t.test('packageExtensions: missing from lock file', async t => { + const errors = validatePackageExtensions(tree({ hash: null }), tree({ hash: 'sha512-abc' })) + t.match(errors[0], /Missing: packageExtensions state from lock file/, 'reports missing lock state') +}) + +t.test('packageExtensions: present in lock but not package.json', async t => { + const errors = validatePackageExtensions(tree({ hash: 'sha512-abc' }), tree({ hash: null })) + t.match(errors[0], /lock file records packageExtensions state but package.json has none/, 'reports stray lock state') +}) + +t.test('packageExtensions: hash mismatch', async t => { + const errors = validatePackageExtensions(tree({ hash: 'sha512-aaa' }), tree({ hash: 'sha512-bbb' })) + t.match(errors[0], /do not match the lock file/, 'reports a mismatch') +}) + +t.test('packageExtensions: stale provenance with matching hash', async t => { + // both hashes equal, but a locked node references a selector that no longer exists + const node = { name: 'foo', version: '1.0.0', packageExtensionsApplied: { selector: 'foo@1', dependencies: ['bar'] } } + const errors = validatePackageExtensions( + tree({ hash: 'h', nodes: [node] }), + tree({ hash: 'h', packageExtensions: {} }) + ) + t.match(errors[0], /stale packageExtensions provenance for foo@1.0.0/, 'reports stale provenance') +}) + +t.test('packageExtensions: valid provenance with matching hash', async t => { + const node = { name: 'foo', version: '1.0.0', packageExtensionsApplied: { selector: 'foo@1', dependencies: ['bar'] } } + // root and workspace nodes are skipped by the validation + const root = { name: 'root', version: '1.0.0', isProjectRoot: true } + const errors = validatePackageExtensions( + tree({ hash: 'h', nodes: [root, node] }), + tree({ hash: 'h', packageExtensions: { 'foo@1': { dependencies: { bar: '^1' } } } }) + ) + t.strictSame(errors, [], 'no errors when provenance still matches a selector') +}) + +t.test('packageExtensions: ideal tree without a target uses the tree itself', async t => { + const idealTree = { + meta: { packageExtensionsHash: 'h' }, + package: { packageExtensions: { 'foo@1': { dependencies: { bar: '^1' } } } }, + inventory: { values: () => [] }, + } + t.strictSame(validatePackageExtensions(tree({ hash: 'h' }), idealTree), [], 'reads package off the tree directly') +}) + +t.test('packageExtensions: invalid rule set surfaces the engine error', async t => { + const errors = validatePackageExtensions( + tree({ hash: 'h' }), + tree({ hash: 'h', packageExtensions: { foo: { devDependencies: { a: '1' } } } }) + ) + t.match(errors[0], /Invalid: .*unsupported field/, 'reports the engine validation error') +}) + +t.test('packageExtensions: alias node matches the underlying package name', async t => { + // an aliased install: node.name is the alias, node.packageName is the real package + const node = { + name: 'my-alias', + packageName: 'real-pkg', + version: '1.0.0', + packageExtensionsApplied: { selector: 'real-pkg@1', dependencies: ['bar'] }, + } + const errors = validatePackageExtensions( + tree({ hash: 'h', nodes: [node] }), + tree({ hash: 'h', packageExtensions: { 'real-pkg@1': { dependencies: { bar: '^1' } } } }) + ) + t.strictSame(errors, [], 'provenance validated against the underlying package name, not the alias') +}) + +t.test('packageExtensions: locked identity matching two selectors', async t => { + const node = { name: 'foo', version: '1.0.0' } + const errors = validatePackageExtensions( + tree({ hash: 'h', nodes: [node] }), + tree({ hash: 'h', packageExtensions: { foo: { dependencies: { a: '^1' } }, 'foo@1': { dependencies: { b: '^1' } } } }) + ) + t.match(errors[0], /Multiple packageExtensions selectors match foo@1.0.0/, 'reports a selector conflict') +}) t.test('identical inventory for both idealTree and virtualTree', async t => { t.matchSnapshot( @@ -67,6 +164,38 @@ 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('lock file records a patch package.json no longer declares', async t => { + t.matchSnapshot( + validateLockfile( + new Map([ + ['foo', { name: 'foo', version: '1.0.0', patched: { path: 'patches/foo.patch', integrity: 'sha512-aaa' } }], + ]), + new Map([ + ['foo', { name: 'foo', version: '1.0.0' }], + ]) + ), + 'should report a stray lock file 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 9bb4ad048acf3..f3c6d7ec846d7 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -25,6 +25,8 @@ const debug = require('../debug.js') const fromPath = require('../from-path.js') const calcDepFlags = require('../calc-dep-flags.js') const { isReleaseAgeExcluded, trustedSpecName } = require('../release-age-exclude.js') +const { resolvePatchedDependencies } = require('../patched-dependencies.js') +const PackageExtensions = require('../package-extensions.js') const Shrinkwrap = require('../shrinkwrap.js') const { defaultLockfileVersion } = Shrinkwrap const Node = require('../node.js') @@ -97,6 +99,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { #loadFailures = new Set() #manifests = new Map() #mutateTree = false + #packageExtensions = null // a map of each module in a peer set to the thing that depended on // that set of peers in the first place. Use a WeakMap so that we // don't hold onto references for nodes that are garbage collected. @@ -174,12 +177,19 @@ module.exports = cls => class IdealTreeBuilder extends cls { try { await this.#initTree() + this.#loadPackageExtensions() await this.#inflateAncientLockfile() await this.#applyUserRequests(options) await this.#buildDeps() await this.#fixDepFlags() await this.#pruneFailedOptional() await this.#checkEngineAndPlatform() + await resolvePatchedDependencies(this.idealTree, { + path: this.path, + allowUnusedPatches: this.options.allowUnusedPatches, + rm: options.rm || [], + }) + this.#warnWorkspacePackageExtensions() } finally { timeEnd() this.finishTracker('idealTree') @@ -227,6 +237,68 @@ module.exports = cls => class IdealTreeBuilder extends cls { } } + // Load the root project's packageExtensions rule set. + // Only the workspace root is authoritative, matching the root-only model of overrides. + // The canonical hash is stashed on the lockfile meta so commit() can persist it. + #loadPackageExtensions () { + const rootPkg = this.idealTree.target.package + const lockedHash = this.idealTree.meta.packageExtensionsHash + this.#packageExtensions = new PackageExtensions(rootPkg.packageExtensions) + this.idealTree.meta.packageExtensionsHash = this.#packageExtensions.hash + + // When the rule set has changed since the lockfile was written, the locked manifests for affected packages are stale. + // The locked manifest is the effective, already-extended manifest, so detach those nodes and rebuild them from fresh manifests under the current rules. + if (this.idealTree.meta.loadedFromDisk && lockedHash !== this.#packageExtensions.hash) { + for (const node of [...this.idealTree.inventory.values()]) { + if (node.isProjectRoot || node.isWorkspace || node.isTop) { + continue + } + // a node is affected if it carries provenance from the old rules or matches a current selector + const affected = node.packageExtensionsApplied || + this.#packageExtensions.wouldMatch(node.packageName, node.version) + if (affected) { + for (const edge of node.edgesIn) { + this.#depsQueue.push(edge.from) + } + node.parent = null + } + } + } + } + + // Apply a matching root packageExtension to a copy of a candidate manifest. + // Returns the possibly-extended manifest and the provenance to attach to the node. + // Workspace candidates are never extended; that warning is emitted separately. + #applyPackageExtension (pkg) { + if (!this.#packageExtensions?.present) { + return { pkg, applied: null } + } + const res = this.#packageExtensions.apply(pkg) + return res ? { pkg: res.pkg, applied: res.applied } : { pkg, applied: null } + } + + // Warn when packageExtensions appears in a non-root workspace, or when a root selector matches a workspace member. + // Workspace package manifests are edited directly and are never extension targets. + #warnWorkspacePackageExtensions () { + if (!this.#packageExtensions?.present) { + return + } + for (const node of this.idealTree.inventory.values()) { + // a workspace is in the inventory as both a Link and its target node; warn once by skipping the link + if (!node.isWorkspace || node.isLink) { + continue + } + if (node.package.packageExtensions !== undefined) { + log.warn('packageExtensions', + `"packageExtensions" in workspace ${node.name} is ignored; it is only honored at the workspace root`) + } + if (this.#packageExtensions.wouldMatch(node.name, node.version)) { + log.warn('packageExtensions', + `selector matches workspace package ${node.name}@${node.version}; edit its package.json directly instead of using packageExtensions`) + } + } + } + #parseSettings (options) { const update = options.update === true ? { all: true } : Array.isArray(options.update) ? { names: options.update } @@ -1396,7 +1468,13 @@ This is a one-time fix-up, please be patient... ) return this.#failureNode(name, parent, error, edge) } - return new Node({ name, pkg, parent, installLinks, legacyPeerDeps }) + // Apply a matching root packageExtension to a manifest copy before the Node reads its dependency and peer edges. + const { pkg: extended, applied } = this.#applyPackageExtension(pkg) + const node = new Node({ name, pkg: extended, parent, installLinks, legacyPeerDeps }) + if (applied) { + node.packageExtensionsApplied = applied + } + return node }, error => this.#failureNode(name, parent, error, edge) ) diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 80c907aa49b6d..8d5911c8b6efe 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 { @@ -49,6 +54,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, @@ -173,6 +179,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-actual.js b/workspaces/arborist/lib/arborist/load-actual.js index 2356227a5baa3..2a8538ea9f6e7 100644 --- a/workspaces/arborist/lib/arborist/load-actual.js +++ b/workspaces/arborist/lib/arborist/load-actual.js @@ -13,6 +13,7 @@ const calcDepFlags = require('../calc-dep-flags.js') const Node = require('../node.js') const Link = require('../link.js') const realpath = require('../realpath.js') +const PackageExtensions = require('../package-extensions.js') // public symbols const _changePath = Symbol.for('_changePath') @@ -173,6 +174,8 @@ module.exports = cls => class ActualLoader extends cls { await Promise.all(promises) } + this.#applyPackageExtensions() + if (!ignoreMissing) { await this.#findMissingEdges() } @@ -352,6 +355,34 @@ module.exports = cls => class ActualLoader extends cls { } } + // packageExtensions never rewrite a package's package.json, so a filesystem-scanned actual tree lacks the extension-created edges and provenance. + // Re-derive them from the root rule set, as buildIdealTree does. + // This is always required under the linked strategy, whose store layout forces the filesystem-scan path. + #applyPackageExtensions () { + const rootPkg = this.#actualTree.target?.package + const pe = new PackageExtensions(rootPkg?.packageExtensions) + if (!pe.present || !pe.selectors.length) { + return + } + for (const node of this.#actualTree.inventory.values()) { + // only installed dependencies are extended, never the root or a workspace + if (node.isLink || node.isProjectRoot || !node.name || !node.inNodeModules()) { + continue + } + const res = pe.apply(node.package) + if (res) { + node.package = res.pkg + node.packageExtensionsApplied = res.applied + } + } + // mirror the provenance onto links so the logical tree location reports it too + for (const node of this.#actualTree.inventory.values()) { + if (node.isLink && node.target?.packageExtensionsApplied) { + node.packageExtensionsApplied = node.target.packageExtensionsApplied + } + } + } + async #findMissingEdges () { // try to resolve any missing edges by walking up the directory tree, // checking for the package in each node_modules folder. stop at the diff --git a/workspaces/arborist/lib/arborist/load-virtual.js b/workspaces/arborist/lib/arborist/load-virtual.js index 36e57a011da5f..acf8b6c4220ac 100644 --- a/workspaces/arborist/lib/arborist/load-virtual.js +++ b/workspaces/arborist/lib/arborist/load-virtual.js @@ -242,6 +242,8 @@ To fix: path, realpath: path, integrity: sw.integrity, + patched: sw.patched, + packageExtensionsApplied: sw.packageExtensionsApplied, resolved: consistentResolve(sw.resolved, this.path, path), pkg: sw, loadOverrides, diff --git a/workspaces/arborist/lib/arborist/rebuild.js b/workspaces/arborist/lib/arborist/rebuild.js index 994cac843acac..53c6f63e77d94 100644 --- a/workspaces/arborist/lib/arborist/rebuild.js +++ b/workspaces/arborist/lib/arborist/rebuild.js @@ -11,7 +11,7 @@ const { depth: dfwalk } = require('treeverse') const { isNodeGypPackage, defaultGypInstallScript } = require('@npmcli/node-gyp') const { promiseRetry } = require('@gar/promise-retry') const { log, time } = require('proc-log') -const { resolve } = require('node:path') +const { resolve, delimiter } = require('node:path') const { isScriptAllowed } = require('../script-allowed.js') const boolEnv = b => b ? '1' : '' @@ -307,6 +307,7 @@ module.exports = cls => class Builder extends cls { await promiseCallLimit(queue.map(node => async () => { const { path, + name, integrity, resolved, optional, @@ -315,6 +316,7 @@ module.exports = cls => class Builder extends cls { devOptional, package: pkg, location, + isInStore, } = node.target // skip any that we know we'll be deleting @@ -336,6 +338,12 @@ module.exports = cls => class Builder extends cls { npm_package_dev_optional: boolEnv(devOptional && !dev && !optional), } + // In the linked strategy a store package's dependencies are symlinked siblings in its store node_modules. + // A separate bin invoked by the script (e.g. napi-postinstall) resolves modules from its own realpath in the store and cannot see those deps, so expose them via NODE_PATH. + if (isInStore) { + const storeNodeModules = resolve(path, ...name.split('/').map(() => '..')) + env.NODE_PATH = [storeNodeModules, process.env.NODE_PATH].filter(Boolean).join(delimiter) + } const runOpts = { event, path, diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index e76a277ebbf92..ade8842ae6eb4 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') @@ -720,6 +722,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 } @@ -747,10 +750,61 @@ 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 + // the lockfile and package.json now disagree, so warn that npm ci will reject the tree + log.warn('patch', `${node.name} was installed unpatched; package.json still declares this patch, so the lockfile is out of sync and \`npm ci\` will fail until the patch is fixed or its patchedDependencies entry is removed`) + 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) @@ -851,12 +905,14 @@ module.exports = cls => class Reifier extends cls { return false } try { - const resolved = new URL(node.resolved) + // Match the effective fetch URL, not the raw lockfile value. + // #registryResolved applies replace-registry-host, rewriting a public-registry pin to the configured proxy/mirror so it matches. + const resolvedURL = new URL(this.#registryResolved(node.resolved)) // pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency. const registry = new URL(pickRegistry(npa(node.name), this.options)) const registryPath = registry.pathname.replace(/\/?$/, '/') - return resolved.origin === registry.origin && - (registryPath === '/' || resolved.pathname.startsWith(registryPath)) + return resolvedURL.origin === registry.origin && + (registryPath === '/' || resolvedURL.pathname.startsWith(registryPath)) } catch { return false } @@ -1772,6 +1828,8 @@ module.exports = cls => class Reifier extends cls { // field so defaulting this to an empty array would add that field to // every package.json file. bundleDependencies, + // resolvePatchedDependencies drops entries orphaned by uninstall; persist that removal + patchedDependencies, } = tree.package pkgJson.update({ @@ -1780,6 +1838,7 @@ module.exports = cls => class Reifier extends cls { optionalDependencies, peerDependencies, bundleDependencies, + patchedDependencies, }) await pkgJson.save() } 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/edge.js b/workspaces/arborist/lib/edge.js index c56799ea01133..04b7f14953ef3 100644 --- a/workspaces/arborist/lib/edge.js +++ b/workspaces/arborist/lib/edge.js @@ -160,6 +160,16 @@ class Edge { } if (this.#from) { explanation.from = this.#from.explain(null, seen) + // note when this edge was created by a root packageExtensions repair on the from node + const applied = this.#from.packageExtensionsApplied + if (applied) { + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']) { + if (applied[field]?.includes(this.#name)) { + explanation.packageExtensions = { selector: applied.selector, field } + break + } + } + } } this.#explanation = explanation } diff --git a/workspaces/arborist/lib/index.js b/workspaces/arborist/lib/index.js index 5baaee6ee7c93..2f0c4aec7938b 100644 --- a/workspaces/arborist/lib/index.js +++ b/workspaces/arborist/lib/index.js @@ -4,3 +4,4 @@ module.exports.Node = require('./node.js') module.exports.Link = require('./link.js') module.exports.Edge = require('./edge.js') module.exports.Shrinkwrap = require('./shrinkwrap.js') +module.exports.PackageExtensions = require('./package-extensions.js') diff --git a/workspaces/arborist/lib/isolated-classes.js b/workspaces/arborist/lib/isolated-classes.js index c4894e3da4437..d451b605d8ca2 100644 --- a/workspaces/arborist/lib/isolated-classes.js +++ b/workspaces/arborist/lib/isolated-classes.js @@ -25,6 +25,7 @@ class IsolatedNode { linksIn = new Set() meta = { loadedFromDisk: false } optional = false + patched = null parent = null root = null tops = new Set() @@ -61,6 +62,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..94da3c48c6e98 100644 --- a/workspaces/arborist/lib/node.js +++ b/workspaces/arborist/lib/node.js @@ -93,7 +93,9 @@ class Node { name, // allow setting name explicitly when we haven't set a path yet optional = true, overrides, + packageExtensionsApplied = null, parent, + patched = null, path, peer = true, realpath, @@ -169,6 +171,11 @@ 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 + // Provenance for a root packageExtensions repair applied to this node's manifest, or null. + // Shape: { selector, dependencies?, optionalDependencies?, peerDependencies?, peerDependenciesMeta? }. + this.packageExtensionsApplied = packageExtensionsApplied this.installLinks = installLinks this.legacyPeerDeps = legacyPeerDeps diff --git a/workspaces/arborist/lib/package-extensions.js b/workspaces/arborist/lib/package-extensions.js new file mode 100644 index 0000000000000..870ac4cbeaf2d --- /dev/null +++ b/workspaces/arborist/lib/package-extensions.js @@ -0,0 +1,236 @@ +// Root-owned `packageExtensions`: declarative repairs to third-party manifests applied before Arborist reads a candidate's dependency edges. +// See RFC: https://github.com/npm/rfcs/pull/889 +// This module is pure: it parses and validates the root rule set, matches a candidate manifest by name and version, and returns an extended manifest copy plus minimal provenance. +// It never mutates the input manifest or any shared cache object. +const semver = require('semver') +const ssri = require('ssri') +const validateName = require('validate-npm-package-name') + +// The only manifest fields a package extension may add or correct, because they are the fields that affect dependency and peer resolution. +const EXTENSION_FIELDS = [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', +] + +// The two normal dependency fields; a name may exist in only one of them. +const NORMAL_DEP_FIELDS = ['dependencies', 'optionalDependencies'] + +const err = (message, code, extra = {}) => + Object.assign(new Error(message), { code, ...extra }) + +// Parse a selector key into { name, range }, where range is null for a name-only key. +// Selectors are a package name with an optional semver range; dist-tags, git, file, directory, url, and alias specs are rejected. +const parseSelector = key => { + if (typeof key !== 'string' || !key) { + throw err(`Invalid packageExtensions selector: ${JSON.stringify(key)}`, 'EEXTENSIONSELECTOR') + } + // The separator @ is the first @ after a leading scope @. + const at = key.indexOf('@', key.startsWith('@') ? 1 : 0) + const name = at === -1 ? key : key.slice(0, at) + const range = at === -1 ? null : key.slice(at + 1) + + const { validForOldPackages, validForNewPackages } = validateName(name) + if (!validForOldPackages && !validForNewPackages) { + throw err(`Invalid package name in packageExtensions selector: "${key}"`, 'EEXTENSIONSELECTOR', { selector: key }) + } + // A blank range such as "foo@" is malformed; the name-only form "foo" is how you match every version. + if (range !== null && range.trim() === '') { + throw err( + `Invalid packageExtensions selector: "${key}". Use the name only to match every version.`, + 'EEXTENSIONSELECTOR', { selector: key }) + } + // A versioned selector must be a valid semver range, which rejects dist-tags, git, file, url, and alias specs. + if (range !== null && semver.validRange(range, { loose: true }) === null) { + throw err( + `Invalid version range in packageExtensions selector: "${key}". Selectors accept a package name with an optional semver range only.`, + 'EEXTENSIONSELECTOR', { selector: key }) + } + return { name, range } +} + +// A selector matches a candidate manifest by its own name and version. +// Name-only selectors match every version, including non-semver versions. +// Versioned selectors only match versions that parse as semver and satisfy the range. +const rangeMatches = (range, version) => { + if (range === null) { + return true + } + return semver.valid(version, { loose: true }) !== null && + semver.satisfies(version, range, { loose: true }) +} + +// Validate a single selector's extension object before it is ever applied. +const validateExtensionObject = (key, ext) => { + if (ext === null || typeof ext !== 'object' || Array.isArray(ext)) { + throw err(`packageExtensions["${key}"] must be an object`, 'EEXTENSIONVALUE', { selector: key }) + } + for (const field of Object.keys(ext)) { + if (!EXTENSION_FIELDS.includes(field)) { + throw err( + `packageExtensions["${key}"] has unsupported field "${field}". Supported fields: ${EXTENSION_FIELDS.join(', ')}.`, + 'EEXTENSIONFIELD', { selector: key, field }) + } + const val = ext[field] + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + throw err(`packageExtensions["${key}"].${field} must be an object`, 'EEXTENSIONVALUE', { selector: key, field }) + } + } + // Deletion is not supported in v1, so a null, false, or "-" value is an error. + for (const field of [...NORMAL_DEP_FIELDS, 'peerDependencies']) { + for (const [name, spec] of Object.entries(ext[field] || {})) { + if (spec === null || spec === false || spec === '-') { + throw err( + `packageExtensions["${key}"].${field}.${name} attempts deletion, which is not supported.`, + 'EEXTENSIONDELETE', { selector: key, field, name }) + } + } + } + // Each peerDependenciesMeta entry must be a non-null metadata object, never a deletion sentinel or primitive. + for (const [name, meta] of Object.entries(ext.peerDependenciesMeta || {})) { + if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { + throw err( + `packageExtensions["${key}"].peerDependenciesMeta.${name} must be an object`, + 'EEXTENSIONVALUE', { selector: key, field: 'peerDependenciesMeta', name }) + } + } +} + +// Apply a matched extension to a manifest, returning { pkg, applied } where pkg is a copy with extended fields and applied is minimal provenance. +// The input manifest is never mutated. +const applyExtension = (pkg, { key, ext }) => { + const applied = { selector: key } + + // Clone only the fields we may touch; the rest of the manifest is shared by reference since it is never mutated. + const next = { ...pkg } + for (const field of EXTENSION_FIELDS) { + if (pkg[field] && typeof pkg[field] === 'object') { + next[field] = field === 'peerDependenciesMeta' + ? Object.fromEntries(Object.entries(pkg[field]).map(([n, m]) => [n, { ...m }])) + : { ...pkg[field] } + } + } + + // dependencies and optionalDependencies add missing names only. + // A name already declared in either normal dependency field is an error, which also prevents moving a name between the fields. + for (const field of NORMAL_DEP_FIELDS) { + const adds = ext[field] + if (!adds) { + continue + } + for (const [name, spec] of Object.entries(adds)) { + for (const existingField of NORMAL_DEP_FIELDS) { + if (next[existingField] && name in next[existingField]) { + throw err( + `packageExtensions["${key}"].${field}.${name} conflicts with the package's existing ${existingField}.${name}. Use overrides to change a dependency version; packageExtensions only adds missing dependencies.`, + 'EEXTENSIONDUPDEP', { selector: key, field, name, existingField }) + } + } + next[field] = next[field] || {} + next[field][name] = spec + ;(applied[field] = applied[field] || []).push(name) + } + } + + // peerDependencies shallow-merges by peer name, and the extension value replaces an existing range. + if (ext.peerDependencies) { + next.peerDependencies = next.peerDependencies || {} + for (const [name, spec] of Object.entries(ext.peerDependencies)) { + next.peerDependencies[name] = spec + ;(applied.peerDependencies = applied.peerDependencies || []).push(name) + } + } + + // peerDependenciesMeta merges by peer name, then shallow-merges each meta object so an extension can add optional without dropping other meta keys. + if (ext.peerDependenciesMeta) { + next.peerDependenciesMeta = next.peerDependenciesMeta || {} + for (const [name, meta] of Object.entries(ext.peerDependenciesMeta)) { + next.peerDependenciesMeta[name] = { ...next.peerDependenciesMeta[name], ...meta } + ;(applied.peerDependenciesMeta = applied.peerDependenciesMeta || []).push(name) + // Every peerDependenciesMeta entry an extension adds must correspond to a peerDependencies entry present after extension application. + if (!next.peerDependencies || !(name in next.peerDependencies)) { + throw err( + `packageExtensions["${key}"].peerDependenciesMeta.${name} has no corresponding peerDependencies.${name} after extension application.`, + 'EEXTENSIONORPHANMETA', { selector: key, name }) + } + } + } + + return { pkg: next, applied } +} + +// Deterministic JSON for hashing: keys sorted lexicographically at every level, string and number values preserved exactly, no insignificant whitespace. +const canonicalStringify = val => { + if (Array.isArray(val)) { + return `[${val.map(canonicalStringify).join(',')}]` + } + if (val && typeof val === 'object') { + return `{${Object.keys(val).sort() + .map(k => `${JSON.stringify(k)}:${canonicalStringify(val[k])}`) + .join(',')}}` + } + return JSON.stringify(val) +} + +// Hash the canonical form of the root packageExtensions object using npm's existing lockfile digest encoding. +const canonicalHash = packageExtensions => + ssri.fromData(canonicalStringify(packageExtensions), { algorithms: ['sha512'] }).toString() + +class PackageExtensions { + constructor (raw) { + this.raw = raw + this.present = raw !== undefined + this.selectors = [] + this.hash = null + + if (!this.present) { + return + } + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw err('packageExtensions must be an object', 'EEXTENSIONROOT') + } + for (const [key, ext] of Object.entries(raw)) { + const { name, range } = parseSelector(key) + validateExtensionObject(key, ext) + this.selectors.push({ key, name, range, ext }) + } + this.hash = canonicalHash(raw) + } + + // Non-throwing check used for warnings: whether any selector matches the candidate. + wouldMatch (name, version) { + return this.selectors.some(s => s.name === name && rangeMatches(s.range, version)) + } + + // Return the single selector matching a candidate manifest, or null. + // Throws EEXTENSIONCONFLICT when more than one selector matches the same candidate. + match (name, version) { + const matches = this.selectors.filter(s => s.name === name && rangeMatches(s.range, version)) + if (matches.length > 1) { + const keys = matches.map(s => `"${s.key}"`).join(', ') + throw err( + `Multiple packageExtensions selectors match ${name}@${version}: ${keys}. Narrow or remove one of the overlapping rules.`, + 'EEXTENSIONCONFLICT', { name, version, selectors: matches.map(s => s.key) }) + } + return matches[0] || null + } + + // Apply the matching extension to a manifest copy, returning { pkg, applied } or null when no selector matches. + // Throws on selector conflict or invalid merge. + apply (pkg) { + if (!this.present || !this.selectors.length || !pkg || !pkg.name) { + return null + } + const sel = this.match(pkg.name, pkg.version) + return sel ? applyExtension(pkg, sel) : null + } +} + +module.exports = PackageExtensions +module.exports.PackageExtensions = PackageExtensions +module.exports.parseSelector = parseSelector +module.exports.rangeMatches = rangeMatches +module.exports.canonicalHash = canonicalHash +module.exports.canonicalStringify = canonicalStringify +module.exports.EXTENSION_FIELDS = EXTENSION_FIELDS 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..411406f0df71a --- /dev/null +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -0,0 +1,168 @@ +// 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 { log } = require('proc-log') +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, rm }) => { + 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) { + const unused = selectors.filter(s => !usedKeys.has(s.key)) + + // an explicit `npm uninstall ` orphans that package's patch entry, so drop it instead of failing + const removed = new Set(rm) + const dropped = unused.filter(s => removed.has(s.name)) + if (dropped.length) { + const patched = { ...tree.package.patchedDependencies } + for (const s of dropped) { + delete patched[s.key] + log.notice('patch', `Removing patch entry "${s.key}" for uninstalled ${s.name}; left ${s.patchPath} in place.`) + } + // undefined drops the key entirely when reify writes package.json + tree.package.patchedDependencies = Object.keys(patched).length ? patched : undefined + } + + const stillUnused = unused.filter(s => !removed.has(s.name)) + if (stillUnused.length && !allowUnusedPatches) { + throw err( + `The following patches were registered but matched no installed ` + + `package:\n${stillUnused.map(s => ` ${s.key} -> ${s.patchPath}`).join('\n')}\n` + + `Use --allow-unused-patches to install anyway.`, + 'EPATCHUNUSED', + { unused: stillUnused.map(s => s.key) } + ) + } + } +} + +module.exports = { resolvePatchedDependencies, matchSelector, parseSelector } diff --git a/workspaces/arborist/lib/place-dep.js b/workspaces/arborist/lib/place-dep.js index c7b3e10d408d0..6fd272e50600d 100644 --- a/workspaces/arborist/lib/place-dep.js +++ b/workspaces/arborist/lib/place-dep.js @@ -247,6 +247,9 @@ class PlaceDep { installLinks: this.installLinks, legacyPeerDeps: this.legacyPeerDeps, error: this.dep.errors[0], + ...(this.dep.packageExtensionsApplied + ? { packageExtensionsApplied: this.dep.packageExtensionsApplied } + : {}), ...(this.dep.overrides ? { overrides: this.dep.overrides } : {}), ...(this.dep.isLink ? { target: this.dep.target, realpath: this.dep.realpath } : {}), }) diff --git a/workspaces/arborist/lib/shrinkwrap.js b/workspaces/arborist/lib/shrinkwrap.js index e9e31d8090d0e..132afd59676c2 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -10,6 +10,12 @@ 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 +// packageExtensions provenance also forces lockfileVersion 4 so older clients abort rather than silently dropping the repaired graph. +// Both features share version 4: they are root-owned graph repairs an old npm must not drop. +const packageExtensionsLockfileVersion = 4 +const maxLockfileVersion = 4 // for comparing nodes to yarn.lock entries const mismatch = (a, b) => a && b && a !== b @@ -107,6 +113,8 @@ const nodeMetaKeys = [ 'integrity', 'inBundle', 'hasInstallScript', + 'patched', + 'packageExtensionsApplied', ] const metaFieldFromPkg = (pkg, key) => { @@ -347,6 +355,7 @@ class Shrinkwrap { reset () { this.tree = null this.#awaitingUpdate = new Map() + this.packageExtensionsHash = null const lockfileVersion = this.lockfileVersion || defaultLockfileVersion this.originalLockfileVersion = lockfileVersion @@ -458,6 +467,13 @@ class Shrinkwrap { this.ancientLockfile = false data = {} } + // refuse lockfiles newer than we understand so we never drop a patched or repaired graph we cannot read + 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 @@ -478,6 +494,9 @@ class Shrinkwrap { this.originalLockfileVersion = data.lockfileVersion + // the canonical packageExtensions hash, if the lockfile recorded one on its root entry + this.packageExtensionsHash = data.packages?.['']?.packageExtensionsHash || null + // use default if it wasn't explicitly set, and the current file is // less than our default. otherwise, keep whatever is in the file, // unless we had an explicit setting already. @@ -895,6 +914,10 @@ class Shrinkwrap { this.tree.target, this.path, this.resolveOptions) + // record the canonical packageExtensions hash on the root entry so npm ci can detect stale extension state + if (this.packageExtensionsHash) { + root.packageExtensionsHash = this.packageExtensionsHash + } this.data.packages = {} if (Object.keys(root).length) { this.data.packages[''] = root @@ -940,6 +963,22 @@ class Shrinkwrap { if (!this.lockfileVersion) { this.lockfileVersion = defaultLockfileVersion } + // patched nodes force lockfileVersion 4 so older clients abort the install + // the hidden lockfile is an internal cache pinned to version 3, so it never drives this upgrade + const hasPatched = !this.hiddenLockfile && + 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 + } + // packageExtensions state likewise forces lockfileVersion 4 so older clients abort instead of dropping the repaired graph + const hasExtensionState = !this.hiddenLockfile && + (this.packageExtensionsHash || + Object.values(this.data.packages).some(p => p.packageExtensionsApplied)) + if (hasExtensionState && this.lockfileVersion < packageExtensionsLockfileVersion) { + log.warn('shrinkwrap', `packageExtensions requires lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`) + this.lockfileVersion = packageExtensionsLockfileVersion + } 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 50e4d00828a96..2e5f9a66827b5 100644 --- a/workspaces/arborist/package.json +++ b/workspaces/arborist/package.json @@ -18,6 +18,7 @@ "bin-links": "^7.0.0", "cacache": "^21.0.1", "common-ancestor-path": "^2.0.0", + "diff": "^8.0.2", "hosted-git-info": "^10.1.1", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", @@ -36,6 +37,7 @@ "semver": "^7.3.7", "ssri": "^14.0.0", "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", "walk-up-path": "^4.0.0" }, "devDependencies": { diff --git a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs index 4147de62640d7..af8593a9fce22 100644 --- a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs @@ -26,6 +26,8 @@ Link { "location": "../../../../../some/other/path", "name": "path", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/some/other/path", "peer": true, "queryContext": Object {}, @@ -72,6 +74,8 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "", "name": "path", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/some/kind/of/path", "peer": true, "queryContext": Object {}, @@ -88,6 +92,8 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "../../../../../some/other/path", "name": "path", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/some/other/path", "peer": true, "queryContext": Object {}, @@ -116,6 +122,8 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "", "name": "path", "optional": true, + "packageExtensionsApplied": null, + "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..5664976285c5e 100644 --- a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs @@ -41,6 +41,8 @@ exports[`test/node.js TAP basic instantiation > just a lone root node 1`] = ` }, "parent": undefined, }, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -217,6 +219,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -244,6 +248,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -308,6 +314,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -319,6 +327,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -364,6 +374,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -375,6 +387,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -413,6 +427,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -461,6 +477,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -472,6 +490,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -500,6 +520,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -544,6 +566,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -555,6 +579,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -570,6 +596,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "", "name": "workspaces_root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root", "peer": true, "queryContext": Object {}, @@ -618,6 +646,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -629,6 +659,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -674,6 +706,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -685,6 +719,8 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -724,6 +760,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -761,6 +799,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -813,6 +853,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -832,6 +874,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -871,6 +915,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -903,6 +949,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -935,6 +983,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -967,6 +1017,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -995,6 +1047,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1048,6 +1102,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1059,6 +1115,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1127,6 +1185,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1164,6 +1224,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1216,6 +1278,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1235,6 +1299,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -1267,6 +1333,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1306,6 +1374,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -1338,6 +1408,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -1370,6 +1442,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -1402,6 +1476,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -1430,6 +1506,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1483,6 +1561,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1494,6 +1574,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1524,6 +1606,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1561,6 +1645,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1589,6 +1675,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1603,6 +1691,8 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -1634,6 +1724,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1682,6 +1774,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1734,6 +1828,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1753,6 +1849,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -1792,6 +1890,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -1824,6 +1924,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -1856,6 +1958,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -1888,6 +1992,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -1916,6 +2022,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1993,6 +2101,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2045,6 +2155,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2064,6 +2176,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2096,6 +2210,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2135,6 +2251,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2174,6 +2292,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2206,6 +2326,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2238,6 +2360,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2270,6 +2394,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2298,6 +2424,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2313,6 +2441,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -2344,6 +2474,8 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2404,6 +2536,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2423,6 +2557,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2462,6 +2598,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2494,6 +2632,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2526,6 +2666,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2558,6 +2700,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2586,6 +2730,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2628,6 +2774,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2717,6 +2865,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2736,6 +2886,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2768,6 +2920,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2807,6 +2961,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2839,6 +2995,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2871,6 +3029,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2903,6 +3063,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2931,6 +3093,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2973,6 +3137,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2988,6 +3154,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -3019,6 +3187,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3079,6 +3249,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3098,6 +3270,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3134,6 +3308,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3166,6 +3342,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -3198,6 +3376,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -3230,6 +3410,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -3258,6 +3440,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -3288,6 +3472,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3345,6 +3531,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3356,6 +3544,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -3445,6 +3635,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3464,6 +3656,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3496,6 +3690,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3532,6 +3728,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3564,6 +3762,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -3596,6 +3796,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -3628,6 +3830,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -3656,6 +3860,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -3684,6 +3890,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3713,6 +3921,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3770,6 +3980,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3781,6 +3993,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -3796,6 +4010,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -3827,6 +4043,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3887,6 +4105,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3906,6 +4126,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3942,6 +4164,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3974,6 +4198,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4006,6 +4232,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4038,6 +4266,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4066,6 +4296,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4096,6 +4328,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4153,6 +4387,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4164,6 +4400,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4253,6 +4491,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4272,6 +4512,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -4304,6 +4546,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4340,6 +4584,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -4372,6 +4618,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4404,6 +4652,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4436,6 +4686,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4464,6 +4716,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4492,6 +4746,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4521,6 +4777,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4578,6 +4836,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4589,6 +4849,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4604,6 +4866,8 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -4635,6 +4899,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4674,6 +4940,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4711,6 +4979,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4763,6 +5033,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4782,6 +5054,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -4821,6 +5095,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -4853,6 +5129,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4885,6 +5163,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4917,6 +5197,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4945,6 +5227,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4998,6 +5282,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5009,6 +5295,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5077,6 +5365,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5114,6 +5404,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5166,6 +5458,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5185,6 +5479,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -5217,6 +5513,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5256,6 +5554,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -5288,6 +5588,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -5320,6 +5622,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -5352,6 +5656,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -5380,6 +5686,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -5433,6 +5741,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5444,6 +5754,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5474,6 +5786,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5511,6 +5825,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5539,6 +5855,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5553,6 +5871,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -5584,6 +5904,8 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5632,6 +5954,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5684,6 +6008,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5703,6 +6029,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -5742,6 +6070,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -5774,6 +6104,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -5806,6 +6138,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -5838,6 +6172,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -5866,6 +6202,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -5943,6 +6281,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5995,6 +6335,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6014,6 +6356,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6046,6 +6390,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6085,6 +6431,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6124,6 +6472,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6156,6 +6506,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6188,6 +6540,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6220,6 +6574,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6248,6 +6604,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6263,6 +6621,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -6294,6 +6654,8 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6354,6 +6716,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6373,6 +6737,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6412,6 +6778,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6444,6 +6812,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6476,6 +6846,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6508,6 +6880,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6536,6 +6910,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6578,6 +6954,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6667,6 +7045,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6686,6 +7066,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6718,6 +7100,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6757,6 +7141,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6789,6 +7175,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6821,6 +7209,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6853,6 +7243,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6881,6 +7273,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6923,6 +7317,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6938,6 +7334,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -6969,6 +7367,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7029,6 +7429,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7048,6 +7450,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7084,6 +7488,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7116,6 +7522,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7148,6 +7556,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7180,6 +7590,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -7208,6 +7620,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -7238,6 +7652,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7295,6 +7711,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7306,6 +7724,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -7395,6 +7815,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7414,6 +7836,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7446,6 +7870,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7482,6 +7908,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7514,6 +7942,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7546,6 +7976,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7578,6 +8010,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -7606,6 +8040,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -7634,6 +8070,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7663,6 +8101,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7720,6 +8160,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7731,6 +8173,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -7746,6 +8190,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -7777,6 +8223,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7837,6 +8285,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7856,6 +8306,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7892,6 +8344,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7924,6 +8378,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7956,6 +8412,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7988,6 +8446,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -8016,6 +8476,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -8046,6 +8508,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8103,6 +8567,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8114,6 +8580,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -8203,6 +8671,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -8222,6 +8692,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -8254,6 +8726,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -8290,6 +8764,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -8322,6 +8798,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -8354,6 +8832,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -8386,6 +8866,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -8414,6 +8896,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -8442,6 +8926,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8471,6 +8957,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8528,6 +9016,8 @@ 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, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8539,6 +9029,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -8554,6 +9046,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "", "name": "root", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -8585,6 +9079,8 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, diff --git a/workspaces/arborist/test/arborist/load-actual.js b/workspaces/arborist/test/arborist/load-actual.js index 94ad4e7269286..290107eb4f3d9 100644 --- a/workspaces/arborist/test/arborist/load-actual.js +++ b/workspaces/arborist/test/arborist/load-actual.js @@ -498,3 +498,50 @@ t.test('loading a workspace maintains overrides', async t => { const fooEdge = tree.edgesOut.get('foo') t.equal(tree.overrides, fooEdge.overrides, 'foo edge got the correct overrides') }) + +t.test('applies root packageExtensions to a linked actual tree', async t => { + // packageExtensions never rewrite a package's package.json, so the extension edge lives only in lockfile metadata. + // The linked store layout forces loadActual onto the filesystem-scan path, where the edge must be re-derived from the root rule set. + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { broken: '1.0.0', safe: '1.0.0' }, + packageExtensions: { 'broken@1': { dependencies: { missing: '^1.0.0' } } }, + }), + node_modules: { + broken: t.fixture('symlink', '.store/broken@1.0.0/node_modules/broken'), + // safe matches no selector, exercising the non-extended path + safe: t.fixture('symlink', '.store/safe@1.0.0/node_modules/safe'), + '.store': { + 'broken@1.0.0': { + node_modules: { + // physical manifest deliberately omits the extension-added dependency + broken: { 'package.json': JSON.stringify({ name: 'broken', version: '1.0.0' }) }, + missing: t.fixture('symlink', '../../missing@1.0.0/node_modules/missing'), + }, + }, + 'missing@1.0.0': { + node_modules: { + missing: { 'package.json': JSON.stringify({ name: 'missing', version: '1.0.0' }) }, + }, + }, + 'safe@1.0.0': { + node_modules: { + safe: { 'package.json': JSON.stringify({ name: 'safe', version: '1.0.0' }) }, + }, + }, + }, + }, + }) + + const tree = await loadActual(path) + const brokenLink = tree.children.get('broken') + const broken = brokenLink.target + const edge = broken.edgesOut.get('missing') + t.ok(edge && !edge.error, 'extension-added edge is present and resolves') + t.equal(edge.to.name, 'missing', 'edge resolves to the installed package') + const applied = { selector: 'broken@1', dependencies: ['missing'] } + t.strictSame(broken.packageExtensionsApplied, applied, 'provenance recorded on the store node') + t.strictSame(brokenLink.packageExtensionsApplied, applied, 'provenance mirrored onto the link') +}) diff --git a/workspaces/arborist/test/arborist/package-extensions.js b/workspaces/arborist/test/arborist/package-extensions.js new file mode 100644 index 0000000000000..c37455ee6fbdd --- /dev/null +++ b/workspaces/arborist/test/arborist/package-extensions.js @@ -0,0 +1,217 @@ +const { resolve } = require('node:path') +const t = require('tap') +const Arborist = require('../..') +const fixtures = resolve(__dirname, '../fixtures') +require(fixtures) +const MockRegistry = require('@npmcli/mock-registry') + +const createRegistry = (t) => new MockRegistry({ + strict: false, + tap: t, + registry: 'https://registry.npmjs.org', +}) + +const warningTracker = (t) => { + const warnings = [] + const onlog = (...msg) => msg[0] === 'warn' && warnings.push(msg) + process.on('log', onlog) + t.teardown(() => process.removeListener('log', onlog)) + return warnings +} + +const cache = t.testdir() +const newArb = (path, opt = {}) => new Arborist({ timeout: 30 * 60 * 1000, path, cache, ...opt }) +const buildIdeal = (path, opt) => newArb(path, opt).buildIdealTree(opt) + +// foo@1.0.0 imports bar but does not declare it; bar is published separately. +// withBar is false for tests that reject before bar is ever fetched. +const mockFooBar = async (t, { fooDeps, withBar = true } = {}) => { + const registry = createRegistry(t) + const fooManifest = registry.manifest({ + name: 'foo', + packuments: registry.packuments([{ version: '1.0.0', dependencies: fooDeps }], 'foo'), + }) + await registry.package({ manifest: fooManifest }) + if (withBar) { + const barManifest = registry.manifest({ + name: 'bar', + packuments: registry.packuments(['1.0.0', '1.2.3', '2.0.0'], 'bar'), + }) + await registry.package({ manifest: barManifest }) + } +} + +t.test('adds a missing dependency edge', async t => { + await mockFooBar(t) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { foo: '1.0.0' }, + packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } }, + }), + }) + const tree = await buildIdeal(path) + const foo = tree.edgesOut.get('foo').to + const barEdge = foo.edgesOut.get('bar') + t.ok(barEdge, 'foo has a bar edge created by the extension') + t.equal(barEdge.valid, true, 'bar edge is valid') + t.equal(barEdge.to.version, '1.2.3', 'resolved to the highest 1.x') + t.strictSame(foo.packageExtensionsApplied, { selector: 'foo@1', dependencies: ['bar'] }, + 'provenance attached to the extended node') + t.strictSame(barEdge.explain().packageExtensions, { selector: 'foo@1', field: 'dependencies' }, + 'edge explanation records the extension provenance') +}) + +t.test('edge explanation omits provenance for non-extension edges', async t => { + // foo declares baz itself; the extension only adds bar + const registry = createRegistry(t) + const fooManifest = registry.manifest({ + name: 'foo', + packuments: registry.packuments([{ version: '1.0.0', dependencies: { baz: '1.0.0' } }], 'foo'), + }) + const barManifest = registry.manifest({ name: 'bar', packuments: registry.packuments(['1.2.3'], 'bar') }) + const bazManifest = registry.manifest({ name: 'baz', packuments: registry.packuments(['1.0.0'], 'baz') }) + await registry.package({ manifest: fooManifest }) + await registry.package({ manifest: barManifest }) + await registry.package({ manifest: bazManifest }) + + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { foo: '1.0.0' }, + packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } }, + }), + }) + const tree = await buildIdeal(path) + const foo = tree.edgesOut.get('foo').to + t.ok(foo.edgesOut.get('bar').explain().packageExtensions, 'extension-created edge has provenance') + t.equal(foo.edgesOut.get('baz').explain().packageExtensions, undefined, + 'a self-declared edge from the same node has no provenance') +}) + +t.test('composes with overrides', async t => { + await mockFooBar(t) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { foo: '1.0.0' }, + packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } }, + overrides: { bar: '1.0.0' }, + }), + }) + const tree = await buildIdeal(path) + const foo = tree.edgesOut.get('foo').to + t.equal(foo.edgesOut.get('bar').to.version, '1.0.0', 'override forces the extension-created edge') +}) + +t.test('name-only selector matches every version', async t => { + await mockFooBar(t) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { foo: '1.0.0' }, + packageExtensions: { foo: { dependencies: { bar: '^1.0.0' } } }, + }), + }) + const tree = await buildIdeal(path) + t.ok(tree.edgesOut.get('foo').to.edgesOut.get('bar'), 'name-only selector applied') +}) + +t.test('conflicting selectors fail the install', async t => { + await mockFooBar(t, { withBar: false }) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { foo: '1.0.0' }, + packageExtensions: { + foo: { dependencies: { bar: '^1.0.0' } }, + 'foo@1': { dependencies: { bar: '^2.0.0' } }, + }, + }), + }) + await t.rejects(buildIdeal(path), { code: 'EEXTENSIONCONFLICT' }, 'two matching selectors reject') +}) + +t.test('invalid selector is rejected at load', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + packageExtensions: { 'foo@latest': { dependencies: { bar: '^1.0.0' } } }, + }), + }) + await t.rejects(buildIdeal(path), { code: 'EEXTENSIONSELECTOR' }, 'dist-tag selector rejected') +}) + +t.test('rejects replacing an existing dependency', async t => { + await mockFooBar(t, { fooDeps: { bar: '1.0.0' }, withBar: false }) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { foo: '1.0.0' }, + packageExtensions: { 'foo@1': { dependencies: { bar: '^2.0.0' } } }, + }), + }) + await t.rejects(buildIdeal(path), { code: 'EEXTENSIONDUPDEP' }, 'cannot replace existing dependency') +}) + +t.test('does not extend workspace members but warns', async t => { + const warnings = warningTracker(t) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['packages/*'], + packageExtensions: { ws: { dependencies: { bar: '^1.0.0' } } }, + }), + packages: { + ws: { + 'package.json': JSON.stringify({ + name: 'ws', + version: '1.0.0', + // a non-root workspace declaring packageExtensions is ignored with a warning + packageExtensions: { other: { dependencies: { x: '^1' } } }, + }), + }, + // a second workspace that neither declares packageExtensions nor matches a selector + 'other-ws': { 'package.json': JSON.stringify({ name: 'other-ws', version: '1.0.0' }) }, + }, + }) + createRegistry(t) + const tree = await buildIdeal(path) + const ws = [...tree.inventory.values()].find(n => n.name === 'ws') + t.notOk(ws.edgesOut.get('bar'), 'workspace member is not extended') + // a workspace appears in the inventory as both a Link and its target node, so the warning must be deduped to fire once + t.equal(warnings.filter(w => /workspace package ws/.test(w[2])).length, 1, + 'warns exactly once about the workspace selector match') + t.equal(warnings.filter(w => /in workspace ws is ignored/.test(w[2])).length, 1, + 'warns exactly once about non-root workspace packageExtensions') +}) + +t.test('ignores packageExtensions from an installed dependency', async t => { + const registry = createRegistry(t) + const fooManifest = registry.manifest({ + name: 'foo', + packuments: registry.packuments([{ + version: '1.0.0', + // a published package trying to extend itself must have no effect + packageExtensions: { foo: { dependencies: { bar: '^1.0.0' } } }, + }], 'foo'), + }) + await registry.package({ manifest: fooManifest }) + const path = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' } }), + }) + const tree = await buildIdeal(path) + t.notOk(tree.edgesOut.get('foo').to.edgesOut.get('bar'), + 'dependency-level packageExtensions is ignored') +}) + +t.test('records the canonical hash on the lockfile meta', async t => { + await mockFooBar(t) + const { canonicalHash } = require('../../lib/package-extensions.js') + const packageExtensions = { 'foo@1': { dependencies: { bar: '^1.0.0' } } } + const path = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' }, packageExtensions }), + }) + const tree = await buildIdeal(path) + t.equal(tree.meta.packageExtensionsHash, canonicalHash(packageExtensions), 'hash stashed on meta') +}) diff --git a/workspaces/arborist/test/arborist/reify-package-extensions.js b/workspaces/arborist/test/arborist/reify-package-extensions.js new file mode 100644 index 0000000000000..3090d164d5991 --- /dev/null +++ b/workspaces/arborist/test/arborist/reify-package-extensions.js @@ -0,0 +1,177 @@ +const { join, resolve } = require('node:path') +const fs = require('node:fs') +const t = require('tap') +const Arborist = require('../..') +const fixtures = resolve(__dirname, '../fixtures') +require(fixtures) +const MockRegistry = require('@npmcli/mock-registry') +const { canonicalHash } = require('../../lib/package-extensions.js') + +const createRegistry = (t) => new MockRegistry({ + strict: false, + tap: t, + registry: 'https://registry.npmjs.org', +}) + +// Serve foo@1.0.0 and bar@1.2.3 as installable tarballs; bar is optional so a reify that does not need it leaves no unconsumed mock. +const register = async (t, dir, { withBar = true } = {}) => { + const registry = createRegistry(t) + const fooManifest = registry.manifest({ name: 'foo', packuments: [{ version: '1.0.0' }] }) + await registry.package({ manifest: fooManifest, tarballs: { '1.0.0': join(dir, 'src/foo') } }) + if (withBar) { + const barManifest = registry.manifest({ name: 'bar', packuments: [{ version: '1.2.3' }] }) + await registry.package({ manifest: barManifest, tarballs: { '1.2.3': join(dir, 'src/bar') } }) + } +} + +// foo@1.0.0 does not declare bar; both are served as installable tarballs from source dirs. +const setup = async (t, { packageExtensions, dependencies = { foo: '1.0.0' }, overrides }) => { + const dir = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', dependencies, packageExtensions, overrides }), + src: { + foo: { 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }) }, + bar: { 'package.json': JSON.stringify({ name: 'bar', version: '1.2.3' }) }, + }, + }) + await register(t, dir) + return dir +} + +const newArb = (dir, opt = {}) => new Arborist({ + path: dir, + cache: join(dir, 'cache'), + registry: 'https://registry.npmjs.org', + audit: false, + timeout: 30 * 60 * 1000, + ...opt, +}) + +const readLock = dir => JSON.parse(fs.readFileSync(join(dir, 'package-lock.json'), 'utf8')) + +const ext = { 'foo@1': { dependencies: { bar: '^1.0.0' } } } + +for (const installStrategy of ['hoisted', 'nested', 'shallow', 'linked']) { + t.test(`installs the extension-created edge under install-strategy=${installStrategy}`, async t => { + const dir = await setup(t, { packageExtensions: ext }) + const tree = await newArb(dir, { installStrategy }).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo') + const barEdge = foo.edgesOut.get('bar') + t.ok(barEdge && barEdge.valid && barEdge.to, `bar edge resolved under ${installStrategy}`) + t.equal(barEdge.to.version, '1.2.3', 'bar resolved to a real installed node') + }) +} + +t.test('lockfile records hash, provenance, effective deps, and version 4', async t => { + const dir = await setup(t, { packageExtensions: ext }) + await newArb(dir).reify() + const lock = readLock(dir) + t.equal(lock.lockfileVersion, 4, 'bumped to lockfileVersion 4') + t.equal(lock.packages[''].packageExtensionsHash, canonicalHash(ext), 'root entry carries the canonical hash') + const fooEntry = lock.packages['node_modules/foo'] + t.strictSame(fooEntry.packageExtensionsApplied, { selector: 'foo@1', dependencies: ['bar'] }, + 'foo entry carries minimal provenance') + t.strictSame(fooEntry.dependencies, { bar: '^1.0.0' }, 'foo entry carries the effective dependency metadata') +}) + +t.test('does not rewrite the installed dependency package.json', async t => { + const dir = await setup(t, { packageExtensions: ext }) + await newArb(dir).reify() + const installed = JSON.parse(fs.readFileSync(join(dir, 'node_modules/foo/package.json'), 'utf8')) + t.notOk(installed.dependencies, 'the on-disk foo/package.json is not given a bar dependency') +}) + +t.test('composes with overrides during reify', async t => { + const dir = await setup(t, { packageExtensions: ext, overrides: { bar: '1.2.3' } }) + const tree = await newArb(dir).reify() + const bar = [...tree.inventory.values()].find(n => n.name === 'bar') + t.equal(bar.version, '1.2.3', 'override applied to the extension-created edge') +}) + +t.test('provenance round-trips through the lockfile (npm ci style)', async t => { + const dir = await setup(t, { packageExtensions: ext }) + await newArb(dir).reify() + // a fresh build loaded from the lockfile retains the provenance and hash + const virtual = await newArb(dir).loadVirtual() + const foo = [...virtual.inventory.values()].find(n => n.name === 'foo') + t.strictSame(foo.packageExtensionsApplied, { selector: 'foo@1', dependencies: ['bar'] }, + 'provenance restored from the lockfile') + t.equal(virtual.meta.packageExtensionsHash, canonicalHash(ext), 'hash restored from the lockfile') +}) + +t.test('refuses a lockfile newer than the supported version', async t => { + const dir = await setup(t, { packageExtensions: ext }) + await newArb(dir).reify() + const lock = readLock(dir) + lock.lockfileVersion = 5 + fs.writeFileSync(join(dir, 'package-lock.json'), JSON.stringify(lock)) + await t.rejects(newArb(dir).loadVirtual(), { code: 'ELOCKFILEVERSION' }, 'too-new lockfile is rejected') +}) + +t.test('removing an extension on reinstall reverts the locked graph', async t => { + const dir = await setup(t, { packageExtensions: ext }) + await newArb(dir).reify() + t.ok(readLock(dir).packages['node_modules/bar'], 'bar installed by the extension') + + // remove the extension and reinstall; the stale extended manifest must not persist + fs.writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' } })) + await register(t, dir, { withBar: false }) + await newArb(dir).reify() + const lock = readLock(dir) + t.notOk(lock.packages['node_modules/bar'], 'bar removed once the extension is gone') + t.notOk(lock.packages[''].packageExtensionsHash, 'root hash cleared') + t.notOk(lock.packages['node_modules/foo'].packageExtensionsApplied, 'foo provenance cleared') +}) + +t.test('adding an extension to an existing lockfile applies it on reinstall', async t => { + // first install with no extension, so the lockfile has foo but no bar + const dir = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' } }), + src: { + foo: { 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }) }, + bar: { 'package.json': JSON.stringify({ name: 'bar', version: '1.2.3' }) }, + }, + }) + await register(t, dir, { withBar: false }) + await newArb(dir).reify() + t.notOk(readLock(dir).packages['node_modules/bar'], 'no bar before the extension is added') + + // add the extension and reinstall; the stale foo node must be rebuilt and gain the bar edge + fs.writeFileSync(join(dir, 'package.json'), + JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' }, packageExtensions: ext })) + await register(t, dir) + await newArb(dir).reify() + const lock = readLock(dir) + t.ok(lock.packages['node_modules/bar'], 'bar added after the extension is introduced') + t.strictSame(lock.packages['node_modules/foo'].packageExtensionsApplied, + { selector: 'foo@1', dependencies: ['bar'] }, 'provenance recorded for the newly extended node') +}) + +t.test('changing an extension range on reinstall re-resolves the edge', async t => { + const dir = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' }, packageExtensions: ext }), + src: { + foo: { 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }) }, + bar: { 'package.json': JSON.stringify({ name: 'bar', version: '1.2.3' }) }, + bar2: { 'package.json': JSON.stringify({ name: 'bar', version: '2.0.0' }) }, + }, + }) + const registerBoth = async () => { + const registry = createRegistry(t) + const fooManifest = registry.manifest({ name: 'foo', packuments: [{ version: '1.0.0' }] }) + const barManifest = registry.manifest({ name: 'bar', packuments: [{ version: '1.2.3' }, { version: '2.0.0' }] }) + await registry.package({ manifest: fooManifest, tarballs: { '1.0.0': join(dir, 'src/foo') } }) + await registry.package({ + manifest: barManifest, + tarballs: { '1.2.3': join(dir, 'src/bar'), '2.0.0': join(dir, 'src/bar2') }, + }) + } + await registerBoth() + await newArb(dir).reify() + t.equal(readLock(dir).packages['node_modules/bar'].version, '1.2.3', 'bar resolved to 1.x') + + fs.writeFileSync(join(dir, 'package.json'), + JSON.stringify({ name: 'root', dependencies: { foo: '1.0.0' }, packageExtensions: { 'foo@1': { dependencies: { bar: '^2.0.0' } } } })) + await registerBoth() + await newArb(dir).reify() + t.equal(readLock(dir).packages['node_modules/bar'].version, '2.0.0', 'bar re-resolved to 2.x after the range change') +}) diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js new file mode 100644 index 0000000000000..53ac56feb7a9e --- /dev/null +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -0,0 +1,409 @@ +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('uninstalling a patched package drops its entry from package.json', async t => { + const registry = createRegistry(t) + // one install, then an uninstall that resolves from the lockfile so no extra fetches + 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, 'package installed and patched') + + // npm uninstall : removes the dep but leaves the now-orphaned patch entry + await newArb({ path }).reify({ rm: [PKG_NAME] }) + + const rootPkg = JSON.parse(fs.readFileSync(resolve(path, 'package.json'), 'utf8')) + t.notOk(rootPkg.dependencies?.[PKG_NAME], 'dependency removed from package.json') + t.notOk(rootPkg.patchedDependencies, 'orphaned patch entry dropped from package.json') + t.notOk(fs.existsSync(resolve(path, 'node_modules', PKG_NAME)), 'package removed from node_modules') +}) + +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` }, + }) + + const warnings = [] + const onLog = (level, prefix, msg) => level === 'warn' && warnings.push(`${prefix} ${msg}`) + process.on('log', onLog) + t.teardown(() => process.removeListener('log', onLog)) + + 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') + // the user is told the lockfile is now out of sync with package.json + t.match(warnings.join('\n'), /out of sync and `npm ci` will fail/, + 'warns that the lockfile no longer matches package.json') +}) + +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('does not re-warn or bump the hidden lockfile once the lockfile is already v4', 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` }, + }) + + // the first reify legitimately upgrades the real lockfile to v4 + await newArb({ path }).reify() + + const warnings = [] + const onLog = (level, prefix, msg) => level === 'warn' && warnings.push(`${prefix} ${msg}`) + process.on('log', onLog) + t.teardown(() => process.removeListener('log', onLog)) + + // the hidden lockfile is pinned to v3, so a second reify must not re-fire the upgrade warning + await newArb({ path }).reify() + t.notMatch(warnings.join('\n'), /requires lockfileVersion 4/, 'no spurious upgrade warning when already v4') + + const hidden = JSON.parse(fs.readFileSync(resolve(path, 'node_modules/.package-lock.json'), 'utf8')) + t.equal(hidden.lockfileVersion, 3, 'hidden lockfile stays at version 3') +}) + +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/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index 3c48072270273..e1f67033d4581 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -1,4 +1,4 @@ -const { join, resolve, basename } = require('node:path') +const { join, resolve, basename, delimiter } = require('node:path') const t = require('tap') const runScript = require('@npmcli/run-script') const localeCompare = require('@isaacs/string-locale-compare')('en') @@ -3914,6 +3914,106 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => { await t.resolves(arb.reify(), 'same-origin tarball is allowed for registry root') }) + t.test('allowRemote=none allows registry tarball whose resolved origin differs from the configured registry', async t => { + // Proxy/mirror case: a committed lockfile pins resolved to the public registry while a private mirror is configured. + // replace-registry-host rewrites the host to the configured registry at fetch time, so the effective URL is registry-mediated and must pass allow-remote=none. + const abbrevPackumentNpmjs = JSON.stringify({ + _id: 'abbrev', + _rev: 'lkjadflkjasdf', + name: 'abbrev', + 'dist-tags': { latest: '1.1.1' }, + versions: { + '1.1.1': { + name: 'abbrev', + version: '1.1.1', + dist: { + tarball: 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz', + }, + }, + }, + }) + + const testdir = t.testdir({ + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { + abbrev: '1.1.1', + }, + }), + }, + }) + + tnock(t, 'https://registry.example.com') + .get('/abbrev') + .reply(200, abbrevPackumentNpmjs) + + // replace-registry-host (default 'npmjs') rewrites the npmjs.org tarball host to the configured mirror, so the fetch lands here. + tnock(t, 'https://registry.example.com') + .get('/abbrev/-/abbrev-1.1.1.tgz') + .reply(200, abbrevTGZ) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: 'https://registry.example.com', + cache: resolve(testdir, 'cache'), + allowRemote: 'none', + }) + + await t.resolves(arb.reify(), 'mirror-fronted registry tarball is allowed under allow-remote=none') + }) + + t.test('allowRemote=none allows registry tarball with replaceRegistryHost=always', async t => { + // replace-registry-host=always routes every registry tarball fetch through the configured registry, so the effective URL is never remote and must pass allow-remote=none. + const abbrevPackumentNpmjs = JSON.stringify({ + _id: 'abbrev', + _rev: 'lkjadflkjasdf', + name: 'abbrev', + 'dist-tags': { latest: '1.1.1' }, + versions: { + '1.1.1': { + name: 'abbrev', + version: '1.1.1', + dist: { + tarball: 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz', + }, + }, + }, + }) + + const testdir = t.testdir({ + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { + abbrev: '1.1.1', + }, + }), + }, + }) + + tnock(t, 'https://registry.example.com') + .get('/npm/abbrev') + .reply(200, abbrevPackumentNpmjs) + + // always rewrites the tarball host to the configured registry and prepends the registry path. + tnock(t, 'https://registry.example.com') + .get('/npm/abbrev/-/abbrev-1.1.1.tgz') + .reply(200, abbrevTGZ) + + const arb = new Arborist({ + path: resolve(testdir, 'project'), + registry: 'https://registry.example.com/npm', + cache: resolve(testdir, 'cache'), + allowRemote: 'none', + replaceRegistryHost: 'always', + }) + + await t.resolves(arb.reify(), 'registry tarball routed through the configured registry is allowed') + }) + t.test('allowRemote=none allows registry tarball under linked install strategy', async t => { // The linked strategy extracts store nodes as IsolatedNode, which has no edges to recompute isRegistryDependency from. // The flag must be carried from the source tree node so the registry-tarball allow-remote exemption still applies. @@ -4139,6 +4239,76 @@ t.test('install strategy linked', async (t) => { }) }) +t.test('linked strategy exposes store node_modules via NODE_PATH for lifecycle scripts', async t => { + // Regression for #9549. In the linked strategy a store package's deps are symlinked siblings in its store node_modules. + // A separate bin invoked by the script (e.g. napi-postinstall) resolves modules from its own store realpath and cannot see them, so npm exposes them via NODE_PATH. + const Arborist = require('../../lib/index.js') + const pacote = require('pacote') + + const testdir = t.testdir({ + src: { + 'package.json': JSON.stringify({ + name: 'has-postinstall', + version: '1.0.0', + scripts: { postinstall: 'node -e ""' }, + }), + }, + project: { + 'package.json': JSON.stringify({ + name: 'myproject', + version: '1.0.0', + dependencies: { 'has-postinstall': '1.0.0' }, + }), + }, + }) + + const tgz = await pacote.tarball(resolve(testdir, 'src'), { Arborist }) + + const packument = JSON.stringify({ + _id: 'has-postinstall', + name: 'has-postinstall', + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + name: 'has-postinstall', + version: '1.0.0', + hasInstallScript: true, + scripts: { postinstall: 'node -e ""' }, + dist: { + tarball: 'https://registry.npmjs.org/has-postinstall/-/has-postinstall-1.0.0.tgz', + }, + }, + }, + }) + + tnock(t, 'https://registry.npmjs.org') + .get('/has-postinstall') + .reply(200, packument) + + tnock(t, 'https://registry.npmjs.org') + .get('/has-postinstall/-/has-postinstall-1.0.0.tgz') + .reply(200, tgz) + + const path = resolve(testdir, 'project') + const arb = new Arborist({ + path, + registry: 'https://registry.npmjs.org', + cache: resolve(testdir, 'cache'), + installStrategy: 'linked', + dangerouslyAllowAllScripts: true, + }) + await arb.reify() + + const run = [...arb.scriptsRun] + .find(s => s.pkg.name === 'has-postinstall' && s.event === 'postinstall') + t.ok(run, 'postinstall ran for the store package') + t.match(run.path, /[\\/]\.store[\\/]/, 'script ran on the store entry') + // Assert the leading entry: the fix prepends the store node_modules to any pre-existing NODE_PATH (e.g. the coverage harness on Windows CI). + const [firstNodePath] = run.env.NODE_PATH.split(delimiter) + t.equal(firstNodePath, resolve(run.path, '..'), + 'NODE_PATH leads with the store node_modules holding the package deps') +}) + t.test('workspace installs retain existing versions with newer package specs', async t => { const path = t.testdir({ 'package.json': JSON.stringify({ 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/package-extensions.js b/workspaces/arborist/test/package-extensions.js new file mode 100644 index 0000000000000..72046b3ad1d31 --- /dev/null +++ b/workspaces/arborist/test/package-extensions.js @@ -0,0 +1,214 @@ +const t = require('tap') +const PackageExtensions = require('../lib/package-extensions.js') +const { + parseSelector, + rangeMatches, + canonicalHash, + canonicalStringify, +} = require('../lib/package-extensions.js') + +t.test('parseSelector', async t => { + t.strictSame(parseSelector('foo'), { name: 'foo', range: null }, 'name only') + t.strictSame(parseSelector('foo@1'), { name: 'foo', range: '1' }, 'name with range') + t.strictSame(parseSelector('@scope/foo'), { name: '@scope/foo', range: null }, 'scoped name only') + t.strictSame(parseSelector('@scope/foo@^2.3.0'), { name: '@scope/foo', range: '^2.3.0' }, 'scoped with range') + + for (const bad of ['', null, undefined, 5]) { + t.throws(() => parseSelector(bad), { code: 'EEXTENSIONSELECTOR' }, `rejects ${JSON.stringify(bad)}`) + } + // dist-tags, git, file, url, alias specs are not valid selectors + for (const bad of ['foo@latest', 'foo@next', 'foo@git+https://x.com/a.git', 'foo@file:../x', 'foo@npm:bar@1', 'foo@https://x.com/a.tgz']) { + t.throws(() => parseSelector(bad), { code: 'EEXTENSIONSELECTOR' }, `rejects spec selector ${bad}`) + } + // a blank range is malformed; use the name-only form to match every version + for (const bad of ['foo@', 'foo@ ', '@scope/foo@']) { + t.throws(() => parseSelector(bad), { code: 'EEXTENSIONSELECTOR' }, `rejects blank range ${JSON.stringify(bad)}`) + } + // invalid package names + for (const bad of [' @1', ' space ', '.hidden']) { + t.throws(() => parseSelector(bad), { code: 'EEXTENSIONSELECTOR' }, `rejects invalid name ${bad}`) + } +}) + +t.test('rangeMatches', async t => { + t.ok(rangeMatches(null, '1.2.3'), 'name-only matches semver version') + t.ok(rangeMatches(null, 'not-semver'), 'name-only matches non-semver version') + t.ok(rangeMatches('1', '1.2.3'), 'range matches satisfying version') + t.notOk(rangeMatches('1', '2.0.0'), 'range rejects non-satisfying version') + t.notOk(rangeMatches('1', 'not-semver'), 'versioned selector rejects non-semver version') +}) + +t.test('constructor validation', async t => { + t.equal(new PackageExtensions(undefined).present, false, 'absent field is allowed and not present') + t.equal(new PackageExtensions(undefined).hash, null, 'absent field has no hash') + + const empty = new PackageExtensions({}) + t.equal(empty.present, true, 'empty object is present') + t.ok(empty.hash, 'empty object still hashes') + + for (const bad of [null, [], 'x', 5]) { + t.throws(() => new PackageExtensions(bad), { code: 'EEXTENSIONROOT' }, `rejects root ${JSON.stringify(bad)}`) + } + + t.throws(() => new PackageExtensions({ foo: { devDependencies: { a: '1' } } }), + { code: 'EEXTENSIONFIELD' }, 'rejects unsupported field') + t.throws(() => new PackageExtensions({ foo: { dependencies: [] } }), + { code: 'EEXTENSIONVALUE' }, 'rejects non-object field value') + t.throws(() => new PackageExtensions({ foo: 'bar' }), + { code: 'EEXTENSIONVALUE' }, 'rejects non-object extension') + + for (const del of [null, false, '-']) { + t.throws(() => new PackageExtensions({ foo: { dependencies: { bar: del } } }), + { code: 'EEXTENSIONDELETE' }, `rejects deletion value ${JSON.stringify(del)}`) + } + + for (const bad of [null, false, '-', 'x', 5]) { + t.throws(() => new PackageExtensions({ foo: { peerDependenciesMeta: { bar: bad } } }), + { code: 'EEXTENSIONVALUE' }, `rejects non-object peerDependenciesMeta value ${JSON.stringify(bad)}`) + } +}) + +t.test('match', async t => { + const pe = new PackageExtensions({ + foo: { dependencies: { a: '1' } }, + 'bar@^2': { dependencies: { b: '1' } }, + }) + t.equal(pe.match('foo', '9.9.9').key, 'foo', 'name-only matches any version') + t.equal(pe.match('bar', '2.5.0').key, 'bar@^2', 'range matches satisfying version') + t.equal(pe.match('bar', '1.0.0'), null, 'range misses non-satisfying version') + t.equal(pe.match('nope', '1.0.0'), null, 'unknown name misses') + + const conflict = new PackageExtensions({ + foo: { dependencies: { a: '1' } }, + 'foo@1': { dependencies: { b: '1' } }, + }) + t.throws(() => conflict.match('foo', '1.2.3'), { code: 'EEXTENSIONCONFLICT' }, 'two matching selectors conflict') + t.equal(conflict.match('foo', '2.0.0').key, 'foo', 'only one matches at 2.0.0, no conflict') +}) + +t.test('apply: add missing dependencies and optionalDependencies', async t => { + const pe = new PackageExtensions({ + foo: { + dependencies: { 'missing-dep': '^2.0.0' }, + optionalDependencies: { 'opt-dep': '^1.0.0' }, + }, + }) + const { pkg, applied } = pe.apply({ name: 'foo', version: '1.0.0', dependencies: { existing: '^1' } }) + t.strictSame(pkg.dependencies, { existing: '^1', 'missing-dep': '^2.0.0' }, 'added to dependencies, kept existing') + t.strictSame(pkg.optionalDependencies, { 'opt-dep': '^1.0.0' }, 'created optionalDependencies') + t.strictSame(applied, { + selector: 'foo', + dependencies: ['missing-dep'], + optionalDependencies: ['opt-dep'], + }, 'provenance records selector and changed names') +}) + +t.test('apply: does not mutate the input manifest', async t => { + const pe = new PackageExtensions({ foo: { dependencies: { bar: '^1' } } }) + const input = { name: 'foo', version: '1.0.0', dependencies: { a: '1' } } + const inputDeps = input.dependencies + const { pkg } = pe.apply(input) + t.strictSame(input.dependencies, { a: '1' }, 'input dependencies unchanged') + t.equal(input.dependencies, inputDeps, 'input dependencies object identity unchanged') + t.not(pkg.dependencies, input.dependencies, 'output has a fresh dependencies object') +}) + +t.test('apply: rejects replacing an existing normal dependency', async t => { + const pe = new PackageExtensions({ foo: { dependencies: { bar: '^2' } } }) + t.throws(() => pe.apply({ name: 'foo', version: '1.0.0', dependencies: { bar: '^1' } }), + { code: 'EEXTENSIONDUPDEP' }, 'cannot replace existing dependency') + + const peOpt = new PackageExtensions({ foo: { dependencies: { bar: '^2' } } }) + t.throws(() => peOpt.apply({ name: 'foo', version: '1.0.0', optionalDependencies: { bar: '^1' } }), + { code: 'EEXTENSIONDUPDEP' }, 'cannot add a dependency already in optionalDependencies (no field move)') + + const peMove = new PackageExtensions({ foo: { optionalDependencies: { bar: '^2' } } }) + t.throws(() => peMove.apply({ name: 'foo', version: '1.0.0', dependencies: { bar: '^1' } }), + { code: 'EEXTENSIONDUPDEP' }, 'cannot add an optionalDependency already in dependencies (no field move)') +}) + +t.test('apply: peerDependencies merge and replace', async t => { + const pe = new PackageExtensions({ + foo: { + peerDependencies: { typescript: '>=5', react: '^18' }, + }, + }) + const { pkg, applied } = pe.apply({ + name: 'foo', + version: '1.0.0', + peerDependencies: { typescript: '>=4', vue: '^3' }, + }) + t.strictSame(pkg.peerDependencies, { typescript: '>=5', vue: '^3', react: '^18' }, + 'replaced existing range, added new, kept unrelated') + t.strictSame(applied.peerDependencies.sort(), ['react', 'typescript'], 'provenance lists changed peers') +}) + +t.test('apply: peerDependenciesMeta merge by key', async t => { + const pe = new PackageExtensions({ + foo: { + peerDependenciesMeta: { typescript: { optional: true } }, + }, + }) + const { pkg } = pe.apply({ + name: 'foo', + version: '1.0.0', + peerDependencies: { typescript: '>=5' }, + peerDependenciesMeta: { typescript: { somethingElse: true } }, + }) + t.strictSame(pkg.peerDependenciesMeta.typescript, { somethingElse: true, optional: true }, + 'shallow-merged meta object without dropping existing keys') +}) + +t.test('apply: peerDependenciesMeta with same extension adding the peer', async t => { + const pe = new PackageExtensions({ + foo: { + peerDependencies: { typescript: '>=5' }, + peerDependenciesMeta: { typescript: { optional: true } }, + }, + }) + const { pkg } = pe.apply({ name: 'foo', version: '1.0.0' }) + t.strictSame(pkg.peerDependencies, { typescript: '>=5' }, 'peer added') + t.strictSame(pkg.peerDependenciesMeta, { typescript: { optional: true } }, 'meta added') +}) + +t.test('apply: orphan peerDependenciesMeta is an error', async t => { + const pe = new PackageExtensions({ + foo: { peerDependenciesMeta: { typescript: { optional: true } } }, + }) + t.throws(() => pe.apply({ name: 'foo', version: '1.0.0' }), + { code: 'EEXTENSIONORPHANMETA' }, 'meta without corresponding peer fails') +}) + +t.test('apply: peer may overlap with dependencies', async t => { + const pe = new PackageExtensions({ + foo: { peerDependencies: { bar: '^1' } }, + }) + const { pkg } = pe.apply({ name: 'foo', version: '1.0.0', dependencies: { bar: '^1' } }) + t.strictSame(pkg.dependencies, { bar: '^1' }, 'dependency kept') + t.strictSame(pkg.peerDependencies, { bar: '^1' }, 'peer added alongside dependency') +}) + +t.test('apply: returns null when nothing matches', async t => { + const pe = new PackageExtensions({ foo: { dependencies: { a: '1' } } }) + t.equal(pe.apply({ name: 'other', version: '1.0.0' }), null, 'no match returns null') + t.equal(new PackageExtensions(undefined).apply({ name: 'foo', version: '1' }), null, 'absent returns null') + t.equal(pe.apply(null), null, 'no manifest returns null') +}) + +t.test('canonical hash is order-independent and value-sensitive', async t => { + const a = canonicalHash({ foo: { dependencies: { a: '1', b: '2' } }, bar: { dependencies: { c: '3' } } }) + const b = canonicalHash({ bar: { dependencies: { c: '3' } }, foo: { dependencies: { b: '2', a: '1' } } }) + t.equal(a, b, 'key order does not change the hash') + + const c = canonicalHash({ foo: { dependencies: { a: '1.0.0' } } }) + t.not(a, c, 'value changes change the hash') + t.match(a, /^sha512-/, 'uses sha512 digest encoding') + + t.equal(canonicalStringify({ b: 1, a: 2 }), '{"a":2,"b":1}', 'sorts keys') + t.equal(canonicalStringify({ a: [3, 1] }), '{"a":[3,1]}', 'preserves array order') +}) + +t.test('constructor stores hash of present field', async t => { + const raw = { foo: { dependencies: { bar: '^1' } } } + t.equal(new PackageExtensions(raw).hash, canonicalHash(raw), 'instance hash matches canonicalHash') +}) 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..d213b3cf87019 --- /dev/null +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -0,0 +1,431 @@ +// 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('uninstalling a patched package drops its orphaned patch entry', async t => { + // npm uninstall removes the node but leaves the patchedDependencies entry, which would otherwise be EPATCHUNUSED + 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, { rm: ['dep'] }) + t.notOk(tree.inventory.query('name', 'dep').size, 'dep node is removed') + t.notOk(tree.package.patchedDependencies, 'the orphaned patch entry is dropped') +}) + +t.test('uninstall keeps patch entries for packages that remain', async t => { + // removing one package must not drop another package's still-used patch + const path = t.testdir({ + 'a.patch': PATCH, + 'b.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': 'a.patch', 'b@1.0.0': 'b.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, { rm: ['a'] }) + t.notOk(tree.inventory.query('name', 'a').size, 'a node is removed') + t.same(tree.package.patchedDependencies, { 'b@1.0.0': 'b.patch' }, 'b patch entry survives') + const b = tree.inventory.query('name', 'b').values().next().value + t.ok(b.patched, 'b is still patched') +}) + +t.test('uninstall does not drop a patch when the package survives as a transitive dep', async t => { + // dep is a direct dep being removed but stays in the tree under parent, so its patch is still used + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0', parent: '^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', parent: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + 'node_modules/parent': { + ...lockEntry('parent', '1.0.0'), + dependencies: { dep: '^1.0.0' }, + }, + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + parent: { + 'package.json': JSON.stringify({ + name: 'parent', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + }), + }, + }, + }) + + const tree = await buildIdeal(path, { rm: ['dep'] }) + const dep = tree.inventory.query('name', 'dep').values().next().value + t.ok(dep, 'dep node survives as a transitive dependency') + t.ok(dep.patched, 'dep is still patched') + t.same(tree.package.patchedDependencies, { 'dep@1.0.0': 'fix.patch' }, 'patch entry is kept') +}) + +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 f39262d91a844..c30b59c7c5af4 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1121,11 +1121,12 @@ const definitions = { `, }), 'init-license': new Definition('init-license', { - default: 'ISC', + default: '', hint: '', type: String, description: ` The value \`npm init\` should use by default for the package license. + If not set, the license field will be omitted from new packages. `, }), 'init-module': new Definition('init-module', { @@ -1196,7 +1197,7 @@ const definitions = { `, }), 'init.license': new Definition('init.license', { - default: 'ISC', + default: '', type: String, deprecated: ` Use \`--init-license\` instead. @@ -1792,6 +1793,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, @@ -2458,6 +2516,16 @@ const definitions = { while still writing the timing file, use \`--silent\`. `, }), + to: new Definition('to', { + default: null, + hint: '', + type: [null, String], + description: ` + Used by \`npm patch update\` to set the version to rebase a patch onto + when it cannot be read from \`package-lock.json\` — for example an + exact-version selector, or a version that has not been installed yet. + `, + }), umask: new Definition('umask', { default: 0, type: Umask, diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index eeee087697034..f01a411ee36ff 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(), @@ -472,6 +488,9 @@ Object { null, Function String(), ], + "patches-dir": Array [ + Function String(), + ], "prefer-dedupe": Array [ "boolean value (true or false)", ], @@ -604,6 +623,10 @@ Object { "timing": Array [ "boolean value (true or false)", ], + "to": Array [ + null, + Function String(), + ], "token-description": Array [ null, Function String(), diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 414e07b78bf72..796cef6aa392a 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -20,6 +20,14 @@ Remove the 'private' field from the package.json to publish it.`), ) } + // packageExtensions is root-only project policy and must never reach the registry manifest or the published tarball + if (manifest.packageExtensions !== undefined) { + throw Object.assign( + new Error('packageExtensions is only honored at the project root and must not be published.'), + { code: 'EPACKAGEEXTENSIONS' } + ) + } + // spec is used to pick the appropriate registry/auth combo const spec = npa.resolve(manifest.name, manifest.version) opts = { @@ -87,6 +95,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..3b41656bef17a 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -75,6 +75,77 @@ 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('fails when publishing a package with packageExtensions', async t => { + const { publish } = t.mock('..') + // no registry interceptor: the publish must fail before any request is made + const manifest = { + name: 'libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + packageExtensions: { 'foo@1': { dependencies: { bar: '^1.0.0' } } }, + } + + await t.rejects( + publish(manifest, tarData, { ...opts, npmVersion: null }), + { code: 'EPACKAGEEXTENSIONS', message: /must not be published/ }, + 'refuses to publish a package containing packageExtensions' + ) +}) + t.test('scoped publish', async t => { const { publish } = t.mock('..') const registry = new MockRegistry({