Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,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')
Expand Down Expand Up @@ -474,7 +476,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)
Expand Down
58 changes: 58 additions & 0 deletions workspaces/arborist/test/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -4514,6 +4514,64 @@ 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('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.
Expand Down
Loading