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
4 changes: 3 additions & 1 deletion workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +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.
const isLocal = (n) => n.isWorkspace || node.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.
Expand Down
95 changes: 95 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -1657,6 +1657,101 @@ 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('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 <external>` 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: [
Expand Down
Loading