@npmcli/arborist 9.1.6 → 9.1.7

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.
@@ -42,7 +42,6 @@ const _flagsSuspect = Symbol.for('flagsSuspect')
42
42
  const _setWorkspaces = Symbol.for('setWorkspaces')
43
43
  const _updateNames = Symbol.for('updateNames')
44
44
  const _resolvedAdd = Symbol.for('resolvedAdd')
45
- const _usePackageLock = Symbol.for('usePackageLock')
46
45
  const _rpcache = Symbol.for('realpathCache')
47
46
  const _stcache = Symbol.for('statCache')
48
47
 
@@ -101,39 +100,28 @@ module.exports = cls => class IdealTreeBuilder extends cls {
101
100
  constructor (options) {
102
101
  super(options)
103
102
 
104
- // normalize trailing slash
105
- const registry = options.registry || 'https://registry.npmjs.org'
106
- options.registry = this.registry = registry.replace(/\/+$/, '') + '/'
107
-
108
103
  const {
109
104
  follow = false,
110
105
  installStrategy = 'hoisted',
111
- idealTree = null,
112
- installLinks = false,
113
- legacyPeerDeps = false,
114
- packageLock = true,
115
106
  strictPeerDeps = false,
116
- workspaces,
117
107
  global,
118
108
  } = options
119
109
 
120
110
  this.#strictPeerDeps = !!strictPeerDeps
121
111
 
122
- this.idealTree = idealTree
123
- this.installLinks = installLinks
124
- this.legacyPeerDeps = legacyPeerDeps
125
-
126
- this[_usePackageLock] = packageLock
127
112
  this.#installStrategy = global ? 'shallow' : installStrategy
128
113
  this.#follow = !!follow
129
114
 
130
- if (workspaces?.length && global) {
131
- throw new Error('Cannot operate on workspaces in global mode')
132
- }
133
-
134
115
  this[_updateAll] = false
135
116
  this[_updateNames] = []
136
117
  this[_resolvedAdd] = []
118
+
119
+ // caches for cached realpath calls
120
+ const cwd = process.cwd()
121
+ // assume that the cwd is real enough for our purposes
122
+ this[_rpcache] = new Map([[cwd, cwd]])
123
+ this[_stcache] = new Map()
124
+ this[_flagsSuspect] = false
137
125
  }
138
126
 
139
127
  get explicitRequests () {
@@ -298,7 +286,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
298
286
  .then(root => {
299
287
  if (this.options.global) {
300
288
  return root
301
- } else if (!this[_usePackageLock] || this[_updateAll]) {
289
+ } else if (!this.options.usePackageLock || this[_updateAll]) {
302
290
  return Shrinkwrap.reset({
303
291
  path: this.path,
304
292
  lockfileVersion: this.options.lockfileVersion,
@@ -351,7 +339,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
351
339
  filter: node => node,
352
340
  visit: node => {
353
341
  for (const edge of node.edgesOut.values()) {
354
- if (!edge.to || !edge.valid) {
342
+ if ((!edge.to && edge.type !== 'peerOptional') || !edge.valid) {
355
343
  this.#depsQueue.push(node)
356
344
  break // no need to continue the loop after the first hit
357
345
  }
@@ -754,6 +742,7 @@ This is a one-time fix-up, please be patient...
754
742
 
755
743
  // have to re-calc dep flags, because the nodes don't have edges
756
744
  // until their packages get assigned, so everything looks extraneous
745
+ resetDepFlags(this.idealTree)
757
746
  calcDepFlags(this.idealTree)
758
747
 
759
748
  // yes, yes, this isn't the "original" version, but now that it's been
@@ -1230,7 +1219,7 @@ This is a one-time fix-up, please be patient...
1230
1219
  }
1231
1220
  }
1232
1221
 
1233
- #nodeFromSpec (name, spec, parent, edge) {
1222
+ async #nodeFromSpec (name, spec, parent, edge) {
1234
1223
  // pacote will slap integrity on its options, so we have to clone
1235
1224
  // the object so it doesn't get mutated.
1236
1225
  // Don't bother to load the manifest for link deps, because the target
@@ -1259,7 +1248,13 @@ This is a one-time fix-up, please be patient...
1259
1248
  // Decide whether to link or copy the dependency
1260
1249
  const shouldLink = (isWorkspace || isProjectInternalFileSpec || !installLinks) && !isTransitiveFileDep
1261
1250
  if (spec.type === 'directory' && shouldLink) {
1262
- return this.#linkFromSpec(name, spec, parent, edge)
1251
+ const realpath = spec.fetchSpec
1252
+ const { content: pkg } = await PackageJson.normalize(realpath).catch(() => {
1253
+ return { content: {} }
1254
+ })
1255
+ const link = new Link({ name, parent, realpath, pkg, installLinks, legacyPeerDeps })
1256
+ this.#linkNodes.add(link)
1257
+ return link
1263
1258
  }
1264
1259
 
1265
1260
  // if the spec matches a workspace name, then see if the workspace node will satisfy the edge. if it does, we return the workspace node to make sure it takes priority.
@@ -1300,17 +1295,6 @@ This is a one-time fix-up, please be patient...
1300
1295
  })
1301
1296
  }
1302
1297
 
1303
- async #linkFromSpec (name, spec, parent) {
1304
- const realpath = spec.fetchSpec
1305
- const { installLinks, legacyPeerDeps } = this
1306
- const { content: pkg } = await PackageJson.normalize(realpath).catch(() => {
1307
- return { content: {} }
1308
- })
1309
- const link = new Link({ name, parent, realpath, pkg, installLinks, legacyPeerDeps })
1310
- this.#linkNodes.add(link)
1311
- return link
1312
- }
1313
-
1314
1298
  // load all peer deps and meta-peer deps into the node's parent
1315
1299
  // At the end of this, the node's peer-type outward edges are all
1316
1300
  // resolved, and so are all of theirs, but other dep types are not.
@@ -1445,6 +1429,7 @@ This is a one-time fix-up, please be patient...
1445
1429
  // and add it to the _depsQueue
1446
1430
  //
1447
1431
  // call buildDepStep if anything was added to the queue; otherwise, we're done
1432
+ // XXX load-virtual also has a #resolveLinks, is there overlap?
1448
1433
  #resolveLinks () {
1449
1434
  for (const link of this.#linkNodes) {
1450
1435
  this.#linkNodes.delete(link)
@@ -1508,11 +1493,7 @@ This is a one-time fix-up, please be patient...
1508
1493
  } else {
1509
1494
  // otherwise just unset all the flags on the root node
1510
1495
  // since they will sometimes have the default value
1511
- this.idealTree.extraneous = false
1512
- this.idealTree.dev = false
1513
- this.idealTree.optional = false
1514
- this.idealTree.devOptional = false
1515
- this.idealTree.peer = false
1496
+ this.idealTree.unsetDepFlags()
1516
1497
  }
1517
1498
 
1518
1499
  // at this point, any node marked as extraneous should be pruned.
@@ -1555,12 +1536,7 @@ This is a one-time fix-up, please be patient...
1555
1536
 
1556
1537
  #idealTreePrune () {
1557
1538
  for (const node of this.idealTree.inventory.values()) {
1558
- // optional peer dependencies are meant to be added to the tree
1559
- // through an explicit required dependency (most commonly in the
1560
- // root package.json), at which point they won't be optional so
1561
- // any dependencies still marked as both optional and peer at
1562
- // this point can be pruned as a special kind of extraneous
1563
- if (node.extraneous || (node.peer && node.optional)) {
1539
+ if (node.extraneous) {
1564
1540
  node.parent = null
1565
1541
  }
1566
1542
  }
@@ -68,6 +68,34 @@ class Arborist extends Base {
68
68
  constructor (options = {}) {
69
69
  const timeEnd = time.start('arborist:ctor')
70
70
  super(options)
71
+
72
+ // normalize trailing slash
73
+ const registry = options.registry || 'https://registry.npmjs.org'
74
+ options.registry = this.registry = registry.replace(/(?<!\/)\/+$/, '') + '/'
75
+
76
+ // TODO as we consolidate constructors it's more apparent that we are not parsing options and using this.options consistently
77
+ const {
78
+ actualTree,
79
+ global,
80
+ idealTree = null,
81
+ installLinks = false,
82
+ legacyPeerDeps = false,
83
+ virtualTree,
84
+ workspaces,
85
+ } = options
86
+
87
+ if (workspaces?.length && global) {
88
+ throw new Error('Cannot operate on workspaces in global mode')
89
+ }
90
+
91
+ // the tree of nodes on disk
92
+ this.actualTree = actualTree
93
+ this.idealTree = idealTree
94
+ this.installLinks = installLinks
95
+ this.legacyPeerDeps = legacyPeerDeps
96
+ // the virtual tree we load from a shrinkwrap
97
+ this.virtualTree = virtualTree
98
+
71
99
  this.options = {
72
100
  nodeVersion: process.version,
73
101
  ...options,
@@ -88,6 +116,7 @@ class Arborist extends Base {
88
116
  replaceRegistryHost: options.replaceRegistryHost,
89
117
  savePrefix: 'savePrefix' in options ? options.savePrefix : '^',
90
118
  scriptShell: options.scriptShell,
119
+ usePackageLock: 'packageLock' in options ? options.packageLock : true,
91
120
  workspaces: options.workspaces || [],
92
121
  workspacesEnabled: options.workspacesEnabled !== false,
93
122
  }
@@ -104,6 +133,7 @@ class Arborist extends Base {
104
133
  this.cache = resolve(this.options.cache)
105
134
  this.diff = null
106
135
  this.path = resolve(this.options.path)
136
+ this.scriptsRun = new Set()
107
137
  timeEnd()
108
138
  }
109
139
 
@@ -1,6 +1,5 @@
1
1
  const _makeIdealGraph = Symbol('makeIdealGraph')
2
2
  const _createIsolatedTree = Symbol.for('createIsolatedTree')
3
- const _createBundledTree = Symbol('createBundledTree')
4
3
  const { mkdirSync } = require('node:fs')
5
4
  const pacote = require('pacote')
6
5
  const { join } = require('node:path')
@@ -162,7 +161,7 @@ module.exports = cls => class IsolatedReifier extends cls {
162
161
  result.hasInstallScript = node.hasInstallScript
163
162
  }
164
163
 
165
- async [_createBundledTree] () {
164
+ async #createBundledTree () {
166
165
  // TODO: make sure that idealTree object exists
167
166
  const idealTree = this.idealTree
168
167
  // TODO: test workspaces having bundled deps
@@ -217,7 +216,7 @@ module.exports = cls => class IsolatedReifier extends cls {
217
216
 
218
217
  const proxiedIdealTree = this.idealGraph
219
218
 
220
- const bundledTree = await this[_createBundledTree]()
219
+ const bundledTree = await this.#createBundledTree()
221
220
 
222
221
  const treeHash = (startNode) => {
223
222
  // generate short hash based on the dependency tree
@@ -41,19 +41,6 @@ module.exports = cls => class ActualLoader extends cls {
41
41
  #topNodes = new Set()
42
42
  #transplantFilter
43
43
 
44
- constructor (options) {
45
- super(options)
46
-
47
- // the tree of nodes on disk
48
- this.actualTree = options.actualTree
49
-
50
- // caches for cached realpath calls
51
- const cwd = process.cwd()
52
- // assume that the cwd is real enough for our purposes
53
- this[_rpcache] = new Map([[cwd, cwd]])
54
- this[_stcache] = new Map()
55
- }
56
-
57
44
  // public method
58
45
  // TODO remove options param in next semver major
59
46
  async loadActual (options = {}) {
@@ -18,14 +18,6 @@ const setWorkspaces = Symbol.for('setWorkspaces')
18
18
  module.exports = cls => class VirtualLoader extends cls {
19
19
  #rootOptionProvided
20
20
 
21
- constructor (options) {
22
- super(options)
23
-
24
- // the virtual tree we load from a shrinkwrap
25
- this.virtualTree = options.virtualTree
26
- this[flagsSuspect] = false
27
- }
28
-
29
21
  // public method
30
22
  async loadVirtual (options = {}) {
31
23
  if (this.virtualTree) {
@@ -69,11 +61,7 @@ module.exports = cls => class VirtualLoader extends cls {
69
61
  if (!this.#rootOptionProvided) {
70
62
  // root is never any of these things, but might be a brand new
71
63
  // baby Node object that never had its dep flags calculated.
72
- root.extraneous = false
73
- root.dev = false
74
- root.optional = false
75
- root.devOptional = false
76
- root.peer = false
64
+ root.unsetDepFlags()
77
65
  } else {
78
66
  this[flagsSuspect] = true
79
67
  }
@@ -81,7 +69,21 @@ module.exports = cls => class VirtualLoader extends cls {
81
69
  this.#checkRootEdges(s, root)
82
70
  root.meta = s
83
71
  this.virtualTree = root
84
- const { links, nodes } = this.#resolveNodes(s, root)
72
+ // separate out link metadata, and create Node objects for nodes
73
+ const links = new Map()
74
+ const nodes = new Map([['', root]])
75
+ for (const [location, meta] of Object.entries(s.data.packages)) {
76
+ // skip the root because we already got it
77
+ if (!location) {
78
+ continue
79
+ }
80
+
81
+ if (meta.link) {
82
+ links.set(location, meta)
83
+ } else {
84
+ nodes.set(location, this.#loadNode(location, meta))
85
+ }
86
+ }
85
87
  await this.#resolveLinks(links, nodes)
86
88
  if (!(s.originalLockfileVersion >= 2)) {
87
89
  this.#assignBundles(nodes)
@@ -93,11 +95,7 @@ module.exports = cls => class VirtualLoader extends cls {
93
95
  if (node.isRoot || node === this.#rootOptionProvided) {
94
96
  continue
95
97
  }
96
- node.extraneous = true
97
- node.dev = true
98
- node.optional = true
99
- node.devOptional = true
100
- node.peer = true
98
+ node.resetDepFlags()
101
99
  }
102
100
  calcDepFlags(this.virtualTree, !this.#rootOptionProvided)
103
101
  }
@@ -168,27 +166,9 @@ module.exports = cls => class VirtualLoader extends cls {
168
166
  }
169
167
  }
170
168
 
171
- // separate out link metadata, and create Node objects for nodes
172
- #resolveNodes (s, root) {
173
- const links = new Map()
174
- const nodes = new Map([['', root]])
175
- for (const [location, meta] of Object.entries(s.data.packages)) {
176
- // skip the root because we already got it
177
- if (!location) {
178
- continue
179
- }
180
-
181
- if (meta.link) {
182
- links.set(location, meta)
183
- } else {
184
- nodes.set(location, this.#loadNode(location, meta))
185
- }
186
- }
187
- return { links, nodes }
188
- }
189
-
190
169
  // links is the set of metadata, and nodes is the map of non-Link nodes
191
170
  // Set the targets to nodes in the set, if we have them (we might not)
171
+ // XXX build-ideal-tree also has a #resolveLinks, is there overlap?
192
172
  async #resolveLinks (links, nodes) {
193
173
  for (const [location, meta] of links.entries()) {
194
174
  const targetPath = resolve(this.path, meta.resolved)
@@ -255,11 +235,6 @@ To fix:
255
235
  sw.name = nameFromFolder(path)
256
236
  }
257
237
 
258
- const dev = sw.dev
259
- const optional = sw.optional
260
- const devOptional = dev || optional || sw.devOptional
261
- const peer = sw.peer
262
-
263
238
  const node = new Node({
264
239
  installLinks: this.installLinks,
265
240
  legacyPeerDeps: this.legacyPeerDeps,
@@ -270,18 +245,15 @@ To fix:
270
245
  resolved: consistentResolve(sw.resolved, this.path, path),
271
246
  pkg: sw,
272
247
  hasShrinkwrap: sw.hasShrinkwrap,
273
- dev,
274
- optional,
275
- devOptional,
276
- peer,
277
248
  loadOverrides,
249
+ // cast to boolean because they're undefined in the lock file when false
250
+ extraneous: !!sw.extraneous,
251
+ devOptional: !!(sw.devOptional || sw.dev || sw.optional),
252
+ peer: !!sw.peer,
253
+ optional: !!sw.optional,
254
+ dev: !!sw.dev,
278
255
  })
279
- // cast to boolean because they're undefined in the lock file when false
280
- node.extraneous = !!sw.extraneous
281
- node.devOptional = !!(sw.devOptional || sw.dev || sw.optional)
282
- node.peer = !!sw.peer
283
- node.optional = !!sw.optional
284
- node.dev = !!sw.dev
256
+
285
257
  return node
286
258
  }
287
259
 
@@ -24,13 +24,12 @@ const _trashList = Symbol.for('trashList')
24
24
  module.exports = cls => class Builder extends cls {
25
25
  #doHandleOptionalFailure
26
26
  #oldMeta = null
27
- #queues
28
-
29
- constructor (options) {
30
- super(options)
31
-
32
- this.scriptsRun = new Set()
33
- this.#resetQueues()
27
+ #queues = {
28
+ preinstall: [],
29
+ install: [],
30
+ postinstall: [],
31
+ prepare: [],
32
+ bin: [],
34
33
  }
35
34
 
36
35
  async rebuild ({ nodes, handleOptionalFailure = false } = {}) {
@@ -62,7 +61,13 @@ module.exports = cls => class Builder extends cls {
62
61
 
63
62
  // build link deps
64
63
  if (linkNodes.size) {
65
- this.#resetQueues()
64
+ this.#queues = {
65
+ preinstall: [],
66
+ install: [],
67
+ postinstall: [],
68
+ prepare: [],
69
+ bin: [],
70
+ }
66
71
  await this.#build(linkNodes, { type: 'links' })
67
72
  }
68
73
 
@@ -132,16 +137,6 @@ module.exports = cls => class Builder extends cls {
132
137
  }
133
138
  }
134
139
 
135
- #resetQueues () {
136
- this.#queues = {
137
- preinstall: [],
138
- install: [],
139
- postinstall: [],
140
- prepare: [],
141
- bin: [],
142
- }
143
- }
144
-
145
140
  async #build (nodes, { type = 'deps' }) {
146
141
  const timeEnd = time.start(`build:${type}`)
147
142
 
@@ -57,11 +57,9 @@ const _rollbackRetireShallowNodes = Symbol.for('rollbackRetireShallowNodes')
57
57
  const _rollbackCreateSparseTree = Symbol.for('rollbackCreateSparseTree')
58
58
  const _rollbackMoveBackRetiredUnchanged = Symbol.for('rollbackMoveBackRetiredUnchanged')
59
59
  const _saveIdealTree = Symbol.for('saveIdealTree')
60
- const _reifyPackages = Symbol.for('reifyPackages')
61
60
 
62
61
  // defined by build-ideal-tree mixin
63
62
  const _resolvedAdd = Symbol.for('resolvedAdd')
64
- const _usePackageLock = Symbol.for('usePackageLock')
65
63
  // used by build-ideal-tree mixin
66
64
  const _addNodeToTrashList = Symbol.for('addNodeToTrashList')
67
65
 
@@ -70,12 +68,10 @@ const _createIsolatedTree = Symbol.for('createIsolatedTree')
70
68
  module.exports = cls => class Reifier extends cls {
71
69
  #bundleMissing = new Set() // child nodes we'd EXPECT to be included in a bundle, but aren't
72
70
  #bundleUnpacked = new Set() // the nodes we unpack to read their bundles
73
- #dryRun
74
71
  #nmValidated = new Set()
75
72
  #omit
76
73
  #retiredPaths = {}
77
74
  #retiredUnchanged = {}
78
- #savePrefix
79
75
  #shrinkwrapInflated = new Set()
80
76
  #sparseTreeDirs = new Set()
81
77
  #sparseTreeRoots = new Set()
@@ -122,7 +118,7 @@ module.exports = cls => class Reifier extends cls {
122
118
  this.idealTree = await this[_createIsolatedTree]()
123
119
  }
124
120
  await this[_diffTrees]()
125
- await this[_reifyPackages]()
121
+ await this.#reifyPackages()
126
122
  if (linked) {
127
123
  // swap back in the idealTree
128
124
  // so that the lockfile is preserved
@@ -261,7 +257,7 @@ module.exports = cls => class Reifier extends cls {
261
257
  return treeCheck(this.actualTree)
262
258
  }
263
259
 
264
- async [_reifyPackages] () {
260
+ async #reifyPackages () {
265
261
  // we don't submit the audit report or write to disk on dry runs
266
262
  if (this.options.dryRun) {
267
263
  return
@@ -817,9 +813,17 @@ module.exports = cls => class Reifier extends cls {
817
813
  // Make sure we don't double-include the path if it's already there
818
814
  const registryPath = registryURL.pathname.replace(/\/$/, '')
819
815
 
820
- if (registryPath && registryPath !== '/' && !resolvedURL.pathname.startsWith(registryPath)) {
821
- // Since hostname is changed, we need to ensure the registry path is included
822
- resolvedURL.pathname = registryPath + resolvedURL.pathname
816
+ if (registryPath && registryPath !== '/') {
817
+ // Check if the resolved pathname already starts with the registry path
818
+ // We need to ensure it's a proper path prefix, not just a string prefix
819
+ // e.g., registry path '/npm' should not match '/npm-run-path'
820
+ const hasRegistryPath = resolvedURL.pathname === registryPath ||
821
+ resolvedURL.pathname.startsWith(registryPath + '/')
822
+
823
+ if (!hasRegistryPath) {
824
+ // Since hostname is changed, we need to ensure the registry path is included
825
+ resolvedURL.pathname = registryPath + resolvedURL.pathname
826
+ }
823
827
  }
824
828
 
825
829
  return resolvedURL.toString()
@@ -1496,7 +1500,7 @@ module.exports = cls => class Reifier extends cls {
1496
1500
 
1497
1501
  // before now edge specs could be changing, affecting the `requires` field
1498
1502
  // in the package lock, so we hold off saving to the very last action
1499
- if (this[_usePackageLock]) {
1503
+ if (this.options.usePackageLock) {
1500
1504
  // preserve indentation, if possible
1501
1505
  let format = this.idealTree.package[Symbol.for('indent')]
1502
1506
  if (format === undefined) {
@@ -1,144 +1,102 @@
1
- const { depth } = require('treeverse')
2
-
1
+ // Dep flag (dev, peer, etc.) calculation requires default or reset flags.
2
+ // Flags are true by default and are unset to false as we walk deps.
3
+ // We iterate outward edges looking for dep flags that can
4
+ // be unset based on the current nodes flags and edge type.
5
+ // Examples:
6
+ // - a non-optional node with a non-optional edge out, the edge node should not be optional
7
+ // - a non-peer node with a non-peer edge out, the edge node should not be peer
8
+ // If a node is changed, we add to the queue and continue until no more changes.
9
+ // Flags that remain after all this unsetting should be valid.
10
+ // Examples:
11
+ // - a node still flagged optional must only be reachable via optional edges
12
+ // - a node still flagged peer must only be reachable via peer edges
3
13
  const calcDepFlags = (tree, resetRoot = true) => {
4
14
  if (resetRoot) {
5
- tree.dev = false
6
- tree.optional = false
7
- tree.devOptional = false
8
- tree.peer = false
15
+ tree.unsetDepFlags()
9
16
  }
10
- const ret = depth({
11
- tree,
12
- visit: node => calcDepFlagsStep(node),
13
- filter: node => node,
14
- getChildren: (node, tree) =>
15
- [...tree.edgesOut.values()].map(edge => edge.to),
16
- })
17
- return ret
18
- }
19
-
20
- const calcDepFlagsStep = (node) => {
21
- // This rewalk is necessary to handle cases where devDep and optional
22
- // or normal dependency graphs overlap deep in the dep graph.
23
- // Since we're only walking through deps that are not already flagged
24
- // as non-dev/non-optional, it's typically a very shallow traversal
25
-
26
- node.extraneous = false
27
- resetParents(node, 'extraneous')
28
- resetParents(node, 'dev')
29
- resetParents(node, 'peer')
30
- resetParents(node, 'devOptional')
31
- resetParents(node, 'optional')
32
-
33
- // for links, map their hierarchy appropriately
34
- if (node.isLink) {
35
- // node.target can be null, we check to ensure it's not null before proceeding
36
- if (node.target == null) {
37
- return node
38
- }
39
- node.target.dev = node.dev
40
- node.target.optional = node.optional
41
- node.target.devOptional = node.devOptional
42
- node.target.peer = node.peer
43
- return calcDepFlagsStep(node.target)
44
- }
45
-
46
- node.edgesOut.forEach(({ peer, optional, dev, to }) => {
47
- // if the dep is missing, then its flags are already maximally unset
48
- if (!to) {
49
- return
50
- }
51
- // everything with any kind of edge into it is not extraneous
52
- to.extraneous = false
53
-
54
- // If this is a peer edge, mark the target as peer
55
- if (peer) {
56
- to.peer = true
57
- } else if (to.peer && !hasIncomingPeerEdge(to)) {
58
- unsetFlag(to, 'peer')
59
- }
60
17
 
61
- // devOptional is the *overlap* of the dev and optional tree.
62
- // however, for convenience and to save an extra rewalk, we leave
63
- // it set when we are in *either* tree, and then omit it from the
64
- // package-lock if either dev or optional are set.
65
- const unsetDevOpt = !node.devOptional && !node.dev && !node.optional && !dev && !optional
18
+ const seen = new Set()
19
+ const queue = [tree]
66
20
 
67
- // if we are not in the devOpt tree, then we're also not in
68
- // either the dev or opt trees
69
- const unsetDev = unsetDevOpt || !node.dev && !dev
70
- const unsetOpt = unsetDevOpt || !node.optional && !optional
21
+ let node
22
+ while (node = queue.pop()) {
23
+ seen.add(node)
71
24
 
72
- if (unsetDevOpt) {
73
- unsetFlag(to, 'devOptional')
25
+ // Unset extraneous from all parents to avoid removal of children.
26
+ if (!node.extraneous) {
27
+ for (let n = node.resolveParent; n?.extraneous; n = n.resolveParent) {
28
+ n.extraneous = false
29
+ }
74
30
  }
75
31
 
76
- if (unsetDev) {
77
- unsetFlag(to, 'dev')
32
+ // for links, map their hierarchy appropriately
33
+ if (node.isLink) {
34
+ // node.target can be null, we check to ensure it's not null before proceeding
35
+ if (node.target == null) {
36
+ continue
37
+ }
38
+ node.target.dev = node.dev
39
+ node.target.optional = node.optional
40
+ node.target.devOptional = node.devOptional
41
+ node.target.peer = node.peer
42
+ node.target.extraneous = node.extraneous
43
+ queue.push(node.target)
44
+ continue
78
45
  }
79
46
 
80
- if (unsetOpt) {
81
- unsetFlag(to, 'optional')
82
- }
83
- })
84
-
85
- return node
86
- }
87
-
88
- const hasIncomingPeerEdge = (node) => {
89
- const target = node.isLink && node.target ? node.target : node
90
- for (const edge of target.edgesIn) {
91
- if (edge.type === 'peer') {
92
- return true
47
+ for (const { peer, optional, dev, to } of node.edgesOut.values()) {
48
+ // if the dep is missing, then its flags are already maximally unset
49
+ if (!to) {
50
+ continue
51
+ }
52
+
53
+ let changed = false
54
+
55
+ // only optional peer dependencies should stay extraneous
56
+ if (to.extraneous && !node.extraneous && !(peer && optional)) {
57
+ to.extraneous = false
58
+ changed = true
59
+ }
60
+
61
+ if (to.dev && !node.dev && !dev) {
62
+ to.dev = false
63
+ changed = true
64
+ }
65
+
66
+ if (to.optional && !node.optional && !optional) {
67
+ to.optional = false
68
+ changed = true
69
+ }
70
+
71
+ // devOptional is the *overlap* of the dev and optional tree.
72
+ // A node may be depended on by separate dev and optional nodes.
73
+ // It SHOULD NOT be removed when pruning dev OR optional.
74
+ // It SHOULD be removed when pruning dev AND optional.
75
+ // We only unset here if a node is not dev AND not optional because
76
+ // if we did unset, it would prevent any overlap deeper in the tree.
77
+ // We correct this later by removing if dev OR optional is set.
78
+ if (to.devOptional && !node.devOptional && !node.dev && !node.optional && !dev && !optional) {
79
+ to.devOptional = false
80
+ changed = true
81
+ }
82
+
83
+ if (to.peer && !node.peer && !peer) {
84
+ to.peer = false
85
+ changed = true
86
+ }
87
+
88
+ if (changed) {
89
+ queue.push(to)
90
+ }
93
91
  }
94
92
  }
95
- return false
96
- }
97
93
 
98
- const resetParents = (node, flag) => {
99
- if (node[flag]) {
100
- return
101
- }
102
-
103
- for (let p = node; p && (p === node || p[flag]); p = p.resolveParent) {
104
- p[flag] = false
105
- }
106
- }
107
-
108
- // typically a short walk, since it only traverses deps that have the flag set.
109
- const unsetFlag = (node, flag) => {
110
- if (node[flag]) {
111
- node[flag] = false
112
- depth({
113
- tree: node,
114
- visit: node => {
115
- node.extraneous = node[flag] = false
116
- if (node.isLink && node.target) {
117
- node.target.extraneous = node.target[flag] = false
118
- }
119
- },
120
- getChildren: node => {
121
- const children = []
122
- const targetNode = node.isLink && node.target ? node.target : node
123
- for (const edge of targetNode.edgesOut.values()) {
124
- if (edge.to?.[flag]) {
125
- // For the peer flag, only follow peer edges to unset the flag
126
- // Don't propagate peer flag through prod/dev/optional edges
127
- if (flag === 'peer') {
128
- if (edge.type === 'peer') {
129
- children.push(edge.to)
130
- }
131
- } else {
132
- // For other flags, follow prod edges (and peer edges for non-peer flags)
133
- if (edge.type === 'prod' || edge.type === 'peer') {
134
- children.push(edge.to)
135
- }
136
- }
137
- }
138
- }
139
- return children
140
- },
141
- })
94
+ // Remove incorrect devOptional flags now that we have walked all deps.
95
+ seen.delete(tree)
96
+ for (const node of seen.values()) {
97
+ if (node.devOptional && (node.dev || node.optional)) {
98
+ node.devOptional = false
99
+ }
142
100
  }
143
101
  }
144
102
 
package/lib/edge.js CHANGED
@@ -276,9 +276,15 @@ class Edge {
276
276
  } else if (!this.satisfiedBy(this.#to)) {
277
277
  this.#error = 'INVALID'
278
278
  } else if (this.overrides && this.#to.edgesOut.size && OverrideSet.doOverrideSetsConflict(this.overrides, this.#to.overrides)) {
279
- // Any inconsistency between the edge's override set and the target's override set is potentially problematic.
280
- // But we only say the edge is in error if the override sets are plainly conflicting.
281
- // Note that if the target doesn't have any dependencies of their own, then this inconsistency is irrelevant.
279
+ // Check for conflicts between the edge's override set and the target node's override set.
280
+ // This catches cases where different parts of the tree have genuinely incompatible
281
+ // version requirements for the same package.
282
+ // The improved conflict detection uses semantic comparison (checking for incompatible
283
+ // version ranges) rather than pure structural equality, avoiding false positives from:
284
+ // - Reference overrides ($syntax) that resolve to compatible versions
285
+ // - Peer dependencies with different but compatible override contexts
286
+ // Note: We only check if the target has dependencies (edgesOut.size > 0), since
287
+ // override conflicts are only relevant if the target has its own dependencies.
282
288
  this.#error = 'INVALID'
283
289
  } else {
284
290
  this.#error = 'OK'
package/lib/node.js CHANGED
@@ -1613,6 +1613,22 @@ class Node {
1613
1613
  [util.inspect.custom] () {
1614
1614
  return this.toJSON()
1615
1615
  }
1616
+
1617
+ resetDepFlags () {
1618
+ this.extraneous = true
1619
+ this.dev = true
1620
+ this.optional = true
1621
+ this.devOptional = true
1622
+ this.peer = true
1623
+ }
1624
+
1625
+ unsetDepFlags () {
1626
+ this.extraneous = false
1627
+ this.dev = false
1628
+ this.optional = false
1629
+ this.devOptional = false
1630
+ this.peer = false
1631
+ }
1616
1632
  }
1617
1633
 
1618
1634
  module.exports = Node
@@ -10,10 +10,6 @@
10
10
 
11
11
  const gatherDepSet = require('./gather-dep-set.js')
12
12
  const optionalSet = node => {
13
- if (!node.optional) {
14
- return new Set()
15
- }
16
-
17
13
  // start with the node, then walk up the dependency graph until we
18
14
  // get to the boundaries that define the optional set. since the
19
15
  // node is optional, we know that all paths INTO this area of the
@@ -201,8 +201,82 @@ class OverrideSet {
201
201
 
202
202
  static doOverrideSetsConflict (first, second) {
203
203
  // If override sets contain one another then we can try to use the more specific one.
204
- // If neither one is more specific, then we consider them to be in conflict.
205
- return (this.findSpecificOverrideSet(first, second) === undefined)
204
+ // If neither one is more specific, check for semantic conflicts.
205
+ const specificSet = this.findSpecificOverrideSet(first, second)
206
+ if (specificSet !== undefined) {
207
+ // One contains the other, so no conflict
208
+ return false
209
+ }
210
+
211
+ // The override sets are structurally incomparable, but this doesn't necessarily
212
+ // mean they conflict. We need to check if they have conflicting version requirements
213
+ // for any package that appears in both rulesets.
214
+ return this.haveConflictingRules(first, second)
215
+ }
216
+
217
+ static haveConflictingRules (first, second) {
218
+ // Get all rules from both override sets
219
+ const firstRules = first.ruleset
220
+ const secondRules = second.ruleset
221
+
222
+ // Check each package that appears in both rulesets
223
+ for (const [key, firstRule] of firstRules) {
224
+ const secondRule = secondRules.get(key)
225
+ if (!secondRule) {
226
+ // Package only appears in one ruleset, no conflict
227
+ continue
228
+ }
229
+
230
+ // Same rule object means no conflict
231
+ if (firstRule === secondRule || firstRule.isEqual(secondRule)) {
232
+ continue
233
+ }
234
+
235
+ // Both rulesets have rules for this package with different values.
236
+ // Check if the version requirements are actually incompatible.
237
+ const firstValue = firstRule.value
238
+ const secondValue = secondRule.value
239
+
240
+ // If either value is a reference (starts with $), we can't determine
241
+ // compatibility here - the reference might resolve to compatible versions.
242
+ // We defer to runtime resolution rather than failing early.
243
+ if (firstValue.startsWith('$') || secondValue.startsWith('$')) {
244
+ continue
245
+ }
246
+
247
+ // Check if the version ranges are compatible using semver
248
+ // If both specify version ranges, they conflict only if they have no overlap
249
+ try {
250
+ const firstSpec = npa(`${firstRule.name}@${firstValue}`)
251
+ const secondSpec = npa(`${secondRule.name}@${secondValue}`)
252
+
253
+ // For range/version types, check if they intersect
254
+ if ((firstSpec.type === 'range' || firstSpec.type === 'version') &&
255
+ (secondSpec.type === 'range' || secondSpec.type === 'version')) {
256
+ // Check if the ranges intersect
257
+ const firstRange = firstSpec.fetchSpec
258
+ const secondRange = secondSpec.fetchSpec
259
+
260
+ // If the ranges don't intersect, we have a real conflict
261
+ if (!semver.intersects(firstRange, secondRange)) {
262
+ log.silly('Found conflicting override rules', {
263
+ package: firstRule.name,
264
+ first: firstValue,
265
+ second: secondValue,
266
+ })
267
+ return true
268
+ }
269
+ }
270
+ // For other types (git, file, directory, tag), we can't easily determine
271
+ // compatibility, so we conservatively assume no conflict
272
+ } catch {
273
+ // If we can't parse the specs, conservatively assume no conflict
274
+ // Real conflicts will be caught during dependency resolution
275
+ }
276
+ }
277
+
278
+ // No conflicting rules found
279
+ return false
206
280
  }
207
281
  }
208
282
 
@@ -6,10 +6,6 @@
6
6
  // we can find the set that is actually extraneous.
7
7
  module.exports = tree => {
8
8
  for (const node of tree.inventory.values()) {
9
- node.extraneous = true
10
- node.dev = true
11
- node.devOptional = true
12
- node.peer = true
13
- node.optional = true
9
+ node.resetDepFlags()
14
10
  }
15
11
  }
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.1.6",
3
+ "version": "9.1.7",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@isaacs/string-locale-compare": "^1.1.0",
7
7
  "@npmcli/fs": "^4.0.0",
8
- "@npmcli/installed-package-contents": "^3.0.0",
8
+ "@npmcli/installed-package-contents": "^4.0.0",
9
9
  "@npmcli/map-workspaces": "^5.0.0",
10
10
  "@npmcli/metavuln-calculator": "^9.0.2",
11
- "@npmcli/name-from-folder": "^3.0.0",
12
- "@npmcli/node-gyp": "^4.0.0",
11
+ "@npmcli/name-from-folder": "^4.0.0",
12
+ "@npmcli/node-gyp": "^5.0.0",
13
13
  "@npmcli/package-json": "^7.0.0",
14
14
  "@npmcli/query": "^4.0.0",
15
- "@npmcli/redact": "^3.0.0",
15
+ "@npmcli/redact": "^4.0.0",
16
16
  "@npmcli/run-script": "^10.0.0",
17
- "bin-links": "^5.0.0",
17
+ "bin-links": "^6.0.0",
18
18
  "cacache": "^20.0.1",
19
19
  "common-ancestor-path": "^1.0.1",
20
20
  "hosted-git-info": "^9.0.0",
@@ -22,18 +22,18 @@
22
22
  "lru-cache": "^11.2.1",
23
23
  "minimatch": "^10.0.3",
24
24
  "nopt": "^8.0.0",
25
- "npm-install-checks": "^7.1.0",
25
+ "npm-install-checks": "^8.0.0",
26
26
  "npm-package-arg": "^13.0.0",
27
27
  "npm-pick-manifest": "^11.0.1",
28
28
  "npm-registry-fetch": "^19.0.0",
29
29
  "pacote": "^21.0.2",
30
- "parse-conflict-json": "^4.0.0",
31
- "proc-log": "^5.0.0",
30
+ "parse-conflict-json": "^5.0.1",
31
+ "proc-log": "^6.0.0",
32
32
  "proggy": "^3.0.0",
33
33
  "promise-all-reject-late": "^1.0.0",
34
34
  "promise-call-limit": "^3.0.1",
35
35
  "semver": "^7.3.7",
36
- "ssri": "^12.0.0",
36
+ "ssri": "^13.0.0",
37
37
  "treeverse": "^3.0.0",
38
38
  "walk-up-path": "^4.0.0"
39
39
  },