@npmcli/arborist 8.0.1 → 8.0.3

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,103 @@
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')
6
+ const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
9
7
 
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
- }
8
+ // generate short hash key based on the dependency tree starting at this node
9
+ const getKey = (startNode) => {
10
+ const deps = []
11
+ const branch = []
12
+ depth({
13
+ tree: startNode,
14
+ getChildren: node => node.dependencies,
15
+ visit: node => {
16
+ branch.push(`${node.packageName}@${node.version}`)
17
+ deps.push(`${branch.join('->')}::${node.resolved}`)
18
+ },
19
+ leave: () => {
20
+ branch.pop()
21
+ },
22
+ })
23
+ deps.sort()
24
+ // TODO these replaces were originally to deal with node 14 not supporting base64url and likely don't need to happen anymore
25
+ // Changing this is a pretty significant breaking change, but removing parts of the hash increases collision possibilities (even if slight).
26
+ const hash = crypto.createHash('shake256', { outputLength: 16 })
27
+ .update(deps.join(','))
28
+ .digest('base64')
29
+ .replace(/\+/g, '-')
30
+ .replace(/\//g, '_')
31
+ .replace(/=+$/m, '')
32
+ return `${startNode.packageName}@${startNode.version}-${hash}`
23
33
  }
24
34
 
25
35
  module.exports = cls => class IsolatedReifier extends cls {
36
+ #externalProxies = new Map()
37
+ #omit = new Set()
38
+ #rootDeclaredDeps = new Set()
39
+ #processedEdges = new Set()
40
+ #workspaceProxies = new Map()
41
+
42
+ #generateChild (node, location, pkg, isInStore, root) {
43
+ const newChild = new IsolatedNode({
44
+ isInStore,
45
+ location,
46
+ name: node.packageName || node.name,
47
+ optional: node.optional,
48
+ package: pkg,
49
+ parent: root,
50
+ path: join(this.idealGraph.localPath, location),
51
+ resolved: node.resolved,
52
+ root,
53
+ })
54
+ // XXX top is from place-dep not lib/node.js
55
+ newChild.top = { path: this.idealGraph.localPath }
56
+ root.children.set(newChild.location, newChild)
57
+ root.inventory.set(newChild.location, newChild)
58
+ }
59
+
26
60
  /**
27
61
  * Create an ideal graph.
28
62
  *
29
63
  * An implementation of npm RFC-0042
30
64
  * https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md
31
65
  *
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.
66
+ * This entire file should be considered technical debt that will be resolved with an Arborist refactor or rewrite.
67
+ * 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
68
  *
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.
69
+ * 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
70
  *
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.
71
+ * 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
72
  *
44
73
  **/
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)
74
+ async makeIdealGraph () {
54
75
  const idealTree = this.idealTree
76
+ this.#omit = new Set(this.options.omit)
77
+ const omit = this.#omit
55
78
 
56
- this.rootNode = {}
57
- const root = this.rootNode
58
- this.counter = 0
79
+ // npm auto-creates 'workspace' edges from root to all workspaces.
80
+ // For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
81
+ // When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
82
+ const rootPkg = idealTree.package
83
+ this.#rootDeclaredDeps = new Set(Object.keys(Object.assign({},
84
+ rootPkg.dependencies,
85
+ (!omit.has('dev') && rootPkg.devDependencies),
86
+ (!omit.has('optional') && rootPkg.optionalDependencies),
87
+ (!omit.has('peer') && rootPkg.peerDependencies)
88
+ )))
59
89
 
