Skip to content

Commit 076551b

Browse files
fix(arborist): clean up orphan top-level symlinks in linked strategy (npm#9309)
In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](WordPress/gutenberg#75814), which powers the WordPress Block Editor. When using `install-strategy=linked`, removing a dependency leaves a dangling symlink at `node_modules/<pkg>` (and `<workspace>/node_modules/<pkg>` for workspace deps). The store entry under `node_modules/.store/<pkg>@…` is correctly cleaned up by `#cleanOrphanedStoreEntries`, but the top-level link pointing into it is left behind, so `require('<pkg>')` fails with `Cannot find module` even though the entry still appears in `node_modules/`. The root cause is the same family of issue as npm#9106. `#buildLinkedActualForDiff` builds the synthetic actual tree from the ideal tree, so any dependency that exists on disk but is no longer in the ideal tree is never compared, and the diff produces no REMOVE action for its top-level symlink. Fixed by extending `#cleanOrphanedStoreEntries` to also collect, per `node_modules` directory (root and each workspace), the set of valid top-level link names from the ideal tree, then sweeping each directory and removing any symlink whose name is not in that set. The root `node_modules` and every workspace's `node_modules` (via `idealTree.fsChildren`) are always seeded into the sweep, so the case of removing the last dependency from the project root or from a workspace still triggers cleanup, including when the workspace itself is declared as a root dependency and therefore has its self-link at the root rather than under its own `node_modules`. The sweep is restricted to symlinks whose target resolves inside the project root, so it covers both store links (e.g. `node_modules/eslint -> .store/...`) and workspace self-links that no longer belong (e.g. `node_modules/a -> ../packages/a` after `a` is undeclared) without touching symlinks that point outside the project, such as those created by `npm link <global-pkg>` without `--save`. Real directories and npm-managed entries (`.bin`, `.store`, `.package-lock.json`) are left alone. The workspace self-link inside its own `node_modules` (e.g. `packages/a/node_modules/a -> ..`) is in the ideal tree as a non-store link, so it's preserved. The sweep also respects the install mode: - It is skipped entirely for `dryRun` and `packageLockOnly` installs, both of which short-circuit `#reifyPackages` and must not mutate `node_modules`. - For workspace-filtered installs (`npm install -w <ws> --install-strategy=linked`), the set of `node_modules` directories to sweep is restricted to the workspaces named in `--workspace`, so dropped dependencies from the in-scope workspace get cleaned up while out-of-scope workspaces and the project root are left untouched. `IsolatedNode`/`IsolatedLink` locations are built with `path.join`, which uses backslashes on Windows; locations are normalized to forward slashes inside the sweep so the parser works on both POSIX and Windows. ## Trade-off This aligns the linked strategy with npm's normal `node_modules`-is-managed model: any in-project symlink that isn't in the ideal tree is treated as orphaned, matching what happens today under the default install strategy. A consequence is that hand-made or unsaved `npm link` symlinks pointing to other paths inside the project root (e.g. `node_modules/foo -> ../examples/foo`) are also swept, since npm doesn't currently record which links it owns and they are indistinguishable from workspace self-links by target alone. A more discriminating ownership check (recording managed link names in the hidden lockfile and only sweeping those) is a worthwhile follow-up but materially larger than this fix. ## References Fixes npm#9308 Related to npm#9106
1 parent b8655c7 commit 076551b

2 files changed

Lines changed: 546 additions & 11 deletions

File tree

workspaces/arborist/lib/arborist/reify.js

Lines changed: 155 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const { depth: dfwalk } = require('treeverse')
1111
const { dirname, resolve, relative, join, sep } = require('node:path')
1212
const { log, time } = require('proc-log')
1313
const { existsSync } = require('node:fs')
14-
const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises')
14+
const { lstat, mkdir, readdir, readlink, rm, symlink } = require('node:fs/promises')
1515
const { moveFile } = require('@npmcli/fs')
1616
const { subset, intersects } = require('semver')
1717
const { walkUp } = require('walk-up-path')
@@ -124,7 +124,11 @@ module.exports = cls => class Reifier extends cls {
124124
await this[_diffTrees]()
125125
await this.#reifyPackages()
126126
if (linked) {
127-
await this.#cleanOrphanedStoreEntries()
127+
// The sweep mutates node_modules on disk, so skip it for dry runs and lockfile-only installs (those modes also short-circuit #reifyPackages).
128+
// The sweep itself scopes to in-filter workspaces when a filter is active, so it's safe to run for filtered installs too.
129+
if (!this.options.dryRun && !this.options.packageLockOnly) {
130+
await this.#cleanOrphanedStoreEntries()
131+
}
128132
// swap back in the idealTree
129133
// so that the lockfile is preserved
130134
this.idealTree = oldTree
@@ -1281,35 +1285,175 @@ module.exports = cls => class Reifier extends cls {
12811285

12821286
// After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
12831287
// Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
1288+
// Then sweep the top-level node_modules/ for orphaned symlinks (e.g. an uninstalled dep whose store entry was just removed) so we don't leave dangling links.
12841289
async #cleanOrphanedStoreEntries () {
1285-
const storeDir = resolve(this.path, 'node_modules', '.store')
1290+
const nmDir = resolve(this.path, 'node_modules')
1291+
const storeDir = resolve(nmDir, '.store')
1292+
12861293
let entries
12871294
try {
12881295
entries = await readdir(storeDir)
12891296
} catch {
1290-
return
1297+
entries = null
12911298
}
12921299

1293-
// Collect valid store keys from the isolated ideal tree (location: node_modules/.store/{key}/node_modules/{pkg})
1300+
// Collect valid store keys and valid top-level links per node_modules directory.
1301+
// Store entries have location node_modules/.store/{key}/node_modules/{pkg}.
1302+
// Top-level links have location {prefix}/node_modules/{pkg} or {prefix}/node_modules/@scope/{pkg}, where {prefix} is empty for the root project and the workspace's localLocation for workspace deps.
1303+
// Locations are normalized to forward slashes here because IsolatedNode/IsolatedLink locations are built with path.join, which uses backslashes on Windows.
12941304
const validKeys = new Set()
1305+
const nmDirs = new Map()
1306+
const NM_PREFIX = 'node_modules/'
1307+
const STORE_MARKER = '/.store/'
12951308
for (const child of this.idealTree.children.values()) {
1309+
const loc = child.location.replace(/\\/g, '/')
12961310
if (child.isInStore) {
1297-
const key = child.location.split(sep)[2]
1311+
const key = loc.split('/')[2]
12981312
validKeys.add(key)
1313+
continue
1314+
}
1315+
if (!child.isLink) {
1316+
continue
1317+
}
1318+
const nmIdx = loc.lastIndexOf(NM_PREFIX)
1319+
if (nmIdx === -1 || loc.includes(STORE_MARKER)) {
1320+
continue
1321+
}
1322+
const prefix = loc.slice(0, nmIdx)
1323+
const dir = resolve(this.path, prefix, 'node_modules')
1324+
const rest = loc.slice(nmIdx + NM_PREFIX.length)
1325+
let entry
1326+
if (rest.startsWith('@')) {
1327+
const [scope, name] = rest.split('/')
1328+
entry = `${scope}${sep}${name}`
1329+
} else {
1330+
entry = rest.split('/')[0]
1331+
}
1332+
let set = nmDirs.get(dir)
1333+
if (!set) {
1334+
set = new Set()
1335+
nmDirs.set(dir, set)
1336+
}
1337+
set.add(entry)
1338+
}
1339+
1340+
// Determine which node_modules directories to sweep.
1341+
// For an unfiltered install, sweep the project root and every workspace's node_modules even if no top-level links remain (e.g. last dep was just uninstalled).
1342+
// For a filtered install (npm install -w <ws>), restrict the sweep to the in-scope workspaces so out-of-scope workspaces are left untouched, mirroring what the diff would do.
1343+
// When --include-workspace-root is set, the filter scope pulls in root deps too, so the root node_modules is included in the sweep.
1344+
const filteredNames = this.options.workspaces
1345+
const isFiltered = Array.isArray(filteredNames) && filteredNames.length > 0
1346+
if (isFiltered) {
1347+
const allowedDirs = new Set()
1348+
for (const ws of this.idealTree.fsChildren) {
1349+
if (filteredNames.includes(ws.packageName) || filteredNames.includes(ws.name)) {
1350+
allowedDirs.add(resolve(ws.path, 'node_modules'))
1351+
}
1352+
}
1353+
if (this.options.includeWorkspaceRoot) {
1354+
allowedDirs.add(nmDir)
1355+
}
1356+
for (const dir of [...nmDirs.keys()]) {
1357+
if (!allowedDirs.has(dir)) {
1358+
nmDirs.delete(dir)
1359+
}
1360+
}
1361+
for (const dir of allowedDirs) {
1362+
if (!nmDirs.has(dir)) {
1363+
nmDirs.set(dir, new Set())
1364+
}
1365+
}
1366+
} else {
1367+
if (!nmDirs.has(nmDir)) {
1368+
nmDirs.set(nmDir, new Set())
1369+
}
1370+
for (const ws of this.idealTree.fsChildren) {
1371+
const wsNmDir = resolve(ws.path, 'node_modules')
1372+
if (!nmDirs.has(wsNmDir)) {
1373+
nmDirs.set(wsNmDir, new Set())
1374+
}
1375+
}
1376+
}
1377+
1378+
if (entries) {
1379+
const orphaned = entries.filter(e => !validKeys.has(e))
1380+
if (orphaned.length) {
1381+
log.silly('reify', 'cleaning orphaned store entries', orphaned)
1382+
await promiseAllRejectLate(
1383+
orphaned.map(e =>
1384+
rm(resolve(storeDir, e), { recursive: true, force: true })
1385+
.catch(/* istanbul ignore next -- rm with force rarely fails */
1386+
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1387+
)
1388+
)
1389+
}
1390+
}
1391+
1392+
for (const [dir, valid] of nmDirs) {
1393+
await this.#cleanOrphanedTopLevelLinks(dir, valid)
1394+
}
1395+
}
1396+
1397+
// Remove node_modules/ entries that aren't represented in the ideal tree.
1398+
// Run for the project root and each workspace's node_modules.
1399+
// The linked diff path can't see these because #buildLinkedActualForDiff derives the actual tree from the ideal, so removed deps are never compared.
1400+
// Only symlinks whose target resolves inside the project root are removed — that covers store links (node_modules/.store/...) and workspace self-links (e.g. node_modules/<ws> -> ../packages/<ws>) that npm itself created.
1401+
// Symlinks pointing outside the project (e.g. `npm link foo` without --save targeting the global prefix, or hand-made `ln -s` to an external path) and real directories are preserved.
1402+
async #cleanOrphanedTopLevelLinks (nmDir, validTopLevel) {
1403+
const projectPrefix = resolve(this.path) + sep
1404+
let dirents
1405+
try {
1406+
dirents = await readdir(nmDir, { withFileTypes: true })
1407+
} catch {
1408+
return
1409+
}
1410+
1411+
const isOurOrphan = async (linkPath) => {
1412+
let target
1413+
try {
1414+
target = await readlink(linkPath)
1415+
} catch {
1416+
/* istanbul ignore next -- readlink of an entry we just listed as a symlink should not fail */
1417+
return false
1418+
}
1419+
return resolve(dirname(linkPath), target).startsWith(projectPrefix)
1420+
}
1421+
1422+
const orphaned = []
1423+
for (const ent of dirents) {
1424+
// skip npm-managed entries (.bin, .store, .package-lock.json, etc)
1425+
if (ent.name.startsWith('.')) {
1426+
continue
1427+
}
1428+
if (ent.name.startsWith('@')) {
1429+
let scoped
1430+
try {
1431+
scoped = await readdir(resolve(nmDir, ent.name), { withFileTypes: true })
1432+
} catch {
1433+
/* istanbul ignore next -- readdir of an entry we just listed should not fail */
1434+
continue
1435+
}
1436+
for (const pkgEnt of scoped) {
1437+
const key = `${ent.name}${sep}${pkgEnt.name}`
1438+
if (!validTopLevel.has(key) && pkgEnt.isSymbolicLink() && await isOurOrphan(resolve(nmDir, key))) {
1439+
orphaned.push(key)
1440+
}
1441+
}
1442+
} else if (!validTopLevel.has(ent.name) && ent.isSymbolicLink() && await isOurOrphan(resolve(nmDir, ent.name))) {
1443+
orphaned.push(ent.name)
12991444
}
13001445
}
13011446

1302-
const orphaned = entries.filter(e => !validKeys.has(e))
13031447
if (!orphaned.length) {
13041448
return
13051449
}
13061450

1307-
log.silly('reify', 'cleaning orphaned store entries', orphaned)
1451+
log.silly('reify', 'cleaning orphaned top-level links', orphaned)
13081452
await promiseAllRejectLate(
1309-
orphaned.map(e =>
1310-
rm(resolve(storeDir, e), { recursive: true, force: true })
1453+
orphaned.map(name =>
1454+
rm(resolve(nmDir, name), { recursive: true, force: true })
13111455
.catch(/* istanbul ignore next -- rm with force rarely fails */
1312-
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1456+
er => log.warn('cleanup', `Failed to remove orphaned link ${name}`, er))
13131457
)
13141458
)
13151459
}

0 commit comments

Comments
 (0)