Skip to content

Commit 6dbe21a

Browse files
committed
fix: local transitive dependencies with --install-links=true
1 parent 8042af3 commit 6dbe21a

File tree

2 files changed

+270
-3
lines changed

2 files changed

+270
-3
lines changed

workspaces/arborist/lib/arborist/build-ideal-tree.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const pacote = require('pacote')
66
const cacache = require('cacache')
77
const { callLimit: promiseCallLimit } = require('promise-call-limit')
88
const realpath = require('../../lib/realpath.js')
9-
const { resolve, dirname } = require('node:path')
9+
const { resolve, dirname, sep } = require('node:path')
1010
const treeCheck = require('../tree-check.js')
1111
const { readdirScoped } = require('@npmcli/fs')
1212
const { lstat, readlink } = require('node:fs/promises')
@@ -1226,9 +1226,21 @@ This is a one-time fix-up, please be patient...
12261226
const { installLinks, legacyPeerDeps } = this
12271227
const isWorkspace = this.idealTree.workspaces && this.idealTree.workspaces.has(spec.name)
12281228

1229-
// spec is a directory, link it unless installLinks is set or it's a workspace
1229+
// spec is a directory, link it if:
1230+
// - it's a workspace, OR
1231+
// - it's a project-internal file: dependency (always linked), OR
1232+
// - it's external and installLinks is false
12301233
// TODO post arborist refactor, will need to check for installStrategy=linked
1231-
if (spec.type === 'directory' && (isWorkspace || !installLinks)) {
1234+
let isProjectInternalFileSpec = false
1235+
if (edge?.rawSpec.startsWith('file:../') || edge?.rawSpec.startsWith('file:./')) {
1236+
const targetPath = resolve(parent.realpath, edge.rawSpec.slice(5))
1237+
const resolvedProjectRoot = resolve(this.idealTree.realpath)
1238+
// Check if the target is within the project root
1239+
isProjectInternalFileSpec = targetPath.startsWith(resolvedProjectRoot + sep) || targetPath === resolvedProjectRoot
1240+
}
1241+
// Decide whether to link or copy the dependency
1242+
const shouldLink = isWorkspace || isProjectInternalFileSpec || !installLinks
1243+
if (spec.type === 'directory' && shouldLink) {
12321244
return this.#linkFromSpec(name, spec, parent, edge)
12331245
}
12341246

workspaces/arborist/test/arborist/build-ideal-tree.js

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4135,3 +4135,258 @@ t.test('engine checking respects omit flags', async t => {
41354135
t.pass('should succeed when omitting peer dependencies with engine mismatches')
41364136
})
41374137
})
4138+
4139+
t.test('installLinks behavior with project-internal file dependencies', async t => {
4140+
t.test('project-internal file dependencies are always symlinked regardless of installLinks', async t => {
4141+
const path = t.testdir({
4142+
'package.json': JSON.stringify({
4143+
name: 'test-root',
4144+
version: '1.0.0',
4145+
dependencies: {
4146+
'local-pkg': 'file:./packages/local-pkg',
4147+
},
4148+
}),
4149+
packages: {
4150+
'local-pkg': {
4151+
'package.json': JSON.stringify({
4152+
name: 'local-pkg',
4153+
version: '1.0.0',
4154+
dependencies: {
4155+
'local-shared': 'file:../local-shared', // Project-internal dependency
4156+
},
4157+
}),
4158+
'index.js': 'module.exports = "local-pkg"',
4159+
},
4160+
'local-shared': {
4161+
'package.json': JSON.stringify({
4162+
name: 'local-shared',
4163+
version: '1.0.0',
4164+
}),
4165+
'index.js': 'module.exports = "local-shared"',
4166+
},
4167+
},
4168+
})
4169+
4170+
createRegistry(t, false)
4171+
4172+
// Test with installLinks=true (this used to fail before our fix)
4173+
const arb = newArb(path, { installLinks: true })
4174+
const tree = await arb.buildIdealTree()
4175+
4176+
// Both packages should be present in the tree
4177+
t.ok(tree.children.has('local-pkg'), 'local-pkg should be in the tree')
4178+
t.ok(tree.children.has('local-shared'), 'local-shared should be in the tree')
4179+
4180+
// Both should be Links (symlinked) because they are project-internal
4181+
const localPkg = tree.children.get('local-pkg')
4182+
const localShared = tree.children.get('local-shared')
4183+
4184+
t.ok(localPkg.isLink, 'local-pkg should be a link')
4185+
t.ok(localShared.isLink, 'local-shared should be a link (hoisted from local-pkg)')
4186+
4187+
// Verify the paths are correct
4188+
t.ok(localPkg.realpath.endsWith(join('packages', 'local-pkg')), 'local-pkg should link to correct path')
4189+
t.ok(localShared.realpath.endsWith(join('packages', 'local-shared')), 'local-shared should link to correct path')
4190+
})
4191+
4192+
t.test('external file dependencies respect installLinks setting', async t => {
4193+
// Create test structure with both project and external dependency in same testdir
4194+
const testRoot = t.testdir({
4195+
project: {
4196+
'package.json': JSON.stringify({
4197+
name: 'test-project',
4198+
version: '1.0.0',
4199+
dependencies: {
4200+
'external-lib': 'file:../external-lib', // External dependency (outside project)
4201+
},
4202+
}),
4203+
},
4204+
'external-lib': {
4205+
'package.json': JSON.stringify({
4206+
name: 'external-lib',
4207+
version: '1.0.0',
4208+
}),
4209+
'index.js': 'module.exports = "external-lib"',
4210+
},
4211+
})
4212+
4213+
const projectPath = join(testRoot, 'project')
4214+
createRegistry(t, false)
4215+
4216+
// Test with installLinks=true - external dependency should be copied (not linked)
4217+
const arbWithLinks = newArb(projectPath, { installLinks: true })
4218+
const treeWithLinks = await arbWithLinks.buildIdealTree()
4219+
4220+
t.ok(treeWithLinks.children.has('external-lib'), 'external-lib should be in the tree')
4221+
const externalWithLinks = treeWithLinks.children.get('external-lib')
4222+
t.notOk(externalWithLinks.isLink, 'external-lib should not be a link when installLinks=true')
4223+
4224+
// Test with installLinks=false - external dependency should be linked
4225+
const arbNoLinks = newArb(projectPath, { installLinks: false })
4226+
const treeNoLinks = await arbNoLinks.buildIdealTree()
4227+
4228+
t.ok(treeNoLinks.children.has('external-lib'), 'external-lib should be in the tree')
4229+
const externalNoLinks = treeNoLinks.children.get('external-lib')
4230+
t.ok(externalNoLinks.isLink, 'external-lib should be a link when installLinks=false')
4231+
})
4232+
4233+
t.test('mixed internal and external file dependencies', async t => {
4234+
const testRoot = t.testdir({
4235+
project: {
4236+
'package.json': JSON.stringify({
4237+
name: 'mixed-test',
4238+
version: '1.0.0',
4239+
dependencies: {
4240+
'internal-pkg': 'file:./lib/internal-pkg',
4241+
'external-dep': 'file:../external-dep', // External dependency
4242+
},
4243+
}),
4244+
lib: {
4245+
'internal-pkg': {
4246+
'package.json': JSON.stringify({
4247+
name: 'internal-pkg',
4248+
version: '1.0.0',
4249+
dependencies: {
4250+
'internal-shared': 'file:../internal-shared', // Project-internal
4251+
},
4252+
}),
4253+
'index.js': 'module.exports = "internal-pkg"',
4254+
},
4255+
'internal-shared': {
4256+
'package.json': JSON.stringify({
4257+
name: 'internal-shared',
4258+
version: '1.0.0',
4259+
}),
4260+
'index.js': 'module.exports = "internal-shared"',
4261+
},
4262+
},
4263+
},
4264+
'external-dep': {
4265+
'package.json': JSON.stringify({
4266+
name: 'external-dep',
4267+
version: '1.0.0',
4268+
}),
4269+
'index.js': 'module.exports = "external-dep"',
4270+
},
4271+
})
4272+
4273+
const projectPath = join(testRoot, 'project')
4274+
createRegistry(t, false)
4275+
4276+
const arb = newArb(projectPath, { installLinks: true })
4277+
const tree = await arb.buildIdealTree()
4278+
4279+
// All dependencies should be present
4280+
t.ok(tree.children.has('internal-pkg'), 'internal-pkg should be in tree')
4281+
t.ok(tree.children.has('internal-shared'), 'internal-shared should be in tree')
4282+
t.ok(tree.children.has('external-dep'), 'external-dep should be in tree')
4283+
4284+
// Internal dependencies should be links (project-internal rule)
4285+
const internalPkg = tree.children.get('internal-pkg')
4286+
const internalShared = tree.children.get('internal-shared')
4287+
t.ok(internalPkg.isLink, 'internal-pkg should be a link (project-internal)')
4288+
t.ok(internalShared.isLink, 'internal-shared should be a link (project-internal)')
4289+
4290+
// External dependency should not be a link (respects installLinks=true)
4291+
const externalDep = tree.children.get('external-dep')
4292+
t.notOk(externalDep.isLink, 'external-dep should not be a link (respects installLinks=true)')
4293+
})
4294+
4295+
t.test('code coverage for project-internal file dependency edge cases', async t => {
4296+
const testRoot = t.testdir({
4297+
'parent-dir-external': {
4298+
'package.json': JSON.stringify({
4299+
name: 'parent-dir-external',
4300+
version: '1.0.0',
4301+
}),
4302+
'index.js': 'module.exports = "parent-dir-external"',
4303+
},
4304+
project: {
4305+
'package.json': JSON.stringify({
4306+
name: 'coverage-test',
4307+
version: '1.0.0',
4308+
dependencies: {
4309+
'current-dir-dep': 'file:./current-dir-dep', // file:./ case
4310+
'parent-dir-external': 'file:../parent-dir-external', // file:../ case outside project
4311+
},
4312+
}),
4313+
'current-dir-dep': {
4314+
'package.json': JSON.stringify({
4315+
name: 'current-dir-dep',
4316+
version: '1.0.0',
4317+
}),
4318+
'index.js': 'module.exports = "current-dir-dep"',
4319+
},
4320+
},
4321+
})
4322+
4323+
const projectPath = join(testRoot, 'project')
4324+
createRegistry(t, false)
4325+
4326+
// Test with installLinks=false to verify external dependencies respect setting
4327+
const arb = newArb(projectPath, { installLinks: false })
4328+
const tree = await arb.buildIdealTree()
4329+
4330+
t.ok(tree.children.has('current-dir-dep'), 'current-dir-dep should be in tree')
4331+
t.ok(tree.children.has('parent-dir-external'), 'parent-dir-external should be in tree')
4332+
4333+
const currentDirDep = tree.children.get('current-dir-dep')
4334+
const parentDirExternal = tree.children.get('parent-dir-external')
4335+
4336+
// current-dir-dep should be a link (project-internal always links)
4337+
t.ok(currentDirDep.isLink, 'current-dir-dep should be a link (file:./ within project)')
4338+
4339+
// parent-dir-external should ALSO be a link (external, but installLinks=false means link everything)
4340+
t.ok(parentDirExternal.isLink, 'parent-dir-external should be a link (file:../ outside project, installLinks=false means link)')
4341+
4342+
// Verify the logic branches - current-dir should resolve within project, parent-dir should not
4343+
t.match(currentDirDep.realpath, new RegExp(join(testRoot, 'project').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
4344+
'current-dir-dep realpath should be within project root')
4345+
})
4346+
4347+
t.test('installLinks=true with nested project-internal file dependencies', async t => {
4348+
// Test a more complex scenario with nested dependencies to ensure comprehensive coverage
4349+
const testRoot = t.testdir({
4350+
project: {
4351+
'package.json': JSON.stringify({
4352+
name: 'nested-test',
4353+
version: '1.0.0',
4354+
dependencies: {
4355+
'wrapper-pkg': 'file:./packages/wrapper-pkg',
4356+
},
4357+
}),
4358+
packages: {
4359+
'wrapper-pkg': {
4360+
'package.json': JSON.stringify({
4361+
name: 'wrapper-pkg',
4362+
version: '1.0.0',
4363+
dependencies: {
4364+
'nested-dep': 'file:../nested-dep',
4365+
},
4366+
}),
4367+
},
4368+
'nested-dep': {
4369+
'package.json': JSON.stringify({
4370+
name: 'nested-dep',
4371+
version: '1.0.0',
4372+
}),
4373+
},
4374+
},
4375+
},
4376+
})
4377+
4378+
const projectPath = join(testRoot, 'project')
4379+
createRegistry(t, false)
4380+
4381+
const arb = newArb(projectPath, { installLinks: true })
4382+
const tree = await arb.buildIdealTree()
4383+
4384+
const wrapperPkg = tree.children.get('wrapper-pkg')
4385+
const nestedDep = tree.children.get('nested-dep')
4386+
4387+
t.ok(wrapperPkg, 'wrapper-pkg should be found')
4388+
t.ok(wrapperPkg.isLink, 'wrapper-pkg should be a link (project-internal)')
4389+
t.ok(nestedDep, 'nested-dep should be found')
4390+
t.ok(nestedDep.isLink, 'nested-dep should be a link (project-internal)')
4391+
})
4392+
})

0 commit comments

Comments
 (0)