From 09a38956660a99534185b8b323a7911da6fae167 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 24 Jun 2026 21:36:52 +0530 Subject: [PATCH 1/2] fix(arborist): scope root-dep filter nodes to the linked diff tree Under the linked strategy the diff's actual side is a synthesized wrapper tree. When includeRootDeps was active (--workspaces=false or -w --include-workspace-root), the root-dep filter nodes were pulled from this.actualTree, whose root is the real actual tree rather than the wrapper, tripping Diff.calculate's invalid filterNode guard. Use only the ideal-side root-dep targets when the linked wrapper is in use, matching the existing workspace-node handling. --- workspaces/arborist/lib/arborist/reify.js | 5 ++- workspaces/arborist/test/arborist/reify.js | 41 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 47d936f67a2d4..7606d1bda4ea2 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -462,7 +462,10 @@ module.exports = cls => class Reifier extends cls { } if (includeRootDeps) { // add all non-workspace nodes to filterNodes - for (const tree of [this.idealTree, this.actualTree]) { + // Skip the actual tree under the linked diff wrapper: its edge targets have root===actualTree, not the wrapper, which trips Diff.calculate's filterNode guard. + // The ideal-side targets alone scope the diff. + const trees = this.#linkedActualForDiff ? [this.idealTree] : [this.idealTree, this.actualTree] + for (const tree of trees) { for (const { type, to } of tree.edgesOut.values()) { if (type !== 'workspace' && to) { filterNodes.push(to) diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index 81d91f08f1d95..c031b0ca546cc 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -4254,6 +4254,47 @@ t.test('install strategy linked', async (t) => { }) }) +t.test('linked strategy --workspaces=false and --include-workspace-root do not crash', async t => { + // Regression for #9614. Under linked, the root-dep filter nodes came from the real actual tree, not the synthesized diff wrapper, tripping Diff.calculate's "invalid filterNode" guard. + const manifest = deps => JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: ['packages/*'], + dependencies: deps, + }) + const path = t.testdir({ + 'package.json': manifest({ abbrev: '1.1.1', wrappy: '1.0.2' }), + packages: { + a: { + 'package.json': JSON.stringify({ name: 'a', version: '1.0.0' }), + }, + }, + }) + + createRegistry(t, true) + await reify(path, { installStrategy: 'linked' }) + + // --workspaces=false: only root deps are in scope. + await t.resolves( + reify(path, { installStrategy: 'linked', workspacesEnabled: false }), + '--workspaces=false does not crash' + ) + + // -w a --include-workspace-root: workspace a plus root deps in scope. + await t.resolves( + reify(path, { installStrategy: 'linked', workspaces: ['a'], includeWorkspaceRoot: true }), + '-w a --include-workspace-root does not crash' + ) + + t.ok(fs.lstatSync(resolve(path, 'node_modules/abbrev')).isSymbolicLink(), 'root dep still linked') + + // Dropping the actual-side filter nodes must not stop a filtered install from pruning a removed root dep. + fs.writeFileSync(resolve(path, 'package.json'), manifest({ abbrev: '1.1.1' })) + await reify(path, { installStrategy: 'linked', workspacesEnabled: false }) + t.notOk(fs.existsSync(resolve(path, 'node_modules/wrappy')), 'removed root dep pruned under filtered install') + t.ok(fs.lstatSync(resolve(path, 'node_modules/abbrev')).isSymbolicLink(), 'remaining root dep still linked') +}) + t.test('linked strategy exposes store node_modules via NODE_PATH for lifecycle scripts', async t => { // Regression for #9549. In the linked strategy a store package's deps are symlinked siblings in its store node_modules. // A separate bin invoked by the script (e.g. napi-postinstall) resolves modules from its own store realpath and cannot see them, so npm exposes them via NODE_PATH. From 62cc482697c59d24d4e203fcdf4953b8eccebf5d Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 24 Jun 2026 21:57:53 +0530 Subject: [PATCH 2/2] fix(arborist): ignore per-call linked strategy for global installs Global installs are normalized to the shallow strategy in the Arborist constructor, but reify() re-derived the linked flag from the raw per-call installStrategy option, bypassing that normalization. A global install passed installStrategy:'linked' would engage the unsupported linked path: re-installing an already-present global package tripped Diff.calculate's invalid filterNode guard, and once the guard was bypassed the isolated reifier deleted the global package instead of materializing it. Honor the global-to-shallow normalization in reify() so global installs never use the linked strategy. --- workspaces/arborist/lib/arborist/reify.js | 4 +++- workspaces/arborist/test/arborist/reify.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index 7606d1bda4ea2..eec6df4868621 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -89,7 +89,9 @@ module.exports = cls => class Reifier extends cls { // public method async reify (options = {}) { - const linked = (options.installStrategy || this.options.installStrategy) === 'linked' + // Global installs are normalized to the shallow strategy in the constructor; honor that here so a per-call installStrategy:'linked' can't re-engage the unsupported linked path. + const linked = !this.options.global && + (options.installStrategy || this.options.installStrategy) === 'linked' if (this.options.packageLockOnly && this.options.global) { const er = new Error('cannot generate lockfile for global packages') diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index c031b0ca546cc..9e6a7438ca968 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -4295,6 +4295,23 @@ t.test('linked strategy --workspaces=false and --include-workspace-root do not c t.ok(fs.lstatSync(resolve(path, 'node_modules/abbrev')).isSymbolicLink(), 'remaining root dep still linked') }) +t.test('global install ignores a per-call linked strategy', async t => { + // Regression for #9614. Global installs are normalized to shallow; a per-call installStrategy:'linked' must not re-engage the linked path, which would trip Diff.calculate's filterNode guard on re-install and delete the global package. + const path = t.testdir({ lib: {} }) + const lib = resolve(path, 'lib') + const nm = resolve(lib, 'node_modules') + + createRegistry(t, true) + await reify(lib, { add: ['abbrev@1.1.1'], global: true }) + + // Re-install the already-present package under linked: must not crash and must not remove it. + await t.resolves( + reify(lib, { add: ['abbrev@1.1.1'], global: true, installStrategy: 'linked' }), + 'global re-install under linked does not crash' + ) + t.strictSame(fs.readdirSync(nm), ['abbrev'], 'global package retained, no .store created') +}) + t.test('linked strategy exposes store node_modules via NODE_PATH for lifecycle scripts', async t => { // Regression for #9549. In the linked strategy a store package's deps are symlinked siblings in its store node_modules. // A separate bin invoked by the script (e.g. napi-postinstall) resolves modules from its own store realpath and cannot see them, so npm exposes them via NODE_PATH.