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
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand Down Expand Up @@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand Down Expand Up @@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading