Skip to content

Commit d41a9e3

Browse files
manzoorwanijkowlstronaut
authored andcommitted
fix(arborist): clean up orphaned scoped store entries in linked strategy
(cherry picked from commit 275bc69)
1 parent 8ff3e48 commit d41a9e3

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

workspaces/arborist/lib/arborist/reify.js

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,9 +1362,27 @@ module.exports = cls => class Reifier extends cls {
13621362
const nmDir = resolve(this.path, 'node_modules')
13631363
const storeDir = resolve(nmDir, '.store')
13641364

1365+
// Enumerate on-disk store entries as full keys, descending one level into each @scope directory because scoped keys nest as .store/@scope/pkg@version-hash.
13651366
let entries
13661367
try {
1367-
entries = await readdir(storeDir)
1368+
const topLevel = await readdir(storeDir, { withFileTypes: true })
1369+
entries = []
1370+
for (const ent of topLevel) {
1371+
if (ent.name.startsWith('@')) {
1372+
let scoped
1373+
try {
1374+
scoped = await readdir(resolve(storeDir, ent.name))
1375+
} catch {
1376+
/* istanbul ignore next -- readdir of an entry we just listed should not fail */
1377+
continue
1378+
}
1379+
for (const name of scoped) {
1380+
entries.push(`${ent.name}/${name}`)
1381+
}
1382+
} else {
1383+
entries.push(ent.name)
1384+
}
1385+
}
13681386
} catch {
13691387
entries = null
13701388
}
@@ -1380,7 +1398,10 @@ module.exports = cls => class Reifier extends cls {
13801398
for (const child of this.idealTree.children.values()) {
13811399
const loc = child.location.replace(/\\/g, '/')
13821400
if (child.isInStore) {
1383-
const key = loc.split('/')[2]
1401+
// Store location is node_modules/.store/{key}/node_modules/{pkg}.
1402+
// For a scoped package the key is @scope/pkg@version-hash, which spans two path segments, so reconstruct both instead of taking only the scope.
1403+
const parts = loc.split('/')
1404+
const key = parts[2].startsWith('@') ? `${parts[2]}/${parts[3]}` : parts[2]
13841405
validKeys.add(key)
13851406
continue
13861407
}
@@ -1462,6 +1483,23 @@ module.exports = cls => class Reifier extends cls {
14621483
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
14631484
)
14641485
)
1486+
// Removing the last scoped orphan under a scope leaves an empty @scope directory behind, so prune any scope directory that is now empty.
1487+
const scopes = new Set(
1488+
orphaned.filter(e => e.startsWith('@')).map(e => e.split('/')[0])
1489+
)
1490+
await promiseAllRejectLate(
1491+
[...scopes].map(async scope => {
1492+
const scopeDir = resolve(storeDir, scope)
1493+
try {
1494+
const remaining = await readdir(scopeDir)
1495+
if (!remaining.length) {
1496+
await rm(scopeDir, { recursive: true, force: true })
1497+
}
1498+
} catch {
1499+
/* istanbul ignore next -- readdir of a scope dir we just listed should not fail */
1500+
}
1501+
})
1502+
)
14651503
}
14661504
}
14671505

workspaces/arborist/test/isolated-mode.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1994,6 +1994,85 @@ tap.test('orphaned store entries are cleaned up on dependency update', async t =
19941994
'old which@1.0.0 store entry is removed after update')
19951995
})
19961996

1997+
tap.test('orphaned scoped store entries are cleaned up on dependency update', async t => {
1998+
// https://github.com/npm/cli/issues/9440 — a scoped store key spans two path segments (.store/@scope/pkg@version-hash), so the single-segment orphan cleanup never swept the stale entry.
1999+
const graph = {
2000+
registry: [
2001+
{ name: '@scope/which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
2002+
{ name: '@scope/which', version: '2.0.0', dependencies: { isexe: '^1.0.0' } },
2003+
{ name: 'isexe', version: '1.0.0' },
2004+
],
2005+
root: {
2006+
name: 'myproject',
2007+
version: '1.0.0',
2008+
dependencies: { '@scope/which': '1.0.0' },
2009+
},
2010+
}
2011+
const { dir, registry } = await getRepo(graph)
2012+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
2013+
const storeDir = path.join(dir, 'node_modules', '.store')
2014+
const scopeDir = path.join(storeDir, '@scope')
2015+
2016+
// First install — @scope/which@1.0.0
2017+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
2018+
await arb1.reify({ installStrategy: 'linked' })
2019+
2020+
const entriesAfterV1 = fs.readdirSync(scopeDir)
2021+
t.ok(entriesAfterV1.some(e => e.startsWith('which@1.0.0-')),
2022+
'store has @scope/which@1.0.0 entry after first install')
2023+
2024+
// Update package.json to depend on @scope/which@2.0.0
2025+
const pkgPath = path.join(dir, 'package.json')
2026+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
2027+
pkg.dependencies['@scope/which'] = '2.0.0'
2028+
fs.writeFileSync(pkgPath, JSON.stringify(pkg))
2029+
2030+
// Second install — @scope/which@2.0.0
2031+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
2032+
await arb2.reify({ installStrategy: 'linked' })
2033+
2034+
const entriesAfterV2 = fs.readdirSync(scopeDir)
2035+
t.ok(entriesAfterV2.some(e => e.startsWith('which@2.0.0-')),
2036+
'store has @scope/which@2.0.0 entry after update')
2037+
t.notOk(entriesAfterV2.some(e => e.startsWith('which@1.0.0-')),
2038+
'old @scope/which@1.0.0 store entry is removed after update')
2039+
})
2040+
2041+
tap.test('orphaned scoped store entries leave no empty scope directory when last dep is removed', async t => {
2042+
// https://github.com/npm/cli/issues/9440 — when the last package under a scope is orphaned, the now-empty @scope directory should also be pruned.
2043+
const graph = {
2044+
registry: [
2045+
{ name: '@scope/which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
2046+
{ name: 'isexe', version: '1.0.0' },
2047+
],
2048+
root: {
2049+
name: 'myproject',
2050+
version: '1.0.0',
2051+
dependencies: { '@scope/which': '1.0.0' },
2052+
},
2053+
}
2054+
const { dir, registry } = await getRepo(graph)
2055+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
2056+
const storeDir = path.join(dir, 'node_modules', '.store')
2057+
2058+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
2059+
await arb1.reify({ installStrategy: 'linked' })
2060+
2061+
t.ok(fs.existsSync(path.join(storeDir, '@scope')), 'store has @scope directory after install')
2062+
2063+
// Remove the dependency
2064+
const pkgPath = path.join(dir, 'package.json')
2065+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
2066+
delete pkg.dependencies
2067+
fs.writeFileSync(pkgPath, JSON.stringify(pkg))
2068+
2069+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
2070+
await arb2.reify({ installStrategy: 'linked' })
2071+
2072+
t.notOk(fs.existsSync(path.join(storeDir, '@scope')),
2073+
'empty @scope directory is pruned after the last scoped dep is removed')
2074+
})
2075+
19972076
tap.test('orphaned store entries are cleaned up on dependency removal', async t => {
19982077
const graph = {
19992078
registry: [

0 commit comments

Comments
 (0)