diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 380f071039dbd..5afcc63b7e4b2 100644 --- a/workspaces/arborist/lib/arborist/isolated-reifier.js +++ b/workspaces/arborist/lib/arborist/isolated-reifier.js @@ -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() @@ -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 @@ -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. diff --git a/workspaces/arborist/test/isolated-mode.js b/workspaces/arborist/test/isolated-mode.js index 097e5df9437eb..2a4549749b02d 100644 --- a/workspaces/arborist/test/isolated-mode.js +++ b/workspaces/arborist/test/isolated-mode.js @@ -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: * --------------------------------------