@npmcli/arborist 9.3.1 → 9.4.1

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,71 +1,103 @@
1
- const _makeIdealGraph = Symbol('makeIdealGraph')
2
- const _createIsolatedTree = Symbol.for('createIsolatedTree')
3
1
  const { mkdirSync } = require('node:fs')
4
2
  const pacote = require('pacote')
5
3
  const { join } = require('node:path')
6
4
  const { depth } = require('treeverse')
7
5
  const crypto = require('node:crypto')
6
+ const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
8
7
 
9
- // cache complicated function results
10
- const memoize = (fn) => {
11
- const memo = new Map()
12
- return async function (arg) {
13
- const key = arg
14
- if (memo.has(key)) {
15
- return memo.get(key)
16
- }
17
- const result = {}
18
- memo.set(key, result)
19
- await fn(result, arg)
20
- return result
21
- }
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}`
22
33
  }
23
34
 
24
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
+
25
60
  /**
26
61
  * Create an ideal graph.
27
62
  *
28
63
  * An implementation of npm RFC-0042
29
64
  * https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md
30
65
  *
31
- * This entire file should be considered technical debt that will be resolved
32
- * with an Arborist refactor or rewrite. Embedded logic in Nodes and Links,
33
- * and the incremental state of building trees and reifying contains too many
34
- * 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.
35
68
  *
36
- * Instead, this approach takes a tree built from build-ideal-tree, and
37
- * returns a new tree-like structure without the embedded logic of Node and
38
- * 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.
39
70
  *
40
- * Since the RFC requires leaving the package-lock in place, this approach
41
- * 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.
42
72
  *
43
73
  **/
44
- async [_makeIdealGraph] (options) {
45
- /* Make sure that the ideal tree is build as the rest of
46
- * the algorithm depends on it.
47
- */
48
- const bitOpt = {
49
- ...options,
50
- complete: false,
51
- }
52
- await this.buildIdealTree(bitOpt)
74
+ async makeIdealGraph () {
53
75
  const idealTree = this.idealTree
76
+ this.#omit = new Set(this.options.omit)
77
+ const omit = this.#omit
54
78
 
55
- this.rootNode = {}
56
- const root = this.rootNode
57
- 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
+ )))
58
89
 
59
- // memoize to cache generating proxy Nodes
60
- this.externalProxyMemo = memoize(this.externalProxy.bind(this))
61
- 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
62
99
 
63
- root.external = []
64
- root.isProjectRoot = true
65
- root.localLocation = idealTree.location
66
- root.localPath = idealTree.path
67
- root.workspaces = await Promise.all(
68
- Array.from(idealTree.fsChildren.values(), this.workspaceProxyMemo))
100
+ this.idealGraph.workspaces = await Promise.all(Array.from(idealTree.fsChildren.values(), w => this.#workspaceProxy(w)))
69
101
  const processed = new Set()
70
102
  const queue = [idealTree, ...idealTree.fsChildren]
71
103
  while (queue.length !== 0) {
@@ -74,42 +106,53 @@ module.exports = cls => class IsolatedReifier extends cls {
74
106
  continue
75
107
  }
76
108
  processed.add(next.location)
77
- next.edgesOut.forEach(e => {
78
- if (!e.to || (next.package.bundleDependencies || next.package.bundledDependencies || []).includes(e.to.name)) {
79
- 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)
80
112
  }
81
- queue.push(e.to)
82
113
  })
83
- if (!next.isProjectRoot && !next.isWorkspace && !next.inert) {
84
- 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))
85
118
  }
86
119
  }
87
120
 
88
- await this.assignCommonProperties(idealTree, root)
89
-
90
- this.idealGraph = root
121
+ await this.#assignCommonProperties(idealTree, this.idealGraph)
91
122
  }
92
123
 
93
- 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)
94
131
  result.localLocation = node.location
95
132
  result.localPath = node.path
96
133
  result.isWorkspace = true
97
134
  result.resolved = node.resolved
98
- await this.assignCommonProperties(node, result)
135
+ await this.#assignCommonProperties(node, result)
136
+ return result
99
137
  }
100
138
 
101
- async externalProxy (result, node) {
102
- 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)
103
147
  if (node.hasShrinkwrap) {
104
148
  const dir = join(
105
149
  node.root.path,
106
150
  'node_modules',
107
151
  '.store',
108
- `${node.name}@${node.version}`
152
+ `${node.packageName}@${node.version}`
109
153
  )
110
154
  mkdirSync(dir, { recursive: true })
111
- // TODO this approach feels wrong
112
- // and shouldn't be necessary for shrinkwraps
155
+ // TODO this approach feels wrong and shouldn't be necessary for shrinkwraps
113
156
  await pacote.extract(node.resolved, dir, {
114
157
  ...this.options,
115
158
  resolved: node.resolved,
@@ -117,48 +160,100 @@ module.exports = cls => class IsolatedReifier extends cls {
117
160
  })
118
161
  const Arborist = this.constructor
119
162
  const arb = new Arborist({ ...this.options, path: dir })
120
- await arb[_makeIdealGraph]({ dev: false })
121
- this.rootNode.external.push(...arb.idealGraph.external)
122
- arb.idealGraph.external.forEach(e => {
123
- e.root = this.rootNode
124
- 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,
125
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
+ }
126
174
  result.localDependencies = []
127
175
  result.externalDependencies = arb.idealGraph.externalDependencies
128
176
  result.externalOptionalDependencies = arb.idealGraph.externalOptionalDependencies
129
177
  result.dependencies = [
130
178
  ...result.externalDependencies,
131
- ...result.localDependencies,
132
179
  ...result.externalOptionalDependencies,
133
180
  ]
134
181
  }
135
182
  result.optional = node.optional
136
183
  result.resolved = node.resolved
137
184
  result.version = node.version
185
+ return result
138
186
  }
139
187
 
140
- async assignCommonProperties (node, result) {
141
- function validEdgesOut (node) {
142
- return [...node.edgesOut.values()].filter(e => e.to && e.to.target && !(node.package.bundledDependencies || 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
+ result.packageName = node.packageName || node.name
195
+ result.package = { ...node.package }
196
+ result.package.bundleDependencies = undefined
197
+
198
+ if (!populateDeps) {
199
+ return
200
+ }
201
+
202
+ let edges = [...node.edgesOut.values()].filter(edge =>
203
+ edge.to?.target &&
204
+ !(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
205
+ )
206
+
207
+ // Only omit edge types for root and workspace nodes (matching shouldOmit scope)
208
+ if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
209
+ edges = edges.filter(edge => {
210
+ if (edge.dev && this.#omit.has('dev')) {
211
+ return false
212
+ }
213
+ if (edge.optional && this.#omit.has('optional')) {
214
+ return false
215
+ }
216
+ if (edge.peer && this.#omit.has('peer')) {
217
+ return false
218
+ }
219
+ return true
220
+ })
221
+ }
222
+
223
+ let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)
224
+
225
+ // npm auto-creates 'workspace' edges from root to all workspaces.
226
+ // For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
227
+ if (node.isProjectRoot) {
228
+ nonOptionalDeps = nonOptionalDeps.filter(n => !n.isWorkspace || this.#rootDeclaredDeps.has(n.packageName))
229
+ }
230
+
231
+ // When legacyPeerDeps is enabled, peer dep edges are not created on the node.
232
+ // Resolve them from the tree so they get symlinked in the store.
233
+ const peerDeps = node.package.peerDependencies
234
+ if (peerDeps && node.legacyPeerDeps) {
235
+ const edgeNames = new Set(edges.map(edge => edge.name))
236
+ for (const peerName in peerDeps) {
237
+ if (!edgeNames.has(peerName)) {
238
+ const resolved = node.resolve(peerName)
239
+ if (resolved && resolved !== node && !resolved.inert) {
240
+ nonOptionalDeps.push(resolved.target)
241
+ }
242
+ }
243
+ }
143
244
  }
144
- const edges = validEdgesOut(node)
145
- const optionalDeps = edges.filter(e => e.optional).map(e => e.to.target)
146
- const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)
147
245
 
148
- result.localDependencies = await Promise.all(nonOptionalDeps.filter(n => n.isWorkspace).map(this.workspaceProxyMemo))
149
- result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace && !n.inert).map(this.externalProxyMemo))
150
- result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(this.externalProxyMemo))
246
+ // 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.
247
+ const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n)
248
+ const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target)
249
+ result.localDependencies = await Promise.all(nonOptionalDeps.filter(isLocal).map(n => this.#workspaceProxy(n)))
250
+ result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !isLocal(n) && !n.inert).map(n => this.#externalProxy(n)))
251
+ result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(n => this.#externalProxy(n)))
151
252
  result.dependencies = [
152
253
  ...result.externalDependencies,
153
254
  ...result.localDependencies,
154
255
  ...result.externalOptionalDependencies,
155
256
  ]
156
- result.root = this.rootNode
157
- result.id = this.counter++
158
- result.name = node.name
159
- result.package = { ...node.package }
160
- result.package.bundleDependencies = undefined
161
- result.hasInstallScript = node.hasInstallScript
162
257
  }
163
258
 
164
259
  async #createBundledTree () {
@@ -200,253 +295,178 @@ module.exports = cls => class IsolatedReifier extends cls {
200
295
  nodes.set(to.location, { location: to.location, resolved: to.resolved, name: to.name, optional: to.optional, pkg: { ...to.package, bundleDependencies: undefined } })
201
296
  edges.push({ from: from.isRoot ? 'root' : from.location, to: to.location })
202
297
 
203
- to.edgesOut.forEach(e => {
298
+ to.edgesOut.forEach(edge => {
204
299
  // an edge out should always have a to
205
300
  /* istanbul ignore else */
206
- if (e.to) {
207
- queue.push({ from: e.from, to: e.to })
301
+ if (edge.to) {
302
+ queue.push({ from: edge.from, to: edge.to })
208
303
  }
209
304
  })
210
305
  }
211
306
  return { edges, nodes }
212
307
  }
213
308
 
214
- async [_createIsolatedTree] () {
215
- await this[_makeIdealGraph](this.options)
216
-
217
- const proxiedIdealTree = this.idealGraph
218
-
309
+ async createIsolatedTree () {
310
+ await this.makeIdealGraph()
219
311
  const bundledTree = await this.#createBundledTree()
220
312
 
221
- const treeHash = (startNode) => {
222
- // generate short hash based on the dependency tree
223
- // starting at this node
224
- const deps = []
225
- const branch = []
226
- depth({
227
- tree: startNode,
228
- getChildren: node => node.dependencies,
229
- filter: node => node,
230
- visit: node => {
231
- branch.push(`${node.name}@${node.version}`)
232
- deps.push(`${branch.join('->')}::${node.resolved}`)
233
- },
234
- leave: () => {
235
- branch.pop()
236
- },
237
- })
238
- deps.sort()
239
- return crypto.createHash('shake256', { outputLength: 16 })
240
- .update(deps.join(','))
241
- .digest('base64')
242
- // Node v14 doesn't support base64url
243
- .replace(/\+/g, '-')
244
- .replace(/\//g, '_')
245
- .replace(/=+$/m, '')
246
- }
247
-
248
- const getKey = (idealTreeNode) => {
249
- return `${idealTreeNode.name}@${idealTreeNode.version}-${treeHash(idealTreeNode)}`
250
- }
251
-
252
- const root = {
253
- fsChildren: [],
254
- integrity: null,
255
- inventory: new Map(),
256
- isLink: false,
257
- isRoot: true,
258
- binPaths: [],
259
- edgesIn: new Set(),
260
- edgesOut: new Map(),
261
- hasShrinkwrap: false,
262
- parent: null,
263
- // TODO: we should probably not reference this.idealTree
264
- resolved: this.idealTree.resolved,
265
- isTop: true,
266
- path: proxiedIdealTree.root.localPath,
267
- realpath: proxiedIdealTree.root.localPath,
268
- package: proxiedIdealTree.root.package,
269
- meta: { loadedFromDisk: false },
270
- global: false,
271
- isProjectRoot: true,
272
- children: [],
273
- }
274
- // root.inventory.set('', t)
275
- // root.meta = this.idealTree.meta
276
- // TODO We should mock better the inventory object because it is used by audit-report.js ... maybe
277
- root.inventory.query = () => {
278
- return []
279
- }
313
+ const root = new IsolatedNode(this.idealGraph)
314
+ root.root = root
315
+ root.inventory.set('', root)
280
316
  const processed = new Set()
281
- proxiedIdealTree.workspaces.forEach(c => {
282
- const workspace = {
283
- edgesIn: new Set(),
284
- edgesOut: new Map(),
285
- children: [],
286
- hasInstallScript: c.hasInstallScript,
287
- binPaths: [],
288
- package: c.package,
317
+ for (const c of this.idealGraph.workspaces) {
318
+ const wsName = c.packageName
319
+ // XXX parent? root?
320
+ const workspace = new IsolatedNode({
289
321
  location: c.localLocation,
322
+ name: wsName,
323
+ package: c.package,
290
324
  path: c.localPath,
291
- realpath: c.localPath,
292
325
  resolved: c.resolved,
293
- }
294
- root.fsChildren.push(workspace)
326
+ })
327
+ root.fsChildren.add(workspace)
295
328
  root.inventory.set(workspace.location, workspace)
296
- })
297
- const generateChild = (node, location, pkg, inStore) => {
298
- const newChild = {
299
- global: false,
300
- globalTop: false,
301
- isProjectRoot: false,
302
- isTop: false,
303
- location,
304
- name: node.name,
305
- optional: node.optional,
306
- top: { path: proxiedIdealTree.root.localPath },
307
- children: [],
308
- edgesIn: new Set(),
309
- edgesOut: new Map(),
310
- binPaths: [],
311
- fsChildren: [],
312
- /* istanbul ignore next -- emulate Node */
313
- getBundler () {
314
- return null
315
- },
316
- hasShrinkwrap: false,
317
- inDepBundle: false,
318
- integrity: null,
319
- isLink: false,
320
- isRoot: false,
321
- isInStore: inStore,
322
- path: join(proxiedIdealTree.root.localPath, location),
323
- realpath: join(proxiedIdealTree.root.localPath, location),
324
- resolved: node.resolved,
325
- version: pkg.version,
326
- package: pkg,
329
+ root.workspaces.set(wsName, workspace.path)
330
+
331
+ // 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).
332
+ const isDeclared = this.#rootDeclaredDeps.has(wsName)
333
+ const wsLink = new IsolatedLink({
334
+ location: isDeclared ? join('node_modules', wsName) : join(c.localLocation, 'node_modules', wsName),
335
+ name: wsName,
336
+ package: workspace.package,
337
+ parent: root,
338
+ path: isDeclared ? join(root.path, 'node_modules', wsName) : join(root.path, c.localLocation, 'node_modules', wsName),
339
+ realpath: workspace.path,
340
+ root,
341
+ target: workspace,
342
+ })
343
+ if (!isDeclared) {
344
+ workspace.children.set(wsName, wsLink)
327
345
  }
328
- newChild.target = newChild
329
- root.children.push(newChild)
330
- root.inventory.set(newChild.location, newChild)
346
+ root.children.set(wsName, wsLink)
347
+ root.inventory.set(wsLink.location, wsLink)
348
+ workspace.linksIn.add(wsLink)
331
349
  }
332
- proxiedIdealTree.external.forEach(c => {
350
+
351
+ this.idealGraph.external.forEach(c => {
333
352
  const key = getKey(c)
334
353
  if (processed.has(key)) {
335
354
  return
336
355
  }
337
356
  processed.add(key)
338
- const location = join('node_modules', '.store', key, 'node_modules', c.name)
339
- generateChild(c, location, c.package, true)
357
+ const location = join('node_modules', '.store', key, 'node_modules', c.packageName)
358
+ this.#generateChild(c, location, c.package, true, root)
340
359
  })
360
+
341
361
  bundledTree.nodes.forEach(node => {
342
- generateChild(node, node.location, node.pkg, false)
362
+ this.#generateChild(node, node.location, node.pkg, false, root)
343
363
  })
344
- bundledTree.edges.forEach(e => {
345
- const from = e.from === 'root' ? root : root.inventory.get(e.from)
346
- const to = root.inventory.get(e.to)
364
+
365
+ bundledTree.edges.forEach(edge => {
366
+ const from = edge.from === 'root' ? root : root.inventory.get(edge.from)
367
+ const to = root.inventory.get(edge.to)
347
368
  // Maybe optional should be propagated from the original edge
348
- const edge = { optional: false, from, to }
349
- from.edgesOut.set(to.name, edge)
350
- to.edgesIn.add(edge)
369
+ const newEdge = { optional: false, from, to }
370
+ from.edgesOut.set(to.name, newEdge)
371
+ to.edgesIn.add(newEdge)
351
372
  })
352
- const memo = new Set()
353
373
 
354
- function processEdges (node, externalEdge) {
355
- externalEdge = !!externalEdge
356
- const key = getKey(node)
357
- if (memo.has(key)) {
358
- return
359
- }
360
- memo.add(key)
361
-
362
- let from, nmFolder
363
- if (externalEdge) {
364
- const fromLocation = join('node_modules', '.store', key, 'node_modules', node.name)
365
- from = root.children.find(c => c.location === fromLocation)
366
- nmFolder = join('node_modules', '.store', key, 'node_modules')
367
- } else {
368
- from = node.isProjectRoot ? root : root.fsChildren.find(c => c.location === node.localLocation)
369
- nmFolder = join(node.localLocation, 'node_modules')
370
- }
374
+ this.#processEdges(this.idealGraph, false, root)
375
+ for (const node of this.idealGraph.workspaces) {
376
+ this.#processEdges(node, false, root)
377
+ }
378
+ return root
379
+ }
371
380
 
372
- const processDeps = (dep, optional, external) => {
373
- optional = !!optional
374
- external = !!external
381
+ #processEdges (node, externalEdge, root) {
382
+ const key = getKey(node)
383
+ if (this.#processedEdges.has(key)) {
384
+ return
385
+ }
386
+ this.#processedEdges.add(key)
375
387
 
376
- const location = join(nmFolder, dep.name)
377
- const binNames = dep.package.bin && Object.keys(dep.package.bin) || []
378
- const toKey = getKey(dep)
388
+ let from, nmFolder
389
+ if (externalEdge) {
390
+ const fromLocation = join('node_modules', '.store', key, 'node_modules', node.packageName)
391
+ from = root.children.get(fromLocation)
392
+ nmFolder = join('node_modules', '.store', key, 'node_modules')
393
+ } else {
394
+ from = node.isProjectRoot ? root : root.inventory.get(node.localLocation)
395
+ nmFolder = join(node.localLocation, 'node_modules')
396
+ }
397
+ /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
398
+ if (!from) {
399
+ return
400
+ }
379
401
 
380
- let target
381
- if (external) {
382
- const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.name)
383
- target = root.children.find(c => c.location === toLocation)
384
- } else {
385
- target = root.fsChildren.find(c => c.location === dep.localLocation)
386
- }
387
- // TODO: we should no-op is an edge has already been created with the same fromKey and toKey
388
-
389
- binNames.forEach(bn => {
390
- target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))
391
- })
392
-
393
- const link = {
394
- global: false,
395
- globalTop: false,
396
- isProjectRoot: false,
397
- edgesIn: new Set(),
398
- edgesOut: new Map(),
399
- binPaths: [],
400
- isTop: false,
401
- optional,
402
- location: location,
403
- path: join(dep.root.localPath, nmFolder, dep.name),
404
- realpath: target.path,
405
- name: toKey,
406
- resolved: dep.resolved,
407
- top: { path: dep.root.localPath },
408
- children: [],
409
- fsChildren: [],
410
- isLink: true,
411
- isStoreLink: true,
412
- isRoot: false,
413
- package: { _id: 'abc', bundleDependencies: undefined, deprecated: undefined, bin: target.package.bin, scripts: dep.package.scripts },
414
- target,
415
- }
416
- const newEdge1 = { optional, from, to: link }
417
- from.edgesOut.set(dep.name, newEdge1)
418
- link.edgesIn.add(newEdge1)
419
- const newEdge2 = { optional: false, from: link, to: target }
420
- link.edgesOut.set(dep.name, newEdge2)
421
- target.edgesIn.add(newEdge2)
422
- root.children.push(link)
423
- }
402
+ for (const dep of node.localDependencies) {
403
+ this.#processEdges(dep, false, root)
404
+ // nonOptional, local
405
+ this.#processDeps(dep, false, false, root, from, nmFolder)
406
+ }
407
+ for (const dep of node.externalDependencies) {
408
+ this.#processEdges(dep, true, root)
409
+ // nonOptional, external
410
+ this.#processDeps(dep, false, true, root, from, nmFolder)
411
+ }
412
+ for (const dep of node.externalOptionalDependencies) {
413
+ this.#processEdges(dep, true, root)
414
+ // optional, external
415
+ this.#processDeps(dep, true, true, root, from, nmFolder)
416
+ }
417
+ }
424
418
 
425
- for (const dep of node.localDependencies) {
426
- processEdges(dep, false)
427
- // nonOptional, local
428
- processDeps(dep, false, false)
429
- }
430
- for (const dep of node.externalDependencies) {
431
- processEdges(dep, true)
432
- // nonOptional, external
433
- processDeps(dep, false, true)
434
- }
435
- for (const dep of node.externalOptionalDependencies) {
436
- processEdges(dep, true)
437
- // optional, external
438
- processDeps(dep, true, true)
419
+ #processDeps (dep, optional, external, root, from, nmFolder) {
420
+ const toKey = getKey(dep)
421
+
422
+ let target
423
+ if (external) {
424
+ const toLocation = join('node_modules', '.store', toKey, 'node_modules', dep.packageName)
425
+ target = root.children.get(toLocation)
426
+ } else {
427
+ target = root.inventory.get(dep.localLocation)
428
+ }
429
+ // TODO: we should no-op is an edge has already been created with the same fromKey and toKey
430
+ /* istanbul ignore next - strict-peer-deps can exclude nodes from the tree */
431
+ if (!target) {
432
+ return
433
+ }
434
+
435
+ if (dep.package.bin) {
436
+ for (const bn in dep.package.bin) {
437
+ target.binPaths.push(join(dep.root.localPath, nmFolder, '.bin', bn))
439
438
  }
440
439
  }
441
440
 
442
- processEdges(proxiedIdealTree, false)
443
- for (const node of proxiedIdealTree.workspaces) {
444
- processEdges(node, false)
441
+ const pkg = {
442
+ _id: dep.package._id,
443
+ bin: target.package.bin,
444
+ bundleDependencies: undefined,
445
+ deprecated: undefined,
446
+ scripts: dep.package.scripts,
447
+ version: dep.package.version,
445
448
  }
446
- root.children.forEach(c => c.parent = root)
447
- root.children.forEach(c => c.root = root)
448
- root.root = root
449
- root.target = root
450
- return root
449
+ const link = new IsolatedLink({
450
+ isStoreLink: true,
451
+ location: join(nmFolder, dep.name),
452
+ name: toKey,
453
+ optional,
454
+ parent: root,
455
+ package: pkg,
456
+ path: join(dep.root.localPath, nmFolder, dep.name),
457
+ realpath: target.path,
458
+ resolved: external ? `file:.store/${toKey}/node_modules/${dep.packageName}` : dep.resolved,
459
+ root,
460
+ target,
461
+ })
462
+ // XXX top is from place-dep not lib/link.js
463
+ link.top = { path: dep.root.localPath }
464
+ const newEdge1 = { optional, from, to: link }
465
+ from.edgesOut.set(dep.name, newEdge1)
466
+ link.edgesIn.add(newEdge1)
467
+ const newEdge2 = { optional: false, from: link, to: target }
468
+ link.edgesOut.set(dep.name, newEdge2)
469
+ target.edgesIn.add(newEdge2)
470
+ root.children.set(link.location, link)
451
471
  }
452
472
  }
@@ -9,6 +9,7 @@ const runScript = require('@npmcli/run-script')
9
9
  const { callLimit: promiseCallLimit } = require('promise-call-limit')
10
10
  const { depth: dfwalk } = require('treeverse')
11
11
  const { isNodeGypPackage, defaultGypInstallScript } = require('@npmcli/node-gyp')
12
+ const { promiseRetry } = require('@gar/promise-retry')
12
13
  const { log, time } = require('proc-log')
13
14
  const { resolve } = require('node:path')
14
15
 
@@ -295,12 +296,12 @@ module.exports = cls => class Builder extends cls {
295
296
  devOptional,
296
297
  package: pkg,
297
298
  location,
298
- isStoreLink,
299
299
  } = node.target
300
300
 
301
301
  // skip any that we know we'll be deleting
302
- // or storeLinks
303
- if (this[_trashList].has(path) || isStoreLink) {
302
+ // or links to store entries (their scripts run on the store
303
+ // entry itself, not through the link)
304
+ if (this[_trashList].has(path) || (node.isLink && node.target?.isInStore)) {
304
305
  return
305
306
  }
306
307
 
@@ -381,13 +382,20 @@ module.exports = cls => class Builder extends cls {
381
382
 
382
383
  const timeEnd = time.start(`build:link:${node.location}`)
383
384
 
384
- const p = binLinks({
385
+ // 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.
386
+ const p = promiseRetry((retry) => binLinks({
385
387
  pkg: node.package,
386
388
  path: node.path,
387
389
  top: !!(node.isTop || node.globalTop),
388
390
  force: this.options.force,
389
391
  global: !!node.globalTop,
390
- })
392
+ }).catch(/* istanbul ignore next - Windows-only transient antivirus locks */ err => {
393
+ if (process.platform === 'win32' &&
394
+ (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) {
395
+ return retry(err)
396
+ }
397
+ throw err
398
+ }), { retries: 5, minTimeout: 500 })
391
399
 
392
400
  await (this.#doHandleOptionalFailure
393
401
  ? this[_handleOptionalFailure](node, p)
@@ -8,9 +8,10 @@ const promiseAllRejectLate = require('promise-all-reject-late')
8
8
  const runScript = require('@npmcli/run-script')
9
9
  const { callLimit: promiseCallLimit } = require('promise-call-limit')
10
10
  const { depth: dfwalk } = require('treeverse')
11
- const { dirname, resolve, relative, join } = require('node:path')
11
+ const { dirname, resolve, relative, join, sep } = require('node:path')
12
12
  const { log, time } = require('proc-log')
13
- const { lstat, mkdir, rm, symlink } = require('node:fs/promises')
13
+ const { existsSync } = require('node:fs')
14
+ const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises')
14
15
  const { moveFile } = require('@npmcli/fs')
15
16
  const { subset, intersects } = require('semver')
16
17
  const { walkUp } = require('walk-up-path')
@@ -26,6 +27,7 @@ const retirePath = require('../retire-path.js')
26
27
  const treeCheck = require('../tree-check.js')
27
28
  const { defaultLockfileVersion } = require('../shrinkwrap.js')
28
29
  const { saveTypeMap, hasSubKey } = require('../add-rm-pkg-deps.js')
30
+ const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
29
31
 
30
32
  // Part of steps (steps need refactoring before we can do anything about these)
31
33
  const _retireShallowNodes = Symbol.for('retireShallowNodes')
@@ -63,8 +65,6 @@ const _resolvedAdd = Symbol.for('resolvedAdd')
63
65
  // used by build-ideal-tree mixin
64
66
  const _addNodeToTrashList = Symbol.for('addNodeToTrashList')
65
67
 
66
- const _createIsolatedTree = Symbol.for('createIsolatedTree')
67
-
68
68
  module.exports = cls => class Reifier extends cls {
69
69
  #bundleMissing = new Set() // child nodes we'd EXPECT to be included in a bundle, but aren't
70
70
  #bundleUnpacked = new Set() // the nodes we unpack to read their bundles
@@ -75,6 +75,7 @@ module.exports = cls => class Reifier extends cls {
75
75
  #shrinkwrapInflated = new Set()
76
76
  #sparseTreeDirs = new Set()
77
77
  #sparseTreeRoots = new Set()
78
+ #linkedActualForDiff = null
78
79
 
79
80
  constructor (options) {
80
81
  super(options)
@@ -115,16 +116,21 @@ module.exports = cls => class Reifier extends cls {
115
116
  // this is currently technical debt which will be resolved in a refactor
116
117
  // of Node/Link trees
117
118
  log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
118
- this.idealTree = await this[_createIsolatedTree]()
119
+ this.idealTree = await this.createIsolatedTree()
120
+ this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
121
+ this.idealTree, this.actualTree
122
+ )
119
123
  }
120
124
  await this[_diffTrees]()
121
125
  await this.#reifyPackages()
122
126
  if (linked) {
127
+ await this.#cleanOrphanedStoreEntries()
123
128
  // swap back in the idealTree
124
129
  // so that the lockfile is preserved
125
130
  this.idealTree = oldTree
126
131
  }
127
132
  await this[_saveIdealTree](options)
133
+ this.#linkedActualForDiff = null
128
134
  // clean inert
129
135
  for (const node of this.idealTree.inventory.values()) {
130
136
  if (node.inert) {
@@ -146,7 +152,7 @@ module.exports = cls => class Reifier extends cls {
146
152
  // was not changed, delete anything in the ideal and not actual.
147
153
  // Then we move the entire idealTree over to this.actualTree, and
148
154
  // save the hidden lockfile.
149
- if (this.diff && this.diff.filterSet.size) {
155
+ if (this.diff && this.diff.filterSet.size && !linked) {
150
156
  const reroot = new Set()
151
157
 
152
158
  const { filterSet } = this.diff
@@ -426,9 +432,14 @@ module.exports = cls => class Reifier extends cls {
426
432
  if (ideal) {
427
433
  filterNodes.push(ideal)
428
434
  }
429
- const actual = this.actualTree.children.get(ws)
430
- if (actual) {
431
- filterNodes.push(actual)
435
+ // Skip actual-side filterNodes when using the linked diff wrapper.
436
+ // Those nodes have root===actualTree, not root===linkedActualForDiff, and Diff.calculate requires filterNode.root to match actual.
437
+ // The ideal filterNode alone is sufficient to scope the workspace diff.
438
+ if (!this.#linkedActualForDiff) {
439
+ const actual = this.actualTree.children.get(ws)
440
+ if (actual) {
441
+ filterNodes.push(actual)
442
+ }
432
443
  }
433
444
  }
434
445
  }
@@ -450,7 +461,7 @@ module.exports = cls => class Reifier extends cls {
450
461
  omit: this.#omit,
451
462
  shrinkwrapInflated: this.#shrinkwrapInflated,
452
463
  filterNodes,
453
- actual: this.actualTree,
464
+ actual: this.#linkedActualForDiff || this.actualTree,
454
465
  ideal: this.idealTree,
455
466
  })
456
467
 
@@ -573,6 +584,7 @@ module.exports = cls => class Reifier extends cls {
573
584
  // if the directory already exists, made will be undefined. if that's the case
574
585
  // we don't want to remove it because we aren't the ones who created it so we
575
586
  // omit it from the #sparseTreeRoots
587
+ /* istanbul ignore next -- pre-existing: mkdir returns undefined when dir exists, covered in reify tests but lost in aggregate coverage merge */
576
588
  if (made) {
577
589
  this.#sparseTreeRoots.add(made)
578
590
  }
@@ -789,6 +801,59 @@ module.exports = cls => class Reifier extends cls {
789
801
  return join(filePath)
790
802
  }
791
803
 
804
+ // Build a flat actual tree wrapper for linked installs so the diff can correctly match store entries that already exist on disk.
805
+ // The proxy tree from createIsolatedTree() is flat (all children on root), but loadActual() produces a nested tree where store entries are deep link targets.
806
+ // This wrapper surfaces them at the root level for comparison.
807
+ #buildLinkedActualForDiff (idealTree, actualTree) {
808
+ // Combined Map keyed by path (how allChildren() in diff.js keys)
809
+ const combined = new Map()
810
+
811
+ // Create synthetic actual entries for ALL ideal children that exist on disk.
812
+ // 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.
813
+ // 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).
814
+ for (const child of idealTree.children.values()) {
815
+ if (combined.has(child.path) || !existsSync(child.path)) {
816
+ continue
817
+ }
818
+ let entry
819
+ if (child.isLink) {
820
+ entry = new IsolatedLink(child)
821
+ } else {
822
+ entry = new IsolatedNode(child)
823
+ }
824
+ if (child.isLink && combined.has(child.realpath)) {
825
+ entry.target = combined.get(child.realpath)
826
+ }
827
+ combined.set(child.path, entry)
828
+ }
829
+
830
+ // Proxy .get(name) to original actual tree for filterNodes compatibility
831
+ // (scoped workspace installs use .get(name), allChildren uses .values())
832
+ const origGet = actualTree.children.get.bind(actualTree.children)
833
+ const combinedGet = combined.get.bind(combined)
834
+ /* istanbul ignore next -- only reached during scoped workspace installs */
835
+ combined.get = (key) => combinedGet(key) || origGet(key)
836
+
837
+ let wrapper
838
+ /* istanbul ignore next - untested! */
839
+ if (actualTree.isLink) {
840
+ wrapper = new IsolatedLink(actualTree)
841
+ } else {
842
+ wrapper = new IsolatedNode(actualTree)
843
+ }
844
+ wrapper.root = wrapper
845
+ wrapper.binPaths = actualTree.binPaths
846
+ wrapper.children = combined
847
+ wrapper.edgesOut = actualTree.edgesOut
848
+ // Use empty fsChildren so that allChildren() only picks up entries from the combined map.
849
+ // 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().
850
+ wrapper.fsChildren = new Set()
851
+ wrapper.integrity = actualTree.integrity
852
+ wrapper.inventory = actualTree.inventory
853
+
854
+ return wrapper
855
+ }
856
+
792
857
  #registryResolved (resolved) {
793
858
  // the default registry url is a magic value meaning "the currently
794
859
  // configured registry".
@@ -1247,6 +1312,41 @@ module.exports = cls => class Reifier extends cls {
1247
1312
  timeEnd()
1248
1313
  }
1249
1314
 
1315
+ // After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
1316
+ // Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
1317
+ async #cleanOrphanedStoreEntries () {
1318
+ const storeDir = resolve(this.path, 'node_modules', '.store')
1319
+ let entries
1320
+ try {
1321
+ entries = await readdir(storeDir)
1322
+ } catch {
1323
+ return
1324
+ }
1325
+
1326
+ // Collect valid store keys from the isolated ideal tree (location: node_modules/.store/{key}/node_modules/{pkg})
1327
+ const validKeys = new Set()
1328
+ for (const child of this.idealTree.children.values()) {
1329
+ if (child.isInStore) {
1330
+ const key = child.location.split(sep)[2]
1331
+ validKeys.add(key)
1332
+ }
1333
+ }
1334
+
1335
+ const orphaned = entries.filter(e => !validKeys.has(e))
1336
+ if (!orphaned.length) {
1337
+ return
1338
+ }
1339
+
1340
+ log.silly('reify', 'cleaning orphaned store entries', orphaned)
1341
+ await promiseAllRejectLate(
1342
+ orphaned.map(e =>
1343
+ rm(resolve(storeDir, e), { recursive: true, force: true })
1344
+ .catch(/* istanbul ignore next -- rm with force rarely fails */
1345
+ er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
1346
+ )
1347
+ )
1348
+ }
1349
+
1250
1350
  // last but not least, we save the ideal tree metadata to the package-lock
1251
1351
  // or shrinkwrap file, and any additions or removals to package.json
1252
1352
  async [_saveIdealTree] (options) {
@@ -296,6 +296,8 @@ class AuditReport extends Map {
296
296
  if (
297
297
  !node.version ||
298
298
  node.isRoot ||
299
+ node.isLink ||
300
+ node.linksIn?.size > 0 ||
299
301
  (this.filterSet && this.filterSet?.size !== 0 && !this.filterSet?.has(node))
300
302
  ) {
301
303
  return false
package/lib/diff.js CHANGED
@@ -71,6 +71,7 @@ class Diff {
71
71
  tree: filterNode,
72
72
  visit: node => filterSet.add(node),
73
73
  getChildren: node => {
74
+ const orig = node
74
75
  node = node.target
75
76
  const loc = node.location
76
77
  const idealNode = ideal.inventory.get(loc)
@@ -87,7 +88,12 @@ class Diff {
87
88
  }
88
89
  }
89
90
 
90
- return ideals.concat(actuals)
91
+ const result = ideals.concat(actuals)
92
+ // Include link targets so store entries end up in filterSet
93
+ if (orig.isLink) {
94
+ result.push(node)
95
+ }
96
+ return result
91
97
  },
92
98
  })
93
99
  }
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
@@ -73,35 +73,34 @@ class Node {
73
73
  constructor (options) {
74
74
  // NB: path can be null if it's a link target
75
75
  const {
76
- root,
77
- path,
78
- realpath,
79
- parent,
80
- error,
81
- meta,
82
- fsParent,
83
- resolved,
84
- integrity,
85
- // allow setting name explicitly when we haven't set a path yet
86
- name,
87
76
  children,
77
+ dev = true,
78
+ devOptional = true,
79
+ dummy = false,
80
+ error,
81
+ extraneous = true,
88
82
  fsChildren,
83
+ fsParent,
84
+ global = false,
85
+ hasShrinkwrap,
86
+ inert = false,
89
87
  installLinks = false,
88
+ integrity,
89
+ isInStore = false,
90
90
  legacyPeerDeps = false,
91
91
  linksIn,
92
- isInStore = false,
93
- hasShrinkwrap,
94
- overrides,
95
92
  loadOverrides = false,
96
- extraneous = true,
97
- dev = true,
93
+ meta,
94
+ name, // allow setting name explicitly when we haven't set a path yet
98
95
  optional = true,
99
- devOptional = true,
96
+ overrides,
97
+ parent,
98
+ path,
100
99
  peer = true,
101
- global = false,
102
- dummy = false,
100
+ realpath,
101
+ resolved,
102
+ root,
103
103
  sourceReference = null,
104
- inert = false,
105
104
  } = options
106
105
  // this object gives querySelectorAll somewhere to stash context about a node
107
106
  // while processing a query
@@ -427,11 +427,19 @@ class Results {
427
427
  if (!this.currentAstNode.typeValue) {
428
428
  return this.initialItems
429
429
  }
430
+ // TODO this differs subtly with `:type()` because it now iterates on edgesIn, which means extraneous deps won't show up
431
+ // note how "@npmcli/abbrev@2.0.0-beta.45" is in the `:type()` results in the test but not in any of the other results.
430
432
  return this.initialItems
431
433
  .flatMap(node => {
432
434
  const found = []
435
+ const { typeValue } = this.currentAstNode
433
436
  for (const edge of node.edgesIn) {
434
- if (npa(`${edge.name}@${edge.spec}`).type === this.currentAstNode.typeValue) {
437
+ const parsedArg = npa(`${edge.name}@${edge.spec}`)
438
+ if (typeValue === 'registry') {
439
+ if (parsedArg.registry) {
440
+ found.push(edge.to)
441
+ }
442
+ } else if (parsedArg.type === typeValue) {
435
443
  found.push(edge.to)
436
444
  }
437
445
  }
@@ -785,9 +793,14 @@ const hasParent = (node, compareNodes) => {
785
793
  compareNode = compareNode.target
786
794
  }
787
795
 
788
- // follows logical parent for link ancestors
796
+ // Follows logical parent for link ancestors (e.g. workspaces whose target lives outside node_modules).
797
+ // 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
798
  if (node.isTop && (node.resolveParent === compareNode)) {
790
- return true
799
+ for (const link of node.linksIn) {
800
+ if (link.parent === compareNode) {
801
+ return true
802
+ }
803
+ }
791
804
  }
792
805
  // follows edges-in to check if they match a possible parent
793
806
  for (const edge of node.edgesIn) {
package/lib/shrinkwrap.js CHANGED
@@ -95,6 +95,7 @@ const pkgMetaKeys = [
95
95
  'engines',
96
96
  'os',
97
97
  'cpu',
98
+ 'libc',
98
99
  '_integrity',
99
100
  'license',
100
101
  '_hasShrinkwrap',
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.3.1",
3
+ "version": "9.4.1",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
+ "@gar/promise-retry": "^1.0.0",
6
7
  "@isaacs/string-locale-compare": "^1.1.0",
7
8
  "@npmcli/fs": "^5.0.0",
8
9
  "@npmcli/installed-package-contents": "^4.0.0",