diff --git a/docs/lib/build.js b/docs/lib/build.js index f4d632935e040..40a0aaf255f6c 100644 --- a/docs/lib/build.js +++ b/docs/lib/build.js @@ -107,6 +107,7 @@ const generateNav = async (contentPath, navPath) => { '/configuring-npm/npmrc', '/configuring-npm/package-json', '/configuring-npm/package-lock-json', + '/configuring-npm/npm-extension', ] // Hardcoded order for using-npm section (only urls - title/description come from frontmatter) diff --git a/docs/lib/content/configuring-npm/npm-extension.md b/docs/lib/content/configuring-npm/npm-extension.md new file mode 100644 index 0000000000000..80ae3d90d7b33 --- /dev/null +++ b/docs/lib/content/configuring-npm/npm-extension.md @@ -0,0 +1,90 @@ +--- +title: .npm-extension +section: 5 +description: Imperative, root-owned manifest repairs +--- + +### Description + +A root-owned `.npm-extension.mjs` or `.npm-extension.cjs` file lets a project imperatively repair the manifests of third-party dependencies before npm resolves the dependency tree. It exports a `transformManifest(pkg, context)` function that receives a candidate dependency manifest and returns the effective manifest npm should use. + +`.npm-extension` is the imperative counterpart to the declarative [`packageExtensions`](/configuring-npm/package-json#packageextensions) field, and runs in the same pre-resolution phase, **before** `packageExtensions`. Prefer `packageExtensions` for simple, data-only repairs; reach for `.npm-extension` when you need comments and links explaining a repair, conditional logic, repeated repairs expressed as code, deletion or range rewrites, stale-repair guards, or a policy location outside `package.json`. + +### Example + +```js +// .npm-extension.mjs +export function transformManifest (pkg, context) { + if (pkg.name === 'foo' && pkg.version.startsWith('1.')) { + pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' } + context.log(`added bar to ${pkg.name}@${pkg.version}`) + } + return pkg +} +``` + +The `.cjs` form uses CommonJS exports instead: + +```js +// .npm-extension.cjs +module.exports = { + transformManifest (pkg, context) { + return pkg + }, +} +``` + +### The `transformManifest` function + +`transformManifest(pkg, context)` receives a deeply isolated copy of a candidate dependency manifest. It may mutate and return that copy, or return a new manifest object. It **must** return a manifest object synchronously; returning `null`, `undefined`, a primitive, an array, or a promise fails the install. + +The `context` argument is intentionally small: + +* `context.log(message)` writes an npm debug log message. +* `context.root` is the absolute path to the project root. +* `context.extensionPoint` is the string `"transformManifest"`. + +npm provides no registry, fetch, lockfile, or extraction helpers. Keep the extension file self-contained or limited to Node builtins; npm does not guarantee that project dependencies are available when the file is loaded. + +### Supported mutations + +Only the four resolution-affecting fields may change: + +* `dependencies` +* `optionalDependencies` +* `peerDependencies` +* `peerDependenciesMeta` + +Within those fields you may add, replace, or delete entries. Changing any other field (such as `scripts`, `bin`, `engines`, `os`, `cpu`, `exports`, or `main`) is rejected, and the install fails with an error naming `.npm-extension` and the package being processed. The package tarball and the installed `node_modules//package.json` are never rewritten. + +### Discovery and `extension-file` + +npm looks for a single `.npm-extension.mjs` or `.npm-extension.cjs` at the project root (the workspace root in a workspace project). Having both files present is an error. A `.npm-extension` file in a dependency or in a non-root workspace is ignored; a non-root workspace file produces a warning. + +The [`extension-file`](/using-npm/config#extension-file) config selects a different project-local file. It must resolve inside the project root and use a `.mjs` or `.cjs` extension, and it is honored only from project config or the command line — never from user, global, or builtin config. + +### Interaction with `packageExtensions` and `overrides` + +When both are present, `transformManifest` runs first and `packageExtensions` is applied to its output. Avoid targeting the same package with both unless you intend to rely on that ordering. `overrides` still controls the final resolution target of any edge, including edges created by `transformManifest`. + +### Lockfile and `npm ci` + +A lockfile influenced by `.npm-extension` records an `npmExtensionHash` (a digest of the selected file's bytes and module format) on its root entry, and minimal `npmExtensionApplied` provenance on each affected package entry. Extension state requires `lockfileVersion: 4`. + +Changing the file's contents makes `npm install` re-resolve the affected packages. `npm ci` does **not** import or execute `.npm-extension`; it verifies the recorded hash against the file and reifies the locked graph, failing if the file and lockfile disagree (or if one has extension state and the other does not). + +The hash proves only that the install uses the same extension file bytes that generated the lockfile. It does not make arbitrary JavaScript deterministic: extension output that depends on environment variables, the network, the clock, or files imported by the extension can still produce non-reproducible installs. Treat `.npm-extension` as trusted, deterministic project code, and only enable it in repositories you trust. + +### Disabling + +Set [`ignore-extension`](/using-npm/config#ignore-extension) to skip importing and executing `.npm-extension`. [`ignore-scripts`](/using-npm/config#ignore-scripts) implies `ignore-extension`, since both disable root-owned install-time code. `npm ci` still verifies the file hash even when execution is disabled. + +### Publishing + +`.npm-extension.mjs` and `.npm-extension.cjs` are project configuration, not package contents. npm excludes the root file from the package tarball produced by `npm pack` and `npm publish`, even when the package's `files` list would include it, so a public package can keep `.npm-extension` in its repository for local use without publishing it. + +### See also + +* [package.json `packageExtensions`](/configuring-npm/package-json#packageextensions) +* [package-lock.json](/configuring-npm/package-lock-json) +* [config](/using-npm/config) diff --git a/docs/lib/content/nav.yml b/docs/lib/content/nav.yml index 7d148b43eab5f..cc6463d67301f 100644 --- a/docs/lib/content/nav.yml +++ b/docs/lib/content/nav.yml @@ -232,6 +232,9 @@ - title: package-lock.json url: /configuring-npm/package-lock-json description: A manifestation of the manifest + - title: .npm-extension + url: /configuring-npm/npm-extension + description: Imperative, root-owned manifest repairs - title: Using npm shortName: Using url: /using-npm diff --git a/lib/commands/ci.js b/lib/commands/ci.js index 941270e0bad22..17307badede98 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -6,7 +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 { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') const getWorkspaces = require('../utils/get-workspaces.js') @@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd { save: false, // npm ci should never modify the lockfile or package.json workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, + // npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension. + // The extension file hash is still validated below, independent of execution. + ignoreExtension: true, } // generate an inventory from the virtual tree in the lockfile @@ -92,6 +95,16 @@ class CI extends ArboristWorkspaceCmd { 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)) + // Verifies that the root .npm-extension file matches the lockfile hash. + // The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts. + const { NpmExtension } = require('@npmcli/arborist') + let fileHash = null + try { + fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash + } catch (err) { + errors.push(`Invalid: ${err.message}`) + } + errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash)) 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 738052c5a9c05..e222331d09e3e 100644 --- a/lib/commands/ls.js +++ b/lib/commands/ls.js @@ -278,8 +278,8 @@ const augmentItemWithIncludeMetadata = (node, item) => { return item } -// Render a node's packageExtensions provenance as a short "field.name" list, empty when none. -const formatPackageExtensions = (applied) => { +// Render a manifest-extension provenance object as a short "field.name" list, empty when none. +const formatExtensionApplied = (applied) => { if (!applied) { return '' } @@ -354,8 +354,13 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => { : '' ) + ( - formatPackageExtensions(node.packageExtensionsApplied) - ? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`) + formatExtensionApplied(node.packageExtensionsApplied) + ? ' ' + chalk.dim(`packageExtensions: ${formatExtensionApplied(node.packageExtensionsApplied)}`) + : '' + ) + + ( + formatExtensionApplied(node.npmExtensionApplied) + ? ' ' + chalk.dim(`.npm-extension: ${formatExtensionApplied(node.npmExtensionApplied)}`) : '' ) + (isGitNode(node) ? ` (${node.resolved})` : '') + @@ -386,6 +391,10 @@ const getJsonOutputItem = (node, { global, long }) => { item.packageExtensionsApplied = node.packageExtensionsApplied } + if (node.npmExtensionApplied) { + item.npmExtensionApplied = node.npmExtensionApplied + } + item[_name] = node.name // special formatting for top-level package name diff --git a/lib/utils/explain-dep.js b/lib/utils/explain-dep.js index 75af3fbcbc5e9..06722c627ec41 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, packageExtensions }, + { name, type, bundled, from, spec, rawSpec, overridden, packageExtensions, npmExtension }, depth, chalk, seen = new Set() ) => { let dep = type === 'workspace' @@ -93,9 +93,14 @@ const explainEdge = ( ? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`) : '' + // note an edge created or changed by a root .npm-extension repair + const npmExtMsg = npmExtension + ? chalk.dim(` (changed by .npm-extension ${npmExtension.extensionPoint} ${npmExtension.field}.${name})`) + : '' + return (type === 'prod' ? '' : `${colorType(type, chalk)} `) + (bundled ? `${colorType('bundled', chalk)} ` : '') + - `${dep}${fromMsg}${extMsg}` + `${dep}${fromMsg}${extMsg}${npmExtMsg}` } const explainFrom = (from, depth, chalk, seen) => { diff --git a/lib/utils/validate-lockfile.js b/lib/utils/validate-lockfile.js index f55c983a4f905..9039a797c03dd 100644 --- a/lib/utils/validate-lockfile.js +++ b/lib/utils/validate-lockfile.js @@ -98,5 +98,33 @@ function validatePackageExtensions (virtualTree, idealTree) { return errors } +// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file. +// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails. +// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts. +// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance. +// Returns an array of human-readable error strings, empty when valid. +function validateNpmExtension (virtualTree, fileHash) { + const lockHash = virtualTree?.meta?.npmExtensionHash || null + const hasProvenance = !!virtualTree && + [...virtualTree.inventory.values()].some(node => node.npmExtensionApplied) + fileHash = fileHash || null + + if (fileHash) { + if (!lockHash) { + return ['Missing: .npm-extension state from lock file'] + } + if (lockHash !== fileHash) { + return ['Invalid: .npm-extension file does not match the lock file'] + } + return [] + } + // no extension file present + if (lockHash || hasProvenance) { + return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present'] + } + return [] +} + module.exports = validateLockfile module.exports.validatePackageExtensions = validatePackageExtensions +module.exports.validateNpmExtension = validateNpmExtension diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 5efaa3f4136a0..f65b081395088 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -58,6 +58,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "expect-result-count": null, "expect-results": null, "expires": null, + "extension-file": null, "fetch-retries": 2, "fetch-retry-factor": 10, "fetch-retry-maxtimeout": 60000, @@ -76,6 +77,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "heading": "npm", "https-proxy": null, "if-present": false, + "ignore-extension": false, "ignore-scripts": false, "include": [], "include-staged": false, @@ -254,6 +256,7 @@ engine-strict = false expect-result-count = null expect-results = null expires = null +extension-file = null fetch-retries = 2 fetch-retry-factor = 10 fetch-retry-maxtimeout = 60000 @@ -273,6 +276,7 @@ heading = "npm" https-proxy = null if-present = false ignore-existing = false +ignore-extension = false ignore-patch-failures = false ignore-scripts = false include = [] diff --git a/tap-snapshots/test/lib/commands/ls.js.test.cjs b/tap-snapshots/test/lib/commands/ls.js.test.cjs index bc477db1e0e8b..5ceb233215fc4 100644 --- a/tap-snapshots/test/lib/commands/ls.js.test.cjs +++ b/tap-snapshots/test/lib/commands/ls.js.test.cjs @@ -315,6 +315,12 @@ test-npm-ls@1.0.0 {CWD}/prefix \`-- dog@2.0.0 ` +exports[`test/lib/commands/ls.js TAP ls .npm-extension dep > human output annotates the transformed node 1`] = ` +test-npm-extension@1.0.0 {CWD}/prefix +\`-- foo@1.0.0 .npm-extension: dependencies.bar + \`-- bar@1.0.0 +` + exports[`test/lib/commands/ls.js TAP ls broken resolved field > should NOT print git refs in output tree 1`] = ` npm-broken-resolved-field-test@1.0.0 {CWD}/prefix \`-- a@1.0.1 diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 4633ecb73afe7..c28955615d600 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -759,6 +759,19 @@ expiration. +#### \`extension-file\` + +* Default: null +* Type: null or Path + +Path to a project-local npm extension file to load instead of discovering +\`.npm-extension.mjs\` / \`.npm-extension.cjs\` at the project root. Must +resolve inside the project root and use a \`.mjs\` or \`.cjs\` extension. Only +honored from project config or the command line, never from user, global, or +builtin config. + + + #### \`fetch-retries\` * Default: 2 @@ -983,6 +996,18 @@ fresh. +#### \`ignore-extension\` + +* Default: false +* Type: Boolean + +If true, npm does not import or execute a root \`.npm-extension.mjs\` / +\`.npm-extension.cjs\` file (or one selected via \`extension-file\`). +\`ignore-scripts\` implies \`ignore-extension\`, since both disable root-owned +install-time code. + + + #### \`ignore-patch-failures\` * Default: false @@ -1008,6 +1033,9 @@ Note that commands explicitly intended to run a particular script, such as run their intended script if \`ignore-scripts\` is set, but they will *not* run any pre- or post-scripts. +Setting \`ignore-scripts\` also disables \`.npm-extension\` execution, as if +\`ignore-extension\` were set. + #### \`include\` @@ -2580,6 +2608,7 @@ Array [ "expect-result-count", "expect-results", "expires", + "extension-file", "fetch-retries", "fetch-retry-factor", "fetch-retry-maxtimeout", @@ -2598,6 +2627,7 @@ Array [ "heading", "https-proxy", "if-present", + "ignore-extension", "ignore-scripts", "include", "include-staged", @@ -2771,6 +2801,7 @@ Array [ "editor", "engine-strict", "expires", + "extension-file", "fetch-retries", "fetch-retry-factor", "fetch-retry-maxtimeout", @@ -2789,6 +2820,7 @@ Array [ "heading", "https-proxy", "if-present", + "ignore-extension", "ignore-scripts", "include", "include-staged", @@ -2966,6 +2998,7 @@ Object { "editor": "{EDITOR}", "engineStrict": false, "expires": null, + "extensionFile": null, "force": false, "foregroundScripts": false, "formatPackageLock": true, @@ -2978,6 +3011,7 @@ Object { "heading": "npm", "httpsProxy": null, "ifPresent": false, + "ignoreExtension": false, "ignoreScripts": false, "includeAttestations": false, "includeStaged": false, 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 d432d6fc9776e..3fbe92562f879 100644 --- a/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs +++ b/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs @@ -161,6 +161,30 @@ exports[`test/lib/utils/explain-dep.js TAP basic manyDeps > print nocolor 1`] = manydep@1.0.0 ` +exports[`test/lib/utils/explain-dep.js TAP basic npmExtension > explain color deep 1`] = ` +bar@1.2.3 +node_modules/bar + bar@"^1.0.0" from foo@1.0.0 + node_modules/foo (changed by .npm-extension transformManifest dependencies.bar) +` + +exports[`test/lib/utils/explain-dep.js TAP basic npmExtension > explain nocolor shallow 1`] = ` +bar@1.2.3 +node_modules/bar + bar@"^1.0.0" from foo@1.0.0 + node_modules/foo (changed by .npm-extension transformManifest dependencies.bar) +` + +exports[`test/lib/utils/explain-dep.js TAP basic npmExtension > print color 1`] = ` +bar@1.2.3 +node_modules/bar +` + +exports[`test/lib/utils/explain-dep.js TAP basic npmExtension > print nocolor 1`] = ` +bar@1.2.3 +node_modules/bar +` + exports[`test/lib/utils/explain-dep.js TAP basic optional > explain color deep 1`] = ` optdep@1.0.0 optional node_modules/optdep diff --git a/test/lib/commands/ci.js b/test/lib/commands/ci.js index 618560d064a46..a90ca4b0cffe6 100644 --- a/test/lib/commands/ci.js +++ b/test/lib/commands/ci.js @@ -139,6 +139,24 @@ t.test('fails when packageExtensions are out of sync with the lock file', async ) }) +t.test('fails when both .npm-extension files are present', async t => { + const { npm } = await loadMockNpm(t, { + config: { audit: false }, + prefixDir: { + abbrev, + 'package.json': JSON.stringify(packageJson), + 'package-lock.json': JSON.stringify(packageLock), + '.npm-extension.mjs': 'export function transformManifest (p) { return p }\n', + '.npm-extension.cjs': 'module.exports = { transformManifest (p) { return p } }\n', + }, + }) + await t.rejects( + npm.exec('ci', []), + /keep only one/, + 'ci surfaces the ambiguous extension file error' + ) +}) + 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 36867fb14ef56..577c2e7835e2a 100644 --- a/test/lib/commands/ls.js +++ b/test/lib/commands/ls.js @@ -351,6 +351,49 @@ t.test('ls', async t => { t.match(applied, { selector: 'foo@1', dependencies: ['bar'] }, 'json output includes provenance') }) + const npmExtensionPrefix = { + 'package.json': JSON.stringify({ + name: 'test-npm-extension', + version: '1.0.0', + dependencies: { foo: '^1.0.0' }, + }), + node_modules: { + '.package-lock.json': JSON.stringify({ + packages: { + 'node_modules/foo': { + version: '1.0.0', + dependencies: { bar: '^1.0.0' }, + npmExtensionApplied: { extensionPoint: 'transformManifest', 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('.npm-extension dep', async t => { + const { npm, result, ls } = await mockLs(t, { config: {}, prefixDir: npmExtensionPrefix }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + t.matchSnapshot(cleanCwd(result()), 'human output annotates the transformed node') + }) + + t.test('.npm-extension dep --json', async t => { + const { npm, result, ls } = await mockLs(t, { + config: { json: true }, + prefixDir: npmExtensionPrefix, + }) + touchHiddenPackageLock(npm.prefix) + await ls.exec([]) + const applied = JSON.parse(result()).dependencies.foo.npmExtensionApplied + t.match(applied, { extensionPoint: 'transformManifest', dependencies: ['bar'] }, + 'json output includes provenance') + }) + t.test('with filter arg', async t => { const config = { color: 'always', diff --git a/test/lib/utils/explain-dep.js b/test/lib/utils/explain-dep.js index d847c4106e1a7..30f7167e70657 100644 --- a/test/lib/utils/explain-dep.js +++ b/test/lib/utils/explain-dep.js @@ -157,6 +157,23 @@ const getCases = (testdir) => { }, }], }, + + npmExtension: { + name: 'bar', + version: '1.2.3', + location: 'node_modules/bar', + dependents: [{ + type: 'prod', + name: 'bar', + spec: '^1.0.0', + npmExtension: { extensionPoint: 'transformManifest', 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 8beb5e892fad4..cee1891f49183 100644 --- a/test/lib/utils/validate-lockfile.js +++ b/test/lib/utils/validate-lockfile.js @@ -209,3 +209,41 @@ t.test('missing virtualTree inventory', async t => { 'should have errors for each mismatching version' ) }) + +const { validateNpmExtension } = require('../../../lib/utils/validate-lockfile.js') + +// build a mock virtual tree with a root hash and optional provenance-bearing nodes +const extVirtual = (hash, nodes = []) => ({ + meta: { npmExtensionHash: hash }, + inventory: { values: () => nodes }, +}) + +t.test('npmExtension: matching hashes', async t => { + t.strictSame(validateNpmExtension(extVirtual('h'), 'h'), [], 'no errors when hashes match') +}) + +t.test('npmExtension: both absent', async t => { + t.strictSame(validateNpmExtension(extVirtual(null), null), [], 'no errors when both absent') +}) + +t.test('npmExtension: missing from lock file', async t => { + const errors = validateNpmExtension(extVirtual(null), 'h') + t.match(errors[0], /Missing: \.npm-extension state from lock file/, 'reports missing lock state') +}) + +t.test('npmExtension: present in lock but no file', async t => { + const errors = validateNpmExtension(extVirtual('h'), null) + t.match(errors[0], /records \.npm-extension state but no \.npm-extension file is present/, 'reports stray lock state') +}) + +t.test('npmExtension: hash mismatch', async t => { + const errors = validateNpmExtension(extVirtual('h1'), 'h2') + t.match(errors[0], /\.npm-extension file does not match the lock file/, 'reports a hash mismatch') +}) + +t.test('npmExtension: provenance without a root hash and no file fails', async t => { + const nodes = [{ name: 'foo', npmExtensionApplied: { extensionPoint: 'transformManifest', dependencies: ['bar'] } }] + const errors = validateNpmExtension(extVirtual(null, nodes), null) + t.match(errors[0], /records \.npm-extension state but no \.npm-extension file is present/, + 'per-node provenance counts as extension state even without a root hash') +}) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index f3c6d7ec846d7..8e0fc63be04f0 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -27,6 +27,8 @@ 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 NpmExtension = require('../npm-extension.js') +const { hasExtensionFile } = require('../npm-extension.js') const Shrinkwrap = require('../shrinkwrap.js') const { defaultLockfileVersion } = Shrinkwrap const Node = require('../node.js') @@ -100,6 +102,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { #manifests = new Map() #mutateTree = false #packageExtensions = null + #npmExtension = 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. @@ -177,6 +180,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { try { await this.#initTree() + await this.#loadNpmExtension() this.#loadPackageExtensions() await this.#inflateAncientLockfile() await this.#applyUserRequests(options) @@ -190,6 +194,7 @@ module.exports = cls => class IdealTreeBuilder extends cls { rm: options.rm || [], }) this.#warnWorkspacePackageExtensions() + this.#warnWorkspaceNpmExtension() } finally { timeEnd() this.finishTracker('idealTree') @@ -277,6 +282,55 @@ module.exports = cls => class IdealTreeBuilder extends cls { return res ? { pkg: res.pkg, applied: res.applied } : { pkg, applied: null } } + // Load the root project's .npm-extension file and its transformManifest export. + // ignore-extension (and, via flatten, ignore-scripts) disables discovery and execution; the lockfile then carries no extension state. + // The selected file's hash is stashed on the lockfile meta so commit() can persist it and npm ci can detect stale extension state. + async #loadNpmExtension () { + const lockedHash = this.idealTree.meta.npmExtensionHash + // ignore-extension (and, via flatten, ignore-scripts) disables discovery and execution. + // Leave any locked extension state untouched so npm ci reifies the locked graph as-is and the lockfile stays internally consistent. + if (this.options.ignoreExtension) { + return + } + const ext = new NpmExtension({ + root: this.idealTree.realpath, + extensionFile: this.options.extensionFile, + }) + this.#npmExtension = ext + this.idealTree.meta.npmExtensionHash = ext.hash + if (ext.present) { + await ext.load() + } + + // When the extension file changed since the lockfile was written, locked manifests may no longer reflect its output. + // This also covers removal: a now-absent file hashes to null, which differs from the locked hash and reverts the previously transformed nodes. + // Arbitrary code has no selector to predict which packages it affects, so re-resolve any node that carried old provenance or that the current file now transforms. + if (this.idealTree.meta.loadedFromDisk && lockedHash !== ext.hash) { + for (const node of [...this.idealTree.inventory.values()]) { + if (node.isProjectRoot || node.isWorkspace || node.isTop || node.isLink) { + continue + } + // A node with old provenance carries an already-transformed manifest, so always refetch it. + // Otherwise probe the locked manifest (without caching, so the authoritative full-manifest fetch is not pre-seeded). + // The locked manifest carries every resolution-relevant field (name, version, dependencies, peer metadata); a transform that newly targets a node by a field not persisted in the lockfile is the documented edge that may need a manual re-lock. + const affected = node.npmExtensionApplied || ext.apply(node.package, { memoize: false }) + if (affected) { + for (const edge of node.edgesIn) { + this.#depsQueue.push(edge.from) + } + node.parent = null + } + } + } + } + + // Apply the root transformManifest to a copy of a candidate manifest, before any packageExtensions rule. + // Returns the possibly-transformed manifest and the provenance to attach to the node. + #applyNpmExtension (pkg) { + const res = this.#npmExtension?.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 () { @@ -299,6 +353,20 @@ module.exports = cls => class IdealTreeBuilder extends cls { } } + // Warn when a non-root workspace package contains a .npm-extension file; only the workspace root's file is honored. + #warnWorkspaceNpmExtension () { + for (const node of this.idealTree.inventory.values()) { + // a workspace is in the inventory as both a Link and its target node; warn once by skipping the link + if (!node.isWorkspace || node.isLink || node.isProjectRoot) { + continue + } + if (hasExtensionFile(node.realpath)) { + log.warn('npm-extension', + `".npm-extension" in workspace ${node.name} is ignored; it is only honored at the workspace root`) + } + } + } + #parseSettings (options) { const update = options.update === true ? { all: true } : Array.isArray(options.update) ? { names: options.update } @@ -1468,9 +1536,13 @@ This is a one-time fix-up, please be patient... ) return this.#failureNode(name, parent, error, edge) } - // 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) + // Transform the manifest copy before any packageExtensions rule and before the Node reads its dependency and peer edges. + const transformed = this.#applyNpmExtension(pkg) + const { pkg: extended, applied } = this.#applyPackageExtension(transformed.pkg) const node = new Node({ name, pkg: extended, parent, installLinks, legacyPeerDeps }) + if (transformed.applied) { + node.npmExtensionApplied = transformed.applied + } if (applied) { node.packageExtensionsApplied = applied } diff --git a/workspaces/arborist/lib/arborist/load-actual.js b/workspaces/arborist/lib/arborist/load-actual.js index 2a8538ea9f6e7..e23b91dba8a23 100644 --- a/workspaces/arborist/lib/arborist/load-actual.js +++ b/workspaces/arborist/lib/arborist/load-actual.js @@ -14,6 +14,7 @@ const Node = require('../node.js') const Link = require('../link.js') const realpath = require('../realpath.js') const PackageExtensions = require('../package-extensions.js') +const NpmExtension = require('../npm-extension.js') // public symbols const _changePath = Symbol.for('_changePath') @@ -174,6 +175,8 @@ module.exports = cls => class ActualLoader extends cls { await Promise.all(promises) } + // .npm-extension runs before packageExtensions, matching the ideal-tree resolution order + await this.#applyNpmExtension() this.#applyPackageExtensions() if (!ignoreMissing) { @@ -383,6 +386,39 @@ module.exports = cls => class ActualLoader extends cls { } } + // .npm-extension transformManifest, like packageExtensions, never rewrites a package's package.json, so re-derive its edges and provenance on a filesystem-scanned actual tree. + // This executes the root extension code; ignore-extension (and ignore-scripts via flatten) disables it. + async #applyNpmExtension () { + if (this.options.ignoreExtension) { + return + } + const ext = new NpmExtension({ + root: this.#actualTree.realpath, + extensionFile: this.options.extensionFile, + }) + if (!ext.present) { + return + } + await ext.load() + for (const node of this.#actualTree.inventory.values()) { + // only installed dependencies are transformed, never the root or a workspace + if (node.isLink || node.isProjectRoot || !node.name || !node.inNodeModules()) { + continue + } + const res = ext.apply(node.package) + if (res) { + node.package = res.pkg + node.npmExtensionApplied = 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?.npmExtensionApplied) { + node.npmExtensionApplied = node.target.npmExtensionApplied + } + } + } + async #findMissingEdges () { // try to resolve any missing edges by walking up the directory tree, // checking for the package in each node_modules folder. stop at the diff --git a/workspaces/arborist/lib/arborist/load-virtual.js b/workspaces/arborist/lib/arborist/load-virtual.js index acf8b6c4220ac..7e876adc016d5 100644 --- a/workspaces/arborist/lib/arborist/load-virtual.js +++ b/workspaces/arborist/lib/arborist/load-virtual.js @@ -244,6 +244,7 @@ To fix: integrity: sw.integrity, patched: sw.patched, packageExtensionsApplied: sw.packageExtensionsApplied, + npmExtensionApplied: sw.npmExtensionApplied, 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 04b7f14953ef3..1ed2446c8090a 100644 --- a/workspaces/arborist/lib/edge.js +++ b/workspaces/arborist/lib/edge.js @@ -160,7 +160,7 @@ 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 + // note when this edge was created or changed by a root packageExtensions repair on the from node const applied = this.#from.packageExtensionsApplied if (applied) { for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']) { @@ -170,6 +170,16 @@ class Edge { } } } + // note when this edge was created or changed by a root .npm-extension transformManifest repair on the from node + const transformed = this.#from.npmExtensionApplied + if (transformed) { + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']) { + if (transformed[field]?.includes(this.#name)) { + explanation.npmExtension = { extensionPoint: transformed.extensionPoint, field } + break + } + } + } } this.#explanation = explanation } diff --git a/workspaces/arborist/lib/index.js b/workspaces/arborist/lib/index.js index 2f0c4aec7938b..4236427d6c4e3 100644 --- a/workspaces/arborist/lib/index.js +++ b/workspaces/arborist/lib/index.js @@ -5,3 +5,4 @@ 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') +module.exports.NpmExtension = require('./npm-extension.js') diff --git a/workspaces/arborist/lib/node.js b/workspaces/arborist/lib/node.js index 94da3c48c6e98..01c3b13716d73 100644 --- a/workspaces/arborist/lib/node.js +++ b/workspaces/arborist/lib/node.js @@ -94,6 +94,7 @@ class Node { optional = true, overrides, packageExtensionsApplied = null, + npmExtensionApplied = null, parent, patched = null, path, @@ -176,6 +177,9 @@ class Node { // Provenance for a root packageExtensions repair applied to this node's manifest, or null. // Shape: { selector, dependencies?, optionalDependencies?, peerDependencies?, peerDependenciesMeta? }. this.packageExtensionsApplied = packageExtensionsApplied + // Provenance for a root .npm-extension transformManifest repair applied to this node's manifest, or null. + // Shape: { extensionPoint, dependencies?, optionalDependencies?, peerDependencies?, peerDependenciesMeta? }. + this.npmExtensionApplied = npmExtensionApplied this.installLinks = installLinks this.legacyPeerDeps = legacyPeerDeps diff --git a/workspaces/arborist/lib/npm-extension.js b/workspaces/arborist/lib/npm-extension.js new file mode 100644 index 0000000000000..505920968bee3 --- /dev/null +++ b/workspaces/arborist/lib/npm-extension.js @@ -0,0 +1,237 @@ +// Root-owned `.npm-extension.{mjs,cjs}`: an imperative `transformManifest(pkg, context)` extension point that repairs third-party manifests before Arborist reads a candidate's dependency edges. +// See RFC: https://github.com/npm/rfcs/pull/903 +// This module discovers and hashes the root extension file, loads its `transformManifest` export, and applies it to a deeply isolated manifest copy, returning an extended manifest plus minimal provenance. +// It never mutates the input manifest or any shared cache object. +const { resolve, sep } = require('node:path') +const { readFileSync, existsSync } = require('node:fs') +const { pathToFileURL } = require('node:url') +const { isDeepStrictEqual } = require('node:util') +const { log } = require('proc-log') +const ssri = require('ssri') +const { EXTENSION_FIELDS } = require('./package-extensions.js') + +const EXTENSION_POINT = 'transformManifest' + +// The two supported module formats and their default discovery filenames. +const FORMATS = [ + { ext: 'mjs', file: '.npm-extension.mjs' }, + { ext: 'cjs', file: '.npm-extension.cjs' }, +] + +const err = (message, code, extra = {}) => + Object.assign(new Error(message), { code, ...extra }) + +// Read a file's bytes, or null when it does not exist. +const readBytes = path => { + try { + return readFileSync(path) + } catch (e) { + if (e.code === 'ENOENT') { + return null + } + throw e + } +} + +// Resolve which extension file to load, returning { path, format, bytes } or null when none is present. +// A configured `extension-file` wins over default discovery; it must resolve inside the project root and use a `.mjs` or `.cjs` extension. +// When both default files exist npm fails rather than choosing one implicitly. +const discover = (root, extensionFile) => { + if (extensionFile) { + const path = resolve(root, extensionFile) + if (path !== root && !path.startsWith(root + sep)) { + throw err(`extension-file "${extensionFile}" must resolve inside the project root`, 'ENPMEXTENSIONPATH') + } + const format = FORMATS.find(f => path.endsWith(`.${f.ext}`))?.ext + if (!format) { + throw err(`extension-file "${extensionFile}" must use a .mjs or .cjs extension`, 'ENPMEXTENSIONPATH') + } + const bytes = readBytes(path) + if (bytes === null) { + throw err(`extension-file "${extensionFile}" was not found`, 'ENPMEXTENSIONPATH') + } + return { path, format, bytes } + } + const found = FORMATS + .map(f => ({ path: resolve(root, f.file), format: f.ext, bytes: readBytes(resolve(root, f.file)) })) + .filter(f => f.bytes !== null) + if (found.length > 1) { + throw err('found both .npm-extension.mjs and .npm-extension.cjs; keep only one', 'ENPMEXTENSIONDUP') + } + return found[0] || null +} + +// Hash the selected extension file: a format-tagged prefix plus the raw file bytes, using npm's lockfile digest encoding. +// The prefix keeps `.mjs` and `.cjs` files with identical bytes distinct, and excludes any path so the digest is machine-independent. +const hashFile = (format, bytes) => + ssri.fromData( + Buffer.concat([Buffer.from(`npm-extension:v1:${format}\n`), bytes]), + { algorithms: ['sha512'] } + ).toString() + +class NpmExtension { + #cache = new Map() + #transform = null + + constructor ({ root, extensionFile = null } = {}) { + this.root = root + this.path = null + this.format = null + this.hash = null + + const selected = root ? discover(root, extensionFile) : null + if (!selected) { + this.present = false + return + } + this.present = true + this.path = selected.path + this.format = selected.format + this.hash = hashFile(selected.format, selected.bytes) + } + + // Import the extension module and capture its `transformManifest` export. + // ESM files are loaded with dynamic import; CommonJS files with require. + // The export must be a function; anything else fails the install. + async load () { + if (!this.present) { + return + } + // Key the module load by the file hash so a changed file is reloaded rather than served stale from a module cache within one process. + let mod + if (this.format === 'mjs') { + mod = await import(`${pathToFileURL(this.path).href}?h=${this.hash}`) + } else { + delete require.cache[require.resolve(this.path)] + mod = require(this.path) + } + const transform = mod?.transformManifest ?? mod?.default?.transformManifest + if (typeof transform !== 'function') { + throw err(`.npm-extension must export a transformManifest function`, 'ENPMEXTENSIONSHAPE') + } + this.#transform = transform + } + + // Apply transformManifest to a candidate manifest, returning { pkg, applied } or null when nothing changed. + // Results are cached once per resolved package identity; consumers get a deeply isolated copy so they cannot mutate the cached effective manifest. + // Pass { memoize: false } to run without caching, e.g. a staleness probe over partial lockfile manifests that must not seed the cache used by full-manifest fetches. + apply (pkg, { memoize = true } = {}) { + if (!this.#transform || !pkg?.name) { + return null + } + const key = this.#identity(pkg) + let result + if (this.#cache.has(key)) { + result = this.#cache.get(key) + } else { + result = this.#run(pkg) + if (memoize) { + this.#cache.set(key, result) + } + } + return result && { pkg: structuredClone(result.pkg), applied: structuredClone(result.applied) } + } + + // Identity key for the transform cache: package integrity when available, otherwise resolved source plus name and version. + #identity (pkg) { + return pkg._integrity || `${pkg._resolved || ''}:${pkg.name}@${pkg.version || ''}` + } + + // Run transformManifest on a deeply isolated copy and validate the result. + // Returns { pkg, applied } when dependency or peer metadata changed, or null when it did not. + #run (pkg) { + const context = { + log: message => log.silly('npm-extension', message), + root: this.root, + extensionPoint: EXTENSION_POINT, + } + let returned + try { + returned = this.#transform(structuredClone(pkg), context) + } catch (e) { + throw err( + `.npm-extension transformManifest threw while processing ${pkg.name}@${pkg.version}: ${e.message}`, + 'ENPMEXTENSIONTHROW', { pkgid: `${pkg.name}@${pkg.version}` }) + } + if (returned && typeof returned.then === 'function') { + throw err( + `.npm-extension transformManifest must return a manifest synchronously, not a promise, for ${pkg.name}@${pkg.version}`, + 'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` }) + } + if (!returned || typeof returned !== 'object' || Array.isArray(returned)) { + throw err( + `.npm-extension transformManifest must return a manifest object for ${pkg.name}@${pkg.version}`, + 'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` }) + } + // Only dependency and peer fields may change; any other field the returned manifest explicitly alters is rejected. + // Fields the returned object omits are left untouched, so a handler may return a new object with only the fields it repairs. + for (const k of Object.keys(returned)) { + if (!EXTENSION_FIELDS.includes(k) && !isDeepStrictEqual(returned[k], pkg[k])) { + throw err( + `.npm-extension transformManifest changed unsupported field "${k}" on ${pkg.name}@${pkg.version}; only ${EXTENSION_FIELDS.join(', ')} may change`, + 'ENPMEXTENSIONFIELD', { pkgid: `${pkg.name}@${pkg.version}`, field: k }) + } + } + // Build the effective manifest from the normalized baseline plus the returned allowlisted fields. + // A field the handler omits is left as the baseline; delete individual entries by returning a field object without them. + const next = { ...pkg } + for (const field of EXTENSION_FIELDS) { + if (returned[field] === undefined) { + continue + } + this.#validateField(field, returned[field], pkg) + next[field] = returned[field] + } + const applied = this.#provenance(pkg, next) + return applied && { pkg: next, applied } + } + + // Validate a returned allowlisted field and its entries, so invalid output fails with .npm-extension and package context. + // Dependency maps hold version strings; peerDependenciesMeta holds metadata objects. + #validateField (field, value, pkg) { + const fail = (suffix) => err( + `.npm-extension transformManifest set ${suffix} to an invalid value on ${pkg.name}@${pkg.version}`, + 'ENPMEXTENSIONVALUE', { pkgid: `${pkg.name}@${pkg.version}`, field }) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw fail(field) + } + for (const [name, entry] of Object.entries(value)) { + if (field === 'peerDependenciesMeta') { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + throw fail(`${field}.${name}`) + } + } else if (typeof entry !== 'string') { + throw fail(`${field}.${name}`) + } + } + } + + // Minimal provenance: the extension point plus, for each changed allowlisted field, a sorted array of affected dependency names. + // Returns null when nothing changed. + #provenance (before, after) { + const applied = { extensionPoint: EXTENSION_POINT } + let changed = false + for (const field of EXTENSION_FIELDS) { + const b = before[field] || {} + const a = after[field] || {} + const names = [...new Set([...Object.keys(b), ...Object.keys(a)])] + .filter(n => !isDeepStrictEqual(b[n], a[n])) + .sort() + if (names.length) { + applied[field] = names + changed = true + } + } + return changed ? applied : null + } +} + +// Whether a directory contains any default .npm-extension file; a non-throwing existence check used for non-root workspace warnings. +const hasExtensionFile = dir => FORMATS.some(f => existsSync(resolve(dir, f.file))) + +module.exports = NpmExtension +module.exports.NpmExtension = NpmExtension +module.exports.discover = discover +module.exports.hasExtensionFile = hasExtensionFile +module.exports.hashFile = hashFile +module.exports.EXTENSION_POINT = EXTENSION_POINT diff --git a/workspaces/arborist/lib/place-dep.js b/workspaces/arborist/lib/place-dep.js index 6fd272e50600d..83bdd803409e0 100644 --- a/workspaces/arborist/lib/place-dep.js +++ b/workspaces/arborist/lib/place-dep.js @@ -250,6 +250,9 @@ class PlaceDep { ...(this.dep.packageExtensionsApplied ? { packageExtensionsApplied: this.dep.packageExtensionsApplied } : {}), + ...(this.dep.npmExtensionApplied + ? { npmExtensionApplied: this.dep.npmExtensionApplied } + : {}), ...(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 132afd59676c2..645a8a64263d8 100644 --- a/workspaces/arborist/lib/shrinkwrap.js +++ b/workspaces/arborist/lib/shrinkwrap.js @@ -115,6 +115,7 @@ const nodeMetaKeys = [ 'hasInstallScript', 'patched', 'packageExtensionsApplied', + 'npmExtensionApplied', ] const metaFieldFromPkg = (pkg, key) => { @@ -356,6 +357,7 @@ class Shrinkwrap { this.tree = null this.#awaitingUpdate = new Map() this.packageExtensionsHash = null + this.npmExtensionHash = null const lockfileVersion = this.lockfileVersion || defaultLockfileVersion this.originalLockfileVersion = lockfileVersion @@ -496,6 +498,8 @@ class Shrinkwrap { // the canonical packageExtensions hash, if the lockfile recorded one on its root entry this.packageExtensionsHash = data.packages?.['']?.packageExtensionsHash || null + // the .npm-extension file hash, if the lockfile recorded one on its root entry + this.npmExtensionHash = data.packages?.['']?.npmExtensionHash || 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, @@ -918,6 +922,10 @@ class Shrinkwrap { if (this.packageExtensionsHash) { root.packageExtensionsHash = this.packageExtensionsHash } + // record the .npm-extension file hash on the root entry for the same reason + if (this.npmExtensionHash) { + root.npmExtensionHash = this.npmExtensionHash + } this.data.packages = {} if (Object.keys(root).length) { this.data.packages[''] = root @@ -971,12 +979,12 @@ 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 + // packageExtensions and .npm-extension state likewise force 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)) + (this.packageExtensionsHash || this.npmExtensionHash || + Object.values(this.data.packages).some(p => p.packageExtensionsApplied || p.npmExtensionApplied)) if (hasExtensionState && this.lockfileVersion < packageExtensionsLockfileVersion) { - log.warn('shrinkwrap', `packageExtensions requires lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`) + log.warn('shrinkwrap', `manifest extensions require lockfileVersion ${packageExtensionsLockfileVersion}; upgrading the lockfile from version ${this.lockfileVersion}.`) this.lockfileVersion = packageExtensionsLockfileVersion } this.data.lockfileVersion = this.lockfileVersion diff --git a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs index af8593a9fce22..268a5b5ef7bdc 100644 --- a/workspaces/arborist/tap-snapshots/test/link.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/link.js.test.cjs @@ -25,6 +25,7 @@ Link { "linksIn": Set {}, "location": "../../../../../some/other/path", "name": "path", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -73,6 +74,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` }, "location": "", "name": "path", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -91,6 +93,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` "linksIn": Set {}, "location": "../../../../../some/other/path", "name": "path", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -121,6 +124,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = ` }, "location": "", "name": "path", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, diff --git a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs index 5664976285c5e..4ad88295da7ed 100644 --- a/workspaces/arborist/tap-snapshots/test/node.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/node.js.test.cjs @@ -27,6 +27,7 @@ exports[`test/node.js TAP basic instantiation > just a lone root node 1`] = ` "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "overrides": &ref_2 OverrideSet { "children": Map { @@ -218,6 +219,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -247,6 +249,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -313,6 +316,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -326,6 +330,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works }, "location": "foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -373,6 +378,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -386,6 +392,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works }, "location": "unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -426,6 +433,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -476,6 +484,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -489,6 +498,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works }, "location": "foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -519,6 +529,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -565,6 +576,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -578,6 +590,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works }, "location": "unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -595,6 +608,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "", "name": "workspaces_root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -645,6 +659,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -658,6 +673,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works }, "location": "foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -705,6 +721,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works "linksIn": Set {}, "location": "node_modules/unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -718,6 +735,7 @@ exports[`test/node.js TAP set workspaces > should setup edges out for each works }, "location": "unknown", "name": "unknown", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -759,6 +777,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -798,6 +817,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -852,6 +872,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -873,6 +894,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -914,6 +936,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -948,6 +971,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -982,6 +1006,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1016,6 +1041,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1046,6 +1072,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1101,6 +1128,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1114,6 +1142,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1184,6 +1213,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1223,6 +1253,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1277,6 +1308,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1298,6 +1330,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1332,6 +1365,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1373,6 +1407,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1407,6 +1442,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1441,6 +1477,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1475,6 +1512,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1505,6 +1543,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1560,6 +1599,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1573,6 +1613,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1605,6 +1646,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1644,6 +1686,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1674,6 +1717,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1690,6 +1734,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1723,6 +1768,7 @@ exports[`test/node.js TAP testing with dep tree with meta > add new meta under p "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1773,6 +1819,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1827,6 +1874,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1848,6 +1896,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1889,6 +1938,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1923,6 +1973,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1957,6 +2008,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -1991,6 +2043,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2021,6 +2074,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2100,6 +2154,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2154,6 +2209,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2175,6 +2231,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2209,6 +2266,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2250,6 +2308,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2291,6 +2350,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2325,6 +2385,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2359,6 +2420,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2393,6 +2455,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2423,6 +2486,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2440,6 +2504,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2473,6 +2538,7 @@ exports[`test/node.js TAP testing with dep tree with meta > initial load with so "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2535,6 +2601,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2556,6 +2623,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2597,6 +2665,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2631,6 +2700,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2665,6 +2735,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2699,6 +2770,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2729,6 +2801,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2773,6 +2846,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2864,6 +2938,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2885,6 +2960,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2919,6 +2995,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2960,6 +3037,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -2994,6 +3072,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3028,6 +3107,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3062,6 +3142,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3092,6 +3173,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3136,6 +3218,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3153,6 +3236,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3186,6 +3270,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move meta to top lev "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3248,6 +3333,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3269,6 +3355,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3307,6 +3394,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3341,6 +3429,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3375,6 +3464,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3409,6 +3499,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3439,6 +3530,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3471,6 +3563,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3530,6 +3623,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3543,6 +3637,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3634,6 +3729,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3655,6 +3751,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3689,6 +3786,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3727,6 +3825,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3761,6 +3860,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3795,6 +3895,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3829,6 +3930,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3859,6 +3961,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3889,6 +3992,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3920,6 +4024,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3979,6 +4084,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -3992,6 +4098,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4009,6 +4116,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4042,6 +4150,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4104,6 +4213,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4125,6 +4235,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4163,6 +4274,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4197,6 +4309,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4231,6 +4344,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4265,6 +4379,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4295,6 +4410,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4327,6 +4443,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4386,6 +4503,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4399,6 +4517,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4490,6 +4609,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4511,6 +4631,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4545,6 +4666,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4583,6 +4705,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4617,6 +4740,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4651,6 +4775,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4685,6 +4810,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4715,6 +4841,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4745,6 +4872,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4776,6 +4904,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4835,6 +4964,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4848,6 +4978,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4865,6 +4996,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4898,6 +5030,7 @@ exports[`test/node.js TAP testing with dep tree with meta > move new meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4939,6 +5072,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -4978,6 +5112,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5032,6 +5167,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5053,6 +5189,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5094,6 +5231,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5128,6 +5266,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5162,6 +5301,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5196,6 +5336,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5226,6 +5367,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5281,6 +5423,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5294,6 +5437,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5364,6 +5508,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5403,6 +5548,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5457,6 +5603,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5478,6 +5625,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5512,6 +5660,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5553,6 +5702,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5587,6 +5737,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5621,6 +5772,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5655,6 +5807,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5685,6 +5838,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5740,6 +5894,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5753,6 +5908,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5785,6 +5941,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5824,6 +5981,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5854,6 +6012,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5870,6 +6029,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5903,6 +6063,7 @@ exports[`test/node.js TAP testing with dep tree without meta > add new meta unde "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -5953,6 +6114,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6007,6 +6169,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6028,6 +6191,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6069,6 +6233,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6103,6 +6268,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6137,6 +6303,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6171,6 +6338,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6201,6 +6369,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6280,6 +6449,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6334,6 +6504,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6355,6 +6526,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6389,6 +6561,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6430,6 +6603,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod/node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6471,6 +6645,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6505,6 +6680,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6539,6 +6715,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6573,6 +6750,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6603,6 +6781,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6620,6 +6799,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6653,6 +6833,7 @@ exports[`test/node.js TAP testing with dep tree without meta > initial load with "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6715,6 +6896,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6736,6 +6918,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6777,6 +6960,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6811,6 +6995,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6845,6 +7030,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6879,6 +7065,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6909,6 +7096,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -6953,6 +7141,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7044,6 +7233,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7065,6 +7255,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7099,6 +7290,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7140,6 +7332,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7174,6 +7367,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7208,6 +7402,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7242,6 +7437,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7272,6 +7468,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7316,6 +7513,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7333,6 +7531,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7366,6 +7565,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move meta to top "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7428,6 +7628,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7449,6 +7650,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7487,6 +7689,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7521,6 +7724,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7555,6 +7759,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7589,6 +7794,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7619,6 +7825,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7651,6 +7858,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7710,6 +7918,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7723,6 +7932,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7814,6 +8024,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7835,6 +8046,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7869,6 +8081,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7907,6 +8120,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7941,6 +8155,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -7975,6 +8190,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8009,6 +8225,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8039,6 +8256,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8069,6 +8287,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8100,6 +8319,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8159,6 +8379,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8172,6 +8393,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8189,6 +8411,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8222,6 +8445,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8284,6 +8508,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8305,6 +8530,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8343,6 +8569,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8377,6 +8604,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8411,6 +8639,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8445,6 +8674,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8475,6 +8705,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8507,6 +8738,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8566,6 +8798,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8579,6 +8812,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8670,6 +8904,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8691,6 +8926,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod", "name": "prod", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8725,6 +8961,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8763,6 +9000,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/bundled", "name": "bundled", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8797,6 +9035,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/dev", "name": "dev", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8831,6 +9070,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/optional", "name": "optional", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8865,6 +9105,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/peer", "name": "peer", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8895,6 +9136,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/extraneous", "name": "extraneous", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8925,6 +9167,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -8956,6 +9199,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -9015,6 +9259,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/meta/node_modules/metameta", "name": "metameta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -9028,6 +9273,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to }, "location": "node_modules/meta", "name": "meta", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -9045,6 +9291,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "", "name": "root", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, @@ -9078,6 +9325,7 @@ exports[`test/node.js TAP testing with dep tree without meta > move new meta to "linksIn": Set {}, "location": "node_modules/prod/foo", "name": "foo", + "npmExtensionApplied": null, "optional": true, "packageExtensionsApplied": null, "patched": null, diff --git a/workspaces/arborist/test/arborist/reify-npm-extension.js b/workspaces/arborist/test/arborist/reify-npm-extension.js new file mode 100644 index 0000000000000..851d87c08f379 --- /dev/null +++ b/workspaces/arborist/test/arborist/reify-npm-extension.js @@ -0,0 +1,280 @@ +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 { hashFile } = require('../../lib/npm-extension.js') + +const createRegistry = (t) => new MockRegistry({ + strict: false, + tap: t, + registry: 'https://registry.npmjs.org', +}) + +// foo@1.0.0 does not declare bar; both are served as installable tarballs from source dirs. +const register = async (t, dir, { withBar = true, withBaz = false } = {}) => { + 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') } }) + } + if (withBaz) { + const bazManifest = registry.manifest({ name: 'baz', packuments: [{ version: '3.0.0' }] }) + await registry.package({ manifest: bazManifest, tarballs: { '3.0.0': join(dir, 'src/baz') } }) + } +} + +// a transformManifest that adds bar to foo +const addBar = `module.exports = { + transformManifest (pkg) { + if (pkg.name === 'foo') { + pkg.dependencies = { ...pkg.dependencies, bar: '^1.0.0' } + } + return pkg + }, +} +` + +const setup = async (t, { extension = addBar, file = '.npm-extension.cjs', dependencies = { foo: '1.0.0' }, overrides, withBar = true } = {}) => { + const dir = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', dependencies, overrides }), + [file]: extension, + 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 }) + 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')) + +for (const installStrategy of ['hoisted', 'nested', 'shallow', 'linked']) { + t.test(`installs the transform-created edge under install-strategy=${installStrategy}`, async t => { + const dir = await setup(t) + const tree = await newArb(dir, { installStrategy }).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo' && !n.isLink) + 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) + await newArb(dir).reify() + const lock = readLock(dir) + t.equal(lock.lockfileVersion, 4, 'bumped to lockfileVersion 4') + const expectHash = hashFile('cjs', Buffer.from(addBar)) + t.equal(lock.packages[''].npmExtensionHash, expectHash, 'root entry carries the file hash') + const fooEntry = lock.packages['node_modules/foo'] + t.strictSame(fooEntry.npmExtensionApplied, { extensionPoint: 'transformManifest', dependencies: ['bar'] }, + 'foo entry carries minimal provenance') + t.strictSame(fooEntry.dependencies, { bar: '^1.0.0' }, 'foo entry carries the effective dependency metadata') +}) + +t.test('explain annotates the transform-created edge', async t => { + const dir = await setup(t) + const tree = await newArb(dir).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo' && !n.isLink) + const explanation = foo.edgesOut.get('bar').explain() + t.strictSame(explanation.npmExtension, { extensionPoint: 'transformManifest', field: 'dependencies' }, + 'edge explanation carries the transform provenance') +}) + +t.test('explain annotates an edge created in a non-first field', async t => { + // adds bar to optionalDependencies, so the edge explanation loop skips `dependencies` before matching + const dir = await setup(t, { + extension: `module.exports = { + transformManifest (pkg) { + if (pkg.name === 'foo') { + pkg.optionalDependencies = { ...pkg.optionalDependencies, bar: '^1.0.0' } + } + return pkg + }, + } +`, + }) + const tree = await newArb(dir).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo' && !n.isLink) + const explanation = foo.edgesOut.get('bar').explain() + t.strictSame(explanation.npmExtension, { extensionPoint: 'transformManifest', field: 'optionalDependencies' }, + 'edge explanation reports the optionalDependencies field') +}) + +t.test('does not rewrite the installed dependency package.json', async t => { + const dir = await setup(t) + 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 packageExtensions on the same package', async t => { + // .npm-extension adds bar to foo (runs first); packageExtensions adds baz to foo (runs on the transform output) + const dir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { foo: '1.0.0' }, + packageExtensions: { 'foo@1': { dependencies: { baz: '^3.0.0' } } }, + }), + '.npm-extension.cjs': addBar, + src: { + foo: { 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }) }, + bar: { 'package.json': JSON.stringify({ name: 'bar', version: '1.2.3' }) }, + baz: { 'package.json': JSON.stringify({ name: 'baz', version: '3.0.0' }) }, + }, + }) + await register(t, dir, { withBaz: true }) + const tree = await newArb(dir).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo' && !n.isLink) + t.ok(foo.edgesOut.get('bar')?.to, 'transform-created bar edge resolved') + t.ok(foo.edgesOut.get('baz')?.to, 'packageExtensions-created baz edge resolved') + t.same(foo.npmExtensionApplied, { extensionPoint: 'transformManifest', dependencies: ['bar'] }, + 'transform provenance recorded') + t.same(foo.packageExtensionsApplied, { selector: 'foo@1', dependencies: ['baz'] }, + 'packageExtensions provenance recorded') +}) + +t.test('composes with overrides during reify', async t => { + const dir = await setup(t, { 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 transform-created edge') +}) + +t.test('an .mjs extension is honored', async t => { + const dir = await setup(t, { + file: '.npm-extension.mjs', + extension: `export function transformManifest (pkg) { + if (pkg.name === 'foo') pkg.dependencies = { ...pkg.dependencies, bar: '^1.0.0' } + return pkg + } +`, + }) + const tree = await newArb(dir).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo' && !n.isLink) + t.ok(foo.edgesOut.get('bar')?.to, 'bar edge resolved via .mjs extension') +}) + +t.test('ignore-extension disables the transform and records no state', async t => { + const dir = await setup(t, { withBar: false }) + await newArb(dir, { ignoreExtension: true }).reify() + const lock = readLock(dir) + t.notOk(lock.packages[''].npmExtensionHash, 'no extension hash recorded') + t.notOk(lock.packages['node_modules/bar'], 'bar was never added by the disabled transform') + t.notOk(lock.packages['node_modules/foo'].dependencies, 'foo has no extension-added dependency') +}) + +t.test('warns when a non-root workspace contains a .npm-extension file', async t => { + const warnings = [] + const onlog = (...m) => m[0] === 'warn' && m[1] === 'npm-extension' && warnings.push(m[2]) + process.on('log', onlog) + t.teardown(() => process.removeListener('log', onlog)) + + const dir = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + packages: { + a: { + 'package.json': JSON.stringify({ name: 'a', version: '1.0.0' }), + '.npm-extension.cjs': 'module.exports = { transformManifest (p) { return p } }\n', + }, + b: { 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }) }, + }, + }) + await newArb(dir).buildIdealTree() + t.match(warnings.join('\n'), /"\.npm-extension" in workspace a is ignored/, 'warns for the workspace with the file') + t.notMatch(warnings.join('\n'), /workspace b/, 'does not warn for the workspace without one') +}) + +t.test('a project with no .npm-extension installs normally and records no state', async t => { + 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' }) } }, + }) + await register(t, dir, { withBar: false }) + await newArb(dir).reify() + const lock = readLock(dir) + t.notOk(lock.packages[''].npmExtensionHash, 'no extension hash recorded') + t.notOk(lock.packages['node_modules/foo'].dependencies, 'foo unchanged') +}) + +t.test('provenance round-trips under install-strategy=linked', async t => { + const dir = await setup(t) + await newArb(dir, { installStrategy: 'linked' }).reify() + // a second linked reify rescans the store and links, re-deriving provenance on both + const tree = await newArb(dir, { installStrategy: 'linked' }).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo') + t.ok(foo.npmExtensionApplied || foo.target?.npmExtensionApplied, 'provenance present on the linked node or its target') +}) + +t.test('loadActual re-derives provenance only for transformed installed deps', async t => { + // a filesystem-scanned tree: foo is the transform target, qux is an unrelated installed dep + const dir = t.testdir({ + 'package.json': JSON.stringify({ name: 'root', dependencies: { foo: '^1.0.0', qux: '^1.0.0' } }), + '.npm-extension.cjs': addBar, + node_modules: { + foo: { 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }) }, + qux: { 'package.json': JSON.stringify({ name: 'qux', version: '1.0.0' }) }, + }, + }) + const actual = await newArb(dir).loadActual() + const foo = [...actual.inventory.values()].find(n => n.name === 'foo' && !n.isLink) + const qux = [...actual.inventory.values()].find(n => n.name === 'qux' && !n.isLink) + t.strictSame(foo.npmExtensionApplied, { extensionPoint: 'transformManifest', dependencies: ['bar'] }, + 'foo carries provenance from the re-derived transform') + t.equal(qux.npmExtensionApplied, null, 'qux, untouched by the transform, carries no provenance') +}) + +t.test('provenance round-trips through the lockfile', async t => { + const dir = await setup(t) + await newArb(dir).reify() + // a second reify loads the lockfile and the on-disk tree; the edge and provenance must survive + const tree = await newArb(dir).reify() + const foo = [...tree.inventory.values()].find(n => n.name === 'foo' && !n.isLink) + t.ok(foo.edgesOut.get('bar')?.to, 'bar edge still present after reinstall') + t.strictSame(foo.npmExtensionApplied, { extensionPoint: 'transformManifest', dependencies: ['bar'] }, + 'provenance preserved across reinstall') +}) + +t.test('changing the extension file re-resolves affected packages', async t => { + const dir = await setup(t) + await newArb(dir).reify() + t.equal(readLock(dir).packages['node_modules/bar']?.version, '1.2.3', 'bar installed initially') + + // rewrite the extension so it no longer adds bar, then reinstall + fs.writeFileSync(join(dir, '.npm-extension.cjs'), `module.exports = { transformManifest (pkg) { return pkg } }\n`) + await register(t, dir, { withBar: false }) + await newArb(dir).reify() + const lock = readLock(dir) + t.notOk(lock.packages['node_modules/bar'], 'bar removed after the extension stopped adding it') + t.notOk(lock.packages['node_modules/foo']?.npmExtensionApplied, 'provenance cleared') +}) + +t.test('removing the extension file reverts the locked graph', async t => { + const dir = await setup(t) + await newArb(dir).reify() + t.ok(readLock(dir).packages['node_modules/bar'], 'bar installed by the extension') + + // delete the extension file entirely, then reinstall; the transform-added edge and state must be reverted + fs.rmSync(join(dir, '.npm-extension.cjs')) + 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 file is gone') + t.notOk(lock.packages[''].npmExtensionHash, 'root hash cleared') + t.notOk(lock.packages['node_modules/foo']?.npmExtensionApplied, 'foo provenance cleared') +}) diff --git a/workspaces/arborist/test/npm-extension.js b/workspaces/arborist/test/npm-extension.js new file mode 100644 index 0000000000000..774f19c9f2ea9 --- /dev/null +++ b/workspaces/arborist/test/npm-extension.js @@ -0,0 +1,206 @@ +const t = require('tap') +const { resolve } = require('node:path') +const NpmExtension = require('../lib/npm-extension.js') +const { discover, hashFile, EXTENSION_POINT } = require('../lib/npm-extension.js') + +// write a transformManifest module body as a .mjs or .cjs file in a fresh dir +const mjs = body => `export function transformManifest (pkg, context) {\n${body}\n}\n` +const cjs = body => `module.exports = { transformManifest (pkg, context) {\n${body}\n} }\n` + +t.test('discover', async t => { + t.equal(discover(t.testdir({}), null), null, 'nothing present') + + const mjsDir = t.testdir({ '.npm-extension.mjs': mjs('return pkg') }) + t.match(discover(mjsDir, null), { format: 'mjs', path: resolve(mjsDir, '.npm-extension.mjs') }, 'finds .mjs') + + const cjsDir = t.testdir({ '.npm-extension.cjs': cjs('return pkg') }) + t.match(discover(cjsDir, null), { format: 'cjs' }, 'finds .cjs') + + const bothDir = t.testdir({ '.npm-extension.mjs': mjs('return pkg'), '.npm-extension.cjs': cjs('return pkg') }) + t.throws(() => discover(bothDir, null), { code: 'ENPMEXTENSIONDUP' }, 'rejects both files present') + + // extension-file + const cfgDir = t.testdir({ tools: { 'ext.mjs': mjs('return pkg') } }) + t.match(discover(cfgDir, 'tools/ext.mjs'), { format: 'mjs' }, 'loads configured file') + t.throws(() => discover(cfgDir, '../escape.mjs'), { code: 'ENPMEXTENSIONPATH' }, 'rejects path outside root') + t.throws(() => discover(cfgDir, 'tools/ext.js'), { code: 'ENPMEXTENSIONPATH' }, 'rejects non mjs/cjs extension') + t.throws(() => discover(cfgDir, 'tools/missing.mjs'), { code: 'ENPMEXTENSIONPATH' }, 'rejects missing configured file') + + // a non-ENOENT read error (here a directory in the file's place) propagates + const dirNamed = t.testdir({ '.npm-extension.mjs': {} }) + t.throws(() => discover(dirNamed, null), { code: 'EISDIR' }, 'propagates non-ENOENT read errors') +}) + +t.test('hashFile is deterministic and format-tagged', async t => { + const bytes = Buffer.from('export function transformManifest (p) { return p }') + t.equal(hashFile('mjs', bytes), hashFile('mjs', bytes), 'stable for same bytes') + t.not(hashFile('mjs', bytes), hashFile('cjs', bytes), 'mjs and cjs differ for identical bytes') + t.match(hashFile('mjs', bytes), /^sha512-/, 'sha512 ssri string') +}) + +t.test('constructor without root is absent', async t => { + const ext = new NpmExtension() + t.equal(ext.present, false) + t.equal(ext.hash, null) +}) + +t.test('load: honors an ESM default export', async t => { + const dir = t.testdir({ + '.npm-extension.mjs': `export default { transformManifest (pkg) { pkg.dependencies = { d: '1' }; return pkg } }\n`, + }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + t.same(ext.apply({ name: 'foo', version: '1.0.0' }).applied.dependencies, ['d'], 'default export used') +}) + +t.test('apply: keys the cache by resolved source when integrity is absent', async t => { + const dir = t.testdir({ '.npm-extension.cjs': cjs(`pkg.dependencies = { d: '1' }; return pkg`) }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + // a git-style manifest: no _integrity, no version, only a resolved source + const res = ext.apply({ name: 'g', _resolved: 'git+ssh://host/a.git#abc' }) + t.same(res.applied.dependencies, ['d'], 'transform applied to a non-registry manifest') +}) + +t.test('apply: a manifest without a name is skipped', async t => { + const dir = t.testdir({ '.npm-extension.mjs': mjs('pkg.dependencies = { d: "1" }; return pkg') }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + t.equal(ext.apply({ version: '1.0.0' }), null, 'no name means nothing to match') +}) + +t.test('load: rejects a bad export shape', async t => { + const dir = t.testdir({ '.npm-extension.mjs': 'export const transformManifest = 5\n' }) + const ext = new NpmExtension({ root: dir }) + t.ok(ext.present) + t.ok(ext.hash, 'hash computed without executing the module') + await t.rejects(ext.load(), { code: 'ENPMEXTENSIONSHAPE' }, 'non-function export rejected') +}) + +t.test('load: absent extension is a no-op', async t => { + const ext = new NpmExtension({ root: t.testdir({}) }) + await ext.load() + t.equal(ext.apply({ name: 'x', version: '1.0.0' }), null, 'apply is a no-op when absent') +}) + +t.test('apply: cjs adds a dependency', async t => { + const dir = t.testdir({ + '.npm-extension.cjs': cjs(`if (pkg.name === 'foo') { pkg.dependencies = { ...pkg.dependencies, bar: '^2.0.0' }; context.log('added bar') } return pkg`), + }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + const res = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-foo' }) + t.same(res.pkg.dependencies, { bar: '^2.0.0' }, 'dependency added') + t.same(res.applied, { extensionPoint: EXTENSION_POINT, dependencies: ['bar'] }, 'provenance recorded') + t.equal(ext.apply({ name: 'other', version: '1.0.0' }), null, 'non-matching package unchanged') +}) + +t.test('apply: mjs adds optional peer and meta, sorted provenance', async t => { + const dir = t.testdir({ + '.npm-extension.mjs': mjs(` + if (pkg.name !== 'widget') return pkg + pkg.peerDependencies = { ...pkg.peerDependencies, '@types/react': '*', react: '>=18' } + pkg.peerDependenciesMeta = { ...pkg.peerDependenciesMeta, '@types/react': { optional: true } } + return pkg`), + }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + const res = ext.apply({ name: 'widget', version: '2.0.0', _integrity: 'sha512-w' }) + t.same(res.applied, { + extensionPoint: EXTENSION_POINT, + peerDependencies: ['@types/react', 'react'], + peerDependenciesMeta: ['@types/react'], + }, 'affected names sorted per field') +}) + +t.test('apply: can replace a range and delete an entry', async t => { + const dir = t.testdir({ + '.npm-extension.cjs': cjs(` + if (pkg.name === 'rep') pkg.dependencies = { ...pkg.dependencies, dep: '^9.0.0' } + if (pkg.name === 'del') delete pkg.dependencies.gone + return pkg`), + }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + const rep = ext.apply({ name: 'rep', version: '1.0.0', dependencies: { dep: '^1.0.0' } }) + t.equal(rep.pkg.dependencies.dep, '^9.0.0', 'range replaced') + t.same(rep.applied.dependencies, ['dep']) + + const del = ext.apply({ name: 'del', version: '1.0.0', dependencies: { gone: '1', keep: '2' } }) + t.same(del.pkg.dependencies, { keep: '2' }, 'entry deleted') + t.same(del.applied.dependencies, ['gone'], 'deleted name recorded in provenance') +}) + +t.test('apply: no-op transform returns null', async t => { + const dir = t.testdir({ '.npm-extension.mjs': mjs('return pkg') }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + t.equal(ext.apply({ name: 'foo', version: '1.0.0' }), null, 'unchanged manifest yields no provenance') +}) + +t.test('apply: caches per identity and isolates consumers', async t => { + const dir = t.testdir({ + '.npm-extension.cjs': cjs(`pkg.dependencies = { ...pkg.dependencies, added: '1.0.0' }; return pkg`), + }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + // two consumers of the same cached identity must each get a distinct, deeply isolated object + const a = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-shared' }) + const b = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-shared' }) + t.not(a.pkg, b.pkg, 'each consumer gets a distinct object') + a.pkg.dependencies.added = 'mutated' + t.equal(b.pkg.dependencies.added, '1.0.0', 'mutating one copy does not affect another') +}) + +t.test('apply: rejects invalid transform output', async t => { + const cases = [ + ['return null', 'ENPMEXTENSIONRETURN'], + ['return 5', 'ENPMEXTENSIONRETURN'], + ['return []', 'ENPMEXTENSIONRETURN'], + ['return Promise.resolve(pkg)', 'ENPMEXTENSIONRETURN'], + [`pkg.scripts = { build: 'x' }; return pkg`, 'ENPMEXTENSIONFIELD'], + [`return { name: pkg.name, version: pkg.version, scripts: { build: 'x' } }`, 'ENPMEXTENSIONFIELD'], + [`pkg.dependencies = 'nope'; return pkg`, 'ENPMEXTENSIONVALUE'], + [`pkg.dependencies = null; return pkg`, 'ENPMEXTENSIONVALUE'], + [`pkg.dependencies = { x: null }; return pkg`, 'ENPMEXTENSIONVALUE'], + [`pkg.peerDependencies = { p: '*' }; pkg.peerDependenciesMeta = { p: null }; return pkg`, 'ENPMEXTENSIONVALUE'], + [`throw new Error('boom')`, 'ENPMEXTENSIONTHROW'], + ] + // a unique dir per case so require/import cache never serves a previous module + for (const [i, [body, code]] of cases.entries()) { + const dir = t.testdir({ [`c${i}`]: { '.npm-extension.cjs': cjs(body) } }) + const ext = new NpmExtension({ root: resolve(dir, `c${i}`) }) + await ext.load() + t.throws(() => ext.apply({ name: 'foo', version: '1.0.0', scripts: {} }), { code }, `${code}: ${body}`) + } +}) + +t.test('apply: a handler may return a new object with only repaired fields', async t => { + const dir = t.testdir({ + '.npm-extension.cjs': cjs(`return { name: pkg.name, version: pkg.version, dependencies: { ...pkg.dependencies, bar: '^2.0.0' } }`), + }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + const res = ext.apply({ name: 'foo', version: '1.0.0', dependencies: { keep: '1' }, scripts: { build: 'x' }, _integrity: 'sha512-z' }) + t.same(res.pkg.dependencies, { keep: '1', bar: '^2.0.0' }, 'returned dependencies overlaid on the baseline') + t.same(res.pkg.scripts, { build: 'x' }, 'omitted non-allowlisted field preserved from the baseline') +}) + +t.test('apply: isolates cached provenance between consumers', async t => { + const dir = t.testdir({ '.npm-extension.cjs': cjs(`pkg.dependencies = { ...pkg.dependencies, bar: '1' }; return pkg`) }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + const a = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-prov' }) + const b = ext.apply({ name: 'foo', version: '1.0.0', _integrity: 'sha512-prov' }) + a.applied.dependencies.push('mutated') + t.same(b.applied.dependencies, ['bar'], 'mutating one consumer\'s provenance does not affect another') +}) + +t.test('apply: does not mutate the input manifest', async t => { + const dir = t.testdir({ '.npm-extension.cjs': cjs(`pkg.dependencies = { bar: '1' }; return pkg`) }) + const ext = new NpmExtension({ root: dir }) + await ext.load() + const input = { name: 'foo', version: '1.0.0', dependencies: { keep: '2' } } + ext.apply(input) + t.same(input.dependencies, { keep: '2' }, 'caller manifest untouched') +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index c30b59c7c5af4..ecb491cb32f62 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -774,6 +774,18 @@ const definitions = { `, flatten, }), + 'extension-file': new Definition('extension-file', { + default: null, + type: [null, path], + description: ` + Path to a project-local npm extension file to load instead of + discovering \`.npm-extension.mjs\` / \`.npm-extension.cjs\` at the + project root. Must resolve inside the project root and use a \`.mjs\` + or \`.cjs\` extension. Only honored from project config or the command + line, never from user, global, or builtin config. + `, + flatten, + }), 'fetch-retries': new Definition('fetch-retries', { default: 2, type: Number, @@ -1030,6 +1042,17 @@ const definitions = { `, flatten, }), + 'ignore-extension': new Definition('ignore-extension', { + default: false, + type: Boolean, + description: ` + If true, npm does not import or execute a root \`.npm-extension.mjs\` / + \`.npm-extension.cjs\` file (or one selected via \`extension-file\`). + \`ignore-scripts\` implies \`ignore-extension\`, since both disable + root-owned install-time code. + `, + flatten, + }), 'ignore-scripts': new Definition('ignore-scripts', { default: false, type: Boolean, @@ -1040,8 +1063,17 @@ const definitions = { as \`npm start\`, \`npm stop\`, \`npm restart\`, \`npm test\`, and \`npm run\` will still run their intended script if \`ignore-scripts\` is set, but they will *not* run any pre- or post-scripts. + + Setting \`ignore-scripts\` also disables \`.npm-extension\` execution, + as if \`ignore-extension\` were set. `, - flatten, + // ignore-scripts implies ignore-extension: both disable root install-time code + flatten (key, obj, flatOptions) { + flatOptions.ignoreScripts = obj['ignore-scripts'] + if (obj['ignore-scripts']) { + flatOptions.ignoreExtension = true + } + }, }), include: new Definition('include', { default: [], diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 4121c2a7a3840..2bb3503ef7989 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -270,6 +270,17 @@ class Config { // private attributes, as that module also does a bunch of get operations this.#loaded = true + // extension-file selects root-owned install-time code, so it is only honored from project config or the command line + if (this.get('extension-file') != null) { + const where = this.find('extension-file') + if (!['cli', 'project', 'default'].includes(where)) { + throw Object.assign( + new Error(`\`extension-file\` may only be set in project config or on the command line, not from ${where} config`), + { code: 'ENPMEXTENSIONCONFIG' } + ) + } + } + // set proper globalPrefix now that everything is loaded this.globalPrefix = this.get('prefix') 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 f01a411ee36ff..0e3a63289772e 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -195,6 +195,10 @@ Object { null, "numeric value", ], + "extension-file": Array [ + null, + "valid filesystem path", + ], "fetch-retries": Array [ "numeric value", ], @@ -253,6 +257,9 @@ Object { "ignore-existing": Array [ "boolean value (true or false)", ], + "ignore-extension": Array [ + "boolean value (true or false)", + ], "ignore-patch-failures": Array [ "boolean value (true or false)", ], diff --git a/workspaces/config/test/extension-file.js b/workspaces/config/test/extension-file.js new file mode 100644 index 0000000000000..c697aa9c829b3 --- /dev/null +++ b/workspaces/config/test/extension-file.js @@ -0,0 +1,63 @@ +const t = require('tap') +const { join, resolve } = require('node:path') +const Config = require('../lib/index.js') +const { definitions, shorthands, flatten, nerfDarts } = require('../lib/definitions') + +const npmPath = resolve(__dirname, '..') + +// build a Config whose project (local prefix) is the testdir root, with optional npmrc per source +// --prefix forces the local prefix to the testdir, so the project .npmrc lives there +const loadConfig = async (t, { project, user, argv = [] } = {}) => { + const dir = t.testdir({ + '.npmrc': project ?? '', + 'package.json': '{"name":"proj","version":"1.0.0"}', + home: { '.npmrc': user ?? '' }, + }) + const config = new Config({ + npmPath, + shorthands, + definitions, + flatten, + nerfDarts, + env: { HOME: join(dir, 'home') }, + argv: [process.execPath, __filename, '--prefix', dir, '--userconfig', join(dir, 'home/.npmrc'), ...argv], + cwd: dir, + }) + await config.load() + return config +} + +t.test('extension-file is accepted from project config', async t => { + const config = await loadConfig(t, { project: 'extension-file=tools/ext.mjs' }) + t.equal(config.find('extension-file'), 'project', 'sourced from project') + t.match(config.get('extension-file'), /tools[/\\]ext\.mjs$/, 'value resolved') +}) + +t.test('extension-file is accepted from the command line', async t => { + const config = await loadConfig(t, { argv: ['--extension-file', 'tools/ext.cjs'] }) + t.equal(config.find('extension-file'), 'cli', 'sourced from cli') +}) + +t.test('extension-file is rejected from user config', async t => { + await t.rejects( + loadConfig(t, { user: 'extension-file=tools/ext.mjs' }), + { code: 'ENPMEXTENSIONCONFIG' }, + 'user config source is rejected') +}) + +t.test('extension-file unset loads cleanly', async t => { + const config = await loadConfig(t, {}) + t.equal(config.get('extension-file'), null, 'default null') +}) + +t.test('ignore-scripts implies ignoreExtension in flatOptions', async t => { + const onlyScripts = await loadConfig(t, { project: 'ignore-scripts=true' }) + t.equal(onlyScripts.flat.ignoreExtension, true, 'ignore-scripts turns on ignoreExtension') + t.equal(onlyScripts.flat.ignoreScripts, true, 'ignore-scripts still flattens itself') + + const neither = await loadConfig(t, {}) + t.equal(neither.flat.ignoreExtension, false, 'off by default') + + const onlyExt = await loadConfig(t, { project: 'ignore-extension=true' }) + t.equal(onlyExt.flat.ignoreExtension, true, 'ignore-extension alone works') +})