From fc80bb359502699bb0a055157e01eaedd5bd73c8 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Thu, 18 Jun 2026 07:30:34 -0700 Subject: [PATCH 01/11] feat!: remove default license for npm init BREAKING CHANGE: The default license for `npm init` has been changed from "ISC" to an empty string. If not set, the license field will be omitted from new packages. --- smoke-tests/tap-snapshots/test/index.js.test.cjs | 15 +-------------- .../test/lib/commands/config.js.test.cjs | 6 +++--- tap-snapshots/test/lib/docs.js.test.cjs | 7 ++++--- test/lib/commands/init.js | 10 +++++----- workspaces/config/lib/definitions/definitions.js | 5 +++-- 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/smoke-tests/tap-snapshots/test/index.js.test.cjs b/smoke-tests/tap-snapshots/test/index.js.test.cjs index 079dd7d8fcd74..06a6d1b5fc0f6 100644 --- a/smoke-tests/tap-snapshots/test/index.js.test.cjs +++ b/smoke-tests/tap-snapshots/test/index.js.test.cjs @@ -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..d4a06d7fbdb55 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, @@ -271,7 +271,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,7 +279,7 @@ 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 diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 19aacebfa44a3..d4d938fbedf4a 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -1044,10 +1044,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. @@ -2356,7 +2357,7 @@ Alias for \`--init-author-url\` #### \`init.license\` -* Default: "ISC" +* Default: "" * Type: String * DEPRECATED: Use \`--init-license\` instead. 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/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index f39262d91a844..2083d9ef43ded 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. From 1db885c84b2dfc5126ab663abb12262b533922c1 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 18 Jun 2026 21:49:00 +0530 Subject: [PATCH 02/11] feat: native dependency patching (npm patch add/commit/update/ls/rm) (#9439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements native dependency patching per [RFC #862](https://github.com/npm/rfcs/pull/862): a first-class way to apply small, local modifications to an installed dependency and have them re-applied automatically on every install, with no external tooling or postinstall scripts. Patches are declared in a new `patchedDependencies` field of the root `package.json`, stored as plain unified diffs under `patches/`, and recorded with a content hash in `package-lock.json`. Because the patch is applied during the install itself, it works for transitive dependencies, across every `install-strategy`, and is **not** disabled by `--ignore-scripts`. ## The `npm patch` command A new command with five subcommands (and a bare `npm patch ` shorthand for `add`): - **`npm patch add [@]`** — extracts a clean copy of the resolved registry tarball into a temp directory outside `node_modules` and prints the path to edit. Ambiguous when multiple versions are installed; the error lists the exact selectors to retry with. - **`npm patch commit `** — diffs the edited directory against a fresh copy of the original tarball, writes `/@.patch`, adds the `patchedDependencies` entry, and reifies to apply the patch and record its integrity in the lockfile. `package.json` is excluded from the diff — Arborist resolves the pre-patch manifest, so a patched manifest would change resolution-affecting fields on disk without being honored (silent partial application); `commit` warns when an edit only touches it. - **`npm patch update [@] [--to ]`** — rebases an existing patch onto a new version. It reads the target from `--to` or the lockfile, 3-way-merges the existing patch onto the new tarball in a throwaway git repo, and rewrites `package.json` + `package-lock.json` **without touching `node_modules`** (so it works from a failed-install state). On conflict it leaves an edit dir with `<<<<<<<` markers, finalized by `npm patch commit`. Exact selectors are renamed; range/name-only selectors gain a new exact entry and keep the old one while it still wins another installed node. - **`npm patch ls`** — lists registered patches and how many installed nodes each matches (flagging overlapping range selectors that conflict on a node). - **`npm patch rm [@]`** — removes the matching entries, deletes the patch file when no other entry references it, and reifies to revert the files. ## Install-time apply pipeline Patch resolution and application live in Arborist so every install path honors them: - **`resolvePatchedDependencies`** resolves the root `patchedDependencies` map against the ideal tree, attaching `node.patched = { path, integrity }` to each matched node. Selector precedence is exact > range-subset > name-only, with ambiguous overlapping ranges surfaced as a hard error. - **reify** applies the diff after extraction and records the patched integrity in the lockfile. `diff.js` forces re-extraction when a node's patch integrity changes, and re-extracts to revert when a previously-patched node loses its selector (`patchRemoved`). - **`install-strategy=linked`** is supported via a content-addressed side-store: the store key is suffixed with the patch identity (`+patch`) so a patched and unpatched copy of the same version coexist without collision. A failed patch under linked strategy is always a hard error (the side-store cannot represent unpatched contents at a patched key without later installs silently trusting it). ## Lockfile Patches require `lockfileVersion: 4` so that older npm clients abort rather than silently installing unpatched code. When any node is patched, npm writes version 4 and **warns** if this upgrades a lower pinned `lockfile-version` (the safety gate cannot be honored otherwise). `npm ci` revalidates each patch's existence and integrity against the lockfile before installing. ## Failure modes By default any patch problem is a hard error that aborts the install: a patch that fails to apply, a registered patch that matches no installed package, a missing patch file, or a patch whose hash does not match the lockfile. Two **CLI-only** relax flags cover one-off cases — `--allow-unused-patches` and `--ignore-patch-failures` — and are rejected in `npm ci` and when set anywhere other than the command line. ## Non-registry dependencies Patches need a stable registry tarball as their baseline, so a dependency reached through a non-registry consumer edge (`file:`, `git:`, `http(s):`) is rejected with `EPATCHNONREGISTRY`, both by `npm patch add` and at install time. The check is edge-based (the consuming spec's type), not node-based, so it does not falsely reject edgeless nodes such as linked-store entries or extraneous installs, which are still registry deps. `npm:` registry aliases are correctly classified as registry deps and are supported by the install engine; the `npm patch add ` ergonomics will land in a fast-follow. ## Publish / pack `patchedDependencies` is stripped from the published **registry manifest** (libnpmpublish) so the field never leaks to the packument. Stripping it from the **tarball's own `package.json`** and excluding the `patches/` directory from the tarball is a coordinated follow-up in `pacote` + `npm-packlist` (those packages own the packed file list and the manifest written into the tarball, neither editable from the CLI) — see Follow-up work. ## Other surfaces - `npm ls` annotates patched dependencies in its output. - New config: `patches-dir`, `edit-dir`, `ignore-existing`, `keep-edit-dir`, plus the two relax flags. - New `npm-patch` man page and nav entry. ## Tests Unit and integration coverage for every subcommand (including `update`'s clean rebase, conflict→commit, and selector-rename/range-fork paths), the apply pipeline, selector matching, linked-strategy apply/removal, lockfile validation, publish stripping, and the relax flags. Arborist and CLI suites pass at 100% coverage. ## Follow-up work A few additive pieces are deliberately deferred — nothing in this PR depends on them. - **Tarball-side strip for publish/pack** — stripping `patchedDependencies` from the tarball's own `package.json` and excluding the `patches/` directory from the published tarball. This can't be done in the CLI: the tarball's file list and manifest come from `pacote` (packs the raw on-disk files) and `npm-packlist`, so it needs coordinated changes there. Raised in the RFC review; the registry-manifest strip in this PR already prevents the field from being honored or appearing in the packument. - **`npm patch add ` ergonomics for `npm:` registry aliases** — the install engine already treats `npm:` aliases as registry dependencies and applies a hand-written `@` selector correctly today. What remains is the `add`/`commit` convenience: resolving the alias to its real `name@version` tarball as the baseline and keying the written selector on the alias name. Currently `npm patch add ` resolves the alias name as a real package and fails. - **Binary files** — patches are unified text diffs, so binary files (images, wasm, native addons) cannot be patched. This is a limitation of the whole feature (shared with `patch-package`), not a regression; a binary-aware path could be added later. ## References Implements https://github.com/npm/rfcs/pull/862 --- DEPENDENCIES.md | 1 + docs/lib/content/commands/npm-patch.md | 69 + docs/lib/content/nav.yml | 3 + lib/commands/audit.js | 3 + lib/commands/ci.js | 9 + lib/commands/dedupe.js | 2 + lib/commands/install.js | 3 + lib/commands/link.js | 4 + lib/commands/ls.js | 9 + lib/commands/patch.js | 734 ++++++++++ lib/commands/prune.js | 2 + lib/commands/uninstall.js | 2 + lib/commands/update.js | 2 + lib/utils/cli-only-flag.js | 13 + lib/utils/cmd-list.js | 1 + lib/utils/patch-diff.js | 82 ++ lib/utils/validate-lockfile.js | 7 + package-lock.json | 7 +- package.json | 5 +- .../tap-snapshots/test/index.js.test.cjs | 10 +- .../test/lib/commands/config.js.test.cjs | 14 + tap-snapshots/test/lib/docs.js.test.cjs | 157 +++ tap-snapshots/test/lib/npm.js.test.cjs | 92 +- .../lib/utils/validate-lockfile.js.test.cjs | 8 + test/lib/commands/ci.js | 11 + test/lib/commands/ls.js | 44 + test/lib/commands/patch.js | 1244 +++++++++++++++++ test/lib/utils/cli-only-flag.js | 33 + test/lib/utils/patch-diff.js | 149 ++ test/lib/utils/validate-lockfile.js | 18 + .../arborist/lib/arborist/build-ideal-tree.js | 5 + .../arborist/lib/arborist/isolated-reifier.js | 11 +- .../arborist/lib/arborist/load-virtual.js | 1 + workspaces/arborist/lib/arborist/reify.js | 54 +- workspaces/arborist/lib/diff.js | 10 + workspaces/arborist/lib/isolated-classes.js | 4 + workspaces/arborist/lib/node.js | 3 + workspaces/arborist/lib/patch.js | 111 ++ .../arborist/lib/patched-dependencies.js | 152 ++ workspaces/arborist/lib/shrinkwrap.js | 20 + workspaces/arborist/package.json | 1 + .../tap-snapshots/test/link.js.test.cjs | 4 + .../tap-snapshots/test/node.js.test.cjs | 248 ++++ .../arborist/test/arborist/reify-patch.js | 378 +++++ workspaces/arborist/test/diff.js | 24 + workspaces/arborist/test/patch.js | 143 ++ .../test/patched-dependencies-resolve.js | 323 +++++ .../arborist/test/patched-dependencies.js | 52 + workspaces/arborist/test/shrinkwrap.js | 14 + .../config/lib/definitions/definitions.js | 67 + .../test/type-description.js.test.cjs | 23 + workspaces/libnpmpublish/lib/publish.js | 2 + workspaces/libnpmpublish/test/publish.js | 54 + 53 files changed, 4386 insertions(+), 56 deletions(-) create mode 100644 docs/lib/content/commands/npm-patch.md create mode 100644 lib/commands/patch.js create mode 100644 lib/utils/cli-only-flag.js create mode 100644 lib/utils/patch-diff.js create mode 100644 test/lib/commands/patch.js create mode 100644 test/lib/utils/cli-only-flag.js create mode 100644 test/lib/utils/patch-diff.js create mode 100644 workspaces/arborist/lib/patch.js create mode 100644 workspaces/arborist/lib/patched-dependencies.js create mode 100644 workspaces/arborist/test/arborist/reify-patch.js create mode 100644 workspaces/arborist/test/patch.js create mode 100644 workspaces/arborist/test/patched-dependencies-resolve.js create mode 100644 workspaces/arborist/test/patched-dependencies.js diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index bbe20d942aeb9..42166c249a3c5 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -540,6 +540,7 @@ graph LR; npmcli-arborist-->bin-links; npmcli-arborist-->cacache; npmcli-arborist-->common-ancestor-path; + npmcli-arborist-->diff; npmcli-arborist-->gar-promise-retry["@gar/promise-retry"]; npmcli-arborist-->hosted-git-info; npmcli-arborist-->isaacs-string-locale-compare["@isaacs/string-locale-compare"]; diff --git a/docs/lib/content/commands/npm-patch.md b/docs/lib/content/commands/npm-patch.md new file mode 100644 index 0000000000000..030c414f4d6ac --- /dev/null +++ b/docs/lib/content/commands/npm-patch.md @@ -0,0 +1,69 @@ +--- +title: npm-patch +section: 1 +description: Apply local patches to installed dependencies +--- + +### Synopsis + + + +### Description + +`npm patch` lets you apply small, local modifications to an installed +dependency and have them re-applied automatically on every install. Patches +are declared in the `patchedDependencies` field of your root `package.json`, +stored as plain unified diffs under the `patches/` directory, and recorded with +a content hash in `package-lock.json`. + +Because patches are applied during the install itself, they work regardless of +`install-strategy`, apply to transitive dependencies, and are **not** disabled +by `--ignore-scripts`. + +The bare form `npm patch ` is shorthand for `npm patch add `. A +package literally named like a subcommand must use the explicit form, e.g. +`npm patch add add`. + +* `npm patch add [@]` + + Prepares a package for editing. npm extracts a clean copy of the resolved + package tarball into a temporary directory outside `node_modules` and prints + its path. Edit the files there, then run `npm patch commit`. + + If more than one version of `` is installed, re-run with an exact + selector such as `npm patch add lodash@4.17.21`. + +* `npm patch commit ` + + Diffs the edited directory against a clean copy of the original tarball, + writes the unified diff to `/@.patch`, adds the + entry to `patchedDependencies`, and updates `package-lock.json`. + +* `npm patch ls` + + Lists registered patches and how many installed nodes each one matches. + +* `npm patch rm [@]` + + Removes the matching entries from `patchedDependencies`, deletes the patch + file when no other entry references it, and updates `package-lock.json`. If + `` is omitted, all entries for `` are removed. + +### Failure modes + +By default any patch problem is a hard error that aborts the install: a patch +that fails to apply, a registered patch that matches no installed package, a +missing patch file, or a patch whose hash does not match the lockfile. + +Two CLI-only flags relax this for one-off cases: `--allow-unused-patches` and +`--ignore-patch-failures`. + +### Configuration + + +## See Also + +* [npm install](/commands/npm-install) +* [npm ci](/commands/npm-ci) +* [package-lock.json](/configuring-npm/package-lock-json) +* [config](/commands/npm-config) diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index 96614ba6da7d2..7d148b43eab5f 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -120,6 +120,9 @@ - title: npm pack url: /commands/npm-pack description: Create a tarball from a package + - title: npm patch + url: /commands/npm-patch + description: Apply local patches to installed dependencies - title: npm ping url: /commands/npm-ping description: Ping npm registry diff --git a/lib/commands/audit.js b/lib/commands/audit.js index 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..ef5ce206aff6f 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -44,6 +44,15 @@ class CI extends ArboristWorkspaceCmd { }) } + // npm ci is always strict about patches; the relax flags are not accepted + for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) { + if (this.npm.config.find(flag) === 'cli') { + throw Object.assign(new Error(`The --${flag} flag is not allowed with \`npm ci\`.`), { + code: 'ECIPATCHFLAG', + }) + } + } + const dryRun = this.npm.config.get('dry-run') const ignoreScripts = this.npm.config.get('ignore-scripts') const where = this.npm.prefix diff --git a/lib/commands/dedupe.js b/lib/commands/dedupe.js index e703e9fb3741a..0b3fee45bf256 100644 --- a/lib/commands/dedupe.js +++ b/lib/commands/dedupe.js @@ -1,5 +1,6 @@ const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') // dedupe duplicated packages, or find them in the tree @@ -47,6 +48,7 @@ class Dedupe extends ArboristWorkspaceCmd { save: false, workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), } const arb = new Arborist(opts) await arb.dedupe(opts) diff --git a/lib/commands/install.js b/lib/commands/install.js index 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..1e46443d32c58 100644 --- a/lib/commands/ls.js +++ b/lib/commands/ls.js @@ -333,6 +333,11 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => { ? ' ' + chalk.dim('overridden') : '' ) + + ( + node.patched + ? ' ' + chalk.cyan(`[patched: ${node.patched.path}]`) + : '' + ) + (isGitNode(node) ? ` (${node.resolved})` : '') + (node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') + (long ? `\n${node.package.description || ''}` : '') @@ -389,6 +394,10 @@ const getJsonOutputItem = (node, { global, long }) => { item.invalid = node[_invalid] } + if (node.patched) { + item.patched = node.patched.path + } + if (node[_missing] && !isOptional(node)) { item.required = node[_required] item.missing = true diff --git a/lib/commands/patch.js b/lib/commands/patch.js new file mode 100644 index 0000000000000..b012649cbc185 --- /dev/null +++ b/lib/commands/patch.js @@ -0,0 +1,734 @@ +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 + const markerPath = join(editDir, UPDATE_MARKER) + const markerRaw = await readFile(markerPath, 'utf8').catch(() => null) + let marker = null + if (markerRaw !== null) { + // always remove the marker so it never lands in the generated diff, even if it is malformed + await rm(markerPath, { force: true }) + try { + marker = JSON.parse(markerRaw) + } catch { + throw patchErr(`invalid update marker in ${editDir}`, 'EPATCHBADMARKER') + } + } + + // extract a clean baseline to diff against + const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-')) + let diff, packageJsonChanged + try { + await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions) + ;({ diff, packageJsonChanged } = await diffDirs(base, editDir)) + } 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') + } + 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/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/patch-diff.js b/lib/utils/patch-diff.js new file mode 100644 index 0000000000000..2f7fd7bddf939 --- /dev/null +++ b/lib/utils/patch-diff.js @@ -0,0 +1,82 @@ +// 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. +const diffDirs = async (originalDir, editedDir) => { + 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 + } + + 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..cdab0ed0ea046 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -22,6 +22,13 @@ function validateLockfile (virtualTree, idealTree) { errors.push(`Invalid: lock file's ${lock.name}@${lock.version} does ` + `not satisfy ${entry.name}@${entry.version}`) } + + // a patch whose on-disk hash or path diverges from the lockfile is out of sync + if ((lock.patched?.integrity || null) !== (entry.patched?.integrity || null) || + (lock.patched?.path || null) !== (entry.patched?.path || null)) { + errors.push(`Invalid: patch for ${entry.name}@${entry.version} does not ` + + `match the patch recorded in the lock file`) + } } return errors } diff --git a/package-lock.json b/package-lock.json index 05d5476423dfb..439c5044cda74 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", 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 06a6d1b5fc0f6..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 diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index d4a06d7fbdb55..5efaa3f4136a0 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -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 @@ -285,6 +296,7 @@ init.version = "1.0.0" install-links = false install-strategy = "hoisted" json = false +keep-edit-dir = false key = null legacy-bundling = false legacy-peer-deps = false @@ -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/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index d4d938fbedf4a..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 @@ -1132,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 @@ -1572,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 @@ -2052,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 @@ -2575,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", @@ -2623,6 +2707,7 @@ Array [ "tag", "tag-version-prefix", "timing", + "to", "umask", "unicode", "update-notifier", @@ -2741,6 +2826,7 @@ Array [ "package-lock-only", "pack-destination", "packages", + "patches-dir", "parseable", "allow-scripts-pending", "allow-scripts-pin", @@ -2818,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", @@ -2929,6 +3021,7 @@ Object { "packDestination": ".", "parseable": false, "password": null, + "patchesDir": "patches", "preferDedupe": false, "preferOffline": false, "preferOnline": false, @@ -5314,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/validate-lockfile.js.test.cjs b/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs index 98a51267b1f4e..bafd54bd2ed61 100644 --- a/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs +++ b/tap-snapshots/test/lib/utils/validate-lockfile.js.test.cjs @@ -19,6 +19,14 @@ exports[`test/lib/utils/validate-lockfile.js TAP identical inventory for both id Array [] ` +exports[`test/lib/utils/validate-lockfile.js TAP mismatching patch integrity or path > should error on integrity drift, path drift, and a newly added patch 1`] = ` +Array [ + "Invalid: patch for foo@1.0.0 does not match the patch recorded in the lock file", + "Invalid: patch for bar@2.0.0 does not match the patch recorded in the lock file", + "Invalid: patch for baz@3.0.0 does not match the patch recorded in the lock file", +] +` + exports[`test/lib/utils/validate-lockfile.js TAP mismatching versions on inventory > should have errors for each mismatching version 1`] = ` Array [ "Invalid: lock file's foo@1.0.0 does not satisfy foo@2.0.0", diff --git a/test/lib/commands/ci.js b/test/lib/commands/ci.js index 15559dc688b09..e8b2a69264674 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -343,6 +343,17 @@ t.test('should throw ECIGLOBAL', async t => { await t.rejects(npm.exec('ci', []), { code: 'ECIGLOBAL' }) }) +t.test('rejects the patch relax flags', async t => { + for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) { + t.test(flag, async t => { + const { npm } = await loadMockNpm(t, { + config: { [flag]: true }, + }) + await t.rejects(npm.exec('ci', []), { code: 'ECIPATCHFLAG' }) + }) + } +}) + t.test('should throw error when ideal inventory mismatches virtual', async t => { const { npm, registry } = await loadMockNpm(t, { prefixDir: { diff --git a/test/lib/commands/ls.js b/test/lib/commands/ls.js index ab98773bc68e5..878ffb3f38c53 100644 --- a/test/lib/commands/ls.js +++ b/test/lib/commands/ls.js @@ -5405,3 +5405,47 @@ t.test('ls --install-strategy=linked', async t => { 'should report declared workspace as UNMET DEPENDENCY') }) }) + +t.test('patched dependency annotation', async t => { + const patchedLock = { + name: 'test-npm-ls', + version: '1.0.0', + lockfileVersion: 4, + requires: true, + packages: { + '': { name: 'test-npm-ls', version: '1.0.0', dependencies: { foo: '^1.0.0' } }, + 'node_modules/foo': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz', + integrity: 'sha512-deadbeef', + patched: { path: 'patches/foo@1.0.0.patch', integrity: 'sha512-abc' }, + }, + }, + } + const prefixDir = { + 'package.json': JSON.stringify({ + name: 'test-npm-ls', + version: '1.0.0', + dependencies: { foo: '^1.0.0' }, + patchedDependencies: { 'foo@1.0.0': 'patches/foo@1.0.0.patch' }, + }), + node_modules: { + '.package-lock.json': JSON.stringify(patchedLock), + foo: { 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }) }, + }, + } + + t.test('human output annotates the patched dependency', async t => { + const { npm, result, ls } = await mockLs(t, { config: {}, prefixDir }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + t.match(result(), /foo@1\.0\.0 \[patched: patches\/foo@1\.0\.0\.patch\]/) + }) + + t.test('json output records the patch path', async t => { + const { npm, result, ls } = await mockLs(t, { config: { json: true }, prefixDir }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + t.equal(JSON.parse(result()).dependencies.foo.patched, 'patches/foo@1.0.0.patch') + }) +}) diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js new file mode 100644 index 0000000000000..cbf8493972eb5 --- /dev/null +++ b/test/lib/commands/patch.js @@ -0,0 +1,1244 @@ +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 auto-detects the new version and drops a fully-shadowed range', async t => { + const name = 'upd-range' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '1.0.0' }), + }) + await setupVersions(npm, registry, name, { '1.0.0': 'x\n', '1.1.0': 'x\n' }) + await npm.exec('install', []) + // a patch that adds a file applies to any version, so the dep can float + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const editDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(editDir, 'EXTRA.txt'), 'extra\n') + await npm.exec('patch', ['commit', editDir]) + + // turn the exact selector into a range and float the lockfile to 1.1.0 + const pkg = readJson(path.join(npm.prefix, 'package.json')) + pkg.dependencies[name] = '^1.0.0' + pkg.patchedDependencies = { [`${name}@^1.0.0`]: pkg.patchedDependencies[`${name}@1.0.0`] } + fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg)) + // clear the resolved tree so a fresh install floats the range up to 1.1.0 + fs.rmSync(path.join(npm.prefix, 'package-lock.json')) + fs.rmSync(path.join(npm.prefix, 'node_modules'), { recursive: true, force: true }) + await npm.exec('install', []) + + await npm.exec('patch', ['update', name]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${name}@1.1.0`]: `patches/${name}@1.1.0.patch` }, 'shadowed range dropped, new exact entry added') +}) + +t.test('update conflict leaves an edit dir; commit finalizes the rename', async t => { + const name = 'upd-conflict' + const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, { + config: { 'ignore-scripts': true, audit: false }, + strictRegistryNock: false, + prefixDir: rootWith({ [name]: '^1.0.0' }), + }) + // v2 changes the same line the patch edits -> conflict + await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' }) + await npm.exec('install', []) + outputs.length = 0 + await npm.exec('patch', ['add', name]) + const addDir = joinedOutput().match(/directory: (.+)/)[1].trim() + fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n') + await npm.exec('patch', ['commit', addDir]) + + npm.config.set('to', '2.0.0') + outputs.length = 0 + await npm.exec('patch', ['update', name]) + const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim() + t.ok(fs.existsSync(path.join(editDir, '.npm-patch-update.json')), 'cleanup marker written') + t.match(fs.readFileSync(path.join(editDir, 'index.js'), 'utf8'), /<<<<<<>>>>>>[^\n]*\n/, '$1') + fs.writeFileSync(path.join(editDir, 'index.js'), src) + await npm.exec('patch', ['commit', editDir]) + t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies, + { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` }, 'renamed after the resolving commit') + t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed') +}) + +t.test('update conflict on a 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/utils/cli-only-flag.js b/test/lib/utils/cli-only-flag.js new file mode 100644 index 0000000000000..a30d97bc450c9 --- /dev/null +++ b/test/lib/utils/cli-only-flag.js @@ -0,0 +1,33 @@ +const t = require('tap') +const cliOnlyFlag = require('../../../lib/utils/cli-only-flag.js') +const { patchRelaxOpts } = require('../../../lib/utils/cli-only-flag.js') + +// minimal config stub: `where` is the layer find() would resolve the key from +const mockConfig = (where, value) => ({ + find: () => where, + get: () => value, +}) + +t.test('returns the value when set on the cli layer', t => { + t.equal(cliOnlyFlag(mockConfig('cli', true), 'x'), true) + t.end() +}) + +t.test('returns undefined when resolved from any non-cli layer', t => { + for (const where of ['env', 'project', 'user', 'global', 'default']) { + t.equal(cliOnlyFlag(mockConfig(where, true), 'x'), undefined, `${where} is ignored`) + } + t.end() +}) + +t.test('patchRelaxOpts maps the cli-only patch flags to arborist options', t => { + const config = { + find: key => (key === 'allow-unused-patches' ? 'cli' : 'project'), + get: () => true, + } + t.strictSame(patchRelaxOpts(config), { + allowUnusedPatches: true, + ignorePatchFailures: undefined, + }) + t.end() +}) diff --git a/test/lib/utils/patch-diff.js b/test/lib/utils/patch-diff.js new file mode 100644 index 0000000000000..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..a3942a6903658 100644 --- a/test/lib/utils/validate-lockfile.js +++ b/test/lib/utils/validate-lockfile.js @@ -67,6 +67,24 @@ t.test('mismatching versions on inventory', async t => { ) }) +t.test('mismatching patch integrity or path', async t => { + t.matchSnapshot( + validateLockfile( + new Map([ + ['foo', { name: 'foo', version: '1.0.0', patched: { path: 'patches/foo.patch', integrity: 'sha512-aaa' } }], + ['bar', { name: 'bar', version: '2.0.0', patched: { path: 'patches/bar.patch', integrity: 'sha512-bbb' } }], + ['baz', { name: 'baz', version: '3.0.0' }], + ]), + new Map([ + ['foo', { name: 'foo', version: '1.0.0', patched: { path: 'patches/foo.patch', integrity: 'sha512-CHANGED' } }], + ['bar', { name: 'bar', version: '2.0.0', patched: { path: 'patches/moved.patch', integrity: 'sha512-bbb' } }], + ['baz', { name: 'baz', version: '3.0.0', patched: { path: 'patches/baz.patch', integrity: 'sha512-ccc' } }], + ]) + ), + 'should error on integrity drift, path drift, and a newly added patch' + ) +}) + t.test('missing virtualTree inventory', async t => { t.matchSnapshot( validateLockfile( diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 9bb4ad048acf3..5c2b4add9afc5 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -25,6 +25,7 @@ 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 Shrinkwrap = require('../shrinkwrap.js') const { defaultLockfileVersion } = Shrinkwrap const Node = require('../node.js') @@ -180,6 +181,10 @@ module.exports = cls => class IdealTreeBuilder extends cls { await this.#fixDepFlags() await this.#pruneFailedOptional() await this.#checkEngineAndPlatform() + await resolvePatchedDependencies(this.idealTree, { + path: this.path, + allowUnusedPatches: this.options.allowUnusedPatches, + }) } finally { timeEnd() this.finishTracker('idealTree') diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 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-virtual.js b/workspaces/arborist/lib/arborist/load-virtual.js index 36e57a011da5f..d10b198681d44 100644 --- a/workspaces/arborist/lib/arborist/load-virtual.js +++ b/workspaces/arborist/lib/arborist/load-virtual.js @@ -242,6 +242,7 @@ To fix: path, realpath: path, integrity: sw.integrity, + patched: sw.patched, resolved: consistentResolve(sw.resolved, this.path, path), pkg: sw, loadOverrides, diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index e76a277ebbf92..0183324ea0f3b 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,59 @@ module.exports = cls => class Reifier extends cls { return symlink(rel, node.path, 'junction') } + // apply a registered patch to a freshly extracted node, after extract and before rebuild + async #applyPatch (node) { + if (!node.patched) { + return + } + const { path: patchPath, integrity } = node.patched + + // validate the patch file here too, since reify can run on an ideal tree that skipped resolvePatchedDependencies + let contents + try { + contents = await readFile(resolve(this.path, patchPath)) + } catch { + throw Object.assign( + new Error(`patch file not found: ${patchPath}`), + { code: 'EPATCHNOTFOUND', path: patchPath, node: node.name } + ) + } + if (patchIntegrity(contents) !== integrity) { + throw Object.assign( + new Error(`patch file ${patchPath} does not match the recorded integrity`), + { code: 'EPATCHINTEGRITY', path: patchPath, node: node.name } + ) + } + + try { + await applyPatchToDir({ patch: contents, cwd: node.path }) + } catch (er) { + if (this.options.ignorePatchFailures) { + // the linked side-store keys a package by its patch, so an unpatched package cannot be represented at a patched key and would be trusted on later installs + if (node.isInStore) { + throw Object.assign( + new Error(`Cannot skip the failed patch for ${node.name} under install-strategy=linked. ` + + `Fix the patch or install with a different strategy.`), + { code: 'EPATCHFAILED', path: patchPath, node: node.name } + ) + } + log.warn('patch', `failed to apply ${patchPath} to ${node.name}: ${er.message}`) + // the patch was not applied, so do not record it in the lockfile + node.patched = null + return + } + throw er + } + } + // if the node is optional, then the failure of the promise is nonfatal // just add it and its optional set to the trash list. [_handleOptionalFailure] (node, p) { - return (node.optional ? p.catch(() => { + return (node.optional ? p.catch((er) => { + // a declared patch must apply or fail loudly, even on an optional dep + if (typeof er?.code === 'string' && er.code.startsWith('EPATCH')) { + throw er + } const set = optionalSet(node) for (const node of set) { log.verbose('reify', 'failed optional dependency', node.path) diff --git a/workspaces/arborist/lib/diff.js b/workspaces/arborist/lib/diff.js index 12a27ed68157f..704dc7bafc42b 100644 --- a/workspaces/arborist/lib/diff.js +++ b/workspaces/arborist/lib/diff.js @@ -130,6 +130,16 @@ const getAction = ({ actual, ideal }) => { return 'CHANGE' } + // a change in patch state requires re-extracting and re-applying + if ((ideal.patched?.integrity || null) !== (actual.patched?.integrity || null)) { + return 'CHANGE' + } + + // a node whose patch was just removed must be re-extracted to revert the patched files + if (ideal.patchRemoved) { + return 'CHANGE' + } + const binsExist = ideal.binPaths.every((path) => existsSync(path)) // top nodes, links, and git deps won't have integrity, but do have resolved diff --git a/workspaces/arborist/lib/isolated-classes.js b/workspaces/arborist/lib/isolated-classes.js index 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..1e1d1bae298e7 100644 --- a/workspaces/arborist/lib/node.js +++ b/workspaces/arborist/lib/node.js @@ -94,6 +94,7 @@ class Node { optional = true, overrides, parent, + patched = null, path, peer = true, realpath, @@ -169,6 +170,8 @@ class Node { } } this.integrity = integrity || this.package._integrity || null + // Patch record { path, integrity } or null, set from patchedDependencies or the lockfile. + this.patched = patched || null this.installLinks = installLinks this.legacyPeerDeps = legacyPeerDeps diff --git a/workspaces/arborist/lib/patch.js b/workspaces/arborist/lib/patch.js new file mode 100644 index 0000000000000..51d3c1878600f --- /dev/null +++ b/workspaces/arborist/lib/patch.js @@ -0,0 +1,111 @@ +// Native dependency patching helpers shared across build-ideal-tree and reify. +// Patches are plain unified diffs (git apply-compatible) applied with jsdiff using a fuzz factor of 0 so that any context drift fails loudly. +const { applyPatch, parsePatch } = require('diff') +const ssri = require('ssri') +const fs = require('node:fs') +const { promises: fsp } = fs +const { resolve, relative, dirname, isAbsolute } = require('node:path') + +// Compute the SSRI integrity of a patch file's contents. +// Accepts a string or Buffer and returns a sha512 SSRI string. +const patchIntegrity = data => + ssri.fromData(Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'), { + algorithms: ['sha512'], + }).toString() + +// Strip a leading git-style "a/" or "b/" prefix from a diff path. +const stripPrefix = file => file.replace(/^[ab]\//, '') + +// True when a diff path points at /dev/null, signalling a file add or delete. +const isDevNull = file => !file || file === '/dev/null' || /(^|\/)\.dev\/null$/.test(file) + +const patchError = (message, code, file) => + Object.assign(new Error(message), { code, file }) + +// Resolve a diff path under cwd and refuse anything that escapes the package directory. +const containedTarget = (cwd, file) => { + const target = resolve(cwd, file) + const rel = relative(cwd, target) + if (!rel || rel.startsWith('..') || isAbsolute(rel)) { + throw patchError(`patch path escapes the package directory: ${file}`, 'EPATCHUNSAFE', file) + } + return target +} + +// Run a parsed file patch against a source string with fuzz 0. +// Returns the patched text, or throws EPATCHFAILED on any context mismatch. +const strictApply = (source, filePatch, file) => { + const patched = applyPatch(source, filePatch, { fuzzFactor: 0 }) + if (patched === false) { + throw patchError(`patch could not be applied to ${file}`, 'EPATCHFAILED', file) + } + return patched +} + +// Apply a single parsed file patch under cwd. +// Handles modified, added (--- /dev/null) and deleted (+++ /dev/null) files. +const applyFilePatch = async (filePatch, cwd) => { + const isAdd = isDevNull(filePatch.oldFileName) + const isDelete = isDevNull(filePatch.newFileName) + + if (isDelete) { + const file = stripPrefix(filePatch.oldFileName) + const target = containedTarget(cwd, file) + // verify the file still matches the diff before removing it + const source = await fsp.readFile(target, 'utf8').catch(() => { + throw patchError(`patch target to delete is missing: ${file}`, 'EPATCHFAILED', file) + }) + strictApply(source, filePatch, file) + await fsp.rm(target, { force: true }) + return + } + + const file = stripPrefix(filePatch.newFileName) + const target = containedTarget(cwd, file) + + if (isAdd) { + // a new file must not already exist, otherwise the tarball drifted + if (fs.existsSync(target)) { + throw patchError(`patch adds a file that already exists: ${file}`, 'EPATCHFAILED', file) + } + const created = strictApply('', filePatch, file) + await fsp.mkdir(dirname(target), { recursive: true }) + await fsp.writeFile(target, created) + return + } + + const source = await fsp.readFile(target, 'utf8').catch(() => { + throw patchError(`patch target is missing: ${file}`, 'EPATCHFAILED', file) + }) + const mode = (await fsp.stat(target)).mode + const patched = strictApply(source, filePatch, file) + await fsp.writeFile(target, patched) + await fsp.chmod(target, mode) +} + +// Apply a unified diff to the package extracted at `cwd`. +// `patch` is the raw diff contents (string or Buffer). +// Throws with code EPATCHFAILED on any hunk or file that cannot be applied. +const applyPatchToDir = async ({ patch, cwd }) => { + const filePatches = parsePatch(patch.toString('utf8')) + for (const filePatch of filePatches) { + // jsdiff emits an empty trailing patch for some inputs; skip those. + if (!filePatch.hunks.length && isDevNull(filePatch.oldFileName) && isDevNull(filePatch.newFileName)) { + continue + } + try { + await applyFilePatch(filePatch, cwd) + } catch (er) { + // re-code raw filesystem errors so a patch failure is never mistaken for an optional-install skip + if (typeof er?.code === 'string' && er.code.startsWith('EPATCH')) { + throw er + } + throw Object.assign(new Error(`failed to apply patch: ${er.message}`), { code: 'EPATCHFAILED', cause: er }) + } + } +} + +module.exports = { + applyPatchToDir, + patchIntegrity, +} diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js new file mode 100644 index 0000000000000..c814974b5471e --- /dev/null +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -0,0 +1,152 @@ +// Resolve the root patchedDependencies map against an ideal tree. +// Attaches node.patched = { path, integrity } to each matched node. +// Enforces the failure modes (workspace-member entry, missing file, unused patch, non-registry target, ambiguous selectors) as hard errors. +const semver = require('semver') +const npa = require('npm-package-arg') +const { resolve, relative, isAbsolute } = require('node:path') +const { readFile } = require('node:fs/promises') +const { patchIntegrity } = require('./patch.js') + +// Split a selector key into { name, spec }. spec is null for a name-only key. +const parseSelector = key => { + const at = key.indexOf('@', 1) + return at === -1 + ? { name: key, spec: null } + : { name: key.slice(0, at), spec: key.slice(at + 1) } +} + +const err = (message, code, extra = {}) => + Object.assign(new Error(message), { code, ...extra }) + +// Pick the most specific range among several that all match a version. +// Returns the strict subset, or throws when ordering is ambiguous. +// semver.subset is transitive, so the running minimum is a subset of every range it did not throw on. +const pickRange = (ranges, name, version) => { + let best = ranges[0] + for (const r of ranges.slice(1)) { + if (semver.subset(r.spec, best.spec, { loose: true })) { + best = r + } else if (!semver.subset(best.spec, r.spec, { loose: true })) { + throw err( + `Ambiguous patch selectors for ${name}@${version}: ` + + `"${name}@${best.spec}" and "${name}@${r.spec}" overlap but neither ` + + `is a subset. Add an exact "${name}@${version}" entry to disambiguate.`, + 'EPATCHAMBIGUOUS' + ) + } + } + return best +} + +// Choose the winning selector for a node: exact > range subset > name-only. +const matchSelector = (selectors, node) => { + const { name, version } = node + const matches = selectors.filter(s => s.name === name) + if (!matches.length) { + return null + } + + const exact = matches.find(s => + s.spec && semver.valid(s.spec) && semver.eq(s.spec, version, { loose: true })) + if (exact) { + return exact + } + + const ranges = matches.filter(s => + s.spec && !semver.valid(s.spec) && semver.satisfies(version, s.spec, { loose: true })) + if (ranges.length) { + return pickRange(ranges, name, version) + } + + return matches.find(s => s.spec === null) || null +} + +const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => { + const patchedDependencies = tree.package?.patchedDependencies || {} + const selectors = Object.entries(patchedDependencies) + .map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath })) + + // cache patch file integrity by path so shared diffs are read once + const integrityCache = new Map() + const readPatch = async patchPath => { + if (integrityCache.has(patchPath)) { + return integrityCache.get(patchPath) + } + // patch files must live inside the project so the patch set stays auditable + const abs = resolve(path, patchPath) + const rel = relative(path, abs) + if (!rel || rel.startsWith('..') || isAbsolute(rel)) { + throw err(`patch path escapes the project: ${patchPath}`, 'EPATCHUNSAFE', { path: patchPath }) + } + let contents + try { + contents = await readFile(abs) + } catch { + throw err(`patch file not found: ${patchPath}`, 'EPATCHNOTFOUND', { path: patchPath }) + } + const integrity = patchIntegrity(contents) + integrityCache.set(patchPath, integrity) + return integrity + } + + const usedKeys = new Set() + for (const node of tree.inventory.values()) { + // patchedDependencies is honoured only in the root manifest + if (node.isWorkspace) { + // Link.package already delegates to its target's package + const pkg = node.package + if (pkg?.patchedDependencies && Object.keys(pkg.patchedDependencies).length) { + throw err( + `patchedDependencies is only supported in the root package.json, ` + + `but was found in workspace "${node.name}". Move the entry to the root.`, + 'EPATCHWORKSPACE', + { workspace: node.name } + ) + } + continue + } + if (node.isProjectRoot) { + continue + } + + const selector = matchSelector(selectors, node) + if (!selector) { + // a node that was patched but no longer matches a selector must be re-extracted to revert its files + if (node.patched) { + node.patchRemoved = true + } + node.patched = null + continue + } + + // a non-registry consumer edge (file:, git:, http(s)) means there is no registry tarball to patch; npm: aliases stay registry. + // checking edges (not isRegistryDependency) avoids rejecting an edgeless node, which is still a registry dep. + if ([...node.edgesIn].some(e => e.spec && !npa(e.spec).registry)) { + throw err( + `Cannot patch non-registry dependency ${node.name}@${node.version} ` + + `(selector "${selector.key}"). Only registry dependencies can be patched.`, + 'EPATCHNONREGISTRY', + { node: node.name } + ) + } + + const integrity = await readPatch(selector.patchPath) + node.patched = { path: selector.patchPath, integrity } + usedKeys.add(selector.key) + } + + if (selectors.length && !allowUnusedPatches) { + const unused = selectors.filter(s => !usedKeys.has(s.key)) + if (unused.length) { + throw err( + `The following patches were registered but matched no installed ` + + `package:\n${unused.map(s => ` ${s.key} -> ${s.patchPath}`).join('\n')}\n` + + `Use --allow-unused-patches to install anyway.`, + 'EPATCHUNUSED', + { unused: unused.map(s => s.key) } + ) + } + } +} + +module.exports = { resolvePatchedDependencies, matchSelector, parseSelector } diff --git a/workspaces/arborist/lib/shrinkwrap.js b/workspaces/arborist/lib/shrinkwrap.js index e9e31d8090d0e..01e0b11abc33c 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -10,6 +10,9 @@ const localeCompare = require('@isaacs/string-locale-compare')('en') const defaultLockfileVersion = 3 +// Bumped to 4 only when a node carries a patch record, so older clients abort. +const patchedLockfileVersion = 4 +const maxLockfileVersion = 4 // for comparing nodes to yarn.lock entries const mismatch = (a, b) => a && b && a !== b @@ -107,6 +110,7 @@ const nodeMetaKeys = [ 'integrity', 'inBundle', 'hasInstallScript', + 'patched', ] const metaFieldFromPkg = (pkg, key) => { @@ -458,6 +462,14 @@ class Shrinkwrap { this.ancientLockfile = false data = {} } + // refuse lockfiles newer than we understand so we never install unpatched + if (data.lockfileVersion > maxLockfileVersion) { + throw Object.assign( + new Error(`Unsupported lockfileVersion ${data.lockfileVersion}. ` + + `This npm only supports up to ${maxLockfileVersion}. Please upgrade npm.`), + { code: 'ELOCKFILEVERSION' } + ) + } // auto convert v1 lockfiles to v3 // leave v2 in place unless configured // v3 by default @@ -940,6 +952,14 @@ 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 + } 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..e7eb5c9736b5f 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", diff --git a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs index 4147de62640d7..aa2afbd6ccdcf 100644 --- a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs @@ -26,6 +26,7 @@ Link { "location": "../../../../../some/other/path", "name": "path", "optional": true, + "patched": null, "path": "/home/user/some/other/path", "peer": true, "queryContext": Object {}, @@ -72,6 +73,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "", "name": "path", "optional": true, + "patched": null, "path": "/home/user/projects/some/kind/of/path", "peer": true, "queryContext": Object {}, @@ -88,6 +90,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "../../../../../some/other/path", "name": "path", "optional": true, + "patched": null, "path": "/home/user/some/other/path", "peer": true, "queryContext": Object {}, @@ -116,6 +119,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "location": "", "name": "path", "optional": true, + "patched": null, "path": "/home/user/projects/some/kind/of/path", "peer": true, "queryContext": Object {}, diff --git a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs index 03af283d2fbc5..e1075bd0cbdd3 100644 --- a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs @@ -41,6 +41,7 @@ exports[`test/node.js TAP basic instantiation > just a lone root node 1`] = ` }, "parent": undefined, }, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -217,6 +218,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -244,6 +246,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -308,6 +311,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -319,6 +323,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -364,6 +369,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -375,6 +381,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -413,6 +420,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -461,6 +469,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -472,6 +481,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -500,6 +510,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -544,6 +555,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -555,6 +567,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -570,6 +583,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "", "name": "workspaces_root", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root", "peer": true, "queryContext": Object {}, @@ -618,6 +632,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/foo", "peer": true, "queryContext": Object {}, @@ -629,6 +644,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/foo", "peer": true, "queryContext": Object {}, @@ -674,6 +690,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "node_modules/unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/node_modules/unknown", "peer": true, "queryContext": Object {}, @@ -685,6 +702,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "location": "unknown", "name": "unknown", "optional": true, + "patched": null, "path": "/home/user/projects/workspaces_root/unknown", "peer": true, "queryContext": Object {}, @@ -724,6 +742,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -761,6 +780,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -813,6 +833,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -832,6 +853,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -871,6 +893,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -903,6 +926,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -935,6 +959,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -967,6 +992,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -995,6 +1021,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1048,6 +1075,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1059,6 +1087,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1127,6 +1156,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1164,6 +1194,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1216,6 +1247,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1235,6 +1267,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -1267,6 +1300,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1306,6 +1340,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -1338,6 +1373,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -1370,6 +1406,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -1402,6 +1439,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -1430,6 +1468,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1483,6 +1522,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1494,6 +1534,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1524,6 +1565,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1561,6 +1603,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1589,6 +1632,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -1603,6 +1647,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -1634,6 +1679,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1682,6 +1728,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -1734,6 +1781,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -1753,6 +1801,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -1792,6 +1841,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -1824,6 +1874,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -1856,6 +1907,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -1888,6 +1940,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -1916,6 +1969,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -1993,6 +2047,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2045,6 +2100,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2064,6 +2120,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2096,6 +2153,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2135,6 +2193,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2174,6 +2233,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2206,6 +2266,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2238,6 +2299,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2270,6 +2332,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2298,6 +2361,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2313,6 +2377,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -2344,6 +2409,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2404,6 +2470,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2423,6 +2490,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2462,6 +2530,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2494,6 +2563,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2526,6 +2596,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2558,6 +2629,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2586,6 +2658,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2628,6 +2701,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2717,6 +2791,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2736,6 +2811,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -2768,6 +2844,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -2807,6 +2884,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -2839,6 +2917,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -2871,6 +2950,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -2903,6 +2983,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -2931,6 +3012,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -2973,6 +3055,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -2988,6 +3071,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -3019,6 +3103,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3079,6 +3164,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3098,6 +3184,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3134,6 +3221,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3166,6 +3254,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -3198,6 +3287,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -3230,6 +3320,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -3258,6 +3349,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -3288,6 +3380,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3345,6 +3438,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3356,6 +3450,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -3445,6 +3540,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3464,6 +3560,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3496,6 +3593,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3532,6 +3630,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3564,6 +3663,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -3596,6 +3696,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -3628,6 +3729,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -3656,6 +3758,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -3684,6 +3787,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3713,6 +3817,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3770,6 +3875,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -3781,6 +3887,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -3796,6 +3903,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -3827,6 +3935,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3887,6 +3996,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -3906,6 +4016,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -3942,6 +4053,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -3974,6 +4086,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4006,6 +4119,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4038,6 +4152,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4066,6 +4181,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4096,6 +4212,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4153,6 +4270,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4164,6 +4282,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4253,6 +4372,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4272,6 +4392,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -4304,6 +4425,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4340,6 +4462,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -4372,6 +4495,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4404,6 +4528,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4436,6 +4561,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4464,6 +4590,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4492,6 +4619,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4521,6 +4649,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4578,6 +4707,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4589,6 +4719,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4604,6 +4735,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -4635,6 +4767,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4674,6 +4807,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -4711,6 +4845,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -4763,6 +4898,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -4782,6 +4918,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -4821,6 +4958,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -4853,6 +4991,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -4885,6 +5024,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -4917,6 +5057,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -4945,6 +5086,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -4998,6 +5140,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5009,6 +5152,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5077,6 +5221,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5114,6 +5259,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5166,6 +5312,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5185,6 +5332,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -5217,6 +5365,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5256,6 +5405,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -5288,6 +5438,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -5320,6 +5471,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -5352,6 +5504,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -5380,6 +5533,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -5433,6 +5587,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5444,6 +5599,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5474,6 +5630,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5511,6 +5668,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5539,6 +5697,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -5553,6 +5712,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -5584,6 +5744,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5632,6 +5793,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5684,6 +5846,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -5703,6 +5866,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -5742,6 +5906,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -5774,6 +5939,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -5806,6 +5972,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -5838,6 +6005,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -5866,6 +6034,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -5943,6 +6112,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -5995,6 +6165,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6014,6 +6185,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6046,6 +6218,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6085,6 +6258,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6124,6 +6298,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6156,6 +6331,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6188,6 +6364,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6220,6 +6397,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6248,6 +6426,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6263,6 +6442,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -6294,6 +6474,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6354,6 +6535,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6373,6 +6555,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6412,6 +6595,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6444,6 +6628,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6476,6 +6661,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6508,6 +6694,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6536,6 +6723,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6578,6 +6766,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6667,6 +6856,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6686,6 +6876,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -6718,6 +6909,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -6757,6 +6949,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -6789,6 +6982,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -6821,6 +7015,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -6853,6 +7048,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -6881,6 +7077,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -6923,6 +7120,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -6938,6 +7136,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -6969,6 +7168,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7029,6 +7229,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7048,6 +7249,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7084,6 +7286,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7116,6 +7319,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7148,6 +7352,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7180,6 +7385,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -7208,6 +7414,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -7238,6 +7445,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7295,6 +7503,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7306,6 +7515,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -7395,6 +7605,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7414,6 +7625,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7446,6 +7658,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7482,6 +7695,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7514,6 +7728,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7546,6 +7761,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7578,6 +7794,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -7606,6 +7823,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -7634,6 +7852,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7663,6 +7882,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7720,6 +7940,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -7731,6 +7952,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -7746,6 +7968,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -7777,6 +8000,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7837,6 +8061,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -7856,6 +8081,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -7892,6 +8118,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -7924,6 +8151,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -7956,6 +8184,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -7988,6 +8217,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -8016,6 +8246,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -8046,6 +8277,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8103,6 +8335,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8114,6 +8347,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -8203,6 +8437,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -8222,6 +8457,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod", "name": "prod", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, "queryContext": Object {}, @@ -8254,6 +8490,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, @@ -8290,6 +8527,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/bundled", "name": "bundled", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, "queryContext": Object {}, @@ -8322,6 +8560,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/dev", "name": "dev", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, "queryContext": Object {}, @@ -8354,6 +8593,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/optional", "name": "optional", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, "queryContext": Object {}, @@ -8386,6 +8626,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/peer", "name": "peer", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, "queryContext": Object {}, @@ -8414,6 +8655,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, "queryContext": Object {}, @@ -8442,6 +8684,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8471,6 +8714,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8528,6 +8772,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, "queryContext": Object {}, @@ -8539,6 +8784,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta", "name": "meta", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, "queryContext": Object {}, @@ -8554,6 +8800,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "", "name": "root", "optional": true, + "patched": null, "path": "/home/user/projects/root", "peer": true, "queryContext": Object {}, @@ -8585,6 +8832,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, "queryContext": Object {}, diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js new file mode 100644 index 0000000000000..b3b0db980eb75 --- /dev/null +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -0,0 +1,378 @@ +const t = require('tap') +const fs = require('node:fs') +const { resolve } = require('node:path') +const { createTwoFilesPatch } = require('diff') +const MockRegistry = require('@npmcli/mock-registry') +const Arborist = require('../../lib/index.js') + +// build a git-style unified diff for a single file change +const filePatch = (file, before, after) => { + let p = createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after, '', '') + .replace('===================================================================\n', '') + if (before === '') { + p = p.replace(`--- a/${file}\t`, '--- /dev/null\t') + } + if (after === '') { + p = p.replace(`+++ b/${file}\t`, '+++ /dev/null\t') + } + return p +} + +const createRegistry = (t) => new MockRegistry({ + strict: false, + tap: t, + registry: 'https://registry.npmjs.org', +}) + +const newArb = (opt) => new Arborist({ + audit: false, + cache: opt.path, + registry: 'https://registry.npmjs.org', + timeout: 30 * 60 * 1000, + ...opt, +}) + +// the registry package source we patch in these tests +const PKG_NAME = 'patch-me' +const PKG_VERSION = '1.0.0' +const ORIGINAL = 'module.exports = "original"\n' +const PATCHED = 'module.exports = "patched"\n' + +// register the package manifest + tarball on the mock registry. +// manifestTimes controls how many packument GETs are served, tarballTimes how many tarball GETs. +// nock consumes one mock per request and teardown asserts every registered mock is used, so counts must match the requests a test makes. +const mockPackage = async (t, registry, { manifestTimes = 1, tarballTimes = 1 } = {}) => { + const src = t.testdir({ + 'package.json': JSON.stringify({ name: PKG_NAME, version: PKG_VERSION }), + 'index.js': ORIGINAL, + }) + const manifest = registry.manifest({ + name: PKG_NAME, + packuments: [{ version: PKG_VERSION }], + }) + registry.nock = registry.nock + .get(registry.fullPath(`/${PKG_NAME}`)).times(manifestTimes).reply(200, manifest) + for (let i = 0; i < tarballTimes; i++) { + await registry.tarball({ manifest: manifest.versions[PKG_VERSION], tarball: src }) + } + return manifest +} + +// write a project root + on-disk patch file, return its path +const makeProject = (t, { patch, patchedDependencies, extra = {} }) => { + const tree = { + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { [PKG_NAME]: `^${PKG_VERSION}` }, + ...(patchedDependencies ? { patchedDependencies } : {}), + }), + ...extra, + } + if (patch !== undefined) { + tree.patches = { [`${PKG_NAME}@${PKG_VERSION}.patch`]: patch } + } + return t.testdir(tree) +} + +const installedFile = (path) => + resolve(path, 'node_modules', PKG_NAME, 'index.js') + +t.test('registry dep with patch is applied and recorded in lockfile', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + + const patch = filePatch('index.js', ORIGINAL, PATCHED) + const path = makeProject(t, { + patch, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await newArb({ path }).reify() + + t.equal(fs.readFileSync(installedFile(path), 'utf8'), PATCHED, + 'extracted package was patched') + + const lock = JSON.parse(fs.readFileSync(resolve(path, 'package-lock.json'), 'utf8')) + t.equal(lock.lockfileVersion, 4, 'lockfile bumped to version 4') + const pkgEntry = lock.packages[`node_modules/${PKG_NAME}`] + t.ok(pkgEntry.patched, 'lockfile records patched') + t.equal(pkgEntry.patched.path, `patches/${PKG_NAME}@${PKG_VERSION}.patch`, + 'patched.path is the relative patch path') + t.match(pkgEntry.patched.integrity, /^sha512-/, 'patched.integrity is an SSRI') +}) + +t.test('patch is re-applied on a patch-change reify even with ignoreScripts', async t => { + const registry = createRegistry(t) + // two reifys: the second re-extracts the node due to the patch change. + // the second reify resolves the dep from the lockfile, so only one manifest GET. + await mockPackage(t, registry, { manifestTimes: 1, tarballTimes: 2 }) + + // first reify with no patch registered + const path = makeProject(t, {}) + await newArb({ path }).reify() + t.equal(fs.readFileSync(installedFile(path), 'utf8'), ORIGINAL, + 'first install is unpatched') + + // now add a patch + patchedDependencies and reify again with ignoreScripts + const patch = filePatch('index.js', ORIGINAL, PATCHED) + fs.mkdirSync(resolve(path, 'patches'), { recursive: true }) + fs.writeFileSync(resolve(path, 'patches', `${PKG_NAME}@${PKG_VERSION}.patch`), patch) + const rootPkg = JSON.parse(fs.readFileSync(resolve(path, 'package.json'), 'utf8')) + rootPkg.patchedDependencies = { + [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch`, + } + fs.writeFileSync(resolve(path, 'package.json'), JSON.stringify(rootPkg)) + + await newArb({ path, ignoreScripts: true }).reify() + + t.equal(fs.readFileSync(installedFile(path), 'utf8'), PATCHED, + 'patch applied on patch-change reify under ignoreScripts') + const lock = JSON.parse(fs.readFileSync(resolve(path, 'package-lock.json'), 'utf8')) + t.equal(lock.lockfileVersion, 4, 'lockfile bumped to version 4 after patch added') +}) + +t.test('patch that fails to apply throws EPATCHFAILED', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + + // a patch whose context does not match the extracted file + const patch = filePatch('index.js', 'totally different\n', 'something else\n') + const path = makeProject(t, { + patch, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await t.rejects(newArb({ path }).reify(), { code: 'EPATCHFAILED' }, + 'hunk that does not apply hard-errors') +}) + +t.test('ignorePatchFailures downgrades EPATCHFAILED to a warning', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + + const patch = filePatch('index.js', 'totally different\n', 'something else\n') + const path = makeProject(t, { + patch, + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await t.resolves(newArb({ path, ignorePatchFailures: true }).reify(), + 'failure is downgraded and reify continues') + // file remains as extracted since the patch was skipped + t.equal(fs.readFileSync(installedFile(path), 'utf8'), ORIGINAL, + 'package left unpatched after skipped failure') + // the skipped patch must not be recorded in the lockfile + const lock = JSON.parse(fs.readFileSync(resolve(path, 'package-lock.json'), 'utf8')) + t.notOk(lock.packages[`node_modules/${PKG_NAME}`].patched, + 'unapplied patch is not written to the lockfile') +}) + +t.test('missing patch file throws EPATCHNOTFOUND', async t => { + const registry = createRegistry(t) + // resolvePatchedDependencies fails before extract, so the tarball is never fetched + await mockPackage(t, registry, { tarballTimes: 0 }) + + // register patchedDependencies but do NOT write the patch file + const path = makeProject(t, { + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + await t.rejects(newArb({ path }).reify(), { code: 'EPATCHNOTFOUND' }, + 'missing patch file on disk hard-errors') +}) + +t.test('warns when a patch upgrades the lockfile version', async t => { + const registry = createRegistry(t) + await mockPackage(t, registry) + const path = makeProject(t, { + patch: filePatch('index.js', ORIGINAL, PATCHED), + patchedDependencies: { [`${PKG_NAME}@${PKG_VERSION}`]: `patches/${PKG_NAME}@${PKG_VERSION}.patch` }, + }) + + const warnings = [] + const onLog = (level, prefix, msg) => level === 'warn' && warnings.push(`${prefix} ${msg}`) + process.on('log', onLog) + t.teardown(() => process.removeListener('log', onLog)) + + await newArb({ path }).reify() + t.match(warnings.join('\n'), /requires lockfileVersion 4/, 'warns that the lockfile was upgraded') +}) + +t.test('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/diff.js b/workspaces/arborist/test/diff.js index 353085321f142..c0e6dff538ee5 100644 --- a/workspaces/arborist/test/diff.js +++ b/workspaces/arborist/test/diff.js @@ -439,6 +439,30 @@ t.test('extraneous pruning in workspaces', async t => { t.matchSnapshot(pruneWsB, 'prune in workspace B') }) +t.test('a removed patch forces a CHANGE even when other metadata matches', t => { + const integrity = 'sha512-iWml6OqIudarD/AngxZbQoeX0QoPywHRJ2rJbCcB0l9BfL1c5+Tl433R3V+AU404jppRHZGBofm97m48yKTRiA==' + const resolved = 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz' + const build = () => new Node({ + path: '/some/path', + pkg: { dependencies: { foo: '' } }, + children: [ + { name: 'foo', resolved, integrity, pkg: { name: 'foo', version: '1.0.0' } }, + ], + }) + const actual = build() + + // identical trees produce no diff entry for foo + t.equal(Diff.calculate({ actual, ideal: build() }).children.length, 0) + + // but a node marked patchRemoved must be re-extracted to revert its files + const ideal = build() + ideal.children.get('foo').patchRemoved = true + t.match(Diff.calculate({ actual, ideal }).children, [ + { ideal: ideal.children.get('foo'), action: 'CHANGE' }, + ]) + t.end() +}) + t.test('check versions (even if all other metadata is missing)', t => { const actual = new Node({ path: '/some/path', diff --git a/workspaces/arborist/test/patch.js b/workspaces/arborist/test/patch.js new file mode 100644 index 0000000000000..652d7252f00a1 --- /dev/null +++ b/workspaces/arborist/test/patch.js @@ -0,0 +1,143 @@ +const t = require('tap') +const { readFileSync, existsSync } = require('node:fs') +const { resolve } = require('node:path') +const { createTwoFilesPatch } = require('diff') +const { applyPatchToDir, patchIntegrity } = require('../lib/patch.js') + +// build a git-style unified diff for a single file change +const filePatch = (file, before, after) => { + let p = createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after, '', '') + .replace('===================================================================\n', '') + if (before === '') { + p = p.replace(`--- a/${file}\t`, '--- /dev/null\t') + } + if (after === '') { + p = p.replace(`+++ b/${file}\t`, '+++ /dev/null\t') + } + return p +} + +t.test('modifies an existing file', async t => { + const dir = t.testdir({ 'index.js': 'const v = 1\n' }) + await applyPatchToDir({ patch: filePatch('index.js', 'const v = 1\n', 'const v = 2\n'), cwd: dir }) + t.equal(readFileSync(resolve(dir, 'index.js'), 'utf8'), 'const v = 2\n') +}) + +t.test('creates a new file', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await applyPatchToDir({ patch: filePatch('added.js', '', 'new\n'), cwd: dir }) + t.equal(readFileSync(resolve(dir, 'added.js'), 'utf8'), 'new\n') +}) + +t.test('deletes a file', async t => { + const dir = t.testdir({ 'gone.js': 'bye\n' }) + await applyPatchToDir({ patch: filePatch('gone.js', 'bye\n', ''), cwd: dir }) + t.notOk(existsSync(resolve(dir, 'gone.js')), 'file removed') +}) + +t.test('creates nested directories for new files', async t => { + const dir = t.testdir({}) + await applyPatchToDir({ patch: filePatch('lib/deep/x.js', '', 'deep\n'), cwd: dir }) + t.equal(readFileSync(resolve(dir, 'lib/deep/x.js'), 'utf8'), 'deep\n') +}) + +t.test('empty patch content is a no-op', async t => { + const dir = t.testdir({ 'index.js': 'unchanged\n' }) + await applyPatchToDir({ patch: '', cwd: dir }) + t.equal(readFileSync(resolve(dir, 'index.js'), 'utf8'), 'unchanged\n') +}) + +t.test('throws on context drift (fuzz 0)', async t => { + const dir = t.testdir({ 'index.js': 'totally different content\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('index.js', 'const v = 1\n', 'const v = 2\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('refuses to write outside the package directory', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('../escape.js', '', 'pwned\n'), cwd: dir }), + { code: 'EPATCHUNSAFE' } + ) +}) + +t.test('refuses an absolute-path target', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('/tmp/escape.js', '', 'pwned\n'), cwd: dir }), + { code: 'EPATCHUNSAFE' } + ) +}) + +t.test('refuses to delete outside the package directory', async t => { + const dir = t.testdir({ 'index.js': 'x\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('../escape.js', 'secret\n', ''), cwd: dir }), + { code: 'EPATCHUNSAFE' } + ) +}) + +t.test('delete fails when the file drifted from the diff', async t => { + const dir = t.testdir({ 'gone.js': 'different content\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('gone.js', 'original\n', ''), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('delete fails when the target is missing', async t => { + const dir = t.testdir({}) + await t.rejects( + applyPatchToDir({ patch: filePatch('gone.js', 'original\n', ''), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('add fails when the file already exists', async t => { + const dir = t.testdir({ 'added.js': 'already here\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('added.js', '', 'new\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('modify fails when the target is missing', async t => { + const dir = t.testdir({}) + await t.rejects( + applyPatchToDir({ patch: filePatch('index.js', 'a\n', 'b\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('re-codes a raw filesystem error as EPATCHFAILED', async t => { + // "foo" exists as a file, so creating "foo/bar.js" makes mkdir throw a raw FS error + const dir = t.testdir({ foo: 'i am a file, not a directory\n' }) + await t.rejects( + applyPatchToDir({ patch: filePatch('foo/bar.js', '', 'new\n'), cwd: dir }), + { code: 'EPATCHFAILED' } + ) +}) + +t.test('patchIntegrity is stable and content-addressed', t => { + const a = patchIntegrity('hello') + const b = patchIntegrity(Buffer.from('hello')) + const c = patchIntegrity('world') + t.equal(a, b, 'string and buffer match') + t.match(a, /^sha512-/, 'is a sha512 SSRI') + t.not(a, c, 'different content -> different hash') + t.end() +}) + +t.test('round-trips a multi-file diff', async t => { + const dir = t.testdir({ 'a.js': 'aaa\n', 'del.js': 'd\n' }) + const patch = + filePatch('a.js', 'aaa\n', 'AAA\n') + + filePatch('b.js', '', 'bbb\n') + + filePatch('del.js', 'd\n', '') + await applyPatchToDir({ patch, cwd: dir }) + t.equal(readFileSync(resolve(dir, 'a.js'), 'utf8'), 'AAA\n') + t.equal(readFileSync(resolve(dir, 'b.js'), 'utf8'), 'bbb\n') + t.notOk(existsSync(resolve(dir, 'del.js'))) +}) diff --git a/workspaces/arborist/test/patched-dependencies-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js new file mode 100644 index 0000000000000..c2cd830dff1ef --- /dev/null +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -0,0 +1,323 @@ +// Exercises resolvePatchedDependencies, which is not exported, so it must be driven through Arborist. +// We build a real ideal tree against a t.testdir fixture and assert that node.patched is set on matches and that the documented error codes throw. +const t = require('tap') +const Arborist = require('../lib/arborist') + +// a trivial but valid unified diff used as the on-disk patch contents +const PATCH = '--- a/index.js\n+++ b/index.js\n@@ -1 +1 @@\n-old\n+new\n' + +// build a lockfileVersion 3 entry for a registry dependency +const lockEntry = (name, version) => ({ + version, + resolved: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, + integrity: 'sha512-deadbeef', +}) + +// build an offline ideal tree for a fixture directory, so registry deps need no network +const buildIdeal = (path, opts = {}) => + new Arborist({ path, offline: true, ...opts }).buildIdealTree() + +t.test('attaches node.patched on an exact match', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const dep = tree.inventory.query('name', 'dep').values().next().value + t.ok(dep, 'dep node exists') + t.ok(dep.patched, 'node.patched is set') + t.equal(dep.patched.path, 'fix.patch', 'records the relative patch path') + t.match(dep.patched.integrity, /^sha512-/, 'records the sha512 integrity') +}) + +t.test('no patchedDependencies is a no-op', async t => { + // empty patchedDependencies hits the early return guard + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + patchedDependencies: {}, + }), + }) + const tree = await buildIdeal(path) + for (const node of tree.inventory.values()) { + t.notOk(node.patched, `${node.name} is not patched`) + } +}) + +t.test('marks patchRemoved when a lockfile-patched node loses its selector', async t => { + // the lockfile records a patch but package.json declares none, so the node must be re-extracted + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 4, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': { + ...lockEntry('dep', '1.0.0'), + patched: { path: 'patches/dep@1.0.0.patch', integrity: 'sha512-old' }, + }, + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const dep = tree.inventory.query('name', 'dep').values().next().value + t.notOk(dep.patched, 'the stale patch record is cleared') + t.ok(dep.patchRemoved, 'the node is marked for re-extraction') +}) + +t.test('shares integrity cache across selectors pointing at one file', async t => { + // two selectors reference the same patch path, so the file is read once and both matched nodes get the identical integrity value + const path = t.testdir({ + 'shared.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { a: '^1.0.0', b: '^1.0.0' }, + patchedDependencies: { 'a@1.0.0': 'shared.patch', 'b@1.0.0': 'shared.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { a: '^1.0.0', b: '^1.0.0' } }, + 'node_modules/a': lockEntry('a', '1.0.0'), + 'node_modules/b': lockEntry('b', '1.0.0'), + }, + }), + node_modules: { + a: { 'package.json': JSON.stringify({ name: 'a', version: '1.0.0' }) }, + b: { 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const a = tree.inventory.query('name', 'a').values().next().value + const b = tree.inventory.query('name', 'b').values().next().value + t.ok(a.patched && b.patched, 'both nodes are patched') + t.equal(a.patched.integrity, b.patched.integrity, 'integrity is shared from the cache') + t.equal(a.patched.path, 'shared.patch') + t.equal(b.patched.path, 'shared.patch') +}) + +t.test('EPATCHWORKSPACE when a workspace member declares patchedDependencies', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['workspace-a'], + // a root entry is needed so the function does not early-return + patchedDependencies: { 'x@1.0.0': 'fix.patch' }, + }), + 'workspace-a': { + 'package.json': JSON.stringify({ + name: 'workspace-a', + version: '1.0.0', + patchedDependencies: { 'x@1.0.0': 'fix.patch' }, + }), + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHWORKSPACE', workspace: 'workspace-a' }) +}) + +t.test('skips a clean workspace member and patches a root dep', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['workspace-a'], + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, + }), + 'workspace-a': { + 'package.json': JSON.stringify({ name: 'workspace-a', version: '1.0.0' }), + }, + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' }, workspaces: ['workspace-a'] }, + 'workspace-a': { name: 'workspace-a', version: '1.0.0' }, + 'node_modules/workspace-a': { link: true, resolved: 'workspace-a' }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + 'workspace-a': t.fixture('symlink', '../workspace-a'), + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path) + const dep = tree.inventory.query('name', 'dep').values().next().value + t.ok(dep.patched, 'root dep is patched even though a workspace member exists') +}) + +t.test('EPATCHNONREGISTRY when the matched node is not a registry dependency', async t => { + // a file: dependency resolves to a Link/non-registry node and cannot be patched + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: 'file:./localdep' }, + patchedDependencies: { 'dep@1.0.0': 'fix.patch' }, + }), + localdep: { + 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }), + }, + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHNONREGISTRY', node: 'dep' }) +}) + +t.test('EPATCHUNUSED when a registered patch matches no node', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + // ghost has no installed node so it is unused + patchedDependencies: { 'ghost@1.0.0': 'fix.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHUNUSED', unused: ['ghost@1.0.0'] }) +}) + +t.test('allowUnusedPatches:true suppresses EPATCHUNUSED', async t => { + const path = t.testdir({ + 'fix.patch': PATCH, + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'ghost@1.0.0': 'fix.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + const tree = await buildIdeal(path, { allowUnusedPatches: true }) + for (const node of tree.inventory.values()) { + t.notOk(node.patched, `${node.name} is not patched`) + } +}) + +t.test('EPATCHNOTFOUND when the patch file is missing on disk', async t => { + // selector matches an installed node but the referenced patch file is absent + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': 'missing.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHNOTFOUND', path: 'missing.patch' }) +}) + +t.test('EPATCHUNSAFE when the patch path escapes the project', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + dependencies: { dep: '^1.0.0' }, + patchedDependencies: { 'dep@1.0.0': '../outside.patch' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'root', version: '1.0.0', dependencies: { dep: '^1.0.0' } }, + 'node_modules/dep': lockEntry('dep', '1.0.0'), + }, + }), + node_modules: { + dep: { 'package.json': JSON.stringify({ name: 'dep', version: '1.0.0' }) }, + }, + }) + + await t.rejects(buildIdeal(path), { code: 'EPATCHUNSAFE' }) +}) diff --git a/workspaces/arborist/test/patched-dependencies.js b/workspaces/arborist/test/patched-dependencies.js new file mode 100644 index 0000000000000..ea7d95211cbe1 --- /dev/null +++ b/workspaces/arborist/test/patched-dependencies.js @@ -0,0 +1,52 @@ +const t = require('tap') +const { parseSelector, matchSelector } = require('../lib/patched-dependencies.js') + +t.test('parseSelector', t => { + t.strictSame(parseSelector('lodash'), { name: 'lodash', spec: null }) + t.strictSame(parseSelector('lodash@4.17.21'), { name: 'lodash', spec: '4.17.21' }) + t.strictSame(parseSelector('lodash@^4.0.0'), { name: 'lodash', spec: '^4.0.0' }) + t.strictSame(parseSelector('@babel/core@7.23.0'), { name: '@babel/core', spec: '7.23.0' }) + t.strictSame(parseSelector('@babel/core'), { name: '@babel/core', spec: null }) + t.end() +}) + +const sel = (name, spec) => ({ name, spec, key: spec ? `${name}@${spec}` : name }) + +t.test('exact wins over range and name-only', t => { + const selectors = [sel('lodash', '4.17.21'), sel('lodash', '^4.0.0'), sel('lodash', null)] + t.equal(matchSelector(selectors, { name: 'lodash', version: '4.17.21' }).key, 'lodash@4.17.21') + t.end() +}) + +t.test('range wins over name-only', t => { + const selectors = [sel('lodash', '^4.0.0'), sel('lodash', null)] + t.equal(matchSelector(selectors, { name: 'lodash', version: '4.5.0' }).key, 'lodash@^4.0.0') + t.end() +}) + +t.test('name-only is the fallback', t => { + const selectors = [sel('lodash', null)] + t.equal(matchSelector(selectors, { name: 'lodash', version: '3.0.0' }).key, 'lodash') + t.end() +}) + +t.test('most specific (subset) range wins regardless of order', t => { + const wideFirst = [sel('x', '>=1.0.0 <3.0.0'), sel('x', '>=1.5.0 <2.0.0')] + t.equal(matchSelector(wideFirst, { name: 'x', version: '1.7.0' }).key, 'x@>=1.5.0 <2.0.0') + const narrowFirst = [sel('x', '>=1.5.0 <2.0.0'), sel('x', '>=1.0.0 <3.0.0')] + t.equal(matchSelector(narrowFirst, { name: 'x', version: '1.7.0' }).key, 'x@>=1.5.0 <2.0.0') + t.end() +}) + +t.test('ambiguous overlapping ranges throw', t => { + const selectors = [sel('x', '>=1.0.0 <2.0.0'), sel('x', '>=1.5.0 <3.0.0')] + t.throws(() => matchSelector(selectors, { name: 'x', version: '1.7.0' }), { code: 'EPATCHAMBIGUOUS' }) + t.end() +}) + +t.test('no match returns null', t => { + const selectors = [sel('lodash', '4.17.21')] + t.equal(matchSelector(selectors, { name: 'lodash', version: '5.0.0' }), null) + t.equal(matchSelector(selectors, { name: 'other', version: '1.0.0' }), null) + t.end() +}) diff --git a/workspaces/arborist/test/shrinkwrap.js b/workspaces/arborist/test/shrinkwrap.js index 79389a862caa0..d4f9756e8c7c3 100644 --- a/workspaces/arborist/test/shrinkwrap.js +++ b/workspaces/arborist/test/shrinkwrap.js @@ -78,6 +78,20 @@ t.test('starting out with a reset lockfile is an empty lockfile', async t => { t.equal(sw.filename, resolve(fixture, 'package-lock.json')) }) +t.test('errors on a lockfileVersion newer than supported', async t => { + const dir = t.testdir({ + 'package-lock.json': JSON.stringify({ + name: 'x', + version: '1.0.0', + lockfileVersion: 5, + requires: true, + packages: {}, + }), + }) + await t.rejects(Shrinkwrap.load({ path: dir }), { code: 'ELOCKFILEVERSION' }, + 'a lockfile newer than the supported version is refused') +}) + t.test('reset in a bad dir gets an empty lockfile with no lockfile version', async t => { const nullLockDir = t.testdir({ 'package-lock.json': JSON.stringify(null), diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 2083d9ef43ded..c30b59c7c5af4 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1793,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, @@ -2459,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..eae4de7ae9c2d 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -87,6 +87,8 @@ const patchManifest = async (_manifest, opts) => { ) } manifest.version = version + // patchedDependencies is consumer-side state and must never be published + delete manifest.patchedDependencies return manifest } diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index 389c2a8fe98b3..fa2b688f427db 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -75,6 +75,60 @@ t.test('basic publish - no npmVersion', async t => { t.ok(ret, 'publish succeeded') }) +t.test('publish strips patchedDependencies from the registry manifest', async t => { + const { publish } = t.mock('..') + const registry = new MockRegistry({ + tap: t, + registry: opts.registry, + authorization: token, + }) + const manifest = { + name: 'libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + patchedDependencies: { 'lodash@4.17.21': 'patches/lodash@4.17.21.patch' }, + } + const spec = npa(manifest.name) + // patchedDependencies must not appear in the published version metadata + const { patchedDependencies, ...clean } = manifest + + const packument = { + _id: manifest.name, + name: manifest.name, + description: manifest.description, + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: `${manifest.name}@${manifest.version}`, + _nodeVersion: process.versions.node, + ...clean, + dist: { + shasum, + integrity: integrity.sha512[0].toString(), + tarball: 'http://mock.reg/libnpmpublish-test/-/libnpmpublish-test-1.0.0.tgz', + }, + }, + }, + access: null, + _attachments: { + 'libnpmpublish-test-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + } + + registry.nock.put(`/${spec.escapedName}`, packument).reply(201, {}) + const ret = await publish(manifest, tarData, { + ...opts, + npmVersion: null, + }) + t.ok(ret, 'publish succeeded with patchedDependencies stripped') +}) + t.test('scoped publish', async t => { const { publish } = t.mock('..') const registry = new MockRegistry({ From 5ddf6ccce5255d35da217d3e7342dde2bcb478d1 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 18 Jun 2026 22:56:42 +0530 Subject: [PATCH 03/11] fix(patch): keep the update marker on a no-op commit so a retry finalizes (#9567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conflicted `npm patch update` leaves an edit dir and a `.npm-patch-update.json` marker that the finalizing `npm patch commit` reads to finish the update — a metadata-only finalize that drops the renamed-from selector and tolerates the new version not being installed yet. `commit()` deleted that marker before checking whether the edit dir produced a diff. So a first commit that did no net work — the conflict resolved to the new version verbatim, giving "nothing to commit" — consumed the marker. A corrected retry then found none, ran a full reify, and failed `EPATCHUNUSED` (the rebased-to version isn't installed), leaving both the old and new selector in the manifest. The same loss happened on any non-finalizing path (e.g. the `EPATCHUNSAFE` throw). ## Fix The marker was deleted eagerly only to keep it out of the generated patch. Instead, keep it and teach `diffDirs` to skip it: - `diffDirs(originalDir, editedDir, ignore = new Set())` skips a set of root-relative filenames, like it already skips the root `package.json`. - `commit()` no longer deletes the marker — it reads/parses it (still throwing `EPATCHBADMARKER` on bad JSON before any diff), and passes `new Set([UPDATE_MARKER])` to `diffDirs`. The marker now survives any non-finalizing path, so a corrected retry still finalizes. A successful commit removes the whole edit dir (unless `--keep-edit-dir`), so nothing lingers. ## References Fixes #9566 Follow-up to #9439 (native dependency patching). --- lib/commands/patch.js | 5 ++--- lib/utils/patch-diff.js | 8 +++++++- test/lib/commands/patch.js | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index b012649cbc185..0f77029560b72 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -235,12 +235,11 @@ class Patch extends BaseCommand { } // 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) { - // always remove the marker so it never lands in the generated diff, even if it is malformed - await rm(markerPath, { force: true }) try { marker = JSON.parse(markerRaw) } catch { @@ -253,7 +252,7 @@ class Patch extends BaseCommand { let diff, packageJsonChanged try { await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions) - ;({ diff, packageJsonChanged } = await diffDirs(base, editDir)) + ;({ diff, packageJsonChanged } = await diffDirs(base, editDir, new Set([UPDATE_MARKER]))) } finally { await rm(base, { recursive: true, force: true }) } diff --git a/lib/utils/patch-diff.js b/lib/utils/patch-diff.js index 2f7fd7bddf939..b0ab9a3aa0bff 100644 --- a/lib/utils/patch-diff.js +++ b/lib/utils/patch-diff.js @@ -38,7 +38,8 @@ const readMaybe = async file => { // Diff originalDir against editedDir, returning { diff, packageJsonChanged }. // Added files use `--- /dev/null`, deleted files use `+++ /dev/null`. // The root package.json is excluded: Arborist resolves the pre-patch manifest, so a patched manifest would apply to disk without being honored. -const diffDirs = async (originalDir, editedDir) => { +// 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), @@ -63,6 +64,11 @@ const diffDirs = async (originalDir, editedDir) => { 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', '') diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index cbf8493972eb5..fb860c837bd3e 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -648,6 +648,42 @@ t.test('update conflict leaves an edit dir; commit finalizes the rename', async t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed') }) +t.test('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, { From ce7681fe7dbcc20abb5f1379558e14ddd069654f Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 18 Jun 2026 23:24:56 +0530 Subject: [PATCH 04/11] feat: packageExtensions for root-owned dependency manifest repairs (#9496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements package manifest extensions per [RFC #889](https://github.com/npm/rfcs/pull/889): a root-only `packageExtensions` field in `package.json` that applies declarative repairs to third-party dependency manifests **before** Arborist finalizes the ideal tree. It lets a project add missing `dependencies`/`optionalDependencies`, add or correct `peerDependencies`, and mark peers optional via `peerDependenciesMeta`, without forking and republishing a package. ```json { "packageExtensions": { "broken-package@1": { "dependencies": { "missing-runtime-dep": "^2.0.0" } }, "typescript-plugin@4.3.0": { "peerDependencies": { "typescript": ">=5" }, "peerDependenciesMeta": { "typescript": { "optional": true } } } } } ``` ## Why `install-strategy=linked` gives installs strong package boundaries, which is also what makes adoption hard: a package only sees what it actually declared, so one that worked under a hoisted layout because a dependency happened to be hoisted above it can fail. A root-level dependency masks this under hoisting but does not make the package available inside the isolated boundary of the importer — the repair has to be attached to the broken package's manifest before its edges are resolved. This is the pre-resolution complement to `overrides` (which needs an existing edge to retarget) and to [native dependency patching #9439](https://github.com/npm/cli/pull/9439) (which edits package contents after resolution). ## The field Each key is a package selector: a name with an optional semver range (`foo`, `foo@1`, `@scope/foo@^2.3.0`). Selectors match a candidate's own manifest `name`/`version` (the underlying name for aliases) and reject dist-tag, git, file, URL, and `npm:` specs. At most one selector may match a candidate. Honored only in the root `package.json` (the workspace root); the field in dependencies and non-root workspaces, and selectors matching a workspace member, are ignored with a warning — matching the root-authority model of `overrides`. ## Merge semantics Only the four resolution-affecting fields may be extended. - `dependencies`/`optionalDependencies` add a missing name only; providing a name already declared in either field is an error (use `overrides` to change a version), which also forbids moving a name between the two. - `peerDependencies` shallow-merges by name, replacing an existing range. - `peerDependenciesMeta` merges by name then key (e.g. add `optional: true`); every meta entry must have a corresponding `peerDependencies` entry. - Deletion (`null`/`false`/`"-"`) is not supported. The extension applies to a per-tree manifest copy: the shared pacote/cache manifest is never mutated, the installed `node_modules//package.json` is not rewritten, and `bundleDependencies` is unchanged. `overrides` still controls the final resolution target of an extension-created edge. ## Lockfile The root entry stores a canonical `packageExtensionsHash`, and each affected entry stores minimal provenance (`packageExtensionsApplied`); effective dependency metadata is recorded as usual. Extension state forces `lockfileVersion: 4` so older npm clients abort rather than silently dropping the repaired graph. `npm install` re-resolves affected packages when the rule set changes; `npm ci` validates the hash, selector conflicts, and stale provenance before trusting the locked metadata. ## Visibility `npm explain` appends `(added by packageExtensions["foo@1"].dependencies.bar)` to the edge; `npm ls` annotates the node and `npm ls --json` includes `packageExtensionsApplied`. Publishing a non-private package containing the field warns that it does not affect consumers. ## Notes - `lockfileVersion: 4` is shared with native dependency patching ([#9439](https://github.com/npm/cli/pull/9439)) as a common "older npm must not silently drop this" tripwire; both bump only when their own state is present. Whichever lands second should reuse the same `maxLockfileVersion`/bump constants rather than introduce a competing version. - Opt-in and additive, so it can ship in a minor release. ## References Implements https://github.com/npm/rfcs/pull/889 --- DEPENDENCIES.md | 2 + .../content/configuring-npm/package-json.md | 60 +++++ lib/commands/ci.js | 3 + lib/commands/ls.js | 24 ++ lib/commands/publish.js | 15 ++ lib/utils/explain-dep.js | 9 +- lib/utils/validate-lockfile.js | 55 ++++ package-lock.json | 10 + .../test/lib/commands/ls.js.test.cjs | 6 + .../test/lib/utils/explain-dep.js.test.cjs | 24 ++ test/lib/commands/ci.js | 17 ++ test/lib/commands/ls.js | 43 +++ test/lib/commands/publish.js | 61 +++++ test/lib/utils/explain-dep.js | 17 ++ test/lib/utils/validate-lockfile.js | 97 +++++++ .../arborist/lib/arborist/build-ideal-tree.js | 73 +++++- .../arborist/lib/arborist/load-virtual.js | 1 + workspaces/arborist/lib/edge.js | 10 + workspaces/arborist/lib/index.js | 1 + workspaces/arborist/lib/node.js | 4 + workspaces/arborist/lib/package-extensions.js | 236 +++++++++++++++++ workspaces/arborist/lib/place-dep.js | 3 + workspaces/arborist/lib/shrinkwrap.js | 25 +- workspaces/arborist/package.json | 1 + .../tap-snapshots/test/link.js.test.cjs | 4 + .../tap-snapshots/test/node.js.test.cjs | 248 ++++++++++++++++++ .../test/arborist/package-extensions.js | 214 +++++++++++++++ .../test/arborist/reify-package-extensions.js | 177 +++++++++++++ .../arborist/test/package-extensions.js | 214 +++++++++++++++ workspaces/libnpmpublish/lib/publish.js | 8 + workspaces/libnpmpublish/test/publish.js | 17 ++ 31 files changed, 1673 insertions(+), 6 deletions(-) create mode 100644 workspaces/arborist/lib/package-extensions.js create mode 100644 workspaces/arborist/test/arborist/package-extensions.js create mode 100644 workspaces/arborist/test/arborist/reify-package-extensions.js create mode 100644 workspaces/arborist/test/package-extensions.js diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 42166c249a3c5..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"]; @@ -579,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/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/lib/commands/ci.js b/lib/commands/ci.js index ef5ce206aff6f..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') @@ -89,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/ls.js b/lib/commands/ls.js index 1e46443d32c58..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) @@ -338,6 +353,11 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => { ? ' ' + 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 || ''}` : '') @@ -362,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 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/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/validate-lockfile.js b/lib/utils/validate-lockfile.js index cdab0ed0ea046..2aa9e5f4741a0 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -33,4 +33,59 @@ function validateLockfile (virtualTree, idealTree) { 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 439c5044cda74..7f2f76e4da60e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16915,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": { @@ -16935,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/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/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/test/lib/commands/ci.js b/test/lib/commands/ci.js index e8b2a69264674..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: { diff --git a/test/lib/commands/ls.js b/test/lib/commands/ls.js index 878ffb3f38c53..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', 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/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/validate-lockfile.js b/test/lib/utils/validate-lockfile.js index a3942a6903658..eae43b70008fa 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( diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 5c2b4add9afc5..d3dd99e764a27 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -26,6 +26,7 @@ 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') @@ -98,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. @@ -175,6 +177,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { try { await this.#initTree() + this.#loadPackageExtensions() await this.#inflateAncientLockfile() await this.#applyUserRequests(options) await this.#buildDeps() @@ -185,6 +188,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { path: this.path, allowUnusedPatches: this.options.allowUnusedPatches, }) + this.#warnWorkspacePackageExtensions() } finally { timeEnd() this.finishTracker('idealTree') @@ -232,6 +236,67 @@ 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()) { + if (!node.isWorkspace) { + 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 } @@ -1401,7 +1466,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/load-virtual.js b/workspaces/arborist/lib/arborist/load-virtual.js index d10b198681d44..acf8b6c4220ac 100644 --- a/workspaces/arborist/lib/arborist/load-virtual.js +++ b/workspaces/arborist/lib/arborist/load-virtual.js @@ -243,6 +243,7 @@ To fix: 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/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/node.js b/workspaces/arborist/lib/node.js index 1e1d1bae298e7..94da3c48c6e98 100644 --- a/workspaces/arborist/lib/node.js +++ b/workspaces/arborist/lib/node.js @@ -93,6 +93,7 @@ class Node { name, // allow setting name explicitly when we haven't set a path yet optional = true, overrides, + packageExtensionsApplied = null, parent, patched = null, path, @@ -172,6 +173,9 @@ 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/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 01e0b11abc33c..132afd59676c2 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -12,6 +12,9 @@ const localeCompare = require('@isaacs/string-locale-compare')('en') const defaultLockfileVersion = 3 // Bumped to 4 only when a node carries a patch record, so older clients abort. const patchedLockfileVersion = 4 +// 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 @@ -111,6 +114,7 @@ const nodeMetaKeys = [ 'inBundle', 'hasInstallScript', 'patched', + 'packageExtensionsApplied', ] const metaFieldFromPkg = (pkg, key) => { @@ -351,6 +355,7 @@ class Shrinkwrap { reset () { this.tree = null this.#awaitingUpdate = new Map() + this.packageExtensionsHash = null const lockfileVersion = this.lockfileVersion || defaultLockfileVersion this.originalLockfileVersion = lockfileVersion @@ -462,11 +467,10 @@ class Shrinkwrap { this.ancientLockfile = false data = {} } - // refuse lockfiles newer than we understand so we never install unpatched + // 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.`), + new Error(`Unsupported lockfileVersion ${data.lockfileVersion}. This npm only supports up to ${maxLockfileVersion}. Please upgrade npm.`), { code: 'ELOCKFILEVERSION' } ) } @@ -490,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. @@ -907,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 @@ -960,6 +971,14 @@ class Shrinkwrap { 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 e7eb5c9736b5f..2e5f9a66827b5 100644 --- a/workspaces/arborist/package.json +++ b/workspaces/arborist/package.json @@ -37,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 aa2afbd6ccdcf..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,7 @@ Link { "location": "../../../../../some/other/path", "name": "path", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/some/other/path", "peer": true, @@ -73,6 +74,7 @@ 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, @@ -90,6 +92,7 @@ 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, @@ -119,6 +122,7 @@ 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, diff --git a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs index e1075bd0cbdd3..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,7 @@ 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, @@ -218,6 +219,7 @@ 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, @@ -246,6 +248,7 @@ 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, @@ -311,6 +314,7 @@ 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, @@ -323,6 +327,7 @@ 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, @@ -369,6 +374,7 @@ 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, @@ -381,6 +387,7 @@ 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, @@ -420,6 +427,7 @@ 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, @@ -469,6 +477,7 @@ 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, @@ -481,6 +490,7 @@ 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, @@ -510,6 +520,7 @@ 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, @@ -555,6 +566,7 @@ 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, @@ -567,6 +579,7 @@ 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, @@ -583,6 +596,7 @@ 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, @@ -632,6 +646,7 @@ 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, @@ -644,6 +659,7 @@ 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, @@ -690,6 +706,7 @@ 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, @@ -702,6 +719,7 @@ 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, @@ -742,6 +760,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -780,6 +799,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -833,6 +853,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -853,6 +874,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -893,6 +915,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -926,6 +949,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -959,6 +983,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -992,6 +1017,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -1021,6 +1047,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -1075,6 +1102,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -1087,6 +1115,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -1156,6 +1185,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -1194,6 +1224,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -1247,6 +1278,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -1267,6 +1299,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -1300,6 +1333,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -1340,6 +1374,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -1373,6 +1408,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -1406,6 +1442,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -1439,6 +1476,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -1468,6 +1506,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -1522,6 +1561,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -1534,6 +1574,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -1565,6 +1606,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -1603,6 +1645,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -1632,6 +1675,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -1647,6 +1691,7 @@ 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, @@ -1679,6 +1724,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -1728,6 +1774,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -1781,6 +1828,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -1801,6 +1849,7 @@ 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, @@ -1841,6 +1890,7 @@ 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, @@ -1874,6 +1924,7 @@ 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, @@ -1907,6 +1958,7 @@ 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, @@ -1940,6 +1992,7 @@ 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, @@ -1969,6 +2022,7 @@ 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, @@ -2047,6 +2101,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -2100,6 +2155,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -2120,6 +2176,7 @@ 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, @@ -2153,6 +2210,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -2193,6 +2251,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -2233,6 +2292,7 @@ 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, @@ -2266,6 +2326,7 @@ 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, @@ -2299,6 +2360,7 @@ 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, @@ -2332,6 +2394,7 @@ 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, @@ -2361,6 +2424,7 @@ 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, @@ -2377,6 +2441,7 @@ 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, @@ -2409,6 +2474,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -2470,6 +2536,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -2490,6 +2557,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -2530,6 +2598,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -2563,6 +2632,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -2596,6 +2666,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -2629,6 +2700,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -2658,6 +2730,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -2701,6 +2774,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -2791,6 +2865,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -2811,6 +2886,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -2844,6 +2920,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -2884,6 +2961,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -2917,6 +2995,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -2950,6 +3029,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -2983,6 +3063,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -3012,6 +3093,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -3055,6 +3137,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -3071,6 +3154,7 @@ 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, @@ -3103,6 +3187,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -3164,6 +3249,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -3184,6 +3270,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -3221,6 +3308,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -3254,6 +3342,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -3287,6 +3376,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -3320,6 +3410,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -3349,6 +3440,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -3380,6 +3472,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -3438,6 +3531,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -3450,6 +3544,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -3540,6 +3635,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -3560,6 +3656,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -3593,6 +3690,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -3630,6 +3728,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -3663,6 +3762,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -3696,6 +3796,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -3729,6 +3830,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -3758,6 +3860,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -3787,6 +3890,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -3817,6 +3921,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -3875,6 +3980,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -3887,6 +3993,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -3903,6 +4010,7 @@ 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, @@ -3935,6 +4043,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -3996,6 +4105,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -4016,6 +4126,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -4053,6 +4164,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -4086,6 +4198,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -4119,6 +4232,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -4152,6 +4266,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -4181,6 +4296,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -4212,6 +4328,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -4270,6 +4387,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -4282,6 +4400,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -4372,6 +4491,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -4392,6 +4512,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod", "name": "prod", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod", "peer": true, @@ -4425,6 +4546,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -4462,6 +4584,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/bundled", "name": "bundled", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/bundled", "peer": true, @@ -4495,6 +4618,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/dev", "name": "dev", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/dev", "peer": true, @@ -4528,6 +4652,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/optional", "name": "optional", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/optional", "peer": true, @@ -4561,6 +4686,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/peer", "name": "peer", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/peer", "peer": true, @@ -4590,6 +4716,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/extraneous", "name": "extraneous", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/extraneous", "peer": true, @@ -4619,6 +4746,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -4649,6 +4777,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -4707,6 +4836,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -4719,6 +4849,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta", "peer": true, @@ -4735,6 +4866,7 @@ 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, @@ -4767,6 +4899,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -4807,6 +4940,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -4845,6 +4979,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -4898,6 +5033,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -4918,6 +5054,7 @@ 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, @@ -4958,6 +5095,7 @@ 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, @@ -4991,6 +5129,7 @@ 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, @@ -5024,6 +5163,7 @@ 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, @@ -5057,6 +5197,7 @@ 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, @@ -5086,6 +5227,7 @@ 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, @@ -5140,6 +5282,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -5152,6 +5295,7 @@ 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, @@ -5221,6 +5365,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -5259,6 +5404,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -5312,6 +5458,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -5332,6 +5479,7 @@ 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, @@ -5365,6 +5513,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -5405,6 +5554,7 @@ 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, @@ -5438,6 +5588,7 @@ 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, @@ -5471,6 +5622,7 @@ 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, @@ -5504,6 +5656,7 @@ 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, @@ -5533,6 +5686,7 @@ 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, @@ -5587,6 +5741,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -5599,6 +5754,7 @@ 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, @@ -5630,6 +5786,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -5668,6 +5825,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -5697,6 +5855,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta/node_modules/metameta", "peer": true, @@ -5712,6 +5871,7 @@ 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, @@ -5744,6 +5904,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -5793,6 +5954,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -5846,6 +6008,7 @@ 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, @@ -5866,6 +6029,7 @@ 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, @@ -5906,6 +6070,7 @@ 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, @@ -5939,6 +6104,7 @@ 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, @@ -5972,6 +6138,7 @@ 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, @@ -6005,6 +6172,7 @@ 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, @@ -6034,6 +6202,7 @@ 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, @@ -6112,6 +6281,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -6165,6 +6335,7 @@ 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, @@ -6185,6 +6356,7 @@ 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, @@ -6218,6 +6390,7 @@ 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, @@ -6258,6 +6431,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "location": "node_modules/prod/node_modules/meta", "name": "meta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/node_modules/meta", "peer": true, @@ -6298,6 +6472,7 @@ 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, @@ -6331,6 +6506,7 @@ 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, @@ -6364,6 +6540,7 @@ 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, @@ -6397,6 +6574,7 @@ 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, @@ -6426,6 +6604,7 @@ 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, @@ -6442,6 +6621,7 @@ 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, @@ -6474,6 +6654,7 @@ 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, @@ -6535,6 +6716,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -6555,6 +6737,7 @@ 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, @@ -6595,6 +6778,7 @@ 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, @@ -6628,6 +6812,7 @@ 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, @@ -6661,6 +6846,7 @@ 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, @@ -6694,6 +6880,7 @@ 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, @@ -6723,6 +6910,7 @@ 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, @@ -6766,6 +6954,7 @@ 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, @@ -6856,6 +7045,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -6876,6 +7066,7 @@ 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, @@ -6909,6 +7100,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -6949,6 +7141,7 @@ 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, @@ -6982,6 +7175,7 @@ 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, @@ -7015,6 +7209,7 @@ 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, @@ -7048,6 +7243,7 @@ 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, @@ -7077,6 +7273,7 @@ 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, @@ -7120,6 +7317,7 @@ 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, @@ -7136,6 +7334,7 @@ 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, @@ -7168,6 +7367,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -7229,6 +7429,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -7249,6 +7450,7 @@ 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, @@ -7286,6 +7488,7 @@ 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, @@ -7319,6 +7522,7 @@ 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, @@ -7352,6 +7556,7 @@ 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, @@ -7385,6 +7590,7 @@ 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, @@ -7414,6 +7620,7 @@ 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, @@ -7445,6 +7652,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -7503,6 +7711,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -7515,6 +7724,7 @@ 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, @@ -7605,6 +7815,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -7625,6 +7836,7 @@ 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, @@ -7658,6 +7870,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -7695,6 +7908,7 @@ 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, @@ -7728,6 +7942,7 @@ 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, @@ -7761,6 +7976,7 @@ 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, @@ -7794,6 +8010,7 @@ 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, @@ -7823,6 +8040,7 @@ 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, @@ -7852,6 +8070,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -7882,6 +8101,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -7940,6 +8160,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -7952,6 +8173,7 @@ 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, @@ -7968,6 +8190,7 @@ 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, @@ -8000,6 +8223,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -8061,6 +8285,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -8081,6 +8306,7 @@ 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, @@ -8118,6 +8344,7 @@ 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, @@ -8151,6 +8378,7 @@ 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, @@ -8184,6 +8412,7 @@ 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, @@ -8217,6 +8446,7 @@ 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, @@ -8246,6 +8476,7 @@ 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, @@ -8277,6 +8508,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -8335,6 +8567,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -8347,6 +8580,7 @@ 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, @@ -8437,6 +8671,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -8457,6 +8692,7 @@ 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, @@ -8490,6 +8726,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, @@ -8527,6 +8764,7 @@ 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, @@ -8560,6 +8798,7 @@ 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, @@ -8593,6 +8832,7 @@ 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, @@ -8626,6 +8866,7 @@ 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, @@ -8655,6 +8896,7 @@ 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, @@ -8684,6 +8926,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -8714,6 +8957,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -8772,6 +9016,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/meta/node_modules/metameta", "name": "metameta", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/meta/node_modules/metameta", "peer": true, @@ -8784,6 +9029,7 @@ 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, @@ -8800,6 +9046,7 @@ 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, @@ -8832,6 +9079,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "location": "node_modules/prod/foo", "name": "foo", "optional": true, + "packageExtensionsApplied": null, "patched": null, "path": "/home/user/projects/root/node_modules/prod/foo", "peer": true, diff --git a/workspaces/arborist/test/arborist/package-extensions.js b/workspaces/arborist/test/arborist/package-extensions.js new file mode 100644 index 0000000000000..61d81dd3a704f --- /dev/null +++ b/workspaces/arborist/test/arborist/package-extensions.js @@ -0,0 +1,214 @@ +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') + t.ok(warnings.some(w => /workspace package ws/.test(w[2])), 'warns about the workspace selector match') + t.ok(warnings.some(w => /in workspace ws is ignored/.test(w[2])), 'warns 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/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/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index eae4de7ae9c2d..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 = { diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index fa2b688f427db..3b41656bef17a 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -129,6 +129,23 @@ t.test('publish strips patchedDependencies from the registry manifest', async t 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({ From f9c977c2e28896a31e873ed8ffe0ec88b30ec8ef Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 00:44:14 +0530 Subject: [PATCH 05/11] fix(arborist): re-apply packageExtensions to the linked actual tree (#9569) Follow-up of #9496 Under `install-strategy=linked`, a root `packageExtensions` rule that adds a missing dependency installs and works at runtime, but every command that reads the actual tree loses the edge: - `npm ls --all --json` omits the extension-created dependency and its provenance. - `npm explain ` fails with `No dependencies found matching `. - `npm patch add ` fails with `EPATCHNOTINSTALLED`. Hoisted installs and normally-declared transitive deps are unaffected. ## References Fixes #9568 --- .../arborist/lib/arborist/load-actual.js | 31 ++++++++++++ .../arborist/test/arborist/load-actual.js | 47 +++++++++++++++++++ 2 files changed, 78 insertions(+) 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/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') +}) From cfda86742b2cfd992c730a5ec5cfd74ef863104a Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 00:44:55 +0530 Subject: [PATCH 06/11] fix(arborist): warn once for workspace packageExtensions selector match (#9570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace `packageExtensions` warning was printed twice per matching workspace. A workspace appears in the inventory as two `isWorkspace` nodes — the Link and its target — and `#warnWorkspacePackageExtensions` warned for both. The fix skips the link (`node.isLink`) so each workspace is warned once via its target node; the selector/`wouldMatch` checks are otherwise unchanged. ## References Follow up of #9496 --- workspaces/arborist/lib/arborist/build-ideal-tree.js | 3 ++- workspaces/arborist/test/arborist/package-extensions.js | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index d3dd99e764a27..58e803b844cc1 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -283,7 +283,8 @@ module.exports = cls => class IdealTreeBuilder extends cls { return } for (const node of this.idealTree.inventory.values()) { - if (!node.isWorkspace) { + // 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) { diff --git a/workspaces/arborist/test/arborist/package-extensions.js b/workspaces/arborist/test/arborist/package-extensions.js index 61d81dd3a704f..c37455ee6fbdd 100644 --- a/workspaces/arborist/test/arborist/package-extensions.js +++ b/workspaces/arborist/test/arborist/package-extensions.js @@ -179,8 +179,11 @@ t.test('does not extend workspace members but warns', async 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') - t.ok(warnings.some(w => /workspace package ws/.test(w[2])), 'warns about the workspace selector match') - t.ok(warnings.some(w => /in workspace ws is ignored/.test(w[2])), 'warns about non-root workspace packageExtensions') + // 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 => { From 62b0694d5ec4c529076bf968c3fe6cd47c3f2bb0 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 02:24:00 +0530 Subject: [PATCH 07/11] fix(patch): explain out-of-sync lockfile after --ignore-patch-failures (#9576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `--ignore-patch-failures` skips a broken patch, the package installs unpatched and the lockfile records no `patched` entry, but `package.json` still declares it. A later `npm ci` then fails with a generic out-of-sync `EUSAGE` whose "run `npm install`" advice is wrong (a plain install just re-fails with `EPATCHFAILED`). The state can't be reconciled automatically — recording the patch would be a lie, and rewriting `package.json` would discard the user's intent. So this PR makes the out-of-sync state self-explanatory: - `reify.js`: when a patch is skipped, warn that the lockfile is now out of sync with `package.json` and `npm ci` will fail until the patch is fixed or its `patchedDependencies` entry is removed. - `validate-lockfile.js`: make the patch mismatch error direction-aware. A patch declared in `package.json` but missing from the lockfile names `--ignore-patch-failures` and gives remediation; a patch in the lockfile that `package.json` dropped says so; integrity/path drift keeps the existing message. This flows into the `npm ci` `EUSAGE`. ## References Fixes #9573 --- lib/utils/validate-lockfile.js | 15 +++++++++++++-- .../test/lib/utils/validate-lockfile.js.test.cjs | 8 +++++++- test/lib/utils/validate-lockfile.js | 14 ++++++++++++++ workspaces/arborist/lib/arborist/reify.js | 2 ++ workspaces/arborist/test/arborist/reify-patch.js | 8 ++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/utils/validate-lockfile.js b/lib/utils/validate-lockfile.js index 2aa9e5f4741a0..f55c983a4f905 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -26,8 +26,19 @@ function validateLockfile (virtualTree, idealTree) { // a patch whose on-disk hash or path diverges from the lockfile is out of sync if ((lock.patched?.integrity || null) !== (entry.patched?.integrity || null) || (lock.patched?.path || null) !== (entry.patched?.path || null)) { - errors.push(`Invalid: patch for ${entry.name}@${entry.version} does not ` + - `match the patch recorded in the lock file`) + 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 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 bafd54bd2ed61..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,11 +19,17 @@ 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: patch for baz@3.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", ] ` diff --git a/test/lib/utils/validate-lockfile.js b/test/lib/utils/validate-lockfile.js index eae43b70008fa..8beb5e892fad4 100644 --- a/test/lib/utils/validate-lockfile.js +++ b/test/lib/utils/validate-lockfile.js @@ -182,6 +182,20 @@ t.test('mismatching patch integrity or path', async t => { ) }) +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/reify.js b/workspaces/arborist/lib/arborist/reify.js index 0183324ea0f3b..209e3bb2c1eb5 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -788,6 +788,8 @@ module.exports = cls => class Reifier extends cls { } 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 } diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index b3b0db980eb75..d2d794d6c79be 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -157,6 +157,11 @@ t.test('ignorePatchFailures downgrades EPATCHFAILED to a warning', async t => { 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 @@ -166,6 +171,9 @@ t.test('ignorePatchFailures downgrades EPATCHFAILED to a warning', async t => { 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 => { From a847d28d787f4f864de00ad1fd3344ccac016c62 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 02:25:21 +0530 Subject: [PATCH 08/11] fix(patch): warn when `patch update --to` targets an uninstalled version (#9575) `npm patch update --to ` finalizes with `allowUnusedPatches: true`, so a `--to` version absent from the tree is recorded silently with only a success line. The next plain `npm install` then fails with `EPATCHUNUSED`, far from the command that caused it. ## Fix `#resolveNewVersion` now warns when the validated `--to` version is not in the installed tree. It stays silent when the lockfile cannot be read (the power-user upgrade path); the command still succeeds. ## References Fixes #9571 Follow-up to #9439 --- lib/commands/patch.js | 7 ++++++ test/lib/commands/patch.js | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/commands/patch.js b/lib/commands/patch.js index 0f77029560b72..67b8bc4555572 100644 --- a/lib/commands/patch.js +++ b/lib/commands/patch.js @@ -450,6 +450,13 @@ class Patch extends BaseCommand { 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 } diff --git a/test/lib/commands/patch.js b/test/lib/commands/patch.js index fb860c837bd3e..ac409bf2d175a 100644 --- a/test/lib/commands/patch.js +++ b/test/lib/commands/patch.js @@ -582,6 +582,54 @@ t.test('update --to rebases an exact patch onto a new version', async t => { '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, { From 315e3bd7c8ece0e118131c423deb6c8240695089 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 02:38:34 +0530 Subject: [PATCH 09/11] fix(arborist): drop orphaned patch entry on uninstall instead of EPATCHUNUSED (#9574) `npm uninstall ` failed with `EPATCHUNUSED` when the package had a registered patch, because uninstall left the now-orphaned `patchedDependencies` entry in `package.json`. This also wedged the project: every subsequent `npm install` failed the same way until the user manually ran `npm patch rm`. `resolvePatchedDependencies` now receives the `rm` list from `buildIdealTree` and drops an unused selector whose package is being removed, and `reify` persists that removal to `package.json`. Selectors unused for any other reason still throw `EPATCHUNUSED`, and a package that survives as a transitive dependency keeps its patch. ## References Fixes #9572 --- .../arborist/lib/arborist/build-ideal-tree.js | 1 + workspaces/arborist/lib/arborist/reify.js | 3 + .../arborist/lib/patched-dependencies.js | 26 ++++- .../arborist/test/arborist/reify-patch.js | 23 ++++ .../test/patched-dependencies-resolve.js | 108 ++++++++++++++++++ 5 files changed, 156 insertions(+), 5 deletions(-) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 58e803b844cc1..f3c6d7ec846d7 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -187,6 +187,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { await resolvePatchedDependencies(this.idealTree, { path: this.path, allowUnusedPatches: this.options.allowUnusedPatches, + rm: options.rm || [], }) this.#warnWorkspacePackageExtensions() } finally { diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 209e3bb2c1eb5..79f8e89a5ea48 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -1826,6 +1826,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({ @@ -1834,6 +1836,7 @@ module.exports = cls => class Reifier extends cls { optionalDependencies, peerDependencies, bundleDependencies, + patchedDependencies, }) await pkgJson.save() } diff --git a/workspaces/arborist/lib/patched-dependencies.js b/workspaces/arborist/lib/patched-dependencies.js index c814974b5471e..411406f0df71a 100644 --- a/workspaces/arborist/lib/patched-dependencies.js +++ b/workspaces/arborist/lib/patched-dependencies.js @@ -3,6 +3,7 @@ // 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') @@ -61,7 +62,7 @@ const matchSelector = (selectors, node) => { return matches.find(s => s.spec === null) || null } -const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => { +const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches, rm }) => { const patchedDependencies = tree.package?.patchedDependencies || {} const selectors = Object.entries(patchedDependencies) .map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath })) @@ -135,15 +136,30 @@ const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches }) => usedKeys.add(selector.key) } - if (selectors.length && !allowUnusedPatches) { + if (selectors.length) { const unused = selectors.filter(s => !usedKeys.has(s.key)) - if (unused.length) { + + // 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${unused.map(s => ` ${s.key} -> ${s.patchPath}`).join('\n')}\n` + + `package:\n${stillUnused.map(s => ` ${s.key} -> ${s.patchPath}`).join('\n')}\n` + `Use --allow-unused-patches to install anyway.`, 'EPATCHUNUSED', - { unused: unused.map(s => s.key) } + { unused: stillUnused.map(s => s.key) } ) } } diff --git a/workspaces/arborist/test/arborist/reify-patch.js b/workspaces/arborist/test/arborist/reify-patch.js index d2d794d6c79be..53ac56feb7a9e 100644 --- a/workspaces/arborist/test/arborist/reify-patch.js +++ b/workspaces/arborist/test/arborist/reify-patch.js @@ -102,6 +102,29 @@ t.test('registry dep with patch is applied and recorded in lockfile', async t => 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. diff --git a/workspaces/arborist/test/patched-dependencies-resolve.js b/workspaces/arborist/test/patched-dependencies-resolve.js index c2cd830dff1ef..d213b3cf87019 100644 --- a/workspaces/arborist/test/patched-dependencies-resolve.js +++ b/workspaces/arborist/test/patched-dependencies-resolve.js @@ -239,6 +239,114 @@ t.test('EPATCHUNUSED when a registered patch matches no node', async t => { 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, From 8bbd70d939cec237b4e045993955895971c9eb85 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 02:39:51 +0530 Subject: [PATCH 10/11] fix(arborist): allow-remote exemption for proxy/mirror-fronted registry tarballs (#9550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `allow-remote=none` (the default on npm 12) or `allow-remote=root`, a `npm install` fails with `EALLOWREMOTE` on ordinary registry dependencies when the configured registry origin differs from the lockfile `resolved` origin. ``` npm error code EALLOWREMOTE npm error Fetching packages of type "remote" have been disabled ``` This is the common proxy/mirror case: a committed `package-lock.json` whose `resolved` URLs point to `https://registry.npmjs.org/...`, while the machine (or CI) is configured to use a private registry proxy/mirror with a different origin. It affects both the hoisted and the linked install strategy. ## Why When extracting a registry-resolved package, reify hands pacote a `name@URL` spec, which pacote re-parses as `type=remote` and gates with allow-remote. To avoid mis-firing on registry tarballs, `#isRegistryResolvedTarball` exempts them — but it compared the raw lockfile `resolved` URL against the configured registry origin. With a proxy/mirror configured, `resolved` is the canonical `registry.npmjs.org` URL while the configured registry is the proxy, so the origins never matched, the exemption returned `false`, and the registry tarball was rejected as remote. Crucially, reify already fetches a different URL than the raw `resolved`: `#registryResolved` applies `replace-registry-host` (default `npmjs`), rewriting the `registry.npmjs.org` host to the configured registry while preserving the path. So npm fetches the tarball from the proxy correctly; only the allow-remote check was evaluating the wrong (pre-rewrite) URL. ## How Evaluate the effective URL npm actually fetches, not the raw lockfile value. `#isRegistryResolvedTarball` now parses `this.#registryResolved(node.resolved)` — the host-rewritten URL — before the same origin + registry-path-prefix comparison. After rewriting, a public-registry-pinned tarball resolves to the configured registry and is correctly recognized as registry-mediated. The existing security boundary is preserved: under the default `replace-registry-host`, a same-origin tarball pointing outside the registry path is not rewritten and is still rejected, and a genuinely URL-declared dependency still fails the `node.isRegistryDependency` guard. Under `replace-registry-host=always`, every tarball is routed through the configured registry, so registry dependencies are no longer treated as remote — consistent with what `always` means. ## References Fixes #9548 --- workspaces/arborist/lib/arborist/reify.js | 8 +- workspaces/arborist/test/arborist/reify.js | 100 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 79f8e89a5ea48..ade8842ae6eb4 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -905,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 } diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index 3c48072270273..f12f7a28a6743 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -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. From afce42408b4643d01ce0c9303effe8f94cc02d64 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 19 Jun 2026 02:46:18 +0530 Subject: [PATCH 11/11] fix(arborist): expose store node_modules via NODE_PATH for linked-strategy install scripts (#9551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](https://github.com/WordPress/gutenberg/pull/75814), which powers the WordPress Block Editor. Under `install-strategy=linked`, a package whose install script invokes a separate helper bin to locate a native binding fails (e.g. `unrs-resolver` via `napi-postinstall`, and the wider `napi-postinstall` family). The helper bin is reached via a `.bin` symlink, so Node resolves it to its own realpath in its own store dir, from which it can't `require.resolve` the host's binding — so it declares it missing and re-downloads (fatal on a restricted registry/proxy). Hoisted works because the binding is hoisted onto the helper's resolution path. ## How When running a store node's lifecycle script, set `NODE_PATH` to the host package's store `node_modules` so a script-invoked bin can resolve the host's sibling deps: ```js if (isInStore) { const storeNodeModules = resolve(path, ...name.split('/').map(() => '..')) env.NODE_PATH = [storeNodeModules, process.env.NODE_PATH].filter(Boolean).join(delimiter) } ``` `NODE_PATH` is fallback-only (can't shadow working resolution), added only to the script's env (never `process.env`), and scoped to the store-local `node_modules` — install-time only, runtime isolation untouched. ## References Fixes #9549 --- workspaces/arborist/lib/arborist/rebuild.js | 10 ++- workspaces/arborist/test/arborist/reify.js | 72 ++++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) 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/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index f12f7a28a6743..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') @@ -4239,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({