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) => {