From ca71c5242795aa666f7b47317394b30608eb397b Mon Sep 17 00:00:00 2001 From: Victor <70475442+vsolano9@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:24:07 +0200 Subject: [PATCH] fix(arborist): reuse a link target spelled with a different drive-letter case On Windows, junctions keep the target spelling they were created with, so links can name one directory as D:\pkg and d:\pkg. loadActual cached link targets by the exact realpath string but placed nodes by a case-insensitive relative location, so a second spelling built a duplicate target that evicted the first and left its links without a target. Adding the next link then read null.package in set root. Reuse the node already at the target's inventory location before falling back to the realpath cache. Refs: #9079 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../arborist/lib/arborist/load-actual.js | 10 ++++-- .../arborist/test/arborist/load-actual.js | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/workspaces/arborist/lib/arborist/load-actual.js b/workspaces/arborist/lib/arborist/load-actual.js index 02a466e655aa6..4bf30db0d0725 100644 --- a/workspaces/arborist/lib/arborist/load-actual.js +++ b/workspaces/arborist/lib/arborist/load-actual.js @@ -13,6 +13,7 @@ const calcDepFlags = require('../calc-dep-flags.js') const Node = require('../node.js') const Link = require('../link.js') const realpath = require('../realpath.js') +const relpath = require('../relpath.js') const PackageExtensions = require('../package-extensions.js') const NpmExtension = require('../npm-extension.js') @@ -361,8 +362,13 @@ module.exports = cls => class ActualLoader extends cls { async #newLink (options) { const { realpath } = options this.#topNodes.add(realpath) - const target = this.#cache.get(realpath) - const link = new Link({ ...options, target }) + // On Windows, links can spell one target differently (e.g. `D:\pkg` and `d:\pkg`). + // Those spellings share an inventory location, so a second target node there would evict + // the first and leave its links without a target. Reuse the node already at that location. + const root = options.root || options.parent?.root + const target = root?.inventory.get(relpath(root.realpath, realpath)) || + this.#cache.get(realpath) + const link = new Link({ ...options, realpath: target?.path || realpath, target }) if (!target) { // Link set its target itself in this case diff --git a/workspaces/arborist/test/arborist/load-actual.js b/workspaces/arborist/test/arborist/load-actual.js index 4552248b7ab94..fe59c632a8722 100644 --- a/workspaces/arborist/test/arborist/load-actual.js +++ b/workspaces/arborist/test/arborist/load-actual.js @@ -934,3 +934,37 @@ t.test('a project located under a .store path still loads its own devDependencie t.equal(tree.isInStore, false, 'loaded root is not flagged isInStore') t.ok(tree.edgesOut.get('a-dev-dep'), 'root devDependency is a required edge') }) + +t.test('links that spell one target with different drive-letter case share it', { + skip: process.platform !== 'win32' && 'drive letters are Windows-only', +}, async t => { + // Windows junctions keep the target spelling they were created with (npm/cli#9079). + const path = t.testdir({ + pkg: { 'package.json': JSON.stringify({ name: 'pkg', version: '1.0.0' }) }, + app: { + 'package.json': JSON.stringify({ + name: 'app', + version: '1.0.0', + dependencies: { a: 'file:../pkg', b: 'file:../pkg', c: 'file:../pkg' }, + }), + node_modules: {}, + }, + }) + const pkg = resolve(path, 'pkg') + const flipped = pkg[0] === pkg[0].toUpperCase() + ? pkg[0].toLowerCase() + pkg.slice(1) + : pkg[0].toUpperCase() + pkg.slice(1) + for (const [name, target] of [['a', pkg], ['b', flipped], ['c', pkg]]) { + fs.symlinkSync(target, resolve(path, 'app/node_modules', name), 'junction') + } + + const tree = await loadActual(resolve(path, 'app')) + const links = ['a', 'b', 'c'].map(name => tree.children.get(name)) + const [target] = links.map(link => link.target) + t.ok(target, 'links have a target') + t.equal(target.root, tree, 'the target is in the tree') + for (const link of links) { + t.equal(link.target, target, `${link.name} shares the target`) + } + t.equal(target.linksIn.size, 3, 'the target knows all three links') +})