@npmcli/arborist 9.0.0 → 9.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ const { lstat, readlink } = require('node:fs/promises')
13
13
  const { depth } = require('treeverse')
14
14
  const { log, time } = require('proc-log')
15
15
  const { redact } = require('@npmcli/redact')
16
+ const semver = require('semver')
16
17
 
17
18
  const {
18
19
  OK,
@@ -279,14 +280,23 @@ module.exports = cls => class IdealTreeBuilder extends cls {
279
280
  // When updating all, we load the shrinkwrap, but don't bother
280
281
  // to build out the full virtual tree from it, since we'll be
281
282
  // reconstructing it anyway.
282
- .then(root => this.options.global ? root
283
- : !this[_usePackageLock] || this[_updateAll]
284
- ? Shrinkwrap.reset({
285
- path: this.path,
286
- lockfileVersion: this.options.lockfileVersion,
287
- resolveOptions: this.options,
288
- }).then(meta => Object.assign(root, { meta }))
289
- : this.loadVirtual({ root }))
283
+ .then(root => {
284
+ if (this.options.global) {
285
+ return root
286
+ } else if (!this[_usePackageLock] || this[_updateAll]) {
287
+ return Shrinkwrap.reset({
288
+ path: this.path,
289
+ lockfileVersion: this.options.lockfileVersion,
290
+ resolveOptions: this.options,
291
+ }).then(meta => Object.assign(root, { meta }))
292
+ } else {
293
+ return this.loadVirtual({ root })
294
+ .then(tree => {
295
+ this.#applyRootOverridesToWorkspaces(tree)
296
+ return tree
297
+ })
298
+ }
299
+ })
290
300
 
291
301
  // if we don't have a lockfile to go from, then start with the
292
302
  // actual tree, so we only make the minimum required changes.
@@ -447,7 +457,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
447
457
  .catch(/* istanbul ignore next */ () => null)
448
458
  if (st && st.isSymbolicLink()) {
449
459
  const target = await readlink(dir)
450
- const real = resolve(dirname(dir), target).replace(/#/g, '%23')
460
+ const real = resolve(dirname(dir), target)
451
461
  tree.package.dependencies[name] = `file:${real}`
452
462
  } else {
453
463
  tree.package.dependencies[name] = '*'
@@ -522,12 +532,12 @@ module.exports = cls => class IdealTreeBuilder extends cls {
522
532
 
523
533
  const { name } = spec
524
534
  if (spec.type === 'file') {
525
- spec = npa(`file:${relpath(path, spec.fetchSpec).replace(/#/g, '%23')}`, path)
535
+ spec = npa(`file:${relpath(path, spec.fetchSpec)}`, path)
526
536
  spec.name = name
527
537
  } else if (spec.type === 'directory') {
528
538
  try {
529
539
  const real = await realpath(spec.fetchSpec, this[_rpcache], this[_stcache])
530
- spec = npa(`file:${relpath(path, real).replace(/#/g, '%23')}`, path)
540
+ spec = npa(`file:${relpath(path, real)}`, path)
531
541
  spec.name = name
532
542
  } catch {
533
543
  // TODO: create synthetic test case to simulate realpath failure
@@ -799,7 +809,8 @@ This is a one-time fix-up, please be patient...
799
809
  const crackOpen = this.#complete &&
800
810
  node !== this.idealTree &&
801
811
  node.resolved &&
802
- (hasBundle || hasShrinkwrap)
812
+ (hasBundle || hasShrinkwrap) &&
813
+ !node.ideallyInert
803
814
  if (crackOpen) {
804
815
  const Arborist = this.constructor
805
816
  const opt = { ...this.options }
@@ -1475,6 +1486,32 @@ This is a one-time fix-up, please be patient...
1475
1486
  timeEnd()
1476
1487
  }
1477
1488
 
1489
+ #applyRootOverridesToWorkspaces (tree) {
1490
+ const rootOverrides = tree.root.package.overrides || {}
1491
+
1492
+ for (const node of tree.root.inventory.values()) {
1493
+ if (!node.isWorkspace) {
1494
+ continue
1495
+ }
1496
+
1497
+ for (const depName of Object.keys(rootOverrides)) {
1498
+ const edge = node.edgesOut.get(depName)
1499
+ const rootNode = tree.root.children.get(depName)
1500
+
1501
+ // safely skip if either edge or rootNode doesn't exist yet
1502
+ if (!edge || !rootNode) {
1503
+ continue
1504
+ }
1505
+
1506
+ const resolvedRootVersion = rootNode.package.version
1507
+ if (!semver.satisfies(resolvedRootVersion, edge.spec)) {
1508
+ edge.detach()
1509
+ node.children.delete(depName)
1510
+ }
1511
+ }
1512
+ }
1513
+ }
1514
+
1478
1515
  #idealTreePrune () {
1479
1516
  for (const node of this.idealTree.inventory.values()) {
1480
1517
  if (node.extraneous) {
@@ -1491,7 +1528,7 @@ This is a one-time fix-up, please be patient...
1491
1528
 
1492
1529
  const set = optionalSet(node)
1493
1530
  for (const node of set) {
1494
- node.parent = null
1531
+ node.ideallyInert = true
1495
1532
  }
1496
1533
  }
1497
1534
  }
@@ -1512,6 +1549,7 @@ This is a one-time fix-up, please be patient...
1512
1549
  node.parent !== null
1513
1550
  && !node.isProjectRoot
1514
1551
  && !excludeNodes.has(node)
1552
+ && !node.ideallyInert
1515
1553
  ) {
1516
1554
  this[_addNodeToTrashList](node)
1517
1555
  }
@@ -81,7 +81,7 @@ module.exports = cls => class IsolatedReifier extends cls {
81
81
  }
82
82
  queue.push(e.to)
83
83
  })
84
- if (!next.isProjectRoot && !next.isWorkspace) {
84
+ if (!next.isProjectRoot && !next.isWorkspace && !next.ideallyInert) {
85
85
  root.external.push(await this.externalProxyMemo(next))
86
86
  }
87
87
  }
@@ -147,8 +147,8 @@ module.exports = cls => class IsolatedReifier extends cls {
147
147
  const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)
148
148
 
149
149
  result.localDependencies = await Promise.all(nonOptionalDeps.filter(n => n.isWorkspace).map(this.workspaceProxyMemo))
150
- result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace).map(this.externalProxyMemo))
151
- result.externalOptionalDependencies = await Promise.all(optionalDeps.map(this.externalProxyMemo))
150
+ result.externalDependencies = await Promise.all(nonOptionalDeps.filter(n => !n.isWorkspace && !n.ideallyInert).map(this.externalProxyMemo))
151
+ result.externalOptionalDependencies = await Promise.all(optionalDeps.filter(n => !n.ideallyInert).map(this.externalProxyMemo))
152
152
  result.dependencies = [
153
153
  ...result.externalDependencies,
154
154
  ...result.localDependencies,
@@ -216,7 +216,7 @@ module.exports = cls => class ActualLoader extends cls {
216
216
  const actualRoot = tree.isLink ? tree.target : tree
217
217
  const { dependencies = {} } = actualRoot.package
218
218
  for (const [name, kid] of actualRoot.children.entries()) {
219
- const def = kid.isLink ? `file:${kid.realpath.replace(/#/g, '%23')}` : '*'
219
+ const def = kid.isLink ? `file:${kid.realpath}` : '*'
220
220
  dependencies[name] = dependencies[name] || def
221
221
  }
222
222
  actualRoot.package = { ...actualRoot.package, dependencies }
@@ -149,7 +149,7 @@ module.exports = cls => class VirtualLoader extends cls {
149
149
  })
150
150
 
151
151
  for (const [name, path] of workspaces.entries()) {
152
- lockWS[name] = `file:${path.replace(/#/g, '%23')}`
152
+ lockWS[name] = `file:${path}`
153
153
  }
154
154
 
155
155
  // Should rootNames exclude optional?
@@ -267,6 +267,7 @@ module.exports = cls => class VirtualLoader extends cls {
267
267
  integrity: sw.integrity,
268
268
  resolved: consistentResolve(sw.resolved, this.path, path),
269
269
  pkg: sw,
270
+ ideallyInert: sw.ideallyInert,
270
271
  hasShrinkwrap: sw.hasShrinkwrap,
271
272
  dev,
272
273
  optional,
@@ -8,7 +8,6 @@ const semver = require('semver')
8
8
  const debug = require('../debug.js')
9
9
  const { walkUp } = require('walk-up-path')
10
10
  const { log, time } = require('proc-log')
11
- const hgi = require('hosted-git-info')
12
11
  const rpj = require('read-package-json-fast')
13
12
 
14
13
  const { dirname, resolve, relative, join } = require('node:path')
@@ -150,7 +149,7 @@ module.exports = cls => class Reifier extends cls {
150
149
  for (const path of this[_trashList]) {
151
150
  const loc = relpath(this.idealTree.realpath, path)
152
151
  const node = this.idealTree.inventory.get(loc)
153
- if (node && node.root === this.idealTree) {
152
+ if (node && node.root === this.idealTree && !node.ideallyInert) {
154
153
  node.parent = null
155
154
  }
156
155
  }
@@ -238,6 +237,18 @@ module.exports = cls => class Reifier extends cls {
238
237
  this.idealTree.meta.hiddenLockfile = true
239
238
  this.idealTree.meta.lockfileVersion = defaultLockfileVersion
240
239
 
240
+ // Preserve inertness for failed stuff.
241
+ if (this.actualTree) {
242
+ for (const [loc, actual] of this.actualTree.inventory.entries()) {
243
+ if (actual.ideallyInert) {
244
+ const ideal = this.idealTree.inventory.get(loc)
245
+ if (ideal) {
246
+ ideal.ideallyInert = true
247
+ }
248
+ }
249
+ }
250
+ }
251
+
241
252
  this.actualTree = this.idealTree
242
253
  this.idealTree = null
243
254
 
@@ -600,6 +611,9 @@ module.exports = cls => class Reifier extends cls {
600
611
  // retire the same path at the same time.
601
612
  const dirsChecked = new Set()
602
613
  return promiseAllRejectLate(leaves.map(async node => {
614
+ if (node.ideallyInert) {
615
+ return
616
+ }
603
617
  for (const d of walkUp(node.path)) {
604
618
  if (d === node.top.path) {
605
619
  break
@@ -744,6 +758,10 @@ module.exports = cls => class Reifier extends cls {
744
758
  }
745
759
 
746
760
  async #extractOrLink (node) {
761
+ if (node.ideallyInert) {
762
+ return
763
+ }
764
+
747
765
  const nm = resolve(node.parent.path, 'node_modules')
748
766
  await this.#validateNodeModules(nm)
749
767
 
@@ -819,6 +837,7 @@ module.exports = cls => class Reifier extends cls {
819
837
  const set = optionalSet(node)
820
838
  for (node of set) {
821
839
  log.verbose('reify', 'failed optional dependency', node.path)
840
+ node.ideallyInert = true
822
841
  this[_addNodeToTrashList](node)
823
842
  }
824
843
  }) : p).then(() => node)
@@ -833,21 +852,23 @@ module.exports = cls => class Reifier extends cls {
833
852
  // ${REGISTRY} or something. This has to be threaded through the
834
853
  // Shrinkwrap and Node classes carefully, so for now, just treat
835
854
  // the default reg as the magical animal that it has been.
836
- const resolvedURL = hgi.parseUrl(resolved)
837
-
838
- if (!resolvedURL) {
855
+ try {
856
+ const resolvedURL = new URL(resolved)
857
+
858
+ if ((this.options.replaceRegistryHost === resolvedURL.hostname) ||
859
+ this.options.replaceRegistryHost === 'always') {
860
+ const registryURL = new URL(this.registry)
861
+ // Replace the host with the registry host while keeping the path intact
862
+ resolvedURL.hostname = registryURL.hostname
863
+ resolvedURL.port = registryURL.port
864
+ return resolvedURL.toString()
865
+ }
866
+ return resolved
867
+ } catch (e) {
839
868
  // if we could not parse the url at all then returning nothing
840
869
  // here means it will get removed from the tree in the next step
841
- return
870
+ return undefined
842
871
  }
843
-
844
- if ((this.options.replaceRegistryHost === resolvedURL.hostname)
845
- || this.options.replaceRegistryHost === 'always') {
846
- // this.registry always has a trailing slash
847
- return `${this.registry.slice(0, -1)}${resolvedURL.pathname}${resolvedURL.searchParams}`
848
- }
849
-
850
- return resolved
851
872
  }
852
873
 
853
874
  // bundles are *sort of* like shrinkwraps, in that the branch is defined
@@ -1151,6 +1172,9 @@ module.exports = cls => class Reifier extends cls {
1151
1172
 
1152
1173
  this.#retiredUnchanged[retireFolder] = []
1153
1174
  return promiseAllRejectLate(diff.unchanged.map(node => {
1175
+ if (node.ideallyInert) {
1176
+ return
1177
+ }
1154
1178
  // no need to roll back links, since we'll just delete them anyway
1155
1179
  if (node.isLink) {
1156
1180
  return mkdir(dirname(node.path), { recursive: true, force: true })
@@ -1230,7 +1254,7 @@ module.exports = cls => class Reifier extends cls {
1230
1254
  // skip links that only live within node_modules as they are most
1231
1255
  // likely managed by packages we installed, we only want to rebuild
1232
1256
  // unchanged links we directly manage
1233
- const linkedFromRoot = node.parent === tree || node.target.fsTop === tree
1257
+ const linkedFromRoot = (node.parent === tree && !node.ideallyInert) || node.target.fsTop === tree
1234
1258
  if (node.isLink && linkedFromRoot) {
1235
1259
  nodes.push(node)
1236
1260
  }
@@ -1364,7 +1388,7 @@ module.exports = cls => class Reifier extends cls {
1364
1388
  // path initially, in which case we can end up with the wrong
1365
1389
  // thing, so just get the ultimate fetchSpec and relativize it.
1366
1390
  const p = req.fetchSpec.replace(/^file:/, '')
1367
- const rel = relpath(addTree.realpath, p).replace(/#/g, '%23')
1391
+ const rel = relpath(addTree.realpath, p)
1368
1392
  newSpec = `file:${rel}`
1369
1393
  }
1370
1394
  } else {
@@ -20,11 +20,10 @@ const consistentResolve = (resolved, fromPath, toPath, relPaths = false) => {
20
20
  raw,
21
21
  } = npa(resolved, fromPath)
22
22
  if (type === 'file' || type === 'directory') {
23
- const cleanFetchSpec = fetchSpec.replace(/#/g, '%23')
24
23
  if (relPaths && toPath) {
25
- return `file:${relpath(toPath, cleanFetchSpec)}`
24
+ return `file:${relpath(toPath, fetchSpec)}`
26
25
  }
27
- return `file:${cleanFetchSpec}`
26
+ return `file:${fetchSpec}`
28
27
  }
29
28
  if (hosted) {
30
29
  return `git+${hosted.auth ? hosted.https(hostedOpt) : hosted.sshurl(hostedOpt)}`
package/lib/dep-valid.js CHANGED
@@ -101,7 +101,7 @@ const depValid = (child, requested, requestor) => {
101
101
  })
102
102
  }
103
103
 
104
- default: // unpossible, just being cautious
104
+ default: // impossible, just being cautious
105
105
  break
106
106
  }
107
107
 
package/lib/edge.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const util = require('node:util')
5
5
  const npa = require('npm-package-arg')
6
6
  const depValid = require('./dep-valid.js')
7
+ const OverrideSet = require('./override-set.js')
7
8
 
8
9
  class ArboristEdge {
9
10
  constructor (edge) {
@@ -103,7 +104,7 @@ class Edge {
103
104
  }
104
105
 
105
106
  satisfiedBy (node) {
106
- if (node.name !== this.#name) {
107
+ if (node.name !== this.#name || !this.#from) {
107
108
  return false
108
109
  }
109
110
 
@@ -112,7 +113,31 @@ class Edge {
112
113
  if (node.hasShrinkwrap || node.inShrinkwrap || node.inBundle) {
113
114
  return depValid(node, this.rawSpec, this.#accept, this.#from)
114
115
  }
115
- return depValid(node, this.spec, this.#accept, this.#from)
116
+
117
+ // If there's no override we just use the spec.
118
+ if (!this.overrides?.keySpec) {
119
+ return depValid(node, this.spec, this.#accept, this.#from)
120
+ }
121
+ // There's some override. If the target node satisfies the overriding spec
122
+ // then it's okay.
123
+ if (depValid(node, this.spec, this.#accept, this.#from)) {
124
+ return true
125
+ }
126
+ // If it doesn't, then it should at least satisfy the original spec.
127
+ if (!depValid(node, this.rawSpec, this.#accept, this.#from)) {
128
+ return false
129
+ }
130
+ // It satisfies the original spec, not the overriding spec. We need to make
131
+ // sure it doesn't use the overridden spec.
132
+ // For example:
133
+ // we might have an ^8.0.0 rawSpec, and an override that makes
134
+ // keySpec=8.23.0 and the override value spec=9.0.0.
135
+ // If the node is 9.0.0, then it's okay because it's consistent with spec.
136
+ // If the node is 8.24.0, then it's okay because it's consistent with the rawSpec.
137
+ // If the node is 8.23.0, then it's not okay because even though it's consistent
138
+ // with the rawSpec, it's also consistent with the keySpec.
139
+ // So we're looking for ^8.0.0 or 9.0.0 and not 8.23.0.
140
+ return !depValid(node, this.overrides.keySpec, this.#accept, this.#from)
116
141
  }
117
142
 
118
143
  // return the edge data, and an explanation of how that edge came to be here
@@ -181,24 +206,21 @@ class Edge {
181
206
  if (this.overrides?.value && this.overrides.value !== '*' && this.overrides.name === this.#name) {
182
207
  if (this.overrides.value.startsWith('$')) {
183
208
  const ref = this.overrides.value.slice(1)
184
- // we may be a virtual root, if we are we want to resolve reference overrides
185
- // from the real root, not the virtual one
186
- const pkg = this.#from.sourceReference
187
- ? this.#from.sourceReference.root.package
188
- : this.#from.root.package
189
- if (pkg.devDependencies?.[ref]) {
190
- return pkg.devDependencies[ref]
191
- }
192
- if (pkg.optionalDependencies?.[ref]) {
193
- return pkg.optionalDependencies[ref]
194
- }
195
- if (pkg.dependencies?.[ref]) {
196
- return pkg.dependencies[ref]
197
- }
198
- if (pkg.peerDependencies?.[ref]) {
199
- return pkg.peerDependencies[ref]
209
+ let pkg = this.#from?.sourceReference
210
+ ? this.#from?.sourceReference.root.package
211
+ : this.#from?.root?.package
212
+
213
+ let specValue = this.#calculateReferentialOverrideSpec(ref, pkg)
214
+
215
+ // If the package isn't found in the root package, fall back to the local package.
216
+ if (!specValue) {
217
+ pkg = this.#from?.package
218
+ specValue = this.#calculateReferentialOverrideSpec(ref, pkg)
200
219
  }
201
220
 
221
+ if (specValue) {
222
+ return specValue
223
+ }
202
224
  throw new Error(`Unable to resolve reference ${this.overrides.value}`)
203
225
  }
204
226
  return this.overrides.value
@@ -206,6 +228,21 @@ class Edge {
206
228
  return this.#spec
207
229
  }
208
230
 
231
+ #calculateReferentialOverrideSpec (ref, pkg) {
232
+ if (pkg.devDependencies?.[ref]) {
233
+ return pkg.devDependencies[ref]
234
+ }
235
+ if (pkg.optionalDependencies?.[ref]) {
236
+ return pkg.optionalDependencies[ref]
237
+ }
238
+ if (pkg.dependencies?.[ref]) {
239
+ return pkg.dependencies[ref]
240
+ }
241
+ if (pkg.peerDependencies?.[ref]) {
242
+ return pkg.peerDependencies[ref]
243
+ }
244
+ }
245
+
209
246
  get accept () {
210
247
  return this.#accept
211
248
  }
@@ -234,10 +271,15 @@ class Edge {
234
271
  } else {
235
272
  this.#error = 'MISSING'
236
273
  }
237
- } else if (this.peer && this.#from === this.#to.parent && !this.#from.isTop) {
274
+ } else if (this.peer && this.#from === this.#to.parent && !this.#from?.isTop) {
238
275
  this.#error = 'PEER LOCAL'
239
276
  } else if (!this.satisfiedBy(this.#to)) {
240
277
  this.#error = 'INVALID'
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.
282
+ this.#error = 'INVALID'
241
283
  } else {
242
284
  this.#error = 'OK'
243
285
  }
@@ -250,15 +292,26 @@ class Edge {
250
292
 
251
293
  reload (hard = false) {
252
294
  this.#explanation = null
253
- if (this.#from.overrides) {
254
- this.overrides = this.#from.overrides.getEdgeRule(this)
295
+
296
+ let needToUpdateOverrideSet = false
297
+ let newOverrideSet
298
+ let oldOverrideSet
299
+ if (this.#from?.overrides) {
300
+ newOverrideSet = this.#from.overrides.getEdgeRule(this)
301
+ if (newOverrideSet && !newOverrideSet.isEqual(this.overrides)) {
302
+ // If there's a new different override set we need to propagate it to the nodes.
303
+ // If we're deleting the override set then there's no point propagating it right now since it will be filled with another value later.
304
+ needToUpdateOverrideSet = true
305
+ oldOverrideSet = this.overrides
306
+ this.overrides = newOverrideSet
307
+ }
255
308
  } else {
256
309
  delete this.overrides
257
310
  }
258
- const newTo = this.#from.resolve(this.#name)
311
+ const newTo = this.#from?.resolve(this.#name)
259
312
  if (newTo !== this.#to) {
260
313
  if (this.#to) {
261
- this.#to.edgesIn.delete(this)
314
+ this.#to.deleteEdgeIn(this)
262
315
  }
263
316
  this.#to = newTo
264
317
  this.#error = null
@@ -267,15 +320,19 @@ class Edge {
267
320
  }
268
321
  } else if (hard) {
269
322
  this.#error = null
323
+ } else if (needToUpdateOverrideSet && this.#to) {
324
+ // Propagate the new override set to the target node.
325
+ this.#to.updateOverridesEdgeInRemoved(oldOverrideSet)
326
+ this.#to.updateOverridesEdgeInAdded(newOverrideSet)
270
327
  }
271
328
  }
272
329
 
273
330
  detach () {
274
331
  this.#explanation = null
275
332
  if (this.#to) {
276
- this.#to.edgesIn.delete(this)
333
+ this.#to.deleteEdgeIn(this)
277
334
  }
278
- this.#from.edgesOut.delete(this.#name)
335
+ this.#from?.edgesOut.delete(this.#name)
279
336
  this.#to = null
280
337
  this.#error = 'DETACHED'
281
338
  this.#from = null
package/lib/link.js CHANGED
@@ -99,7 +99,7 @@ class Link extends Node {
99
99
  // the path/realpath guard is there for the benefit of setting
100
100
  // these things in the "wrong" order
101
101
  return this.path && this.realpath
102
- ? `file:${relpath(dirname(this.path), this.realpath).replace(/#/g, '%23')}`
102
+ ? `file:${relpath(dirname(this.path), this.realpath)}`
103
103
  : null
104
104
  }
105
105
 
package/lib/node.js CHANGED
@@ -40,6 +40,7 @@ const debug = require('./debug.js')
40
40
  const gatherDepSet = require('./gather-dep-set.js')
41
41
  const treeCheck = require('./tree-check.js')
42
42
  const { walkUp } = require('walk-up-path')
43
+ const { log } = require('proc-log')
43
44
 
44
45
  const { resolve, relative, dirname, basename } = require('node:path')
45
46
  const util = require('node:util')
@@ -102,6 +103,7 @@ class Node {
102
103
  global = false,
103
104
  dummy = false,
104
105
  sourceReference = null,
106
+ ideallyInert = false,
105
107
  } = options
106
108
  // this object gives querySelectorAll somewhere to stash context about a node
107
109
  // while processing a query
@@ -196,6 +198,8 @@ class Node {
196
198
  this.extraneous = false
197
199
  }
198
200
 
201
+ this.ideallyInert = ideallyInert
202
+
199
203
  this.edgesIn = new Set()
200
204
  this.edgesOut = new CaseInsensitiveMap()
201
205
 
@@ -344,7 +348,28 @@ class Node {
344
348
  }
345
349
 
346
350
  get overridden () {
347
- return !!(this.overrides && this.overrides.value && this.overrides.name === this.name)
351
+ if (!this.overrides) {
352
+ return false
353
+ }
354
+ if (!this.overrides.value) {
355
+ return false
356
+ }
357
+ if (this.overrides.name !== this.name) {
358
+ return false
359
+ }
360
+
361
+ // The overrides rule is for a package with this name, but some override rules only apply to specific
362
+ // versions. To make sure this package was actually overridden, we check whether any edge going in
363
+ // had the rule applied to it, in which case its overrides set is different than its source node.
364
+ for (const edge of this.edgesIn) {
365
+ if (edge.overrides && edge.overrides.name === this.name && edge.overrides.value === this.version) {
366
+ if (!edge.overrides.isEqual(edge.from.overrides)) {
367
+ return true
368
+ }
369
+ }
370
+ }
371
+
372
+ return false
348
373
  }
349
374
 
350
375
  get package () {
@@ -822,9 +847,6 @@ class Node {
822
847
  target.root = root
823
848
  }
824
849
 
825
- if (!this.overrides && this.parent && this.parent.overrides) {
826
- this.overrides = this.parent.overrides.getNodeRule(this)
827
- }
828
850
  // tree should always be valid upon root setter completion.
829
851
  treeCheck(this)
830
852
  if (this !== root) {
@@ -842,7 +864,7 @@ class Node {
842
864
  }
843
865
 
844
866
  for (const [name, path] of this.#workspaces.entries()) {
845
- new Edge({ from: this, name, spec: `file:${path.replace(/#/g, '%23')}`, type: 'workspace' })
867
+ new Edge({ from: this, name, spec: `file:${path}`, type: 'workspace' })
846
868
  }
847
869
  }
848
870
 
@@ -1006,10 +1028,21 @@ class Node {
1006
1028
  return false
1007
1029
  }
1008
1030
 
1009
- // XXX need to check for two root nodes?
1010
- if (node.overrides !== this.overrides) {
1011
- return false
1031
+ // If this node has no dependencies, then it's irrelevant to check the override
1032
+ // rules of the replacement node.
1033
+ if (this.edgesOut.size) {
1034
+ // XXX need to check for two root nodes?
1035
+ if (node.overrides) {
1036
+ if (!node.overrides.isEqual(this.overrides)) {
1037
+ return false
1038
+ }
1039
+ } else {
1040
+ if (this.overrides) {
1041
+ return false
1042
+ }
1043
+ }
1012
1044
  }
1045
+
1013
1046
  ignorePeers = new Set(ignorePeers)
1014
1047
 
1015
1048
  // gather up all the deps of this node and that are only depended
@@ -1044,7 +1077,7 @@ class Node {
1044
1077
  // return true if it's safe to remove this node, because anything that
1045
1078
  // is depending on it would be fine with the thing that they would resolve
1046
1079
  // to if it was removed, or nothing is depending on it in the first place.
1047
- canDedupe (preferDedupe = false) {
1080
+ canDedupe (preferDedupe = false, explicitRequest = false) {
1048
1081
  // not allowed to mess with shrinkwraps or bundles
1049
1082
  if (this.inDepBundle || this.inShrinkwrap) {
1050
1083
  return false
@@ -1077,8 +1110,18 @@ class Node {
1077
1110
  return false
1078
1111
  }
1079
1112
 
1080
- // if we prefer dedupe, or if the version is greater/equal, take the other
1081
- if (preferDedupe || semver.gte(other.version, this.version)) {
1113
+ // if we prefer dedupe, or if the version is equal, take the other
1114
+ if (preferDedupe || semver.eq(other.version, this.version)) {
1115
+ return true
1116
+ }
1117
+
1118
+ // if our current version isn't the result of an override, then prefer to take the greater version
1119
+ if (!this.overridden && semver.gt(other.version, this.version)) {
1120
+ return true
1121
+ }
1122
+
1123
+ // if the other version was an explicit request, then prefer to take the other version
1124
+ if (explicitRequest) {
1082
1125
  return true
1083
1126
  }
1084
1127
 
@@ -1249,10 +1292,6 @@ class Node {
1249
1292
  this[_changePath](newPath)
1250
1293
  }
1251
1294
 
1252
- if (parent.overrides) {
1253
- this.overrides = parent.overrides.getNodeRule(this)
1254
- }
1255
-
1256
1295
  // clobbers anything at that path, resets all appropriate references
1257
1296
  this.root = parent.root
1258
1297
  }
@@ -1346,9 +1385,87 @@ class Node {
1346
1385
  this.edgesOut.set(edge.name, edge)
1347
1386
  }
1348
1387
 
1349
- addEdgeIn (edge) {
1388
+ recalculateOutEdgesOverrides () {
1389
+ // For each edge out propogate the new overrides through.
1390
+ for (const edge of this.edgesOut.values()) {
1391
+ edge.reload(true)
1392
+ if (edge.to) {
1393
+ edge.to.updateOverridesEdgeInAdded(edge.overrides)
1394
+ }
1395
+ }
1396
+ }
1397
+
1398
+ updateOverridesEdgeInRemoved (otherOverrideSet) {
1399
+ // If this edge's overrides isn't equal to this node's overrides, then removing it won't change newOverrideSet later.
1400
+ if (!this.overrides || !this.overrides.isEqual(otherOverrideSet)) {
1401
+ return false
1402
+ }
1403
+ let newOverrideSet
1404
+ for (const edge of this.edgesIn) {
1405
+ if (newOverrideSet && edge.overrides) {
1406
+ newOverrideSet = OverrideSet.findSpecificOverrideSet(edge.overrides, newOverrideSet)
1407
+ } else {
1408
+ newOverrideSet = edge.overrides
1409
+ }
1410
+ }
1411
+ if (this.overrides.isEqual(newOverrideSet)) {
1412
+ return false
1413
+ }
1414
+ this.overrides = newOverrideSet
1415
+ if (this.overrides) {
1416
+ // Optimization: if there's any override set at all, then no non-extraneous node has an empty override set. So if we temporarily have no
1417
+ // override set (for example, we removed all the edges in), there's no use updating all the edges out right now. Let's just wait until
1418
+ // we have an actual override set later.
1419
+ this.recalculateOutEdgesOverrides()
1420
+ }
1421
+ return true
1422
+ }
1423
+
1424
+ // This logic isn't perfect either. When we have two edges in that have different override sets, then we have to decide which set is correct.
1425
+ // This function assumes the more specific override set is applicable, so if we have dependencies A->B->C and A->C
1426
+ // and an override set that specifies what happens for C under A->B, this will work even if the new A->C edge comes along and tries to change
1427
+ // the override set.
1428
+ // The strictly correct logic is not to allow two edges with different overrides to point to the same node, because even if this node can satisfy
1429
+ // both, one of its dependencies might need to be different depending on the edge leading to it.
1430
+ // However, this might cause a lot of duplication, because the conflict in the dependencies might never actually happen.
1431
+ updateOverridesEdgeInAdded (otherOverrideSet) {
1432
+ if (!otherOverrideSet) {
1433
+ // Assuming there are any overrides at all, the overrides field is never undefined for any node at the end state of the tree.
1434
+ // So if the new edge's overrides is undefined it will be updated later. So we can wait with updating the node's overrides field.
1435
+ return false
1436
+ }
1437
+ if (!this.overrides) {
1438
+ this.overrides = otherOverrideSet
1439
+ this.recalculateOutEdgesOverrides()
1440
+ return true
1441
+ }
1442
+ if (this.overrides.isEqual(otherOverrideSet)) {
1443
+ return false
1444
+ }
1445
+ const newOverrideSet = OverrideSet.findSpecificOverrideSet(this.overrides, otherOverrideSet)
1446
+ if (newOverrideSet) {
1447
+ if (!this.overrides.isEqual(newOverrideSet)) {
1448
+ this.overrides = newOverrideSet
1449
+ this.recalculateOutEdgesOverrides()
1450
+ return true
1451
+ }
1452
+ return false
1453
+ }
1454
+ // This is an error condition. We can only get here if the new override set is in conflict with the existing.
1455
+ log.silly('Conflicting override sets', this.name)
1456
+ }
1457
+
1458
+ deleteEdgeIn (edge) {
1459
+ this.edgesIn.delete(edge)
1350
1460
  if (edge.overrides) {
1351
- this.overrides = edge.overrides
1461
+ this.updateOverridesEdgeInRemoved(edge.overrides)
1462
+ }
1463
+ }
1464
+
1465
+ addEdgeIn (edge) {
1466
+ // We need to handle the case where the new edge in has an overrides field which is different from the current value.
1467
+ if (!this.overrides || !this.overrides.isEqual(edge.overrides)) {
1468
+ this.updateOverridesEdgeInAdded(edge.overrides)
1352
1469
  }
1353
1470
 
1354
1471
  this.edgesIn.add(edge)
@@ -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) {
@@ -55,7 +93,9 @@ class OverrideSet {
55
93
  return rule
56
94
  }
57
95
 
58
- let spec = npa(`${edge.name}@${edge.spec}`)
96
+ // We need to use the rawSpec here, because the spec has the overrides applied to it already.
97
+ // rawSpec can be undefined, so we need to use the fallback value of spec if it is.
98
+ let spec = npa(`${edge.name}@${edge.rawSpec || edge.spec}`)
59
99
  if (spec.type === 'alias') {
60
100
  spec = spec.subSpec
61
101
  }
@@ -142,6 +182,28 @@ class OverrideSet {
142
182
 
143
183
  return ruleset
144
184
  }
185
+
186
+ static findSpecificOverrideSet (first, second) {
187
+ for (let overrideSet = second; overrideSet; overrideSet = overrideSet.parent) {
188
+ if (overrideSet.isEqual(first)) {
189
+ return second
190
+ }
191
+ }
192
+ for (let overrideSet = first; overrideSet; overrideSet = overrideSet.parent) {
193
+ if (overrideSet.isEqual(second)) {
194
+ return first
195
+ }
196
+ }
197
+
198
+ // The override sets are incomparable. Neither one contains the other.
199
+ log.silly('Conflicting override sets', first, second)
200
+ }
201
+
202
+ static doOverrideSetsConflict (first, second) {
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)
206
+ }
145
207
  }
146
208
 
147
209
  module.exports = OverrideSet
package/lib/place-dep.js CHANGED
@@ -423,7 +423,7 @@ class PlaceDep {
423
423
  // is another satisfying node further up the tree, and if so, dedupes.
424
424
  // Even in installStategy is nested, we do this amount of deduplication.
425
425
  pruneDedupable (node, descend = true) {
426
- if (node.canDedupe(this.preferDedupe)) {
426
+ if (node.canDedupe(this.preferDedupe, this.explicitRequest)) {
427
427
  // gather up all deps that have no valid edges in from outside
428
428
  // the dep set, except for this node we're deduping, so that we
429
429
  // also prune deps that would be made extraneous.
package/lib/shrinkwrap.js CHANGED
@@ -109,6 +109,7 @@ const nodeMetaKeys = [
109
109
  'inBundle',
110
110
  'hasShrinkwrap',
111
111
  'hasInstallScript',
112
+ 'ideallyInert',
112
113
  ]
113
114
 
114
115
  const metaFieldFromPkg = (pkg, key) => {
@@ -135,6 +136,10 @@ const assertNoNewer = async (path, data, lockTime, dir, seen) => {
135
136
 
136
137
  const parent = isParent ? dir : resolve(dir, 'node_modules')
137
138
  const rel = relpath(path, dir)
139
+ const inert = data.packages[rel]?.ideallyInert
140
+ if (inert) {
141
+ return
142
+ }
138
143
  seen.add(rel)
139
144
  let entries
140
145
  if (dir === path) {
@@ -173,7 +178,7 @@ const assertNoNewer = async (path, data, lockTime, dir, seen) => {
173
178
 
174
179
  // assert that all the entries in the lockfile were seen
175
180
  for (const loc in data.packages) {
176
- if (!seen.has(loc)) {
181
+ if (!seen.has(loc) && !data.packages[loc].ideallyInert) {
177
182
  throw new Error(`missing from node_modules: ${loc}`)
178
183
  }
179
184
  }
@@ -783,6 +788,10 @@ class Shrinkwrap {
783
788
  // ok, I did my best! good luck!
784
789
  }
785
790
 
791
+ if (lock.ideallyInert) {
792
+ meta.ideallyInert = true
793
+ }
794
+
786
795
  if (lock.bundled) {
787
796
  meta.inBundle = true
788
797
  }
@@ -817,7 +826,7 @@ class Shrinkwrap {
817
826
  if (!/^file:/.test(resolved)) {
818
827
  pathFixed = resolved
819
828
  } else {
820
- pathFixed = `file:${resolve(this.path, resolved.slice(5)).replace(/#/g, '%23')}`
829
+ pathFixed = `file:${resolve(this.path, resolved.slice(5))}`
821
830
  }
822
831
  }
823
832
 
@@ -953,6 +962,12 @@ class Shrinkwrap {
953
962
  this.#buildLegacyLockfile(this.tree, this.data)
954
963
  }
955
964
 
965
+ if (!this.hiddenLockfile) {
966
+ for (const node of Object.values(this.data.packages)) {
967
+ delete node.ideallyInert
968
+ }
969
+ }
970
+
956
971
  // lf version 1 = dependencies only
957
972
  // lf version 2 = dependencies and packages
958
973
  // lf version 3 = packages only
@@ -1011,7 +1026,7 @@ class Shrinkwrap {
1011
1026
  }
1012
1027
 
1013
1028
  if (node.isLink) {
1014
- lock.version = `file:${relpath(this.path, node.realpath).replace(/#/g, '%23')}`
1029
+ lock.version = `file:${relpath(this.path, node.realpath)}`
1015
1030
  } else if (spec && (spec.type === 'file' || spec.type === 'remote')) {
1016
1031
  lock.version = spec.saveSpec
1017
1032
  } else if (spec && spec.type === 'git' || rSpec.type === 'git') {
@@ -1089,7 +1104,7 @@ class Shrinkwrap {
1089
1104
  // this especially shows up with workspace edges when the root
1090
1105
  // node is also a workspace in the set.
1091
1106
  const p = resolve(node.realpath, spec.slice('file:'.length))
1092
- set[k] = `file:${relpath(node.realpath, p).replace(/#/g, '%23')}`
1107
+ set[k] = `file:${relpath(node.realpath, p)}`
1093
1108
  } else {
1094
1109
  set[k] = spec
1095
1110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.0.0",
3
+ "version": "9.0.2",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@isaacs/string-locale-compare": "^1.1.0",