From 4568721342129978cb40b7219307071242a0deef Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 22 Apr 2026 12:32:35 -0700 Subject: [PATCH] fix!: refuse to pack when overrides apply to bundled packages BREAKING CHANGE: npm pack and npm publish now error when a package's overrides apply to one or more of its bundled packages (bundledDependencies / bundleDependencies). Defining both fields is still allowed as long as no override actually targets a bundled package. To resolve the error, remove the affected entries from either overrides or the bundle. --- workspaces/libnpmpack/lib/index.js | 36 +++++ workspaces/libnpmpack/test/index.js | 226 ++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) diff --git a/workspaces/libnpmpack/lib/index.js b/workspaces/libnpmpack/lib/index.js index df6d35cb172c6..0c220bb7ceb2e 100644 --- a/workspaces/libnpmpack/lib/index.js +++ b/workspaces/libnpmpack/lib/index.js @@ -14,6 +14,42 @@ async function pack (spec = 'file:.', opts = {}) { const manifest = await pacote.manifest(spec, { ...opts, Arborist, _isRoot: true }) + if (spec.type === 'directory') { + const hasBundled = manifest.bundleDependencies?.length > 0 + const hasOverrides = manifest.overrides + && typeof manifest.overrides === 'object' + && Object.keys(manifest.overrides).length > 0 + if (hasBundled && hasOverrides) { + // Only refuse when an override rule actually applies to a package that is bundled by the root. + // Overrides targeting dev dependencies or any package outside the bundled tree are harmless to consumers, because consumers do not apply the publishing package's overrides. + // We rely on Arborist's own semantics (inBundle/inDepBundle/overridden) rather than reimplementing what npm-packlist/arborist already knows. + const arb = new Arborist({ path: spec.fetchSpec }) + const tree = await arb.loadActual() + const offenders = new Set() + for (const node of tree.inventory.values()) { + if (node.isRoot) { + continue + } + // Only packages bundled by the root are at risk: nested dep-bundles are published as-is and arborist already treats them as immune to the root's overrides (see Edge#satisfiedBy). + if (!node.inBundle || node.inDepBundle) { + continue + } + if (node.overridden) { + offenders.add(node.name) + } + } + if (offenders.size) { + const names = [...offenders].sort() + const list = names.join(', ') + const isOne = names.length === 1 + throw Object.assign( + new Error(`Cannot pack or publish: "overrides" ${isOne ? 'affects a bundled package' : 'affect bundled packages'} (${list}). Consumers do not apply your package's overrides, so the published bundle will produce invalid dependency edges. Remove ${isOne ? 'this package' : 'these packages'} from "bundledDependencies"/"bundleDependencies" or from "overrides" before publishing.`), + { code: 'EBUNDLEOVERRIDE', packages: names } + ) + } + } + } + const stdio = opts.foregroundScripts ? 'inherit' : 'pipe' if (spec.type === 'directory' && !opts.ignoreScripts) { diff --git a/workspaces/libnpmpack/test/index.js b/workspaces/libnpmpack/test/index.js index 62d5af1a80d2c..369c295428263 100644 --- a/workspaces/libnpmpack/test/index.js +++ b/workspaces/libnpmpack/test/index.js @@ -212,3 +212,229 @@ t.test('doesn\'t run scripts when ignoreScripts === true', async t => { spawk.clean() }) }) + +t.test('refuses to pack when overrides affect a bundled package', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + bundledDependencies: ['foo'], + dependencies: { foo: '1.0.0' }, + overrides: { bar: '2.0.0' }, + }, null, 2), + node_modules: { + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + dependencies: { bar: '^1.0.0' }, + }), + node_modules: { + bar: { + 'package.json': JSON.stringify({ name: 'bar', version: '2.0.0' }), + }, + }, + }, + }, + }) + + const cwd = process.cwd() + process.chdir(testDir) + t.teardown(() => process.chdir(cwd)) + + await t.rejects( + pack('file:.'), + { + code: 'EBUNDLEOVERRIDE', + packages: ['bar'], + message: /affects a bundled package \(bar\)/, + }, + 'throws EBUNDLEOVERRIDE listing the offending bundled package' + ) +}) + +t.test('lists all offenders when multiple bundled packages are overridden', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + bundledDependencies: ['foo'], + dependencies: { foo: '1.0.0' }, + overrides: { bar: '2.0.0', baz: '3.0.0' }, + }, null, 2), + node_modules: { + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + dependencies: { bar: '^1.0.0', baz: '^1.0.0' }, + }), + node_modules: { + bar: { + 'package.json': JSON.stringify({ name: 'bar', version: '2.0.0' }), + }, + baz: { + 'package.json': JSON.stringify({ name: 'baz', version: '3.0.0' }), + }, + }, + }, + }, + }) + + const cwd = process.cwd() + process.chdir(testDir) + t.teardown(() => process.chdir(cwd)) + + await t.rejects( + pack('file:.'), + { + code: 'EBUNDLEOVERRIDE', + packages: ['bar', 'baz'], + message: /affect bundled packages \(bar, baz\)/, + }, + 'lists every overridden bundled package and uses plural wording' + ) +}) + +t.test('refuses to pack with bundleDependencies (alt spelling) + affected override', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + bundleDependencies: ['foo'], + dependencies: { foo: '1.0.0' }, + overrides: { bar: '2.0.0' }, + }, null, 2), + node_modules: { + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + dependencies: { bar: '^1.0.0' }, + }), + node_modules: { + bar: { + 'package.json': JSON.stringify({ name: 'bar', version: '2.0.0' }), + }, + }, + }, + }, + }) + + const cwd = process.cwd() + process.chdir(testDir) + t.teardown(() => process.chdir(cwd)) + + await t.rejects( + pack('file:.'), + { code: 'EBUNDLEOVERRIDE' }, + 'throws EBUNDLEOVERRIDE with alternate bundleDependencies spelling' + ) +}) + +t.test('packs when overrides target only a dev dependency (not bundled)', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + bundledDependencies: ['keep'], + dependencies: { keep: '1.0.0' }, + devDependencies: { dev: '1.0.0' }, + overrides: { transdev: '2.0.0' }, + }, null, 2), + node_modules: { + keep: { + 'package.json': JSON.stringify({ name: 'keep', version: '1.0.0' }), + }, + dev: { + 'package.json': JSON.stringify({ + name: 'dev', + version: '1.0.0', + dependencies: { transdev: '^1.0.0' }, + }), + node_modules: { + transdev: { + 'package.json': JSON.stringify({ name: 'transdev', version: '2.0.0' }), + }, + }, + }, + }, + }) + + const cwd = process.cwd() + process.chdir(testDir) + t.teardown(() => process.chdir(cwd)) + + const tarball = await pack('file:.') + t.ok(tarball, 'pack succeeds — overridden dev-only transitive dep is not in the bundle') +}) + +t.test('packs when overrides target a package outside the bundled subtree', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + bundledDependencies: ['foo'], + dependencies: { foo: '1.0.0', qux: '^1.0.0' }, + overrides: { baz: '2.0.0' }, + }, null, 2), + node_modules: { + foo: { + 'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }), + }, + qux: { + 'package.json': JSON.stringify({ + name: 'qux', + version: '1.0.0', + dependencies: { baz: '^1.0.0' }, + }), + node_modules: { + baz: { + 'package.json': JSON.stringify({ name: 'baz', version: '2.0.0' }), + }, + }, + }, + }, + }) + + const cwd = process.cwd() + process.chdir(testDir) + t.teardown(() => process.chdir(cwd)) + + const tarball = await pack('file:.') + t.ok(tarball, 'pack succeeds — overridden package is not bundled') +}) + +t.test('packs with only bundledDependencies (no overrides)', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + bundledDependencies: [], + }, null, 2), + }) + + const cwd = process.cwd() + process.chdir(testDir) + t.teardown(() => process.chdir(cwd)) + + const tarball = await pack('file:.') + t.ok(tarball) +}) + +t.test('packs with only overrides (no bundled)', async t => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + overrides: { 'lru-cache': '6.0.0' }, + }, null, 2), + }) + + const cwd = process.cwd() + process.chdir(testDir) + t.teardown(() => process.chdir(cwd)) + + const tarball = await pack('file:.') + t.ok(tarball) +})