@npmcli/arborist 9.4.3 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,6 @@ const { lstat, readlink } = require('node:fs/promises')
13
13
  const { depth } = require('treeverse')
14
14
  const { log, time } = require('proc-log')
15
15
  const { redact } = require('@npmcli/redact')
16
- const semver = require('semver')
17
16
 
18
17
  const {
19
18
  OK,
@@ -294,10 +293,6 @@ module.exports = cls => class IdealTreeBuilder extends cls {
294
293
  }).then(meta => Object.assign(root, { meta }))
295
294
  } else {
296
295
  return this.loadVirtual({ root })
297
- .then(tree => {
298
- this.#applyRootOverridesToWorkspaces(tree)
299
- return tree
300
- })
301
296
  }
302
297
  })
303
298
 
@@ -406,6 +401,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
406
401
  global: this.options.global,
407
402
  installLinks: this.installLinks,
408
403
  legacyPeerDeps: this.legacyPeerDeps,
404
+ loadOverrides: true,
409
405
  root,
410
406
  })
411
407
  }
@@ -450,6 +446,11 @@ module.exports = cls => class IdealTreeBuilder extends cls {
450
446
  const paths = await readdirScoped(nm).catch(() => [])
451
447
  for (const p of paths) {
452
448
  const name = p.replace(/\\/g, '/')
449
+ // Match loadActual behavior: hidden entries and retired scoped package
450
+ // folders are not installed global packages.
451
+ if (/^(@[^/]+\/)?\./.test(name)) {
452
+ continue
453
+ }
453
454
  const updateName = this[_updateNames].includes(name)
454
455
  if (this[_updateAll] || updateName) {
455
456
  if (updateName) {
@@ -913,8 +914,21 @@ This is a one-time fix-up, please be patient...
913
914
  // be forced to agree on a version of z.
914
915
  const required = new Set([edge.from])
915
916
  const parent = edge.peer ? virtualRoot : null
916
- const dep = vrDep && vrDep.satisfies(edge) ? vrDep
917
- : await this.#nodeFromEdge(edge, parent, null, required)
917
+ let dep = vrDep && vrDep.satisfies(edge) ? vrDep : null
918
+
919
+ // A peerOptional conflict can be resolved by finding an existing node in the tree that satisfies the edge, avoiding a registry fetch that may introduce an extraneous package. See npm/cli#9249.
920
+ // Skip the shortcut when the user has signaled an explicit re-fetch intent (npm update by name, explicit request, or audit fix), so we honor those signals rather than silently keeping the existing node.
921
+ const skipExistingShortcut = this[_updateNames].includes(edge.name)
922
+ || this.#explicitRequests.has(edge)
923
+ || (edge.to && this.auditReport?.isVulnerable(edge.to))
924
+ if (!dep && edge.type === 'peerOptional' && !skipExistingShortcut) {
925
+ dep = this.#findHoistableNode(
926
+ /* istanbul ignore next - resolveParent is always set for non-root nodes */
927
+ edge.from.resolveParent || edge.from, edge)
928
+ }
929
+ if (!dep) {
930
+ dep = await this.#nodeFromEdge(edge, parent, null, required)
931
+ }
918
932
 
919
933
  /* istanbul ignore next */
920
934
  debug(() => {
@@ -1044,6 +1058,24 @@ This is a one-time fix-up, please be patient...
1044
1058
  return this.#buildDepStep()
1045
1059
  }
1046
1060
 
1061
+ // BFS descendants of `root` for a node satisfying `edge`.
1062
+ // Prefers nodes closer to root. Skips bundled nodes.
1063
+ #findHoistableNode (root, edge) {
1064
+ const queue = [...root.children.values()]
1065
+ while (queue.length) {
1066
+ const node = queue.shift()
1067
+ if (node.name === edge.name
1068
+ && !node.inDepBundle
1069
+ && node.satisfies(edge)) {
1070
+ return node
1071
+ }
1072
+ for (const child of node.children.values()) {
1073
+ queue.push(child)
1074
+ }
1075
+ }
1076
+ return null
1077
+ }
1078
+
1047
1079
  // loads a node from an edge, and then loads its peer deps (and their peer deps, on down the line) into a virtual root parent.
1048
1080
  async #nodeFromEdge (edge, parent_, secondEdge, required) {
1049
1081
  // create a virtual root node with the same deps as the node that is requesting this one, so that we can get all the peer deps in a context where they're likely to be resolvable.
@@ -1507,32 +1539,6 @@ This is a one-time fix-up, please be patient...
1507
1539
  timeEnd()
1508
1540
  }
1509
1541
 
1510
- #applyRootOverridesToWorkspaces (tree) {
1511
- const rootOverrides = tree.root.package.overrides || {}
1512
-
1513
- for (const node of tree.root.inventory.values()) {
1514
- if (!node.isWorkspace) {
1515
- continue
1516
- }
1517
-
1518
- for (const depName of Object.keys(rootOverrides)) {
1519
- const edge = node.edgesOut.get(depName)
1520
- const rootNode = tree.root.children.get(depName)
1521
-
1522
- // safely skip if either edge or rootNode doesn't exist yet
1523
- if (!edge || !rootNode) {
1524
- continue
1525
- }
1526
-
1527
- const resolvedRootVersion = rootNode.package.version
1528
- if (!semver.satisfies(resolvedRootVersion, edge.spec)) {
1529
- edge.detach()
1530
- node.children.delete(depName)
1531
- }
1532
- }
1533
- }
1534
- }
1535
-
1536
1542
  #idealTreePrune () {
1537
1543
  for (const node of this.idealTree.inventory.values()) {
1538
1544
  if (node.extraneous) {
@@ -288,6 +288,16 @@ class Arborist extends Base {
288
288
  return ret
289
289
  }
290
290
 
291
+ // Build an ideal tree (or reuse an already-built one) and return the
292
+ // resulting lockfile contents as a string, without writing to disk.
293
+ // Useful for callers that want to inspect, diff, or store a lockfile
294
+ // somewhere other than the project's `package-lock.json`.
295
+ async lockfileString (options = {}) {
296
+ await this.buildIdealTree(options)
297
+
298
+ return this.idealTree.meta.toString(options)
299
+ }
300
+
291
301
  async dedupe (options = {}) {
292
302
  // allow the user to set options on the ctor as well.
293
303
  // XXX: deprecate separate method options objects.
@@ -11,7 +11,7 @@ const { depth: dfwalk } = require('treeverse')
11
11
  const { dirname, resolve, relative, join, sep } = require('node:path')
12
12
  const { log, time } = require('proc-log')
13
13
  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')
15
15
  const { moveFile } = require('@npmcli/fs')
16
16
  const { subset, intersects } = require('semver')
17
17
  const { walkUp } = require('walk-up-path')
@@ -126,7 +126,11 @@ module.exports = cls => class Reifier extends cls {
126
126
  await this[_diffTrees]()
127
127
  await this.#reifyPackages()
128
128
  if (linked) {
129
- await this.#cleanOrphanedStoreEntries()
129
+ // The sweep mutates node_modules on disk, so skip it for dry runs and lockfile-only installs (those modes also short-circuit #reifyPackages).
130
+ // The sweep itself scopes to in-filter workspaces when a filter is active, so it's safe to run for filtered installs too.
131
+ if (!this.options.dryRun && !this.options.packageLockOnly) {
132
+ await this.#cleanOrphanedStoreEntries()
133
+ }
130
134
  // swap back in the idealTree
131
135
  // so that the lockfile is preserved
132
136
  this.idealTree = oldTree
@@ -1321,35 +1325,175 @@ module.exports = cls => class Reifier extends cls {
1321
1325
 
1322
1326
  // After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
1323
1327
  // Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
1328
+ // 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.
1324
1329
  async #cleanOrphanedStoreEntries () {
1325
- const storeDir = resolve(this.path, 'node_modules', '.store')
1330
+ const nmDir = resolve(this.path, 'node_modules')
1331
+ const storeDir = resolve(nmDir, '.store')
1332
+
1326
1333
  let entries
1327
1334
  try {
1328
1335
  entries = await readdir(storeDir)
1329
1336
  } catch {
1330
- return
1337
+ entries = null
1331
1338
  }
1332
1339
 
1333
- // Collect valid store keys from the isolated ideal tree (location: node_modules/.store/{key}/node_modules/{pkg})
1340
+ // Collect valid store keys and valid top-level links per node_modules directory.
1341
+ // Store entries have location node_modules/.store/{key}/node_modules/{pkg}.
1342
+ // 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.
1343
+ // Locations are normalized to forward slashes here because IsolatedNode/IsolatedLink locations are built with path.join, which uses backslashes on Windows.
1334
1344
  const validKeys = new Set()
1345
+ const nmDirs = new Map()
1346
+ const NM_PREFIX = 'node_modules/'
1347
+ const STORE_MARKER = '/.store/'
1335
1348
  for (const child of this.idealTree.children.values()) {
1349
+ const loc = child.location.replace(/\\/g, '/')
1336
1350
  if (child.isInStore) {
1337
- const key = child.location.split(sep)[2]
1351
+ const key = loc.split('/')[2]
1338
1352
  validKeys.add(key)
1353
+ continue
1354
+ }
1355
+ if (!child.isLink) {
1356
+ continue
1357
+ }
1358
+ const nmIdx = loc.lastIndexOf(NM_PREFIX)
1359
+ if (nmIdx === -1 || loc.includes(STORE_MARKER)) {
1360
+ continue
1361
+ }
1362
+ const prefix = loc.slice(0, nmIdx)
1363
+ const dir = resolve(this.path, prefix, 'node_modules')
1364
+ const rest = loc.slice(nmIdx + NM_PREFIX.length)
1365
+ let entry
1366
+ if (rest.startsWith('@')) {
1367
+ const [scope, name] = rest.split('/')
1368
+ entry = `${scope}${sep}${name}`
1369
+ } else {
1370
+ entry = rest.split('/')[0]
1371
+ }
1372
+ let set = nmDirs.get(dir)
1373
+ if (!set) {
1374
+ set = new Set()
1375
+ nmDirs.set(dir, set)
1376
+ }
1377
+ set.add(entry)
1378
+ }
1379
+
1380
+ // Determine which node_modules directories to sweep.
1381
+ // 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).
1382
+ // 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.
1383
+ // When --include-workspace-root is set, the filter scope pulls in root deps too, so the root node_modules is included in the sweep.
1384
+ const filteredNames = this.options.workspaces
1385
+ const isFiltered = Array.isArray(filteredNames) && filteredNames.length > 0
1386
+ if (isFiltered) {
1387
+ const allowedDirs = new Set()
1388
+ for (const ws of this.idealTree.fsChildren) {
1389
+ if (filteredNames.includes(ws.packageName) || filteredNames.includes(ws.name)) {
1390
+ allowedDirs.add(resolve(ws.path, 'node_modules'))
1391
+ }
1392
+ }
1393
+ if (this.options.includeWorkspaceRoot) {
1394
+ allowedDirs.add(nmDir)
1395
+ }
1396
+ for (const dir of [...nmDirs.keys()]) {
1397
+ if (!allowedDirs.has(dir)) {
1398
+ nmDirs.delete(dir)
1399
+ }
1400
+ }
1401
+ for (const dir of allowedDirs) {
1402
+ if (!nmDirs.has(dir)) {
1403
+ nmDirs.set(dir, new Set())
1404
+ }
1405
+ }
1406
+ } else {
1407
+ if (!nmDirs.has(nmDir)) {
1408
+ nmDirs.set(nmDir, new Set())
1409
+ }
1410
+ for (const ws of this.idealTree.fsChildren) {
1411
+ const wsNmDir = resolve(ws.path, 'node_modules')
1412
+ if (!nmDirs.has(wsNmDir)) {
1413
+ nmDirs.set(wsNmDir, new Set())
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ if (entries) {
1419
+ const orphaned = entries.filter(e => !validKeys.has(e))
1420
+ if (orphaned.length) {
1421
+ log.silly('reify', 'cleaning orphaned store entries', orphaned)
1422
+ await promiseAllRejectLate(
1423
+ orphaned.map(e =>
1424
+ rm(resolve(storeDir, e), { recursive: true, force: true })
1425
+ .catch(/* istanbul ignore next -- rm with force rarely fails */
1426
+ er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1427
+ )
1428
+ )
1429
+ }
1430
+ }
1431
+
1432
+ for (const [dir, valid] of nmDirs) {
1433
+ await this.#cleanOrphanedTopLevelLinks(dir, valid)
1434
+ }
1435
+ }
1436
+
1437
+ // Remove node_modules/ entries that aren't represented in the ideal tree.
1438
+ // Run for the project root and each workspace's node_modules.
1439
+ // The linked diff path can't see these because #buildLinkedActualForDiff derives the actual tree from the ideal, so removed deps are never compared.
1440
+ // 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.
1441
+ // 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.
1442
+ async #cleanOrphanedTopLevelLinks (nmDir, validTopLevel) {
1443
+ const projectPrefix = resolve(this.path) + sep
1444
+ let dirents
1445
+ try {
1446
+ dirents = await readdir(nmDir, { withFileTypes: true })
1447
+ } catch {
1448
+ return
1449
+ }
1450
+
1451
+ const isOurOrphan = async (linkPath) => {
1452
+ let target
1453
+ try {
1454
+ target = await readlink(linkPath)
1455
+ } catch {
1456
+ /* istanbul ignore next -- readlink of an entry we just listed as a symlink should not fail */
1457
+ return false
1458
+ }
1459
+ return resolve(dirname(linkPath), target).startsWith(projectPrefix)
1460
+ }
1461
+
1462
+ const orphaned = []
1463
+ for (const ent of dirents) {
1464
+ // skip npm-managed entries (.bin, .store, .package-lock.json, etc)
1465
+ if (ent.name.startsWith('.')) {
1466
+ continue
1467
+ }
1468
+ if (ent.name.startsWith('@')) {
1469
+ let scoped
1470
+ try {
1471
+ scoped = await readdir(resolve(nmDir, ent.name), { withFileTypes: true })
1472
+ } catch {
1473
+ /* istanbul ignore next -- readdir of an entry we just listed should not fail */
1474
+ continue
1475
+ }
1476
+ for (const pkgEnt of scoped) {
1477
+ const key = `${ent.name}${sep}${pkgEnt.name}`
1478
+ if (!validTopLevel.has(key) && pkgEnt.isSymbolicLink() && await isOurOrphan(resolve(nmDir, key))) {
1479
+ orphaned.push(key)
1480
+ }
1481
+ }
1482
+ } else if (!validTopLevel.has(ent.name) && ent.isSymbolicLink() && await isOurOrphan(resolve(nmDir, ent.name))) {
1483
+ orphaned.push(ent.name)
1339
1484
  }
1340
1485
  }
1341
1486
 
1342
- const orphaned = entries.filter(e => !validKeys.has(e))
1343
1487
  if (!orphaned.length) {
1344
1488
  return
1345
1489
  }
1346
1490
 
1347
- log.silly('reify', 'cleaning orphaned store entries', orphaned)
1491
+ log.silly('reify', 'cleaning orphaned top-level links', orphaned)
1348
1492
  await promiseAllRejectLate(
1349
- orphaned.map(e =>
1350
- rm(resolve(storeDir, e), { recursive: true, force: true })
1493
+ orphaned.map(name =>
1494
+ rm(resolve(nmDir, name), { recursive: true, force: true })
1351
1495
  .catch(/* istanbul ignore next -- rm with force rarely fails */
1352
- er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1496
+ er => log.warn('cleanup', `Failed to remove orphaned link ${name}`, er))
1353
1497
  )
1354
1498
  )
1355
1499
  }
package/lib/link.js CHANGED
@@ -109,6 +109,14 @@ class Link extends Node {
109
109
  // so this is a no-op
110
110
  [_loadDeps] () {}
111
111
 
112
+ // When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut.
113
+ // Without this, overrides stop at the Link and never reach the target's dependency edges.
114
+ recalculateOutEdgesOverrides () {
115
+ if (this.target) {
116
+ this.target.updateOverridesEdgeInAdded(this.overrides)
117
+ }
118
+ }
119
+
112
120
  // links can't have children, only their targets can
113
121
  // fix it to an empty list so that we can still call
114
122
  // things that iterate over them, just as a no-op
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.4.3",
3
+ "version": "9.5.0",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@gar/promise-retry": "^1.0.0",