diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 1edd0b643b60d..7d22c2f19ff0b 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -1238,15 +1238,19 @@ This is a one-time fix-up, please be patient... // Check if the target is within the project root isProjectInternalFileSpec = targetPath.startsWith(resolvedProjectRoot + sep) || targetPath === resolvedProjectRoot } + + // When using --install-links, we need to handle transitive file dependencies specially + // If the parent was installed (not linked) due to --install-links, and this is a file: dep, we should also install it rather than link it + const parentWasInstalled = parent && !parent.isLink && parent.resolved?.startsWith('file:') + const isTransitiveFileDep = spec.type === 'directory' && parentWasInstalled && installLinks + // Decide whether to link or copy the dependency - const shouldLink = isWorkspace || isProjectInternalFileSpec || !installLinks + const shouldLink = (isWorkspace || isProjectInternalFileSpec || !installLinks) && !isTransitiveFileDep if (spec.type === 'directory' && shouldLink) { return this.#linkFromSpec(name, spec, parent, edge) } - // if the spec matches a workspace name, then see if the workspace node will - // satisfy the edge. if it does, we return the workspace node to make sure it - // takes priority. + // if the spec matches a workspace name, then see if the workspace node will satisfy the edge. if it does, we return the workspace node to make sure it takes priority. if (isWorkspace) { const existingNode = this.idealTree.edgesOut.get(spec.name).to if (existingNode && existingNode.isWorkspace && existingNode.satisfies(edge)) { @@ -1254,6 +1258,15 @@ This is a one-time fix-up, please be patient... } } + // For file: dependencies that we're installing (not linking), ensure proper resolution + if (isTransitiveFileDep && edge) { + // For transitive file deps, resolve relative to the parent's original source location + const parentOriginalPath = parent.resolved.slice(5) // Remove 'file:' prefix + const relativePath = edge.rawSpec.slice(5) // Remove 'file:' prefix + const absolutePath = resolve(parentOriginalPath, relativePath) + spec = npa.resolve(name, `file:${absolutePath}`) + } + // spec isn't a directory, and either isn't a workspace or the workspace we have // doesn't satisfy the edge. try to fetch a manifest and build a node from that. return this.#fetchManifest(spec) diff --git a/workspaces/arborist/test/arborist/build-ideal-tree.js b/workspaces/arborist/test/arborist/build-ideal-tree.js index 32bc6b25ed39c..aadc09e3bee7d 100644 --- a/workspaces/arborist/test/arborist/build-ideal-tree.js +++ b/workspaces/arborist/test/arborist/build-ideal-tree.js @@ -4389,4 +4389,64 @@ t.test('installLinks behavior with project-internal file dependencies', async t t.ok(nestedDep, 'nested-dep should be found') t.ok(nestedDep.isLink, 'nested-dep should be a link (project-internal)') }) + + t.test('installLinks=true with transitive external file dependencies', async t => { + // mainpkg installs b (external file dep) with --install-links + // b depends on a (another external file dep via file:../a) + // Both should be installed (not linked) and dependencies should resolve correctly + const testRoot = t.testdir({ + a: { + 'package.json': JSON.stringify({ + name: 'a', + main: 'index.js', + }), + 'index.js': 'export const A = "A";', + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + main: 'index.js', + dependencies: { + a: 'file:../a', + }, + }), + 'index.js': 'import {A} from "a";export const fn = () => console.log(A);', + }, + mainpkg: { + 'package.json': JSON.stringify({}), + }, + }) + + const mainpkgPath = join(testRoot, 'mainpkg') + const bPath = join(testRoot, 'b') + createRegistry(t, false) + + const arb = newArb(mainpkgPath, { installLinks: true }) + + // Add the external file dependency using the full path + await arb.buildIdealTree({ add: [`file:${bPath}`] }) + + const tree = arb.idealTree + + // Both packages should be present in the tree + const packageB = tree.children.get('b') + const packageA = tree.children.get('a') + + t.ok(packageB, 'package b should be found in tree') + t.ok(packageA, 'package a should be found in tree (transitive dependency)') + + // Both should be installed (not linked) due to installLinks=true + t.notOk(packageB.isLink, 'package b should not be a link (installLinks=true)') + t.notOk(packageA.isLink, 'package a should not be a link (transitive with installLinks=true)') + + // Verify that the resolved paths are correct + t.match(packageB.resolved, /file:.*[/\\]b$/, 'package b should have correct resolved path') + t.match(packageA.resolved, /file:.*[/\\]a$/, 'package a should have correct resolved path') + + // Verify the dependency relationship + const edgeToA = packageB.edgesOut.get('a') + t.ok(edgeToA, 'package b should have an edge to a') + t.ok(edgeToA.valid, 'the edge from b to a should be valid') + t.equal(edgeToA.to, packageA, 'the edge from b should point to package a') + }) })