From 501235fce8ba2eba6f7f1581bc12a061ea6185cc Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 22 Jun 2026 09:41:51 +0530 Subject: [PATCH 1/2] fix(arborist): symlink workspace file: deps on non-workspace local packages with linked strategy --- .../arborist/lib/arborist/isolated-reifier.js | 3 +- workspaces/arborist/test/isolated-mode.js | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 8d5911c8b6efe..1a84390b1411f 100644 --- a/workspaces/arborist/lib/arborist/isolated-reifier.js +++ b/workspaces/arborist/lib/arborist/isolated-reifier.js @@ -228,7 +228,8 @@ module.exports = cls => class IsolatedReifier extends cls { } // local `file:` deps (non-workspace fsChildren) should be treated as local dependencies, not external, so they get symlinked directly instead of being extracted into the store. - const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n) + // fsChildren only exists on the root ideal tree, so also consult it directly to catch file: targets depended on by a workspace (node.fsChildren is undefined there). + const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n) || this.idealTree.fsChildren?.has(n) const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target) // Optional peers declared only in peerDependenciesMeta (e.g. `@types/react`) have no edge, so the materialization above misses them. diff --git a/workspaces/arborist/test/isolated-mode.js b/workspaces/arborist/test/isolated-mode.js index 9d289ebefa974..6340da5741b2e 100644 --- a/workspaces/arborist/test/isolated-mode.js +++ b/workspaces/arborist/test/isolated-mode.js @@ -1657,6 +1657,53 @@ tap.test('npm link (external file: dep) with linked strategy', async t => { t.notOk(storeEntries.some(e => e.startsWith('external-pkg@')), 'external-pkg is NOT in the store') }) +tap.test('workspace file: dependency on a non-workspace local package with linked strategy', async t => { + // Regression test for https://github.com/npm/cli/issues/9589 + // A workspace declaring a file: dep on a local package that is NOT itself a workspace was silently skipped: no symlink, no error. + const graph = { + registry: [], + root: { + name: 'mono', + version: '1.0.0', + }, + workspaces: [ + { name: 'ws-a', version: '1.0.0', dependencies: { 'local-dep': 'file:../../local-dep' } }, + ], + } + + const { dir, registry } = await getRepo(graph) + + // Create the non-workspace local package on disk, outside the workspaces globs + const depDir = path.join(dir, 'local-dep') + fs.mkdirSync(depDir, { recursive: true }) + fs.writeFileSync(path.join(depDir, 'package.json'), JSON.stringify({ + name: 'local-dep', + version: '1.0.0', + })) + fs.writeFileSync(path.join(depDir, 'index.js'), "module.exports = 'local-dep'") + + const cache = fs.mkdtempSync(`${getTempDir()}/test-`) + const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache }) + await arborist.reify({ installStrategy: 'linked' }) + + // The file dep should be symlinked into the workspace's node_modules + const linkPath = path.join(dir, 'packages', 'ws-a', 'node_modules', 'local-dep') + const stat = fs.lstatSync(linkPath) + t.ok(stat.isSymbolicLink(), 'local-dep is a symlink in the workspace node_modules') + + // The symlink should resolve to the actual local directory + t.equal(fs.realpathSync(linkPath), fs.realpathSync(depDir), 'symlink points to the correct local directory') + + // It must be symlinked directly, not extracted into the store + const storePath = path.join(dir, 'node_modules', '.store') + if (fs.existsSync(storePath)) { + t.notOk(fs.readdirSync(storePath).some(e => e.startsWith('local-dep@')), 'local-dep is NOT in the store') + } + + // The package should be requireable from inside the workspace + t.ok(setupRequire(path.join(dir, 'packages', 'ws-a'))('local-dep'), 'local-dep can be required from the workspace') +}) + tap.test('subsequent linked install is a no-op', async t => { const graph = { registry: [ From 2ca29c2accd2165017754e5ce86dcbc626385703 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 22 Jun 2026 10:36:00 +0530 Subject: [PATCH 2/2] fix(arborist): symlink workspace file: deps on out-of-repo local packages with linked strategy --- .../arborist/lib/arborist/isolated-reifier.js | 5 +- workspaces/arborist/test/isolated-mode.js | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 1a84390b1411f..784c389c43756 100644 --- a/workspaces/arborist/lib/arborist/isolated-reifier.js +++ b/workspaces/arborist/lib/arborist/isolated-reifier.js @@ -228,8 +228,9 @@ module.exports = cls => class IsolatedReifier extends cls { } // local `file:` deps (non-workspace fsChildren) should be treated as local dependencies, not external, so they get symlinked directly instead of being extracted into the store. - // fsChildren only exists on the root ideal tree, so also consult it directly to catch file: targets depended on by a workspace (node.fsChildren is undefined there). - const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n) || this.idealTree.fsChildren?.has(n) + // A file: dep surfaces as a Link edge whose resolved spec starts with file:; detect it from the edge so the target is treated as local even when it is absent from idealTree.fsChildren (a workspace consumer, or a target outside the repo root via npm link). + const fileLinkTargets = new Set(edges.filter(e => e.to?.isLink && e.to.resolved?.startsWith('file:')).map(e => e.to.target)) + const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n) || fileLinkTargets.has(n) const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target) // Optional peers declared only in peerDependenciesMeta (e.g. `@types/react`) have no edge, so the materialization above misses them. diff --git a/workspaces/arborist/test/isolated-mode.js b/workspaces/arborist/test/isolated-mode.js index 6340da5741b2e..f80235721fb67 100644 --- a/workspaces/arborist/test/isolated-mode.js +++ b/workspaces/arborist/test/isolated-mode.js @@ -1704,6 +1704,54 @@ tap.test('workspace file: dependency on a non-workspace local package with linke t.ok(setupRequire(path.join(dir, 'packages', 'ws-a'))('local-dep'), 'local-dep can be required from the workspace') }) +tap.test('workspace file: dependency on a package outside the repo root with linked strategy', async t => { + // Regression test for the out-of-repo variant of https://github.com/npm/cli/issues/9589 (the real `npm --workspace link ` case, https://github.com/npm/cli/issues/9115). + // A workspace file: dep whose target resolves OUTSIDE the repo root was silently skipped. + // The target is not in idealTree.fsChildren, so the fix must detect it from the file: link edge. + const graph = { + registry: [], + root: { + name: 'mono', + version: '1.0.0', + }, + workspaces: [ + { name: 'ws-a', version: '1.0.0', dependencies: { 'ext-pkg': 'file:../../../ext-pkg' } }, + ], + } + + const { dir, registry } = await getRepo(graph) + + // Create the external package OUTSIDE the repo root + const extDir = path.join(path.dirname(dir), 'ext-pkg') + fs.mkdirSync(extDir, { recursive: true }) + fs.writeFileSync(path.join(extDir, 'package.json'), JSON.stringify({ + name: 'ext-pkg', + version: '1.0.0', + })) + fs.writeFileSync(path.join(extDir, 'index.js'), "module.exports = 'ext-pkg'") + + const cache = fs.mkdtempSync(`${getTempDir()}/test-`) + const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache }) + await arborist.reify({ installStrategy: 'linked' }) + + // The file dep should be symlinked into the workspace's node_modules + const linkPath = path.join(dir, 'packages', 'ws-a', 'node_modules', 'ext-pkg') + const stat = fs.lstatSync(linkPath) + t.ok(stat.isSymbolicLink(), 'ext-pkg is a symlink in the workspace node_modules') + + // The symlink should resolve to the actual external directory + t.equal(fs.realpathSync(linkPath), fs.realpathSync(extDir), 'symlink points to the correct external directory') + + // It must be symlinked directly, not extracted into the store + const storePath = path.join(dir, 'node_modules', '.store') + if (fs.existsSync(storePath)) { + t.notOk(fs.readdirSync(storePath).some(e => e.startsWith('ext-pkg@')), 'ext-pkg is NOT in the store') + } + + // The package should be requireable from inside the workspace + t.ok(setupRequire(path.join(dir, 'packages', 'ws-a'))('ext-pkg'), 'ext-pkg can be required from the workspace') +}) + tap.test('subsequent linked install is a no-op', async t => { const graph = { registry: [