@npmcli/arborist 8.0.2 → 8.0.4

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.
@@ -3,6 +3,7 @@ const pacote = require('pacote')
3
3
  const { join } = require('node:path')
4
4
  const { depth } = require('treeverse')
5
5
  const crypto = require('node:crypto')
6
+ const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
6
7
 
7
8
  // generate short hash key based on the dependency tree starting at this node
8
9
  const getKey = (startNode) => {
@@ -33,43 +34,25 @@ const getKey = (startNode) => {
33
34
 
34
35
  module.exports = cls => class IsolatedReifier extends cls {
35
36
  #externalProxies = new Map()
37
+ #omit = new Set()
38
+ #rootDeclaredDeps = new Set()
36
39
  #processedEdges = new Set()
37
40
  #workspaceProxies = new Map()
38
41
 
39
- #generateChild (node, location, pkg, inStore, root) {
40
- const newChild = {
41
- binPaths: [],
42
- children: new Map(),
43
- edgesIn: new Set(),
44
- edgesOut: new Map(),
45
- fsChildren: new Set(),
46
- /* istanbul ignore next -- emulate Node */
47
- getBundler () {
48
- return null
49
- },
50
- global: false,
51
- globalTop: false,
52
- hasShrinkwrap: false,
53
- inDepBundle: false,
54
- integrity: null,
55
- isInStore: inStore,
56
- isLink: false,
57
- isProjectRoot: false,
58
- isRoot: false,
59
- isTop: false,
42
+ #generateChild (node, location, pkg, isInStore, root) {
43
+ const newChild = new IsolatedNode({
44
+ isInStore,
60
45
  location,
61
46
  name: node.packageName || node.name,
62
47
  optional: node.optional,
63
48
  package: pkg,
64
49
  parent: root,
65
- path: join(this.idealGraph.root.localPath, location),
66
- realpath: join(this.idealGraph.root.localPath, location),
50
+ path: join(this.idealGraph.localPath, location),
67
51
  resolved: node.resolved,
68
52
  root,
69
- top: { path: this.idealGraph.root.localPath },
70
- version: pkg.version,
71
- }
72
- newChild.target = newChild
53
+ })
54
+ // XXX top is from place-dep not lib/node.js
55
+ newChild.top = { path: this.idealGraph.localPath }
73
56
  root.children.set(newChild.location, newChild)
74
57
  root.inventory.set(newChild.location, newChild)
75
58
  }
@@ -90,16 +73,31 @@ module.exports = cls => class IsolatedReifier extends cls {
90
73
  **/
91
74
  async makeIdealGraph () {
92
75
  const idealTree = this.idealTree
93
-
76
+ this.#omit = new Set(this.options.omit)
77
+ const omit = this.#omit
78
+
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
+ )))
89
+
90
+ // XXX this sometimes acts like a node too
94
91
  this.idealGraph = {
95
92
  external: [],
96
93
  isProjectRoot: true,
97
94
  localLocation: idealTree.location,
98
95
  localPath: idealTree.path,
96
+ path: idealTree.path,
99
97
  }
100
98
  this.counter = 0
101
99
 
102
- this.idealGraph.workspaces = await Promise.all(Array.from(idealTree.fsChildren.values(), w => this.workspaceProxy(w)))
100
+ this.idealGraph.workspaces = await Promise.all(Array.from(idealTree.fsChildren.values(), w => this.#workspaceProxy(w)))
103
101
  const processed = new Set()
104
102
  const queue = [idealTree, ...idealTree.fsChildren]
105
103
  while (queue.length !== 0) {
@@ -109,43 +107,43 @@ module.exports = cls => class IsolatedReifier extends cls {
109
107
  }
110
108
  processed.add(next.location)
111
109
  next.edgesOut.forEach(edge => {
112
- if (edge.to && !(next.package.bundleDependencies || next.package.bundledDependencies || []).includes(edge.to.name)) {
110
+ if (edge.to && !(next.package.bundleDependencies || next.package.bundledDependencies || []).includes(edge.to.name) && !edge.to.shouldOmit?.(omit)) {
113
111
  queue.push(edge.to)
114
112
  }
115
113
  })
116
114
  // local `file:` deps are in fsChildren but are not workspaces.
117
115
  // they are already handled as workspace-like proxies above and should not go through the external/store extraction path.
118
116
  if (!next.isProjectRoot && !next.isWorkspace && !next.inert && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
119
- this.idealGraph.external.push(await this.externalProxy(next))
117
+ this.idealGraph.external.push(await this.#externalProxy(next))
120
118
  }
121
119
  }
122
120
 
123
- await this.assignCommonProperties(idealTree, this.idealGraph)
121
+ await this.#assignCommonProperties(idealTree, this.idealGraph)
124
122
  }
125
123
 
126
- async workspaceProxy (node) {
124
+ async #workspaceProxy (node) {
127
125
  if (this.#workspaceProxies.has(node)) {
128
126
  return this.#workspaceProxies.get(node)
129
127
  }
130
128
  const result = {}
131
- // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.workspaceProxy
129
+ // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.#workspaceProxy
132
130
  this.#workspaceProxies.set(node, result)
133
131
  result.localLocation = node.location
134
132
  result.localPath = node.path
135
133
  result.isWorkspace = true
136
134
  result.resolved = node.resolved
137
- await this.assignCommonProperties(node, result)
135
+ await this.#assignCommonProperties(node, result)
138
136
  return result
139
137
  }
140
138
 
141
- async externalProxy (node) {
139
+ async #externalProxy (node) {
142
140
  if (this.#externalProxies.has(node)) {
143
141
  return this.#externalProxies.get(node)
144
142
  }
145
143
  const result = {}
146
- // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.externalProxy
144
+ // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.#externalProxy
147
145
  this.#externalProxies.set(node, result)
148
- await this.assignCommonProperties(node, result, !node.hasShrinkwrap)
146
+ await this.#assignCommonProperties(node, result, !node.hasShrinkwrap)
149
147
  if (node.hasShrinkwrap) {
150
148
  const dir = join(
151
149
  node.root.path,
@@ -187,8 +185,9 @@ module.exports = cls => class IsolatedReifier extends cls {
187
185
  return result
188
186
  }
189
187
 
190
- async assignCommonProperties (node, result, populateDeps = true) {
188
+ async #assignCommonProperties (node, result, populateDeps = true) {
191
189
  result.root = this.idealGraph
190
+ // XXX does anything need this?
192
191
  result.id = this.counter++
193
192
  /* istanbul ignore next - packageName is always set for real packages */
194
193
  result.name = result.isWorkspace ? (node.packageName || node.name) : node.name
@@ -196,17 +195,39 @@ module.exports = cls => class IsolatedReifier extends cls {
196
195
  result.packageName = node.packageName || node.name
197
196
  result.package = { ...node.package }
198
197
  result.package.bundleDependencies = undefined
199
- result.hasInstallScript = node.hasInstallScript
200
198
 
201
199
  if (!populateDeps) {
202
200
  return
203
201
  }
204
202
 
205
- const edges = [...node.edgesOut.values()].filter(edge =>
203
+ let edges = [...node.edgesOut.values()].filter(edge =>
206
204
  edge.to?.target &&
207
205
  !(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
208
206
  )
209
- const nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)
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
+ }
210
231
 
211
232
  // When legacyPeerDeps is enabled, peer dep edges are not created on the node.
212
233
  // Resolve them from the tree so they get symlinked in the store.
@@ -226,9 +247,9 @@ module.exports = cls => class IsolatedReifier extends cls {
226
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.
227
248
  const isLocal = (n) => n.isWorkspace || node.fsChildren?.has(n)
228
249
  const optionalDeps = edges.filter(edge => edge.optional).map(edge => edge.to.target)
229
- result.localDependencies = await Promise.all(nonOptionalDeps.filter(isLocal).map(n => this.workspaceProxy(n)))
230
- result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !isLocal(n) && !n.inert).map(n => this.externalProxy(n)))
231
- result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.inert).map(n => this.externalProxy(n)))
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)))
232
253
  result.dependencies = [
233
254
  ...result.externalDependencies,
234
255
  ...result.localDependencies,
@@ -288,91 +309,47 @@ module.exports = cls => class IsolatedReifier extends cls {
288
309
 
289
310
  async createIsolatedTree () {
290
311
  await this.makeIdealGraph()
291
-
292
312
  const bundledTree = await this.#createBundledTree()
293
313
 
294
- const root = {
295
- binPaths: [],
296
- children: new Map(),
297
- edgesIn: new Set(),
298
- edgesOut: new Map(),
299
- fsChildren: new Set(),
300
- global: false,
301
- hasShrinkwrap: false,
302
- integrity: null,
303
- inventory: new Map(),
304
- isLink: false,
305
- isProjectRoot: true,
306
- isRoot: true,
307
- isTop: true,
308
- linksIn: new Set(),
309
- meta: { loadedFromDisk: false },
310
- package: this.idealGraph.root.package,
311
- parent: null,
312
- path: this.idealGraph.root.localPath,
313
- realpath: this.idealGraph.root.localPath,
314
- // TODO: we should probably not reference this.idealTree
315
- resolved: this.idealTree.resolved,
316
- tops: new Set(),
317
- workspaces: new Map(),
318
- }
319
- root.inventory.set('', root)
314
+ const root = new IsolatedNode(this.idealGraph)
320
315
  root.root = root
321
- root.target = root
322
- // TODO inventory.query is a stub; audit-report needs 'packageName' support
323
- root.inventory.query = () => {
324
- return []
325
- }
316
+ root.top = root
317
+ root.inventory.set('', root)
326
318
  const processed = new Set()
327
319
  for (const c of this.idealGraph.workspaces) {
328
320
  const wsName = c.packageName
329
- const workspace = {
330
- binPaths: [],
331
- children: new Map(),
332
- edgesIn: new Set(),
333
- edgesOut: new Map(),
334
- fsChildren: new Set(),
335
- hasInstallScript: c.hasInstallScript,
336
- isLink: false,
337
- isRoot: false,
338
- linksIn: new Set(),
321
+ // XXX parent? root?
322
+ const workspace = new IsolatedNode({
339
323
  location: c.localLocation,
340
324
  name: wsName,
341
325
  package: c.package,
342
326
  path: c.localPath,
343
- realpath: c.localPath,
344
327
  resolved: c.resolved,
345
- }
346
- workspace.target = workspace
328
+ })
329
+ workspace.top = workspace
330
+ workspace.isWorkspace = true
347
331
  root.fsChildren.add(workspace)
348
332
  root.inventory.set(workspace.location, workspace)
333
+ root.workspaces.set(wsName, workspace.path)
349
334
 
350
- // Create workspace Link entry in children for _diffTrees lookup
351
- const wsLink = {
352
- binPaths: [],
353
- children: new Map(),
354
- edgesIn: new Set(),
355
- edgesOut: new Map(),
356
- fsChildren: new Set(),
357
- global: false,
358
- globalTop: false,
359
- isLink: true,
360
- isProjectRoot: false,
361
- isRoot: false,
362
- isTop: false,
363
- linksIn: new Set(),
364
- location: join('node_modules', wsName),
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),
365
339
  name: wsName,
366
340
  package: workspace.package,
367
341
  parent: root,
368
- path: join(root.path, 'node_modules', wsName),
342
+ path: isDeclared ? join(root.path, 'node_modules', wsName) : join(root.path, c.localLocation, 'node_modules', wsName),
369
343
  realpath: workspace.path,
370
344
  root,
371
345
  target: workspace,
346
+ })
347
+ wsLink.top = root
348
+ if (!isDeclared) {
349
+ workspace.children.set(wsName, wsLink)
372
350
  }
373
- root.children.set(wsLink.name, wsLink)
351
+ root.children.set(wsName, wsLink)
374
352
  root.inventory.set(wsLink.location, wsLink)
375
- root.workspaces.set(wsName, workspace.path)
376
353
  workspace.linksIn.add(wsLink)
377
354
  }
378
355
 
@@ -466,35 +443,29 @@ module.exports = cls => class IsolatedReifier extends cls {
466
443
  }
467
444
  }
468
445
 
469
- const link = {
470
- binPaths: [],
471
- children: new Map(),
472
- edgesIn: new Set(),
473
- edgesOut: new Map(),
474
- fsChildren: new Set(),
475
- global: false,
476
- globalTop: false,
477
- isLink: true,
478
- isProjectRoot: false,
479
- isRoot: false,
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,
453
+ }
454
+ const link = new IsolatedLink({
480
455
  isStoreLink: true,
481
- isTop: false,
482
456
  location: join(nmFolder, dep.name),
483
457
  name: toKey,
484
458
  optional,
485
- // TODO _id: 'abc' ?
486
- package: { _id: 'abc', bundleDependencies: undefined, deprecated: undefined, bin: target.package.bin, scripts: dep.package.scripts },
487
459
  parent: root,
460
+ package: pkg,
488
461
  path: join(dep.root.localPath, nmFolder, dep.name),
489
462
  realpath: target.path,
490
- resolved: external
491
- ? `file:.store/${toKey}/node_modules/${dep.packageName}`
492
- : dep.resolved,
463
+ resolved: external ? `file:.store/${toKey}/node_modules/${dep.packageName}` : dep.resolved,
493
464
  root,
494
465
  target,
495
- version: dep.version,
496
- top: { path: dep.root.localPath },
497
- }
466
+ })
467
+ // XXX top is from place-dep not lib/link.js
468
+ link.top = { path: dep.root.localPath }
498
469
  const newEdge1 = { optional, from, to: link }
499
470
  from.edgesOut.set(dep.name, newEdge1)
500
471
  link.edgesIn.add(newEdge1)
@@ -36,6 +36,7 @@ const { callLimit: promiseCallLimit } = require('promise-call-limit')
36
36
  const optionalSet = require('../optional-set.js')
37
37
  const calcDepFlags = require('../calc-dep-flags.js')
38
38
  const { saveTypeMap, hasSubKey } = require('../add-rm-pkg-deps.js')
39
+ const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
39
40
 
40
41
  const Shrinkwrap = require('../shrinkwrap.js')
41
42
  const { defaultLockfileVersion } = Shrinkwrap
@@ -138,9 +139,11 @@ module.exports = cls => class Reifier extends cls {
138
139
  // of Node/Link trees
139
140
  log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
140
141
  this.idealTree = await this.createIsolatedTree()
141
- this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
142
- this.idealTree, this.actualTree
143
- )
142
+ if (this.actualTree) {
143
+ this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
144
+ this.idealTree, this.actualTree
145
+ )
146
+ }
144
147
  }