60
- // memoize to cache generating proxy Nodes
61
- this.externalProxyMemo = memoize(this.externalProxy.bind(this))
62
- this.workspaceProxyMemo = memoize(this.workspaceProxy.bind(this))
90
+ // XXX this sometimes acts like a node too
91
+ this.idealGraph = {
92
+ external: [],
93
+ isProjectRoot: true,
94
+ localLocation: idealTree.location,
95
+ localPath: idealTree.path,
96
+ path: idealTree.path,
97
+ }
98
+ this.counter = 0
63
99
 
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))
100
+ this.idealGraph.workspaces = await Promise.all(Array.from(idealTree.fsChildren.values(), w => this.#workspaceProxy(w)))
70
101
  const processed = new Set()
71
102
  const queue = [idealTree, ...idealTree.fsChildren]
72
103
  while (queue.length !== 0) {
@@ -75,42 +106,53 @@ module.exports = cls => class IsolatedReifier extends cls {
75
106
  continue
76
107
  }
77
108
  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
109
+ next.edgesOut.forEach(edge => {
110
+ if (edge.to && !(next.package.bundleDependencies || next.package.bundledDependencies || []).includes(edge.to.name) && !edge.to.shouldOmit?.(omit)) {
111
+ queue.push(edge.to)
81
112
  }
82
- queue.push(e.to)
83
113
  })
84
- if (!next.isProjectRoot && !next.isWorkspace) {
85
- root.external.push(await this.externalProxyMemo(next))
114
+ // local `file:` deps are in fsChildren but are not workspaces.
115
+ // they are already handled as workspace-like proxies above and should not go through the external/store extraction path.
116
+ if (!next.isProjectRoot && !next.isWorkspace && !next.inert && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
117
+ this.idealGraph.external.push(await this.#externalProxy(next))
86
118
  }
87
119
  }
88
120
 
89
- await this.assignCommonProperties(idealTree, root)
90
-
91
- this.idealGraph = root
121
+ await this.#assignCommonProperties(idealTree, this.idealGraph)
92
122
  }
93
123
 
94
- async workspaceProxy (result, node) {
124
+ async #workspaceProxy (node) {
125
+ if (this.#workspaceProxies.has(node)) {
126
+ return this.#workspaceProxies.get(node)
127
+ }
128
+ const result = {}
129
+ // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.#workspaceProxy
130
+ this.#workspaceProxies.set(node, result)
95
131
  result.localLocation = node.location
96
132
  result.localPath = node.path
97
133
  result.isWorkspace = true
98
134
  result.resolved = node.resolved
99
- await this.assignCommonProperties(node, result)
135
+ await this.#assignCommonProperties(node, result)
136
+ return result
100
137
  }
101
138
 
102
- async externalProxy (result, node) {
103
- await this.assignCommonProperties(node, result)
139
+ async #externalProxy (node) {
140
+ if (this.#externalProxies.has(node)) {
141
+ return this.#externalProxies.get(node)
142
+ }
143
+ const result = {}
144
+ // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.#externalProxy
145
+ this.#externalProxies.set(node, result)
146
+ await this.#assignCommonProperties(node, result, !node.hasShrinkwrap)
104
147
  if (node.hasShrinkwrap) {
105
148
  const dir = join(
106
149
  node.root.path,
107
150
  'node_modules',
108
151
  '.store',
109
- `${node.name}@${node.version}`
152
+ `${node.packageName}@${node.version}`
110
153
  )
111
154
  mkdirSync(dir, { recursive: true })
112
- // TODO this approach feels wrong
113
- // and shouldn't be necessary for shrinkwraps
155
+ // TODO this approach feels wrong and shouldn't be necessary for shrinkwraps
114
156
  await pacote.extract(node.resolved, dir, {
115
157
  ...this.options,
116
158
  resolved: node.resolved,
@@ -118,51 +160,104 @@ module.exports = cls => class IsolatedReifier extends cls {
118
160
  })
119
161
  const Arborist = this.constructor
120
162
  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}`
163
+ // Make sure that the ideal tree is build as the rest of the algorithm depends on it.
164
+ await arb.buildIdealTree({
165
+ complete: false,
166
+ dev: false,
126
167
  })
168
+ await arb.makeIdealGraph()
169
+ this.idealGraph.external.push(...arb.idealGraph.external)
170
+ for (const edge of arb.idealGraph.external) {
171
+ edge.root = this.idealGraph
172
+ edge.id = `${node.id}=>${edge.id}`
173
+ }
127
174
  result.localDependencies = []
128
175
  result.externalDependencies = arb.idealGraph.externalDependencies
129
176
  result.externalOptionalDependencies = arb.idealGraph.externalOptionalDependencies
130
177
  result.dependencies = [
131
178
  ...result.externalDependencies,
132
- ...result.localDependencies,
133
179
  ...result.externalOptionalDependencies,
134
180
  ]
135
181
  }
136
182
  result.optional = node.optional
137
183
  result.resolved = node.resolved
138
184
  result.version = node.version
185
+ return result
139
186
  }
140
187
 
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))
188
+ async #assignCommonProperties (node, result, populateDeps = true) {
189
+ result.root = this.idealGraph
190
+ // XXX does anything need this?
191
+ result.id = this.counter++
192
+ /* istanbul ignore next - packageName is always set for real packages */
193
+ result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
194
+ /* istanbul ignore next - packageName is always set for real packages */
195
+ result.packageName = node.packageName || node.name
196
+ result.package = { ...node.package }
197
+ result.package.bundleDependencies = undefined
198
+
199
+ if (!populateDeps) {
200
+ return
144
201
  }
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
202
 
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))
203
+ let edges = [...node.edgesOut.values()].filter(edge =>
204
+ edge.to?.target &&
205
+ !(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
206
+ )
207
+
208
+ // Only omit edge types for root and workspace nodes (matching shouldOmit scope)
209
+ if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
210
+ edges = edges.filter(edge => {
211
+ if (edge.dev && this.#omit.has('dev')) {
212
+ return false
213
+ }
214
+ if (edge.optional && this.#omit.has('optional')) {
215
+ return false
216
+ }
217
+ if (edge.peer && this.#omit.has('peer')) {
218
+ return false
219
+ }
220
+ return true
221
+ })
222
+ }
223
+
224
+ let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)
225
+
226
+ // npm auto-creates 'workspace' edges from root to all workspaces.
227
+ // For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
228
+ if (node.isProjectRoot) {
229
+ nonOptionalDeps = nonOptionalDeps.filter(n => !n.isWorkspace || this.#rootDeclaredDeps.has(n.packageName))
230
+ }
231
+
232
+ // When legacyPeerDeps is enabled, peer dep edges are not created on the node.
233
+ // Resolve them from the tree so they get symlinked in the store.
234
+ const peerDeps = node.package.peerDependencies
235
+ if (peerDeps && node.legacyPeerDeps) {
236
+ const edgeNames = new Set(edges.map(edge => edge.name))
237
+ for (const peerName in peerDeps) {
238
+ if (!edgeNames.has(peerName)) {
239
+ const resolved = node.resolve(peerName)
240
+ if (resolved && resolved !== node && !resolved.inert) {
241
+ nonOptionalDeps.push(resolved.target)
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // 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.
248
+ const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n)
249
+ const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target)
250
+ result.localDependencies = await Promise.all(nonOptionalDeps.filter(isLocal).map(n => this.#workspaceProxy(n)))
251
+ result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !isLocal(n) && !n.inert).map(n => this.#externalProxy(n)))
252
+ result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(n => this.#externalProxy(n)))
152
253
  result.dependencies = [
153
254
  ...result.externalDependencies,
154
255
  ...result.localDependencies,
155
256
  ...result.externalOptionalDependencies,
156
257
  ]
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
258
  }
164
259
 
165
- async [_createBundledTree] () {
260
+ async #createBundledTree () {
166
261
  // TODO: make sure that idealTree object exists
167
262
  const idealTree = this.idealTree
168
263
  // TODO: test workspaces having bundled deps
@@ -201,253 +296,182 @@ module.exports = cls => class IsolatedReifier extends cls {
201
296
  nodes.set(to.location, { location: to.location, resolved: to.resolved, name: to.name, optional: to.optional, pkg: { ...to.package, bundleDependencies: undefined } })
202
297
  edges.push({ from: from.isRoot ? 'root' : from.location, to: to.location })
203
298
 
204
- to.edgesOut.forEach(e => {
299
+ to.edgesOut.forEach(edge => {
205
300
  // an edge out should always have a to
206
301
  /* istanbul ignore else */
207
- if (e.to) {
208
- queue.push({ from: e.from, to: e.to })
302
+ if (edge.to) {
303
+ queue.push({ from: edge.from, to: edge.to })
209
304
  }
210
305
  })
211
306
  }
212
307
  return { edges, nodes }
213
308
  }
214
309
 
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
- }
248
-
249
- const getKey = (idealTreeNode) => {
250
- return `${idealTreeNode.name}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
251
- }
310
+ async createIsolatedTree () {
311
+ await this.makeIdealGraph()
312
+ const bundledTree = await this.#createBundledTree()
252
313
 
253
- const root = {
254
- fsChildren: [],
255
- integrity: null,
256
- inventory: new Map(),
257
- isLink: false,
258
- isRoot: true,
259
- binPaths: [],
260
- edgesIn: new Set(),
261
- edgesOut: new Map(),
262
- hasShrinkwrap: false,
263
- parent: null,
264
- // TODO: we should probably not reference this.idealTree
265
- 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: [],
274
- }
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
278
- root.inventory.query = () => {
279
- return []
280
- }
314
+ const root = new IsolatedNode(this.idealGraph)
315
+ root.root = root
316
+ root.top = root
317
+ root.inventory.set('', root)
281
318
  const processed = new Set()
282
- proxiedIdealTree.workspaces.forEach(c => {
283
- const workspace = {
284
- edgesIn: new Set(),
285
- edgesOut: new Map(),
286
- children: [],
287
- hasInstallScript: c.hasInstallScript,
288
- binPaths: [],
289
- package: c.package,
319
+ for (const c of this.idealGraph.workspaces) {
320
+ const wsName = c.packageName
321
+ // XXX parent? root?
322
+ const workspace = new IsolatedNode({
290
323
  location: c.localLocation,
324
+ name: wsName,
325
+ package: c.package,
291
326
  path: c.localPath,
292
- realpath: c.localPath,
293
327
  resolved: c.resolved,
294
- }
295
- root.fsChildren.push(workspace)
328
+ })
329
+ workspace.top = workspace
330
+ workspace.isWorkspace = true
331
+ root.fsChildren.add(workspace)
296
332
  root.inventory.set(workspace.location, workspace)
297
- })
298
- const generateChild = (node, location, pkg, inStore) => {
299
- const newChild = {
300
- global: false,
301
- globalTop: false,
302
- 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
- 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,
333
+ root.workspaces.set(wsName, workspace.path)
334
+
335
+ // Create workspace Link. For root declared deps, link at root node_modules/. For undeclared deps, link at the workspace's own node_modules/ (self-link).
336
+ const isDeclared = this.#rootDeclaredDeps.has(wsName)
337
+ const wsLink = new IsolatedLink({
338
+ location: isDeclared ? join('node_modules', wsName) : join(c.localLocation, 'node_modules', wsName),
339
+ name: wsName,
340
+ package: workspace.package,
341
+ parent: root,
342
+ path: isDeclared ? join(root.path, 'node_modules', wsName) : join(root.path, c.localLocation, 'node_modules', wsName),
343
+ realpath: workspace.path,
344
+ root,
345
+ target: workspace,
346
+ })
347
+ wsLink.top = root
348
+ if (!isDeclared) {
349
+ workspace.children.set(wsName, wsLink)
328
350
  }
329
- newChild.target = newChild
330
- root.children.push(newChild)
331
- root.inventory.set(newChild.location, newChild)
351
+ root.children.set(wsName, wsLink)
352
+ root.inventory.set(wsLink.location, wsLink)
353
+ workspace.linksIn.add(wsLink)
332
354
  }
333
- proxiedIdealTree.external.forEach(c => {
355
+
356
+ this.idealGraph.external.forEach(c => {
334
357
  const key = getKey(c)
335
358
  if (processed.has(key)) {
336
359
  return
337
360
  }
338
361
  processed.add(key)
339
- const location = join('node_modules', '.store', key, 'node_modules', c.name)
340
- generateChild(c, location, c.package, true)
362
+ const location = join('node_modules', '.store', key, 'node_modules', c.packageName)
363
+ this.#generateChild(c, location, c.package, true, root)
341
364
  })
365
+
342
366
  bundledTree.nodes.forEach(node => {
343
- generateChild(node, node.location, node.pkg, false)
367
+ this.#generateChild(node, node.location, node.pkg, false, root)
344
368
  })
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)
369
+
370
+ bundledTree.edges.forEach(edge => {
371
+ const from = edge.from === 'root' ? root : root.inventory.get(edge.from)
372
+ const to = root.inventory.get(edge.to)
348
373
  // 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)
374
+ const newEdge = { optional: false, from, to }
375
+ from.edgesOut.set(to.name, newEdge)
376
+ to.edgesIn.add(newEdge)
352
377
  })
353
- const memo = new Set()
354
378
 
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
- }
379
+ this.#processEdges(this.idealGraph, false, root)
380
+ for (const node of this.idealGraph.workspaces) {
381
+ this.#processEdges(node, false, root)
382
+ }
383
+ return root
384
+ }
372
385
 
373
- const processDeps = (dep, optional, external) => {
374
- optional = !!optional
375
- external = !!external
386
+ #processEdges (node, externalEdge, root) {
387
+ const key = getKey(node)
388
+ if (this.#processedEdges.has(key)) {
389
+ return
390
+ }
391
+ this.#processedEdges.add(key)
376
392
 
377
- const location = join(nmFolder, dep.name)
378
- const binNames = dep.package.bin && Object.keys(dep.package.bin) || []
379
- const toKey = getKey(dep)
393
+ let from, nmFolder
394
+ if (externalEdge) {
395
+ const fromLocation = join('node_modules', '.store', key, 'node_modules', node.packageName)
396
+ from = root.children.get(fromLocation)
397
+ nmFolder = join('node_modules', '.store', key, 'node_modules')
398
+ } else {
399
+ from = node.isProjectRoot ? root : root.inventory.get(node.localLocation)
400
+ nmFolder = join(node.localLocation, 'node_modules')
401
+ }
402
+ /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
403
+ if (!from) {
404
+ return
405
+ }
380
406
 
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
- }
407
+ for (const dep of node.localDependencies) {
408
+ this.#processEdges(dep, false, root)
409
+ // nonOptional, local
410
+ this.#processDeps(dep, false, false, root, from, nmFolder)
411
+ }
412
+ for (const dep of node.externalDependencies) {
413
+ this.#processEdges(dep, true, root)
414
+ // nonOptional, external
415
+ this.#processDeps(dep, false, true, root, from, nmFolder)
416
+ }
417
+ for (const dep of node.externalOptionalDependencies) {
418
+ this.#processEdges(dep, true, root)
419
+ // optional, external
420
+ this.#processDeps(dep, true, true, root, from, nmFolder)
421
+ }
422
+ }
425
423
 
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)
424
+ #processDeps (dep, optional, external, root, from, nmFolder) {
425
+ const toKey = getKey(dep)
426
+
427
+ let target
428
+ if (external) {
429
+ const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.packageName)
430
+ target = root.children.get(toLocation)
431
+ } else {
432
+ target = root.inventory.get(dep.localLocation)
433
+ }
434
+ // TODO: we should no-op is an edge has already been created with the same fromKey and toKey
435
+ /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
436
+ if (!target) {
437
+ return
438
+ }
439
+
440
+ if (dep.package.bin) {
441
+ for (const bn in dep.package.bin) {
442
+ target.binPaths.push(join(dep.root.localPath, nmFolder, '.bin', bn))
440
443
  }
441
444
  }
442
445
 
443
- processEdges(proxiedIdealTree, false)
444
- for (const node of proxiedIdealTree.workspaces) {
445
- processEdges(node, false)
446
+ const pkg = {
447
+ _id: dep.package._id,
448
+ bin: target.package.bin,
449
+ bundleDependencies: undefined,
450
+ deprecated: undefined,
451
+ scripts: dep.package.scripts,
452
+ version: dep.package.version,
446
453
  }
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
454
+ const link = new IsolatedLink({
455
+ isStoreLink: true,
456
+ location: join(nmFolder, dep.name),
457
+ name: toKey,
458
+ optional,
459
+ parent: root,
460
+ package: pkg,
461
+ path: join(dep.root.localPath, nmFolder, dep.name),
462
+ realpath: target.path,
463
+ resolved: external ? `file:.store/${toKey}/node_modules/${dep.packageName}` : dep.resolved,
464
+ root,
465
+ target,
466
+ })
467
+ // XXX top is from place-dep not lib/link.js
468
+ link.top = { path: dep.root.localPath }
469
+ const newEdge1 = { optional, from, to: link }
470
+ from.edgesOut.set(dep.name, newEdge1)
471
+ link.edgesIn.add(newEdge1)
472
+ const newEdge2 = { optional: false, from: link, to: target }
473
+ link.edgesOut.set(dep.name, newEdge2)
474
+ target.edgesIn.add(newEdge2)
475
+ root.children.set(link.location, link)
452
476
  }
453
477
  }
@@ -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')
@@ -34,6 +36,7 @@ const { callLimit: promiseCallLimit } = require('promise-call-limit')
34
36
  const optionalSet = require('../optional-set.js')
35
37
  const calcDepFlags = require('../calc-dep-flags.js')
36
38
  const { saveTypeMap, hasSubKey } = require('../add-rm-pkg-deps.js')
39
+ const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
37
40
 
38
41
  const Shrinkwrap = require('../shrinkwrap.js')
39
42
  const { defaultLockfileVersion } = Shrinkwrap
@@ -77,8 +80,6 @@ const _usePackageLock = Symbol.for('usePackageLock')
77
80
  // used by build-ideal-tree mixin
78
81
  const _addNodeToTrashList = Symbol.for('addNodeToTrashList')
79
82
 
80
- const _createIsolatedTree = Symbol.for('createIsolatedTree')
81
-
82
83
  module.exports = cls => class Reifier extends cls {
83
84
  #bundleMissing = new Set() // child nodes we'd EXPECT to be included in a bundle, but aren't
84
85
  #bundleUnpacked = new Set() // the nodes we unpack to read their bundles
@@ -93,6 +94,7 @@ module.exports = cls => class Reifier extends cls {
93
94
  #shrinkwrapInflated = new Set()
94
95
  #sparseTreeDirs = new Set()
95
96
  #sparseTreeRoots = new Set()
97
+ #linkedActualForDiff = null
96
98
 
97
99
  constructor (options) {
98
100
  super(options)
@@ -136,16 +138,21 @@ module.exports = cls => class Reifier extends cls {
136
138
  // this is currently technical debt which will be resolved in a refactor
137
139
  // of Node/Link trees
138
140
  log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
139
- this.idealTree = await this[_createIsolatedTree]()
141
+ this.idealTree = await this.createIsolatedTree()
142
+ this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
143
+ this.idealTree, this.actualTree
144
+ )
140
145
  }
141
146
  await this[_diffTrees]()
142
147
  await this[_reifyPackages]()
143
148
  if (linked) {
149
+ await this.#cleanOrphanedStoreEntries()
144
150
  // swap back in the idealTree
145
151
  // so that the lockfile is preserved
146
152
  this.idealTree = oldTree
147
153
  }
148
154
  await this[_saveIdealTree](options)
155
+ this.#linkedActualForDiff = null
149
156
  // clean up any trash that is still in the tree
150
157
  for (const path of this[_trashList]) {
151
158
  const loc = relpath(this.idealTree.realpath, path)
@@ -161,7 +168,7 @@ module.exports = cls => class Reifier extends cls {
161
168
  // was not changed, delete anything in the ideal and not actual.
162
169
  // Then we move the entire idealTree over to this.actualTree, and
163
170
  // save the hidden lockfile.
164
- if (this.diff && this.diff.filterSet.size) {
171
+ if (this.diff && this.diff.filterSet.size && !linked) {
165
172
  const reroot = new Set()
166
173
 
167
174
  const { filterSet } = this.diff
@@ -442,9 +449,14 @@ module.exports = cls => class Reifier extends cls {
442
449
  if (ideal) {
443
450
  filterNodes.push(ideal)
444
451
  }
445
- const actual = this.actualTree.children.get(ws)
446
- if (actual) {
447
- filterNodes.push(actual)
452
+ // Skip actual-side filterNodes when using the linked diff wrapper.
453
+ // Those nodes have root===actualTree, not root===linkedActualForDiff, and Diff.calculate requires filterNode.root to match actual.
454
+ // The ideal filterNode alone is sufficient to scope the workspace diff.
455
+ if (!this.#linkedActualForDiff) {
456
+ const actual = this.actualTree.children.get(ws)
457
+ if (actual) {
458
+ filterNodes.push(actual)
459
+ }
448
460
  }
449
461
  }
450
462
  }
@@ -465,7 +477,7 @@ module.exports = cls => class Reifier extends cls {
465
477
  this.diff = Diff.calculate({
466
478
  shrinkwrapInflated: this.#shrinkwrapInflated,
467
479
  filterNodes,
468
- actual: this.actualTree,
480
+ actual: this.#linkedActualForDiff || this.actualTree,
469
481
  ideal: this.idealTree,
470
482
  })
471
483
 
@@ -625,6 +637,7 @@ module.exports = cls => class Reifier extends cls {
625
637
  // if the directory already exists, made will be undefined. if that's the case
626
638
  // we don't want to remove it because we aren't the ones who created it so we
627
639
  // omit it from the #sparseTreeRoots
640
+ /* istanbul ignore next -- pre-existing: mkdir returns undefined when dir exists, covered in reify tests but lost in aggregate coverage merge */
628
641
  if (made) {
629
642
  this.#sparseTreeRoots.add(made)
630
643
  }
@@ -824,6 +837,89 @@ module.exports = cls => class Reifier extends cls {
824
837
  }) : p).then(() => node)
825
838
  }
826
839
 
840
+ // Build a flat actual tree wrapper for linked installs so the diff can
841
+ // correctly match store entries that already exist on disk.
842
+ #buildLinkedActualForDiff (idealTree, actualTree) {
843
+ const combined = new Map()
844
+
845
+ // Create synthetic actual entries for ALL ideal children that exist on disk.
846
+ // The isolated ideal tree is flat (all entries as root children), but loadActual() produces a nested tree where workspace deps are under fsChildren and store entries are deep link targets.
847
+ // Synthetic entries ensure the diff compares matching resolved/integrity values (e.g. workspace links have resolved=undefined in the ideal tree but resolved="file:../packages/..." in the actual tree).
848
+ for (const child of idealTree.children.values()) {
849
+ if (combined.has(child.path) || !existsSync(child.path)) {
850
+ continue
851
+ }
852
+ let entry
853
+ if (child.isLink) {
854
+ entry = new IsolatedLink(child)
855
+ } else {
856
+ entry = new IsolatedNode(child)
857
+ }
858
+ if (child.isLink && combined.has(child.realpath)) {
859
+ entry.target = combined.get(child.realpath)
860
+ }
861
+ combined.set(child.path, entry)
862
+ }
863
+
864
+ const origGet = actualTree.children.get.bind(actualTree.children)
865
+ const combinedGet = combined.get.bind(combined)
866
+ /* istanbul ignore next -- only reached during scoped workspace installs */
867
+ combined.get = (key) => combinedGet(key) || origGet(key)
868
+
869
+ let wrapper
870
+ /* istanbul ignore next - untested! */
871
+ if (actualTree.isLink) {
872
+ wrapper = new IsolatedLink(actualTree)
873
+ } else {
874
+ wrapper = new IsolatedNode(actualTree)
875
+ }
876
+ wrapper.root = wrapper
877
+ wrapper.binPaths = actualTree.binPaths
878
+ wrapper.children = combined
879
+ wrapper.edgesOut = actualTree.edgesOut
880
+ // Use empty fsChildren so that allChildren() only picks up entries from the combined map.
881
+ // The actual fsChildren have real children with different resolved values (e.g. file:../../../node_modules/.store/... vs file:.store/...) that would overwrite our synthetic entries in allChildren().
882
+ wrapper.fsChildren = new Set()
883
+ wrapper.integrity = actualTree.integrity
884
+ wrapper.inventory = actualTree.inventory
885
+
886
+ return wrapper
887
+ }
888
+
889
+ // After a linked install, scan node_modules/.store/ and remove any
890
+ // directories that are not referenced by the current ideal tree.
891
+ async #cleanOrphanedStoreEntries () {
892
+ const storeDir = resolve(this.path, 'node_modules', '.store')
893
+ let entries
894
+ try {
895
+ entries = await readdir(storeDir)
896
+ } catch {
897
+ return
898
+ }
899
+
900
+ const validKeys = new Set()
901
+ for (const child of this.idealTree.children.values()) {
902
+ if (child.isInStore) {
903
+ const key = child.location.split(sep)[2]
904
+ validKeys.add(key)
905
+ }
906
+ }
907
+
908
+ const orphaned = entries.filter(e => !validKeys.has(e))
909
+ if (!orphaned.length) {
910
+ return
911
+ }
912
+
913
+ log.silly('reify', 'cleaning orphaned store entries', orphaned)
914
+ await promiseAllRejectLate(
915
+ orphaned.map(e =>
916
+ rm(resolve(storeDir, e), { recursive: true, force: true })
917
+ .catch(/* istanbul ignore next -- rm with force rarely fails */
918
+ er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
919
+ )
920
+ )
921
+ }
922
+
827
923
  #registryResolved (resolved) {
828
924
  // the default registry url is a magic value meaning "the currently
829
925
  // 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/lib/inventory.js CHANGED
@@ -1,5 +1,4 @@
1
- // a class to manage an inventory and set of indexes of a set of objects based
2
- // on specific fields.
1
+ // a class to manage an inventory and set of indexes of a set of objects based on specific fields.
3
2
  const { hasOwnProperty } = Object.prototype
4
3
  const debug = require('./debug.js')
5
4
 
@@ -0,0 +1,138 @@
1
+ // Alternate versions of different classes that we use for isolated mode
2
+ const CaseInsensitiveMap = require('./case-insensitive-map.js')
3
+ const { resolve } = require('node:path')
4
+
5
+ // fake lib/inventory.js
6
+ class IsolatedInventory extends Map {
7
+ query () {
8
+ return []
9
+ }
10
+ }
11
+
12
+ // fake lib/node.js
13
+ class IsolatedNode {
14
+ binPaths = []
15
+ children = new CaseInsensitiveMap()
16
+ edgesIn = new Set()
17
+ edgesOut = new CaseInsensitiveMap()
18
+ fsChildren = new Set()
19
+ hasShrinkwrap = false
20
+ integrity = null
21
+ inventory = new IsolatedInventory()
22
+ isInStore = false
23
+ linksIn = new Set()
24
+ meta = { loadedFromDisk: false }
25
+ optional = false
26
+ parent = null
27
+ root = null
28
+ tops = new Set()
29
+ workspaces = new Map()
30
+
31
+ constructor (options) {
32
+ this.location = options.location
33
+ this.name = options.name
34
+ this.package = options.package
35
+ this.path = options.path
36
+ this.realpath = !this.isLink ? this.path : resolve(options.realpath)
37
+
38
+ if (options.parent) {
39
+ this.parent = options.parent
40
+ }
41
+ if (options.resolved) {
42
+ this.resolved = options.resolved
43
+ }
44
+ if (options.root) {
45
+ this.root = options.root
46
+ }
47
+ if (options.isInStore) {
48
+ this.isInStore = true
49
+ }
50
+ if (options.optional) {
51
+ this.optional = true
52
+ }
53
+ }
54
+
55
+ get isRoot () {
56
+ return this === this.root
57
+ }
58
+
59
+ // The idealGraph is where this is set to true
60
+ get isProjectRoot () {
61
+ return false
62
+ }
63
+
64
+ get inDepBundle () {
65
+ return false
66
+ }
67
+
68
+ get isLink () {
69
+ return false
70
+ }
71
+
72
+ get isTop () {
73
+ return !this.parent
74
+ }
75
+
76
+ /* istanbul ignore next -- emulate lib/node.js */
77
+ get global () {
78
+ return false
79
+ }
80
+
81
+ get globalTop () {
82
+ return false
83
+ }
84
+
85
+ /* istanbul ignore next -- emulate lib/node.js */
86
+ set target (t) {
87
+ // nop
88
+ // In the real lib/node.js this throws in debug mode
89
+ }
90
+
91
+ get target () {
92
+ return this
93
+ }
94
+
95
+ /* istanbul ignore next -- emulate lib/node.js */
96
+ getBundler () {
97
+ return null
98
+ }
99
+
100
+ /* istanbul ignore next -- emulate lib/node.js */
101
+ get hasInstallScript () {
102
+ const { hasInstallScript, scripts } = this.package
103
+ const { install, preinstall, postinstall } = scripts || {}
104
+ return !!(hasInstallScript || install || preinstall || postinstall)
105
+ }
106
+
107
+ get version () {
108
+ return this.package.version
109
+ }
110
+ }
111
+
112
+ // fake lib/link.js
113
+ class IsolatedLink extends IsolatedNode {
114
+ #target
115
+ isStoreLink = false
116
+
117
+ constructor (options) {
118
+ super(options)
119
+ this.#target = options.target
120
+ if (options.isStoreLink) {
121
+ this.isStoreLink = true
122
+ }
123
+ }
124
+
125
+ get isLink () {
126
+ return true
127
+ }
128
+
129
+ set target (t) {
130
+ this.#target = t
131
+ }
132
+
133
+ get target () {
134
+ return this.#target
135
+ }
136
+ }
137
+
138
+ module.exports = { IsolatedNode, IsolatedLink }
package/lib/node.js CHANGED
@@ -74,33 +74,32 @@ class Node {
74
74
  constructor (options) {
75
75
  // NB: path can be null if it's a link target
76
76
  const {
77
- root,
78
- path,
79
- realpath,
80
- parent,
81
- error,
82
- meta,
83
- fsParent,
84
- resolved,
85
- integrity,
86
- // allow setting name explicitly when we haven't set a path yet
87
- name,
88
77
  children,
78
+ dev = true,
79
+ devOptional = true,
80
+ dummy = false,
81
+ error,
82
+ extraneous = true,
89
83
  fsChildren,
84
+ fsParent,
85
+ global = false,
86
+ hasShrinkwrap,
90
87
  installLinks = false,
88
+ integrity,
89
+ isInStore = false,
91
90
  legacyPeerDeps = false,
92
91
  linksIn,
93
- isInStore = false,
94
- hasShrinkwrap,
95
- overrides,
96
92
  loadOverrides = false,
97
- extraneous = true,
98
- dev = true,
93
+ meta,
94
+ name, // allow setting name explicitly when we haven't set a path yet
99
95
  optional = true,
100
- devOptional = true,
96
+ overrides,
97
+ parent,
98
+ path,
101
99
  peer = true,
102
- global = false,
103
- dummy = false,
100
+ realpath,
101
+ resolved,
102
+ root,
104
103
  sourceReference = null,
105
104
  } = options
106
105
  // this object gives querySelectorAll somewhere to stash context about a node
@@ -464,6 +463,27 @@ class Node {
464
463
  return false
465
464
  }
466
465
 
466
+ shouldOmit (omitSet) {
467
+ if (!omitSet.size) {
468
+ return false
469
+ }
470
+
471
+ const { top } = this
472
+
473
+ // if the top is not the root or workspace then we do not want to omit it
474
+ if (!top.isProjectRoot && !top.isWorkspace) {
475
+ return false
476
+ }
477
+
478
+ // omit node if the dep type matches any omit flags that were set
479
+ return (
480
+ this.peer && omitSet.has('peer') ||
481
+ this.dev && omitSet.has('dev') ||
482
+ this.optional && omitSet.has('optional') ||
483
+ this.devOptional && omitSet.has('optional') && omitSet.has('dev')
484
+ )
485
+ }
486
+
467
487
  getBundler (path = []) {
468
488
  // made a cycle, definitely not bundled!
469
489
  if (path.includes(this)) {
@@ -785,9 +785,14 @@ const hasParent = (node, compareNodes) => {
785
785
  compareNode = compareNode.target
786
786
  }
787
787
 
788
- // follows logical parent for link anscestors
788
+ // Follows logical parent for link ancestors (e.g. workspaces whose target lives outside node_modules).
789
+ // Only match if the node has a link whose parent is the compareNode. Without this check, nodes deep in the store (linked strategy) would incorrectly match as children of root via their fsParent chain.
789
790
  if (node.isTop && (node.resolveParent === compareNode)) {
790
- return true
791
+ for (const link of node.linksIn) {
792
+ if (link.parent === compareNode) {
793
+ return true
794
+ }
795
+ }
791
796
  }
792
797
  // follows edges-in to check if they match a possible parent
793
798
  for (const edge of node.edgesIn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "8.0.1",
3
+ "version": "8.0.3",
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
  }