From 35c957a81ebcfba64916cd0f8a5f846c3ab5651c Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 26 Feb 2026 11:44:20 +0530 Subject: [PATCH] fix(arborist): resolve relative file: deps correctly with linked strategy Local file: dependencies were incorrectly classified as external in the isolated reifier, routing them through store extraction instead of symlinking directly. This caused the relative path to resolve from the wrong directory, producing ENOENT errors. Treat file dep targets found in fsChildren as local dependencies, alongside workspaces, so they get symlinked directly. --- .../arborist/lib/arborist/isolated-reifier.js | 10 +++-- workspaces/arborist/test/isolated-mode.js | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js index 1d691a749ff63..0b4052aa916e1 100644 --- a/workspaces/arborist/lib/arborist/isolated-reifier.js +++ b/workspaces/arborist/lib/arborist/isolated-reifier.js @@ -113,7 +113,9 @@ module.exports = cls => class IsolatedReifier extends cls { queue.push(edge.to) } }) - if (!next.isProjectRoot && !next.isWorkspace && !next.inert) { + // local `file:` deps are in fsChildren but are not workspaces. + // they are already handled as workspace-like proxies above and should not go through the external/store extraction path. + if (!next.isProjectRoot && !next.isWorkspace && !next.inert && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) { this.idealGraph.external.push(await this.externalProxy(next)) } } @@ -220,9 +222,11 @@ 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) const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target) - result.localDependencies = await Promise.all(nonOptionalDeps.filter(n => n.isWorkspace).map(n => this.workspaceProxy(n))) - result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace && !n.inert).map(n => this.externalProxy(n))) + result.localDependencies = await Promise.all(nonOptionalDeps.filter(isLocal).map(n => this.workspaceProxy(n))) + result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !isLocal(n) && !n.inert).map(n => this.externalProxy(n))) result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(n => this.externalProxy(n))) result.dependencies = [ ...result.externalDependencies, diff --git a/workspaces/arborist/test/isolated-mode.js b/workspaces/arborist/test/isolated-mode.js index 14673546dfa86..164f1cbb8f516 100644 --- a/workspaces/arborist/test/isolated-mode.js +++ b/workspaces/arborist/test/isolated-mode.js @@ -1736,6 +1736,49 @@ tap.test('bins are installed', async t => { t.ok(binFromBarToWhich) }) +tap.test('file: dependency with linked strategy', async t => { + /* + * Regression test for https://github.com/npm/cli/issues/7549 + * + * A relative file: dependency (file:./project2) was incorrectly resolved as file:../project2, causing ENOENT errors because the path was resolved one level above the project root. + */ + const graph = { + registry: [], + root: { + name: 'project1', + version: '1.0.0', + dependencies: { project2: 'file:./project2' }, + }, + } + + const { dir, registry } = await getRepo(graph) + + // Create the local file: dependency on disk + const depDir = path.join(dir, 'project2') + fs.mkdirSync(depDir, { recursive: true }) + fs.writeFileSync(path.join(depDir, 'package.json'), JSON.stringify({ + name: 'project2', + version: '1.0.0', + })) + fs.writeFileSync(path.join(depDir, 'index.js'), "module.exports = 'project2'") + + 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 in node_modules + const linkPath = path.join(dir, 'node_modules', 'project2') + const stat = fs.lstatSync(linkPath) + t.ok(stat.isSymbolicLink(), 'project2 is a symlink in node_modules') + + // The symlink should resolve to the actual local directory + const realpath = fs.realpathSync(linkPath) + t.equal(realpath, depDir, 'symlink points to the correct local directory') + + // The package should be requireable + t.ok(setupRequire(dir)('project2'), 'project2 can be required from root') +}) + function setupRequire (cwd) { return function requireChain (...chain) { return chain.reduce((path, name) => {