@npmcli/arborist 8.0.1 → 8.0.2

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.
@@ -1,72 +1,105 @@
1
- const _makeIdealGraph = Symbol('makeIdealGraph')
2
- const _createIsolatedTree = Symbol.for('createIsolatedTree')
3
- const _createBundledTree = Symbol('createBundledTree')
4
1
  const { mkdirSync } = require('node:fs')
5
2
  const pacote = require('pacote')
6
3
  const { join } = require('node:path')
7
4
  const { depth } = require('treeverse')
8
5
  const crypto = require('node:crypto')
9
6
 
10
- // cache complicated function results
11
- const memoize = (fn) => {
12
- const memo = new Map()
13
- return async function (arg) {
14
- const key = arg
15
- if (memo.has(key)) {
16
- return memo.get(key)
17
- }
18
- const result = {}
19
- memo.set(key, result)
20
- await fn(result, arg)
21
- return result
22
- }
7
+ // generate short hash key based on the dependency tree starting at this node
8
+ const getKey = (startNode) => {
9
+ const deps = []
10
+ const branch = []
11
+ depth({
12
+ tree: startNode,
13
+ getChildren: node => node.dependencies,
14
+ visit: node => {
15
+ branch.push(`${node.packageName}@${node.version}`)
16
+ deps.push(`${branch.join('->')}::${node.resolved}`)
17
+ },
18
+ leave: () => {
19
+ branch.pop()
20
+ },
21
+ })
22
+ deps.sort()
23
+ // TODO these replaces were originally to deal with node 14 not supporting base64url and likely don't need to happen anymore
24
+ // Changing this is a pretty significant breaking change, but removing parts of the hash increases collision possibilities (even if slight).
25
+ const hash = crypto.createHash('shake256', { outputLength: 16 })
26
+ .update(deps.join(','))
27
+ .digest('base64')
28
+ .replace(/\+/g, '-')
29
+ .replace(/\//g, '_')
30
+ .replace(/=+$/m, '')
31
+ return `${startNode.packageName}@${startNode.version}-${hash}`
23
32
  }
24
33
 
25
34
  module.exports = cls => class IsolatedReifier extends cls {
35
+ #externalProxies = new Map()
36
+ #processedEdges = new Set()
37
+ #workspaceProxies = new Map()
38
+
39
+ #generateChild (node, location, pkg, inStore, root) {
40
+ const newChild = {
41
+ binPaths: [],
42
+ children: new Map(),
43
+ edgesIn: new Set(),
44
+ edgesOut: new Map(),
45
+ fsChildren: new Set(),
46
+ /* istanbul ignore next -- emulate Node */
47
+ getBundler () {
48
+ return null
49
+ },
50
+ global: false,
51
+ globalTop: false,
52
+ hasShrinkwrap: false,
53
+ inDepBundle: false,
54
+ integrity: null,
55
+ isInStore: inStore,
56
+ isLink: false,
57
+ isProjectRoot: false,
58
+ isRoot: false,
59
+ isTop: false,
60
+ location,
61
+ name: node.packageName || node.name,
62
+ optional: node.optional,
63
+ package: pkg,
64
+ parent: root,
65
+ path: join(this.idealGraph.root.localPath, location),
66
+ realpath: join(this.idealGraph.root.localPath, location),
67
+ resolved: node.resolved,
68
+ root,
69
+ top: { path: this.idealGraph.root.localPath },
70
+ version: pkg.version,
71
+ }
72
+ newChild.target = newChild
73
+ root.children.set(newChild.location, newChild)
74
+ root.inventory.set(newChild.location, newChild)
75
+ }
76
+
26
77
  /**
27
78
  * Create an ideal graph.
28
79
  *
29
80
  * An implementation of npm RFC-0042
30
81
  * https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md
31
82
  *
32
- * This entire file should be considered technical debt that will be resolved
33
- * with an Arborist refactor or rewrite. Embedded logic in Nodes and Links,
34
- * and the incremental state of building trees and reifying contains too many
35
- * assumptions to do a linked mode properly.
83
+ * This entire file should be considered technical debt that will be resolved with an Arborist refactor or rewrite.
84
+ * Embedded logic in Nodes and Links, and the incremental state of building trees and reifying contains too many assumptions to do a linked mode properly.
36
85
  *
37
- * Instead, this approach takes a tree built from build-ideal-tree, and
38
- * returns a new tree-like structure without the embedded logic of Node and
39
- * Link classes.
86
+ * Instead, this approach takes a tree built from build-ideal-tree, and returns a new tree-like structure without the embedded logic of Node and Link classes.
40
87
  *
41
- * Since the RFC requires leaving the package-lock in place, this approach
42
- * temporarily replaces the tree state for a couple of steps of reifying.
88
+ * Since the RFC requires leaving the package-lock in place, this approach temporarily replaces the tree state for a couple of steps of reifying.
43
89
  *
44
90
  **/
45
- async [_makeIdealGraph] (options) {
46
- /* Make sure that the ideal tree is build as the rest of
47
- * the algorithm depends on it.
48
- */
49
- const bitOpt = {
50
- ...options,
51
- complete: false,
52
- }
53
- await this.buildIdealTree(bitOpt)
91
+ async makeIdealGraph () {
54
92
  const idealTree = this.idealTree
55
93
 
56
- this.rootNode = {}
57
- const root = this.rootNode
94
+ this.idealGraph = {
95
+ external: [],
96
+ isProjectRoot: true,
97
+ localLocation: idealTree.location,
98
+ localPath: idealTree.path,
99
+ }
58
100
  this.counter = 0
59
101
 
60
- // memoize to cache generating proxy Nodes
61
- this.externalProxyMemo = memoize(this.externalProxy.bind(this))
62
- this.workspaceProxyMemo = memoize(this.workspaceProxy.bind(this))
63
-
64
- root.external = []
65
- root.isProjectRoot = true
66
- root.localLocation = idealTree.location
67
- root.localPath = idealTree.path
68
- root.workspaces = await Promise.all(
69
- Array.from(idealTree.fsChildren.values(), this.workspaceProxyMemo))
102
+ this.idealGraph.workspaces = await Promise.all(Array.from(idealTree.fsChildren.values(), w => this.workspaceProxy(w)))
70
103
  const processed = new Set()
71
104
  const queue = [idealTree, ...idealTree.fsChildren]
72
105
  while (queue.length !== 0) {
@@ -75,42 +108,53 @@ module.exports = cls => class IsolatedReifier extends cls {
75
108
  continue
76
109
  }
77
110
  processed.add(next.location)
78
- next.edgesOut.forEach(e => {
79
- if (!e.to || (next.package.bundleDependencies || next.package.bundledDependencies || []).includes(e.to.name)) {
80
- return
111
+ next.edgesOut.forEach(edge => {
112
+ if (edge.to && !(next.package.bundleDependencies || next.package.bundledDependencies || []).includes(edge.to.name)) {
113
+ queue.push(edge.to)
81
114
  }
82
- queue.push(e.to)
83
115
  })
84
- if (!next.isProjectRoot && !next.isWorkspace) {
85
- root.external.push(await this.externalProxyMemo(next))
116
+ // local `file:` deps are in fsChildren but are not workspaces.
117
+ // they are already handled as workspace-like proxies above and should not go through the external/store extraction path.
118
+ if (!next.isProjectRoot && !next.isWorkspace && !next.inert && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
119
+ this.idealGraph.external.push(await this.externalProxy(next))
86
120
  }
87
121
  }
88
122
 
89
- await this.assignCommonProperties(idealTree, root)
90
-
91
- this.idealGraph = root
123
+ await this.assignCommonProperties(idealTree, this.idealGraph)
92
124
  }
93
125
 
94
- async workspaceProxy (result, node) {
126
+ async workspaceProxy (node) {
127
+ if (this.#workspaceProxies.has(node)) {
128
+ return this.#workspaceProxies.get(node)
129
+ }
130
+ const result = {}
131
+ // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.workspaceProxy
132
+ this.#workspaceProxies.set(node, result)
95
133
  result.localLocation = node.location
96
134
  result.localPath = node.path
97
135
  result.isWorkspace = true
98
136
  result.resolved = node.resolved
99
137
  await this.assignCommonProperties(node, result)
138
+ return result
100
139
  }
101
140
 
102
- async externalProxy (result, node) {
103
- await this.assignCommonProperties(node, result)
141
+ async externalProxy (node) {
142
+ if (this.#externalProxies.has(node)) {
143
+ return this.#externalProxies.get(node)
144
+ }
145
+ const result = {}
146
+ // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.externalProxy
147
+ this.#externalProxies.set(node, result)
148
+ await this.assignCommonProperties(node, result, !node.hasShrinkwrap)
104
149
  if (node.hasShrinkwrap) {
105
150
  const dir = join(
106
151
  node.root.path,
107
152
  'node_modules',
108
153
  '.store',
109
- `${node.name}@${node.version}`
154
+ `${node.packageName}@${node.version}`
110
155
  )
111
156
  mkdirSync(dir, { recursive: true })
112
- // TODO this approach feels wrong
113
- // and shouldn't be necessary for shrinkwraps
157
+ // TODO this approach feels wrong and shouldn't be necessary for shrinkwraps
114
158
  await pacote.extract(node.resolved, dir, {
115
159
  ...this.options,
116
160
  resolved: node.resolved,
@@ -118,51 +162,81 @@ module.exports = cls => class IsolatedReifier extends cls {
118
162
  })
119
163
  const Arborist = this.constructor
120
164
  const arb = new Arborist({ ...this.options, path: dir })
121
- await arb[_makeIdealGraph]({ dev: false })
122
- this.rootNode.external.push(...arb.idealGraph.external)
123
- arb.idealGraph.external.forEach(e => {
124
- e.root = this.rootNode
125
- e.id = `${node.id}=>${e.id}`
165
+ // Make sure that the ideal tree is build as the rest of the algorithm depends on it.
166
+ await arb.buildIdealTree({
167
+ complete: false,
168
+ dev: false,
126
169
  })
170
+ await arb.makeIdealGraph()
171
+ this.idealGraph.external.push(...arb.idealGraph.external)
172
+ for (const edge of arb.idealGraph.external) {
173
+ edge.root = this.idealGraph
174
+ edge.id = `${node.id}=>${edge.id}`
175
+ }
127
176
  result.localDependencies = []
128
177
  result.externalDependencies = arb.idealGraph.externalDependencies
129
178
  result.externalOptionalDependencies = arb.idealGraph.externalOptionalDependencies
130
179
  result.dependencies = [
131
180
  ...result.externalDependencies,
132
- ...result.localDependencies,
133
181
  ...result.externalOptionalDependencies,
134
182
  ]
135
183
  }
136
184
  result.optional = node.optional
137
185
  result.resolved = node.resolved
138
186
  result.version = node.version
187
+ return result
139
188
  }
140
189
 
141
- async assignCommonProperties (node, result) {
142
- function validEdgesOut (node) {
143
- return [...node.edgesOut.values()].filter(e => e.to && e.to.target && !(node.package.bundledDepenedencies || node.package.bundleDependencies || []).includes(e.to.name))
190
+ async assignCommonProperties (node, result, populateDeps = true) {
191
+ result.root = this.idealGraph
192
+ result.id = this.counter++
193
+ /* istanbul ignore next - packageName is always set for real packages */
194
+ result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
195
+ /* istanbul ignore next - packageName is always set for real packages */
196
+ result.packageName = node.packageName || node.name
197
+ result.package = { ...node.package }
198
+ result.package.bundleDependencies = undefined
199
+ result.hasInstallScript = node.hasInstallScript
200
+
201
+ if (!populateDeps) {
202
+ return
203
+ }
204
+
205
+ const edges = [...node.edgesOut.values()].filter(edge =>
206
+ edge.to?.target &&
207
+ !(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
208
+ )
209
+ const nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)
210
+
211
+ // When legacyPeerDeps is enabled, peer dep edges are not created on the node.
212
+ // Resolve them from the tree so they get symlinked in the store.
213
+ const peerDeps = node.package.peerDependencies
214
+ if (peerDeps && node.legacyPeerDeps) {
215
+ const edgeNames = new Set(edges.map(edge => edge.name))
216
+ for (const peerName in peerDeps) {
217
+ if (!edgeNames.has(peerName)) {
218
+ const resolved = node.resolve(peerName)
219
+ if (resolved && resolved !== node && !resolved.inert) {
220
+ nonOptionalDeps.push(resolved.target)
221
+ }
222
+ }
223
+ }
144
224
  }
145
- const edges = validEdgesOut(node)
146
- const optionalDeps = edges.filter(e => e.optional).map(e => e.to.target)
147
- const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)
148
225
 
149
- result.localDependencies = await Promise.all(nonOptionalDeps.filter(n => n.isWorkspace).map(this.workspaceProxyMemo))
150
- result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace).map(this.externalProxyMemo))
151
- result.externalOptionalDependencies = await Promise.all(optionalDeps.map(this.externalProxyMemo))
226
+ // 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.
227
+ const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n)
228
+ const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target)
229
+ result.localDependencies = await Promise.all(nonOptionalDeps.filter(isLocal).map(n => this.workspaceProxy(n)))
230
+ result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !isLocal(n) && !n.inert).map(n => this.externalProxy(n)))
231
+ result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(n => this.externalProxy(n)))
152
232
  result.dependencies = [
153
233
  ...result.externalDependencies,
154
234
  ...result.localDependencies,
155
235
  ...result.externalOptionalDependencies,
156
236
  ]
157
- result.root = this.rootNode
158
- result.id = this.counter++
159
- result.name = node.name
160
- result.package = { ...node.package }
161
- result.package.bundleDependencies = undefined
162
- result.hasInstallScript = node.hasInstallScript
163
237
  }
164
238
 
165
- async [_createBundledTree] () {
239
+ async #createBundledTree () {
166
240
  // TODO: make sure that idealTree object exists
167
241
  const idealTree = this.idealTree
168
242
  // TODO: test workspaces having bundled deps
@@ -201,253 +275,232 @@ module.exports = cls => class IsolatedReifier extends cls {
201
275
  nodes.set(to.location, { location: to.location, resolved: to.resolved, name: to.name, optional: to.optional, pkg: { ...to.package, bundleDependencies: undefined } })
202
276
  edges.push({ from: from.isRoot ? 'root' : from.location, to: to.location })
203
277
 
204
- to.edgesOut.forEach(e => {
278
+ to.edgesOut.forEach(edge => {
205
279
  // an edge out should always have a to
206
280
  /* istanbul ignore else */
207
- if (e.to) {
208
- queue.push({ from: e.from, to: e.to })
281
+ if (edge.to) {
282
+ queue.push({ from: edge.from, to: edge.to })
209
283
  }
210
284
  })
211
285
  }
212
286
  return { edges, nodes }
213
287
  }
214
288
 
215
- async [_createIsolatedTree] () {
216
- await this[_makeIdealGraph](this.options)
217
-
218
- const proxiedIdealTree = this.idealGraph
219
-
220
- const bundledTree = await this[_createBundledTree]()
221
-
222
- const treeHash = (startNode) => {
223
- // generate short hash based on the dependency tree
224
- // starting at this node
225
- const deps = []
226
- const branch = []
227
- depth({
228
- tree: startNode,
229
- getChildren: node => node.dependencies,
230
- filter: node => node,
231
- visit: node => {
232
- branch.push(`${node.name}@${node.version}`)
233
- deps.push(`${branch.join('->')}::${node.resolved}`)
234
- },
235
- leave: () => {
236
- branch.pop()
237
- },
238
- })
239
- deps.sort()
240
- return crypto.createHash('shake256', { outputLength: 16 })
241
- .update(deps.join(','))
242
- .digest('base64')
243
- // Node v14 doesn't support base64url
244
- .replace(/\+/g, '-')
245
- .replace(/\//g, '_')
246
- .replace(/=+$/m, '')
247
- }
289
+ async createIsolatedTree () {
290
+ await this.makeIdealGraph()
248
291
 
249
- const getKey = (idealTreeNode) => {
250
- return `${idealTreeNode.name}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
251
- }
292
+ const bundledTree = await this.#createBundledTree()
252
293
 
253
294
  const root = {
254
- fsChildren: [],
255
- integrity: null,
256
- inventory: new Map(),
257
- isLink: false,
258
- isRoot: true,
259
295
  binPaths: [],
296
+ children: new Map(),
260
297
  edgesIn: new Set(),
261
298
  edgesOut: new Map(),
299
+ fsChildren: new Set(),
300
+ global: false,
262
301
  hasShrinkwrap: false,
302
+ integrity: null,
303
+ inventory: new Map(),
304
+ isLink: false,
305
+ isProjectRoot: true,
306
+ isRoot: true,
307
+ isTop: true,
308
+ linksIn: new Set(),
309
+ meta: { loadedFromDisk: false },
310
+ package: this.idealGraph.root.package,
263
311
  parent: null,
312
+ path: this.idealGraph.root.localPath,
313
+ realpath: this.idealGraph.root.localPath,
264
314
  // TODO: we should probably not reference this.idealTree
265
315
  resolved: this.idealTree.resolved,
266
- isTop: true,
267
- path: proxiedIdealTree.root.localPath,
268
- realpath: proxiedIdealTree.root.localPath,
269
- package: proxiedIdealTree.root.package,
270
- meta: { loadedFromDisk: false },
271
- global: false,
272
- isProjectRoot: true,
273
- children: [],
316
+ tops: new Set(),
317
+ workspaces: new Map(),
274
318
  }
275
- // root.inventory.set('', t)
276
- // root.meta = this.idealTree.meta
277
- // TODO We should mock better the inventory object because it is used by audit-report.js ... maybe
319
+ root.inventory.set('', root)
320
+ root.root = root
321
+ root.target = root
322
+ // TODO inventory.query is a stub; audit-report needs 'packageName' support
278
323
  root.inventory.query = () => {
279
324
  return []
280
325
  }
281
326
  const processed = new Set()
282
- proxiedIdealTree.workspaces.forEach(c => {
327
+ for (const c of this.idealGraph.workspaces) {
328
+ const wsName = c.packageName
283
329
  const workspace = {
330
+ binPaths: [],
331
+ children: new Map(),
284
332
  edgesIn: new Set(),
285
333
  edgesOut: new Map(),
286
- children: [],
334
+ fsChildren: new Set(),
287
335
  hasInstallScript: c.hasInstallScript,
288
- binPaths: [],
289
- package: c.package,
336
+ isLink: false,
337
+ isRoot: false,
338
+ linksIn: new Set(),
290
339
  location: c.localLocation,
340
+ name: wsName,
341
+ package: c.package,
291
342
  path: c.localPath,
292
343
  realpath: c.localPath,
293
344
  resolved: c.resolved,
294
345
  }
295
- root.fsChildren.push(workspace)
346
+ workspace.target = workspace
347
+ root.fsChildren.add(workspace)
296
348
  root.inventory.set(workspace.location, workspace)
297
- })
298
- const generateChild = (node, location, pkg, inStore) => {
299
- const newChild = {
349
+
350
+ // Create workspace Link entry in children for _diffTrees lookup
351
+ const wsLink = {
352
+ binPaths: [],
353
+ children: new Map(),
354
+ edgesIn: new Set(),
355
+ edgesOut: new Map(),
356
+ fsChildren: new Set(),
300
357
  global: false,
301
358
  globalTop: false,
359
+ isLink: true,
302
360
  isProjectRoot: false,
303
- isTop: false,
304
- location,
305
- name: node.name,
306
- optional: node.optional,
307
- top: { path: proxiedIdealTree.root.localPath },
308
- children: [],
309
- edgesIn: new Set(),
310
- edgesOut: new Map(),
311
- binPaths: [],
312
- fsChildren: [],
313
- /* istanbul ignore next -- emulate Node */
314
- getBundler () {
315
- return null
316
- },
317
- hasShrinkwrap: false,
318
- inDepBundle: false,
319
- integrity: null,
320
- isLink: false,
321
361
  isRoot: false,
322
- isInStore: inStore,
323
- path: join(proxiedIdealTree.root.localPath, location),
324
- realpath: join(proxiedIdealTree.root.localPath, location),
325
- resolved: node.resolved,
326
- version: pkg.version,
327
- package: pkg,
362
+ isTop: false,
363
+ linksIn: new Set(),
364
+ location: join('node_modules', wsName),
365
+ name: wsName,
366
+ package: workspace.package,
367
+ parent: root,
368
+ path: join(root.path, 'node_modules', wsName),
369
+ realpath: workspace.path,
370
+ root,
371
+ target: workspace,
328
372
  }
329
- newChild.target = newChild
330
- root.children.push(newChild)
331
- root.inventory.set(newChild.location, newChild)
373
+ root.children.set(wsLink.name, wsLink)
374
+ root.inventory.set(wsLink.location, wsLink)
375
+ root.workspaces.set(wsName, workspace.path)
376
+ workspace.linksIn.add(wsLink)
332
377
  }
333
- proxiedIdealTree.external.forEach(c => {
378
+
379
+ this.idealGraph.external.forEach(c => {
334
380
  const key = getKey(c)
335
381
  if (processed.has(key)) {
336
382
  return
337
383
  }
338
384
  processed.add(key)
339
- const location = join('node_modules', '.store', key, 'node_modules', c.name)
340
- generateChild(c, location, c.package, true)
385
+ const location = join('node_modules', '.store', key, 'node_modules', c.packageName)
386
+ this.#generateChild(c, location, c.package, true, root)
341
387
  })
388
+
342
389
  bundledTree.nodes.forEach(node => {
343
- generateChild(node, node.location, node.pkg, false)
390
+ this.#generateChild(node, node.location, node.pkg, false, root)
344
391
  })
345
- bundledTree.edges.forEach(e => {
346
- const from = e.from === 'root' ? root : root.inventory.get(e.from)
347
- const to = root.inventory.get(e.to)
392
+
393
+ bundledTree.edges.forEach(edge => {
394
+ const from = edge.from === 'root' ? root : root.inventory.get(edge.from)
395
+ const to = root.inventory.get(edge.to)
348
396
  // Maybe optional should be propagated from the original edge
349
- const edge = { optional: false, from, to }
350
- from.edgesOut.set(to.name, edge)
351
- to.edgesIn.add(edge)
397
+ const newEdge = { optional: false, from, to }
398
+ from.edgesOut.set(to.name, newEdge)
399
+ to.edgesIn.add(newEdge)
352
400
  })
353
- const memo = new Set()
354
401
 
355
- function processEdges (node, externalEdge) {
356
- externalEdge = !!externalEdge
357
- const key = getKey(node)
358
- if (memo.has(key)) {
359
- return
360
- }
361
- memo.add(key)
362
-
363
- let from, nmFolder
364
- if (externalEdge) {
365
- const fromLocation = join('node_modules', '.store', key, 'node_modules', node.name)
366
- from = root.children.find(c => c.location === fromLocation)
367
- nmFolder = join('node_modules', '.store', key, 'node_modules')
368
- } else {
369
- from = node.isProjectRoot ? root : root.fsChildren.find(c => c.location === node.localLocation)
370
- nmFolder = join(node.localLocation, 'node_modules')
371
- }
402
+ this.#processEdges(this.idealGraph, false, root)
403
+ for (const node of this.idealGraph.workspaces) {
404
+ this.#processEdges(node, false, root)
405
+ }
406
+ return root
407
+ }
372
408
 
373
- const processDeps = (dep, optional, external) => {
374
- optional = !!optional
375
- external = !!external
409
+ #processEdges (node, externalEdge, root) {
410
+ const key = getKey(node)
411
+ if (this.#processedEdges.has(key)) {
412
+ return
413
+ }
414
+ this.#processedEdges.add(key)
376
415
 
377
- const location = join(nmFolder, dep.name)
378
- const binNames = dep.package.bin && Object.keys(dep.package.bin) || []
379
- const toKey = getKey(dep)
416
+ let from, nmFolder
417
+ if (externalEdge) {
418
+ const fromLocation = join('node_modules', '.store', key, 'node_modules', node.packageName)
419
+ from = root.children.get(fromLocation)
420
+ nmFolder = join('node_modules', '.store', key, 'node_modules')
421
+ } else {
422
+ from = node.isProjectRoot ? root : root.inventory.get(node.localLocation)
423
+ nmFolder = join(node.localLocation, 'node_modules')
424
+ }
425
+ /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
426
+ if (!from) {
427
+ return
428
+ }
380
429
 
381
- let target
382
- if (external) {
383
- const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.name)
384
- target = root.children.find(c => c.location === toLocation)
385
- } else {
386
- target = root.fsChildren.find(c => c.location === dep.localLocation)
387
- }
388
- // TODO: we should no-op is an edge has already been created with the same fromKey and toKey
389
-
390
- binNames.forEach(bn => {
391
- target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))
392
- })
393
-
394
- const link = {
395
- global: false,
396
- globalTop: false,
397
- isProjectRoot: false,
398
- edgesIn: new Set(),
399
- edgesOut: new Map(),
400
- binPaths: [],
401
- isTop: false,
402
- optional,
403
- location: location,
404
- path: join(dep.root.localPath, nmFolder, dep.name),
405
- realpath: target.path,
406
- name: toKey,
407
- resolved: dep.resolved,
408
- top: { path: dep.root.localPath },
409
- children: [],
410
- fsChildren: [],
411
- isLink: true,
412
- isStoreLink: true,
413
- isRoot: false,
414
- package: { _id: 'abc', bundleDependencies: undefined, deprecated: undefined, bin: target.package.bin, scripts: dep.package.scripts },
415
- target,
416
- }
417
- const newEdge1 = { optional, from, to: link }
418
- from.edgesOut.set(dep.name, newEdge1)
419
- link.edgesIn.add(newEdge1)
420
- const newEdge2 = { optional: false, from: link, to: target }
421
- link.edgesOut.set(dep.name, newEdge2)
422
- target.edgesIn.add(newEdge2)
423
- root.children.push(link)
424
- }
430
+ for (const dep of node.localDependencies) {
431
+ this.#processEdges(dep, false, root)
432
+ // nonOptional, local
433
+ this.#processDeps(dep, false, false, root, from, nmFolder)
434
+ }
435
+ for (const dep of node.externalDependencies) {
436
+ this.#processEdges(dep, true, root)
437
+ // nonOptional, external
438
+ this.#processDeps(dep, false, true, root, from, nmFolder)
439
+ }
440
+ for (const dep of node.externalOptionalDependencies) {
441
+ this.#processEdges(dep, true, root)
442
+ // optional, external
443
+ this.#processDeps(dep, true, true, root, from, nmFolder)
444
+ }
445
+ }
425
446
 
426
- for (const dep of node.localDependencies) {
427
- processEdges(dep, false)
428
- // nonOptional, local
429
- processDeps(dep, false, false)
430
- }
431
- for (const dep of node.externalDependencies) {
432
- processEdges(dep, true)
433
- // nonOptional, external
434
- processDeps(dep, false, true)
435
- }
436
- for (const dep of node.externalOptionalDependencies) {
437
- processEdges(dep, true)
438
- // optional, external
439
- processDeps(dep, true, true)
447
+ #processDeps (dep, optional, external, root, from, nmFolder) {
448
+ const toKey = getKey(dep)
449
+
450
+ let target
451
+ if (external) {
452
+ const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.packageName)
453
+ target = root.children.get(toLocation)
454
+ } else {
455
+ target = root.inventory.get(dep.localLocation)
456
+ }
457
+ // TODO: we should no-op is an edge has already been created with the same fromKey and toKey
458
+ /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
459
+ if (!target) {
460
+ return
461
+ }
462
+
463
+ if (dep.package.bin) {
464
+ for (const bn in dep.package.bin) {
465
+ target.binPaths.push(join(dep.root.localPath, nmFolder, '.bin', bn))
440
466
  }
441
467
  }
442
468
 
443
- processEdges(proxiedIdealTree, false)
444
- for (const node of proxiedIdealTree.workspaces) {
445
- processEdges(node, false)
469
+ const link = {
470
+ binPaths: [],
471
+ children: new Map(),
472
+ edgesIn: new Set(),
473
+ edgesOut: new Map(),
474
+ fsChildren: new Set(),
475
+ global: false,
476
+ globalTop: false,
477
+ isLink: true,
478
+ isProjectRoot: false,
479
+ isRoot: false,
480
+ isStoreLink: true,
481
+ isTop: false,
482
+ location: join(nmFolder, dep.name),
483
+ name: toKey,
484
+ optional,
485
+ // TODO _id: 'abc' ?
486
+ package: { _id: 'abc', bundleDependencies: undefined, deprecated: undefined, bin: target.package.bin, scripts: dep.package.scripts },
487
+ parent: root,
488
+ path: join(dep.root.localPath, nmFolder, dep.name),
489
+ realpath: target.path,
490
+ resolved: external
491
+ ? `file:.store/${toKey}/node_modules/${dep.packageName}`
492
+ : dep.resolved,
493
+ root,
494
+ target,
495
+ version: dep.version,
496
+ top: { path: dep.root.localPath },
446
497
  }
447
- root.children.forEach(c => c.parent = root)
448
- root.children.forEach(c => c.root = root)
449
- root.root = root
450
- root.target = root
451
- return root
498
+ const newEdge1 = { optional, from, to: link }
499
+ from.edgesOut.set(dep.name, newEdge1)
500
+ link.edgesIn.add(newEdge1)
501
+ const newEdge2 = { optional: false, from: link, to: target }
502
+ link.edgesOut.set(dep.name, newEdge2)
503
+ target.edgesIn.add(newEdge2)
504
+ root.children.set(link.location, link)
452
505
  }
453
506
  }
@@ -297,12 +297,12 @@ module.exports = cls => class Builder extends cls {
297
297
  devOptional,
298
298
  package: pkg,
299
299
  location,
300
- isStoreLink,
301
300
  } = node.target
302
301
 
303
302
  // skip any that we know we'll be deleting
304
- // or storeLinks
305
- if (this[_trashList].has(path) || isStoreLink) {
303
+ // or links to store entries (their scripts run on the store
304
+ // entry itself, not through the link)
305
+ if (this[_trashList].has(path) || (node.isLink && node.target?.isInStore)) {
306
306
  return
307
307
  }
308
308
 
@@ -383,13 +383,21 @@ module.exports = cls => class Builder extends cls {
383
383
 
384
384
  const timeEnd = time.start(`build:link:${node.location}`)
385
385
 
386
- const p = binLinks({
386
+ // On Windows, antivirus/indexer can transiently lock files, causing EPERM/EACCES/EBUSY on the rename inside write-file-atomic (used by bin-links/fix-bin.js), so, retry with backoff.
387
+ const promiseRetry = require('promise-retry')
388
+ const p = promiseRetry((retry) => binLinks({
387
389
  pkg: node.package,
388
390
  path: node.path,
389
391
  top: !!(node.isTop || node.globalTop),
390
392
  force: this.options.force,
391
393
  global: !!node.globalTop,
392
- })
394
+ }).catch(/* istanbul ignore next - Windows-only transient antivirus locks */ err => {
395
+ if (process.platform === 'win32' &&
396
+ (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) {
397
+ return retry(err)
398
+ }
399
+ throw err
400
+ }), { retries: 5, minTimeout: 500 })
393
401
 
394
402
  await (this.#doHandleOptionalFailure
395
403
  ? this[_handleOptionalFailure](node, p)
@@ -11,11 +11,13 @@ const { log, time } = require('proc-log')
11
11
  const hgi = require('hosted-git-info')
12
12
  const rpj = require('read-package-json-fast')
13
13
 
14
- const { dirname, resolve, relative, join } = require('node:path')
14
+ const { dirname, resolve, relative, join, sep } = require('node:path')
15
15
  const { depth: dfwalk } = require('treeverse')
16
+ const { existsSync } = require('node:fs')
16
17
  const {
17
18
  lstat,
18
19
  mkdir,
20
+ readdir,
19
21
  rm,
20
22
  symlink,
21
23
  } = require('node:fs/promises')
@@ -77,8 +79,6 @@ const _usePackageLock = Symbol.for('usePackageLock')
77
79
  // used by build-ideal-tree mixin
78
80
  const _addNodeToTrashList = Symbol.for('addNodeToTrashList')
79
81
 
80
- const _createIsolatedTree = Symbol.for('createIsolatedTree')
81
-
82
82
  module.exports = cls => class Reifier extends cls {
83
83
  #bundleMissing = new Set() // child nodes we'd EXPECT to be included in a bundle, but aren't
84
84
  #bundleUnpacked = new Set() // the nodes we unpack to read their bundles
@@ -93,6 +93,7 @@ module.exports = cls => class Reifier extends cls {
93
93
  #shrinkwrapInflated = new Set()
94
94
  #sparseTreeDirs = new Set()
95
95
  #sparseTreeRoots = new Set()
96
+ #linkedActualForDiff = null
96
97
 
97
98
  constructor (options) {
98
99
  super(options)
@@ -136,16 +137,21 @@ module.exports = cls => class Reifier extends cls {
136
137
  // this is currently technical debt which will be resolved in a refactor
137
138
  // of Node/Link trees
138
139
  log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
139
- this.idealTree = await this[_createIsolatedTree]()
140
+ this.idealTree = await this.createIsolatedTree()
141
+ this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
142
+ this.idealTree, this.actualTree
143
+ )
140
144
  }
141
145
  await this[_diffTrees]()
142
146
  await this[_reifyPackages]()
143
147
  if (linked) {
148
+ await this.#cleanOrphanedStoreEntries()
144
149
  // swap back in the idealTree
145
150
  // so that the lockfile is preserved
146
151
  this.idealTree = oldTree
147
152
  }
148
153
  await this[_saveIdealTree](options)
154
+ this.#linkedActualForDiff = null
149
155
  // clean up any trash that is still in the tree
150
156
  for (const path of this[_trashList]) {
151
157
  const loc = relpath(this.idealTree.realpath, path)
@@ -161,7 +167,7 @@ module.exports = cls => class Reifier extends cls {
161
167
  // was not changed, delete anything in the ideal and not actual.
162
168
  // Then we move the entire idealTree over to this.actualTree, and
163
169
  // save the hidden lockfile.
164
- if (this.diff && this.diff.filterSet.size) {
170
+ if (this.diff && this.diff.filterSet.size && !linked) {
165
171
  const reroot = new Set()
166
172
 
167
173
  const { filterSet } = this.diff
@@ -442,9 +448,14 @@ module.exports = cls => class Reifier extends cls {
442
448
  if (ideal) {
443
449
  filterNodes.push(ideal)
444
450
  }
445
- const actual = this.actualTree.children.get(ws)
446
- if (actual) {
447
- filterNodes.push(actual)
451
+ // Skip actual-side filterNodes when using the linked diff wrapper.
452
+ // Those nodes have root===actualTree, not root===linkedActualForDiff, and Diff.calculate requires filterNode.root to match actual.
453
+ // The ideal filterNode alone is sufficient to scope the workspace diff.
454
+ if (!this.#linkedActualForDiff) {
455
+ const actual = this.actualTree.children.get(ws)
456
+ if (actual) {
457
+ filterNodes.push(actual)
458
+ }
448
459
  }
449
460
  }
450
461
  }
@@ -465,7 +476,7 @@ module.exports = cls => class Reifier extends cls {
465
476
  this.diff = Diff.calculate({
466
477
  shrinkwrapInflated: this.#shrinkwrapInflated,
467
478
  filterNodes,
468
- actual: this.actualTree,
479
+ actual: this.#linkedActualForDiff || this.actualTree,
469
480
  ideal: this.idealTree,
470
481
  })
471
482
 
@@ -625,6 +636,7 @@ module.exports = cls => class Reifier extends cls {
625
636
  // if the directory already exists, made will be undefined. if that's the case
626
637
  // we don't want to remove it because we aren't the ones who created it so we
627
638
  // omit it from the #sparseTreeRoots
639
+ /* istanbul ignore next -- pre-existing: mkdir returns undefined when dir exists, covered in reify tests but lost in aggregate coverage merge */
628
640
  if (made) {
629
641
  this.#sparseTreeRoots.add(made)
630
642
  }
@@ -824,6 +836,125 @@ module.exports = cls => class Reifier extends cls {
824
836
  }) : p).then(() => node)
825
837
  }
826
838
 
839
+ // Build a flat actual tree wrapper for linked installs so the diff can
840
+ // correctly match store entries that already exist on disk.
841
+ #buildLinkedActualForDiff (idealTree, actualTree) {
842
+ const combined = new Map()
843
+
844
+ for (const child of actualTree.children.values()) {
845
+ combined.set(child.path, child)
846
+ }
847
+
848
+ for (const child of idealTree.children.values()) {
849
+ if (!combined.has(child.path) && (child.isInStore || child.isStoreLink) &&
850
+ existsSync(child.path)) {
851
+ const entry = {
852
+ global: false,
853
+ globalTop: false,
854
+ isProjectRoot: false,
855
+ isTop: false,
856
+ location: child.location,
857
+ name: child.name,
858
+ optional: child.optional,
859
+ top: child.top,
860
+ children: [],
861
+ edgesIn: new Set(),
862
+ edgesOut: new Map(),
863
+ binPaths: [],
864
+ fsChildren: [],
865
+ /* istanbul ignore next -- emulate Node */
866
+ getBundler () {
867
+ return null
868
+ },
869
+ hasShrinkwrap: false,
870
+ inDepBundle: false,
871
+ integrity: null,
872
+ isLink: Boolean(child.isLink),
873
+ isRoot: false,
874
+ isInStore: Boolean(child.isInStore),
875
+ path: child.path,
876
+ realpath: child.realpath,
877
+ resolved: child.resolved,
878
+ version: child.version,
879
+ package: child.package,
880
+ }
881
+ entry.target = entry
882
+ if (child.isLink && combined.has(child.realpath)) {
883
+ entry.target = combined.get(child.realpath)
884
+ }
885
+ combined.set(child.path, entry)
886
+ }
887
+ }
888
+
889
+ const origGet = actualTree.children.get.bind(actualTree.children)
890
+ const combinedGet = combined.get.bind(combined)
891
+ /* istanbul ignore next -- only reached during scoped workspace installs */
892
+ combined.get = (key) => combinedGet(key) || origGet(key)
893
+
894
+ const wrapper = {
895
+ isRoot: true,
896
+ isLink: actualTree.isLink,
897
+ target: actualTree.target,
898
+ fsChildren: actualTree.fsChildren,
899
+ path: actualTree.path,
900
+ realpath: actualTree.realpath,
901
+ edgesOut: actualTree.edgesOut,
902
+ inventory: actualTree.inventory,
903
+ package: actualTree.package,
904
+ resolved: actualTree.resolved,
905
+ version: actualTree.version,
906
+ integrity: actualTree.integrity,
907
+ binPaths: actualTree.binPaths,
908
+ hasShrinkwrap: false,
909
+ inDepBundle: false,
910
+ parent: null,
911
+ children: combined,
912
+ }
913
+
914
+ for (const child of combined.values()) {
915
+ if (!child.parent) {
916
+ child.parent = wrapper
917
+ child.root = wrapper
918
+ }
919
+ }
920
+
921
+ return wrapper
922
+ }
923
+
924
+ // After a linked install, scan node_modules/.store/ and remove any
925
+ // directories that are not referenced by the current ideal tree.
926
+ async #cleanOrphanedStoreEntries () {
927
+ const storeDir = resolve(this.path, 'node_modules', '.store')
928
+ let entries
929
+ try {
930
+ entries = await readdir(storeDir)
931
+ } catch {
932
+ return
933
+ }
934
+
935
+ const validKeys = new Set()
936
+ for (const child of this.idealTree.children.values()) {
937
+ if (child.isInStore) {
938
+ const key = child.location.split(sep)[2]
939
+ validKeys.add(key)
940
+ }
941
+ }
942
+
943
+ const orphaned = entries.filter(e => !validKeys.has(e))
944
+ if (!orphaned.length) {
945
+ return
946
+ }
947
+
948
+ log.silly('reify', 'cleaning orphaned store entries', orphaned)
949
+ await promiseAllRejectLate(
950
+ orphaned.map(e =>
951
+ rm(resolve(storeDir, e), { recursive: true, force: true })
952
+ .catch(/* istanbul ignore next -- rm with force rarely fails */
953
+ er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
954
+ )
955
+ )
956
+ }
957
+
827
958
  #registryResolved (resolved) {
828
959
  // the default registry url is a magic value meaning "the currently
829
960
  // configured registry".
package/lib/diff.js CHANGED
@@ -69,6 +69,7 @@ class Diff {
69
69
  tree: filterNode,
70
70
  visit: node => filterSet.add(node),
71
71
  getChildren: node => {
72
+ const orig = node
72
73
  node = node.target
73
74
  const loc = node.location
74
75
  const idealNode = ideal.inventory.get(loc)
@@ -85,7 +86,12 @@ class Diff {
85
86
  }
86
87
  }
87
88
 
88
- return ideals.concat(actuals)
89
+ const result = ideals.concat(actuals)
90
+ // Include link targets so store entries end up in filterSet
91
+ if (orig.isLink) {
92
+ result.push(node)
93
+ }
94
+ return result
89
95
  },
90
96
  })
91
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "8.0.1",
3
+ "version": "8.0.2",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@isaacs/string-locale-compare": "^1.1.0",
@@ -33,6 +33,7 @@
33
33
  "proggy": "^3.0.0",
34
34
  "promise-all-reject-late": "^1.0.0",
35
35
  "promise-call-limit": "^3.0.1",
36
+ "promise-retry": "^2.0.1",
36
37
  "read-package-json-fast": "^4.0.0",
37
38
  "semver": "^7.3.7",
38
39
  "ssri": "^12.0.0",
@@ -41,7 +42,7 @@
41
42
  },
42
43
  "devDependencies": {
43
44
  "@npmcli/eslint-config": "^5.0.1",
44
- "@npmcli/template-oss": "4.24.4",
45
+ "@npmcli/template-oss": "4.29.0",
45
46
  "benchmark": "^2.1.4",
46
47
  "minify-registry-metadata": "^4.0.0",
47
48
  "nock": "^13.3.3",
@@ -93,7 +94,7 @@
93
94
  },
94
95
  "templateOSS": {
95
96
  "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
96
- "version": "4.24.4",
97
+ "version": "4.29.0",
97
98
  "content": "../../scripts/template-oss/index.js"
98
99
  }
99
100
  }