145
148
  await this[_diffTrees]()
146
149
  await this[_reifyPackages]()
@@ -841,49 +844,27 @@ module.exports = cls => class Reifier extends cls {
841
844
  #buildLinkedActualForDiff (idealTree, actualTree) {
842
845
  const combined = new Map()
843
846
 
844
- for (const child of actualTree.children.values()) {
845
- combined.set(child.path, child)
846
- }
847
-
847
+ // Create synthetic actual entries for ALL ideal children that exist on disk.
848
+ // 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.
849
+ // 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
850
  for (const child of idealTree.children.values()) {
849
- if (!combined.has(child.path) && (child.isInStore || child.isStoreLink) &&
850
- existsSync(child.path)) {
851
- const entry = {
852
- global: false,
853
- globalTop: false,
854
- isProjectRoot: false,
855
- isTop: false,
856
- location: child.location,
857
- name: child.name,
858
- optional: child.optional,
859
- top: child.top,
860
- children: [],
861
- edgesIn: new Set(),
862
- edgesOut: new Map(),
863
- binPaths: [],
864
- fsChildren: [],
865
- /* istanbul ignore next -- emulate Node */
866
- getBundler () {
867
- return null
868
- },
869
- hasShrinkwrap: false,
870
- inDepBundle: false,
871
- integrity: null,
872
- isLink: Boolean(child.isLink),
873
- isRoot: false,
874
- isInStore: Boolean(child.isInStore),
875
- path: child.path,
876
- realpath: child.realpath,
877
- resolved: child.resolved,
878
- version: child.version,
879
- package: child.package,
880
- }
881
- entry.target = entry
882
- if (child.isLink && combined.has(child.realpath)) {
883
- entry.target = combined.get(child.realpath)
884
- }
885
- combined.set(child.path, entry)
851
+ if (combined.has(child.path) || !existsSync(child.path)) {
852
+ continue
853
+ }
854
+ // Skip store links whose ideal realpath doesn't exist on disk yet — the store hash changed and the symlink needs recreating via ADD.
855
+ if (child.isLink && child.resolved?.startsWith('file:.store/') && !existsSync(child.realpath)) {
856
+ continue
857
+ }
858
+ let entry
859
+ if (child.isLink) {
860
+ entry = new IsolatedLink(child)
861
+ } else {
862
+ entry = new IsolatedNode(child)
863
+ }
864
+ if (child.isLink && combined.has(child.realpath)) {
865
+ entry.target = combined.get(child.realpath)
886
866
  }
867
+ combined.set(child.path, entry)
887
868
  }
888
869
 
889
870
  const origGet = actualTree.children.get.bind(actualTree.children)
@@ -891,32 +872,22 @@ module.exports = cls => class Reifier extends cls {
891
872
  /* istanbul ignore next -- only reached during scoped workspace installs */
892
873
  combined.get = (key) => combinedGet(key) || origGet(key)
893
874
 
894
- const wrapper = {
895
- isRoot: true,
896
- isLink: actualTree.isLink,
897
- target: actualTree.target,
898
- fsChildren: actualTree.fsChildren,
899
- path: actualTree.path,
900
- realpath: actualTree.realpath,
901
- edgesOut: actualTree.edgesOut,
902
- inventory: actualTree.inventory,
903
- package: actualTree.package,
904
- resolved: actualTree.resolved,
905
- version: actualTree.version,
906
- integrity: actualTree.integrity,
907
- binPaths: actualTree.binPaths,
908
- hasShrinkwrap: false,
909
- inDepBundle: false,
910
- parent: null,
911
- children: combined,
912
- }
913
-
914
- for (const child of combined.values()) {
915
- if (!child.parent) {
916
- child.parent = wrapper
917
- child.root = wrapper
918
- }
875
+ let wrapper
876
+ /* istanbul ignore next - untested! */
877
+ if (actualTree.isLink) {
878
+ wrapper = new IsolatedLink(actualTree)
879
+ } else {
880
+ wrapper = new IsolatedNode(actualTree)
919
881
  }
882
+ wrapper.root = wrapper
883
+ wrapper.binPaths = actualTree.binPaths
884
+ wrapper.children = combined
885
+ wrapper.edgesOut = actualTree.edgesOut
886
+ // Use empty fsChildren so that allChildren() only picks up entries from the combined map.
887
+ // 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().
888
+ wrapper.fsChildren = new Set()
889
+ wrapper.integrity = actualTree.integrity
890
+ wrapper.inventory = actualTree.inventory
920
891
 
921
892
  return wrapper
922
893
  }
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)) {
@@ -1,5 +1,6 @@
1
1
  const npa = require('npm-package-arg')
2
2
  const semver = require('semver')
3
+ const { log } = require('proc-log')
3
4
 
4
5
  class OverrideSet {
5
6
  constructor ({ overrides, key, parent }) {
@@ -44,6 +45,43 @@ class OverrideSet {
44
45
  }
45
46
  }
46
47
 
48
+ childrenAreEqual (other) {
49
+ if (this.children.size !== other.children.size) {
50
+ return false
51
+ }
52
+ for (const [key] of this.children) {
53
+ if (!other.children.has(key)) {
54
+ return false
55
+ }
56
+ if (this.children.get(key).value !== other.children.get(key).value) {
57
+ return false
58
+ }
59
+ if (!this.children.get(key).childrenAreEqual(other.children.get(key))) {
60
+ return false
61
+ }
62
+ }
63
+ return true
64
+ }
65
+
66
+ isEqual (other) {
67
+ if (this === other) {
68
+ return true
69
+ }
70
+ if (!other) {
71
+ return false
72
+ }
73
+ if (this.key !== other.key || this.value !== other.value) {
74
+ return false
75
+ }
76
+ if (!this.childrenAreEqual(other)) {
77
+ return false
78
+ }
79
+ if (!this.parent) {
80
+ return !other.parent
81
+ }
82
+ return this.parent.isEqual(other.parent)
83
+ }
84
+
47
85
  getEdgeRule (edge) {
48
86
  for (const rule of this.ruleset.values()) {
49
87
  if (rule.name !== edge.name) {
@@ -142,6 +180,123 @@ class OverrideSet {
142
180
 
143
181
  return ruleset
144
182
  }
183
+
184
+ static findSpecificOverrideSet (first, second) {
185
+ for (let overrideSet = second; overrideSet; overrideSet = overrideSet.parent) {
186
+ if (overrideSet.isEqual(first)) {
187
+ return second
188
+ }
189
+ }
190
+ for (let overrideSet = first; overrideSet; overrideSet = overrideSet.parent) {
191
+ if (overrideSet.isEqual(second)) {
192
+ return first
193
+ }
194
+ }
195
+
196
+ // The override sets are incomparable (e.g. siblings like the "react" and "react-dom" children of the root override set). Check if they have semantically conflicting rules before treating this as an error.
197
+ if (this.haveConflictingRules(first, second)) {
198
+ log.silly('Conflicting override sets', first, second)
199
+ return undefined
200
+ }
201
+
202
+ // The override sets are structurally incomparable but have compatible rules. Fall back to their nearest common ancestor so the node still has a valid override set.
203
+ return this.findCommonAncestor(first, second)
204
+ }
205
+
206
+ static findCommonAncestor (first, second) {
207
+ const firstAncestors = []
208
+ for (const ancestor of first.ancestry()) {
209
+ firstAncestors.push(ancestor)
210
+ }
211
+ for (const secondAnc of second.ancestry()) {
212
+ for (const firstAnc of firstAncestors) {
213
+ if (firstAnc.isEqual(secondAnc)) {
214
+ return firstAnc
215
+ }
216
+ }
217
+ }
218
+ return null
219
+ }
220
+
221
+ static doOverrideSetsConflict (first, second) {
222
+ // If override sets contain one another then we can try to use the more specific one.
223
+ // If neither one is more specific, check for semantic conflicts.
224
+ const specificSet = this.findSpecificOverrideSet(first, second)
225
+ if (specificSet !== undefined) {
226
+ // One contains the other, so no conflict
227
+ return false
228
+ }
229
+
230
+ // The override sets are structurally incomparable, but this doesn't necessarily
231
+ // mean they conflict. We need to check if they have conflicting version requirements
232
+ // for any package that appears in both rulesets.
233
+ return this.haveConflictingRules(first, second)
234
+ }
235
+
236
+ static haveConflictingRules (first, second) {
237
+ // Get all rules from both override sets
238
+ const firstRules = first.ruleset
239
+ const secondRules = second.ruleset
240
+
241
+ // Check each package that appears in both rulesets
242
+ for (const [key, firstRule] of firstRules) {
243
+ const secondRule = secondRules.get(key)
244
+ if (!secondRule) {
245
+ // Package only appears in one ruleset, no conflict
246
+ continue
247
+ }
248
+
249
+ // Same rule object means no conflict
250
+ if (firstRule === secondRule || firstRule.isEqual(secondRule)) {
251
+ continue
252
+ }
253
+
254
+ // Both rulesets have rules for this package with different values.
255
+ // Check if the version requirements are actually incompatible.
256
+ const firstValue = firstRule.value
257
+ const secondValue = secondRule.value
258
+
259
+ // If either value is a reference (starts with $), we can't determine
260
+ // compatibility here - the reference might resolve to compatible versions.
261
+ // We defer to runtime resolution rather than failing early.
262
+ if (firstValue.startsWith('$') || secondValue.startsWith('$')) {
263
+ continue
264
+ }
265
+
266
+ // Check if the version ranges are compatible using semver
267
+ // If both specify version ranges, they conflict only if they have no overlap
268
+ try {
269
+ const firstSpec = npa(`${firstRule.name}@${firstValue}`)
270
+ const secondSpec = npa(`${secondRule.name}@${secondValue}`)
271
+
272
+ // For range/version types, check if they intersect
273
+ if ((firstSpec.type === 'range' || firstSpec.type === 'version') &&
274
+ (secondSpec.type === 'range' || secondSpec.type === 'version')) {
275
+ // Check if the ranges intersect
276
+ const firstRange = firstSpec.fetchSpec
277
+ const secondRange = secondSpec.fetchSpec
278
+
279
+ // If the ranges don't intersect, we have a real conflict
280
+ if (!semver.intersects(firstRange, secondRange)) {
281
+ log.silly('Found conflicting override rules', {
282
+ package: firstRule.name,
283
+ first: firstValue,
284
+ second: secondValue,
285
+ })
286
+ return true
287
+ }
288
+ }
289
+ // For other types (git, file, directory, tag), we can't easily determine
290
+ // compatibility, so we conservatively assume no conflict
291
+ } catch {
292
+ // If we can't parse the specs, conservatively assume no conflict
293
+ // Real conflicts will be caught during dependency resolution
294
+ }
295
+ }
296
+
297
+ // No conflicting rules found
298
+ return false
299
+ }
145
300
  }
146
301
 
147
302
  module.exports = OverrideSet
@@ -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.2",
3
+ "version": "8.0.4",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@isaacs/string-locale-compare": "^1.1.0",