@npmcli/arborist 9.5.0 → 10.0.0-pre.0.0

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.
package/README.md CHANGED
@@ -60,7 +60,7 @@ arb.loadActual().then(tree => {
60
60
  // tree is also stored at arb.virtualTree
61
61
  })
62
62
 
63
- // read just what the package-lock.json/npm-shrinkwrap says
63
+ // read just what the package-lock.json says
64
64
  // This *also* loads the yarn.lock file, but that's only relevant
65
65
  // when building the ideal tree.
66
66
  arb.loadVirtual().then(tree => {
@@ -301,7 +301,7 @@ pruning nodes from the tree.
301
301
  that the dep is brought in by a peer dep at some point, rather than a
302
302
  normal non-peer dependency.
303
303
 
304
- Note: `devOptional` is only set in the shrinkwrap/package-lock file if
304
+ Note: `devOptional` is only set in the package-lock file if
305
305
  _neither_ `dev` nor `optional` are set, as it would be redundant.
306
306
 
307
307
  ## BIN
package/bin/index.js CHANGED
@@ -20,8 +20,7 @@ ${message && '\n' + message + '\n'}
20
20
  * prune: prune the ideal tree and reify (like npm prune)
21
21
  * ideal: generate and print the ideal tree
22
22
  * actual: read and print the actual tree in node_modules
23
- * virtual: read and print the virtual tree in the local shrinkwrap file
24
- * shrinkwrap: load a local shrinkwrap and print its data
23
+ * virtual: read and print the virtual tree in the local package-lock.json
25
24
  * audit: perform a security audit on project dependencies
26
25
  * funding: query funding information in the local package tree. A second
27
26
  positional argument after the path name can limit to a package name.
@@ -28,6 +28,15 @@ const Shrinkwrap = require('../shrinkwrap.js')
28
28
  const { defaultLockfileVersion } = Shrinkwrap
29
29
  const Node = require('../node.js')
30
30
  const Link = require('../link.js')
31
+
32
+ // Maps a parsed spec.type to the corresponding allow-* arborist option name.
33
+ // Hoisted to module scope so #checkAllow doesn't re-allocate it per call.
34
+ const ALLOW_OPTION_FOR_TYPE = {
35
+ git: 'allowGit',
36
+ remote: 'allowRemote',
37
+ file: 'allowFile',
38
+ directory: 'allowDirectory',
39
+ }
31
40
  const addRmPkgDeps = require('../add-rm-pkg-deps.js')
32
41
  const optionalSet = require('../optional-set.js')
33
42
  const { checkEngine, checkPlatform } = require('npm-install-checks')
@@ -649,6 +658,45 @@ module.exports = cls => class IdealTreeBuilder extends cls {
649
658
  return vuln.range
650
659
  }
651
660
 
661
+ // Enforces the allow-git / allow-file / allow-directory / allow-remote configs at the arborist resolution layer, before any branching into the symlink (Link) path or the manifest-fetch path.
662
+ // Pacote also enforces these inside FetcherBase.get() as defense-in-depth, but the symlink branch never reaches pacote, and the manifest cache here would bypass pacote on a cached hit.
663
+ // Throws the same { code: EALLOW${TYPE} } shape pacote uses, so callers and downstream consumers stay consistent.
664
+ #checkAllow (spec, edge) {
665
+ const optName = ALLOW_OPTION_FOR_TYPE[spec.type]
666
+ if (!optName) {
667
+ return
668
+ }
669
+ const allow = this.options[optName] ?? 'all'
670
+ if (allow === 'all') {
671
+ return
672
+ }
673
+ const isRoot = !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace)
674
+ if (allow !== 'none' && isRoot) {
675
+ return
676
+ }
677
+ throw Object.assign(
678
+ new Error(`Fetching${allow === 'root' ? ' non-root' : ''} packages of type "${spec.type}" have been disabled`),
679
+ {
680
+ code: `EALLOW${spec.type.toUpperCase()}`,
681
+ package: spec.toString(),
682
+ }
683
+ )
684
+ }
685
+
686
+ // Builds a Node representing a spec we failed to load (allow-* gate, network failure, ENOTARGET, etc.) and records it in #loadFailures so #pruneFailedOptional can later decide whether the failure is fatal or silently dropped for optional deps.
687
+ #failureNode (name, parent, error, edge) {
688
+ error.requiredBy = edge?.from?.location || '.'
689
+ const n = new Node({
690
+ name,
691
+ parent,
692
+ error,
693
+ installLinks: this.installLinks,
694
+ legacyPeerDeps: this.legacyPeerDeps,
695
+ })
696
+ this.#loadFailures.add(n)
697
+ return n
698
+ }
699
+
652
700
  #queueNamedUpdates () {
653
701
  // ignore top nodes, since they are not loaded the same way, and
654
702
  // probably have their own project associated with them.
@@ -663,7 +711,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
663
711
  for (const node of this.idealTree.inventory.values()) {
664
712
  // XXX add any invalid edgesOut to the queue
665
713
  if (this[_updateNames].includes(node.name) &&
666
- !node.isTop && !node.inDepBundle && !node.inShrinkwrap) {
714
+ !node.isTop && !node.inDepBundle) {
667
715
  for (const edge of node.edgesIn) {
668
716
  this.addTracker('idealTree', edge.from.name, edge.from.location)
669
717
  this.#depsQueue.push(edge.from)
@@ -786,15 +834,11 @@ This is a one-time fix-up, please be patient...
786
834
  const node = this.#depsQueue.pop()
787
835
  const bd = node.package.bundleDependencies
788
836
  const hasBundle = bd && Array.isArray(bd) && bd.length
789
- const { hasShrinkwrap } = node
790
837
 
791
838
  // if the node was already visited, or has since been removed from the
792
- // tree, skip over it and process the rest of the queue. If a node has
793
- // a shrinkwrap, also skip it, because it's going to get its deps
794
- // satisfied by whatever's in that file anyway.
839
+ // tree, skip over it and process the rest of the queue.
795
840
  if (this.#depsSeen.has(node) ||
796
- node.root !== this.idealTree ||
797
- hasShrinkwrap && !this.#complete) {
841
+ node.root !== this.idealTree) {
798
842
  return this.#buildDepStep()
799
843
  }
800
844
 
@@ -804,15 +848,15 @@ This is a one-time fix-up, please be patient...
804
848
 
805
849
  // if we're loading a _complete_ ideal tree, for a --package-lock-only
806
850
  // installation for example, we have to crack open the tarball and
807
- // look inside if it has bundle deps or shrinkwraps. note that this is
851
+ // look inside if it has bundle deps. note that this is
808
852
  // not necessary during a reification, because we just update the
809
- // ideal tree by reading bundles/shrinkwraps in place.
853
+ // ideal tree by reading bundles in place.
810
854
  // Don't bother if the node is from the actual tree and hasn't
811
855
  // been resolved, because we can't fetch it anyway, could be anything!
812
856
  const crackOpen = this.#complete &&
813
857
  node !== this.idealTree &&
814
858
  node.resolved &&
815
- (hasBundle || hasShrinkwrap) &&
859
+ hasBundle &&
816
860
  !node.inert
817
861
  if (crackOpen) {
818
862
  const Arborist = this.constructor
@@ -825,15 +869,8 @@ This is a one-time fix-up, please be patient...
825
869
  integrity: node.integrity,
826
870
  })
827
871
 
828
- if (hasShrinkwrap) {
829
- await new Arborist({ ...this.options, path })
830
- .loadVirtual({ root: node })
831
- }
832
-
833
- if (hasBundle) {
834
- await new Arborist({ ...this.options, path })
835
- .loadActual({ root: node, ignoreMissing: true })
836
- }
872
+ await new Arborist({ ...this.options, path })
873
+ .loadActual({ root: node, ignoreMissing: true })
837
874
  })
838
875
  }
839
876
 
@@ -1040,7 +1077,7 @@ This is a one-time fix-up, please be patient...
1040
1077
  // This can't be changed or removed till we figure out why
1041
1078
  // The test is named "tarball deps with transitive tarball deps"
1042
1079
  promises.push(() =>
1043
- this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)), parent)
1080
+ this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)), parent, e)
1044
1081
  .catch(() => null)
1045
1082
  )
1046
1083
  }
@@ -1180,11 +1217,6 @@ This is a one-time fix-up, please be patient...
1180
1217
  continue
1181
1218
  }
1182
1219
 
1183
- // If it's shrinkwrapped, we use what the shrinkwap wants.
1184
- if (edge.to && edge.to.inShrinkwrap) {
1185
- continue
1186
- }
1187
-
1188
1220
  // If the edge has no destination, that's a problem, unless
1189
1221
  // if it's peerOptional and not explicitly requested.
1190
1222
  if (!edge.to) {
@@ -1231,12 +1263,14 @@ This is a one-time fix-up, please be patient...
1231
1263
  return problems
1232
1264
  }
1233
1265
 
1234
- async #fetchManifest (spec, parent) {
1266
+ async #fetchManifest (spec, parent, edge) {
1267
+ // Enforce allow-* gates before consulting the manifest cache so a cached entry from a different edge cannot bypass the policy.
1268
+ this.#checkAllow(spec, edge)
1235
1269
  const options = {
1236
1270
  ...this.options,
1237
1271
  avoid: this.#avoidRange(spec.name),
1238
1272
  fullMetadata: true,
1239
- _isRoot: parent?.isProjectRoot || parent?.isWorkspace,
1273
+ _isRoot: !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace),
1240
1274
  }
1241
1275
  // get the intended spec and stored metadata from yarn.lock file,
1242
1276
  // if available and valid.
@@ -1253,6 +1287,14 @@ This is a one-time fix-up, please be patient...
1253
1287
  }
1254
1288
 
1255
1289
  async #nodeFromSpec (name, spec, parent, edge) {
1290
+ // Enforce allow-git / allow-file / allow-directory / allow-remote before any branching, so the symlink (Link) path is enforced as well as the manifest-fetch path.
1291
+ // Route the failure through #loadFailures so optional-dep semantics apply (e.g. a transitive optionalDependencies entry that resolves to a disallowed git URL is silently dropped rather than failing the install).
1292
+ try {
1293
+ this.#checkAllow(spec, edge)
1294
+ } catch (error) {
1295
+ return this.#failureNode(name, parent, error, edge)
1296
+ }
1297
+
1256
1298
  // pacote will slap integrity on its options, so we have to clone the object so it doesn't get mutated.
1257
1299
  // Don't bother to load the manifest for link deps, because the target might be within another package that doesn't exist yet.
1258
1300
  const { installLinks, legacyPeerDeps } = this
@@ -1307,23 +1349,26 @@ This is a one-time fix-up, please be patient...
1307
1349
 
1308
1350
  // spec isn't a directory, and either isn't a workspace or the workspace we have
1309
1351
  // doesn't satisfy the edge. try to fetch a manifest and build a node from that.
1310
- return this.#fetchManifest(spec, parent)
1311
- .then(pkg => new Node({ name, pkg, parent, installLinks, legacyPeerDeps }), error => {
1312
- error.requiredBy = edge.from.location || '.'
1313
-
1314
- // failed to load the spec, either because of enotarget or
1315
- // fetch failure of some other sort. save it so we can verify
1316
- // later that it's optional; otherwise, the error is fatal.
1317
- const n = new Node({
1318
- name,
1319
- parent,
1320
- error,
1321
- installLinks,
1322
- legacyPeerDeps,
1323
- })
1324
- this.#loadFailures.add(n)
1325
- return n
1326
- })
1352
+ return this.#fetchManifest(spec, parent, edge)
1353
+ .then(
1354
+ pkg => {
1355
+ // When a proxy/upstream registry returns an incomplete manifest
1356
+ // (e.g. missing version field for platform-specific packages it
1357
+ // hasn't cached), treat it as a load failure so that optional deps
1358
+ // are properly pruned instead of written to the lockfile without
1359
+ // version metadata. Only apply to registry specs — file: deps
1360
+ // legitimately omit version.
1361
+ if (!pkg.version && spec.registry) {
1362
+ const error = Object.assign(
1363
+ new Error(`incomplete manifest for ${name}, missing version`),
1364
+ { code: 'EINCOMPLETEMANIFEST' }
1365
+ )
1366
+ return this.#failureNode(name, parent, error, edge)
1367
+ }
1368
+ return new Node({ name, pkg, parent, installLinks, legacyPeerDeps })
1369
+ },
1370
+ error => this.#failureNode(name, parent, error, edge)
1371
+ )
1327
1372
  }
1328
1373
 
1329
1374
  // load all peer deps and meta-peer deps into the node's parent
@@ -1,5 +1,3 @@
1
- const { mkdirSync } = require('node:fs')
2
- const pacote = require('pacote')
3
1
  const { join } = require('node:path')
4
2
  const { depth } = require('treeverse')
5
3
  const crypto = require('node:crypto')
@@ -97,7 +95,9 @@ module.exports = cls => class IsolatedReifier extends cls {
97
95
  }
98
96
  this.counter = 0
99
97
 
100
- this.idealGraph.workspaces = await Promise.all(Array.from(idealTree.fsChildren.values(), w => this.#workspaceProxy(w)))
98
+ // Skip extraneous fsChildren: workspaces removed from the root manifest can linger in fsChildren via the lockfile, and re-materializing them here would re-create a directory the user just deleted.
99
+ const fsChildren = Array.from(idealTree.fsChildren.values()).filter(w => !w.extraneous)
100
+ this.idealGraph.workspaces = await Promise.all(fsChildren.map(w => this.#workspaceProxy(w)))
101
101
  const processed = new Set()
102
102
  const queue = [idealTree, ...idealTree.fsChildren]
103
103
  while (queue.length !== 0) {
@@ -147,50 +147,14 @@ module.exports = cls => class IsolatedReifier extends cls {
147
147
  const result = {}
148
148
  // XXX this goes recursive if we don't set here because assignCommonProperties also calls this.#externalProxy
149
149
  this.#externalProxies.set(node, result)
150
- await this.#assignCommonProperties(node, result, !node.hasShrinkwrap)
151
- if (node.hasShrinkwrap) {
152
- const dir = join(
153
- node.root.path,
154
- 'node_modules',
155
- '.store',
156
- `${node.packageName}@${node.version}`
157
- )
158
- mkdirSync(dir, { recursive: true })
159
- // TODO this approach feels wrong and shouldn't be necessary for shrinkwraps
160
- await pacote.extract(node.resolved, dir, {
161
- ...this.options,
162
- resolved: node.resolved,
163
- integrity: node.integrity,
164
- // TODO _isRoot
165
- })
166
- const Arborist = this.constructor
167
- const arb = new Arborist({ ...this.options, path: dir })
168
- // Make sure that the ideal tree is build as the rest of the algorithm depends on it.
169
- await arb.buildIdealTree({
170
- complete: false,
171
- dev: false,
172
- })
173
- await arb.makeIdealGraph()
174
- this.idealGraph.external.push(...arb.idealGraph.external)
175
- for (const edge of arb.idealGraph.external) {
176
- edge.root = this.idealGraph
177
- edge.id = `${node.id}=>${edge.id}`
178
- }
179
- result.localDependencies = []
180
- result.externalDependencies = arb.idealGraph.externalDependencies
181
- result.externalOptionalDependencies = arb.idealGraph.externalOptionalDependencies
182
- result.dependencies = [
183
- ...result.externalDependencies,
184
- ...result.externalOptionalDependencies,
185
- ]
186
- }
150
+ await this.#assignCommonProperties(node, result)
187
151
  result.optional = node.optional
188
152
  result.resolved = node.resolved
189
153
  result.version = node.version
190
154
  return result
191
155
  }
192
156
 
193
- async #assignCommonProperties (node, result, populateDeps = true) {
157
+ async #assignCommonProperties (node, result) {
194
158
  result.root = this.idealGraph
195
159
  // XXX does anything need this?
196
160
  result.id = this.counter++
@@ -200,10 +164,6 @@ module.exports = cls => class IsolatedReifier extends cls {
200
164
  result.package = { ...node.package }
201
165
  result.package.bundleDependencies = undefined
202
166
 
203
- if (!populateDeps) {
204
- return
205
- }
206
-
207
167
  let edges = [...node.edgesOut.values()].filter(edge =>
208
168
  edge.to?.target &&
209
169
  !(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
@@ -39,7 +39,7 @@ module.exports = cls => class VirtualLoader extends cls {
39
39
  resolveOptions: this.options,
40
40
  })
41
41
  if (!s.loadedFromDisk && !options.root) {
42
- const er = new Error('loadVirtual requires existing shrinkwrap file')
42
+ const er = new Error('loadVirtual requires existing package-lock.json file')
43
43
  throw Object.assign(er, { code: 'ENOLOCK' })
44
44
  }
45
45
 
@@ -244,7 +244,6 @@ To fix:
244
244
  integrity: sw.integrity,
245
245
  resolved: consistentResolve(sw.resolved, this.path, path),
246
246
  pkg: sw,
247
- hasShrinkwrap: sw.hasShrinkwrap,
248
247
  loadOverrides,
249
248
  // cast to boolean because they're undefined in the lock file when false
250
249
  extraneous: !!sw.extraneous,
@@ -4,6 +4,7 @@ const hgi = require('hosted-git-info')
4
4
  const npa = require('npm-package-arg')
5
5
  const packageContents = require('@npmcli/installed-package-contents')
6
6
  const pacote = require('pacote')
7
+ const { pickRegistry } = require('npm-registry-fetch')
7
8
  const promiseAllRejectLate = require('promise-all-reject-late')
8
9
  const runScript = require('@npmcli/run-script')
9
10
  const { callLimit: promiseCallLimit } = require('promise-call-limit')
@@ -47,7 +48,6 @@ const _checkBins = Symbol.for('checkBins')
47
48
  // TODO tests should not be this deep into internals
48
49
  const _diffTrees = Symbol.for('diffTrees')
49
50
  const _createSparseTree = Symbol.for('createSparseTree')
50
- const _loadShrinkwrapsAndUpdateTrees = Symbol.for('loadShrinkwrapsAndUpdateTrees')
51
51
  const _reifyNode = Symbol.for('reifyNode')
52
52
  const _updateAll = Symbol.for('updateAll')
53
53
  const _updateNames = Symbol.for('updateNames')
@@ -72,7 +72,6 @@ module.exports = cls => class Reifier extends cls {
72
72
  #omit
73
73
  #retiredPaths = {}
74
74
  #retiredUnchanged = {}
75
- #shrinkwrapInflated = new Set()
76
75
  #sparseTreeDirs = new Set()
77
76
  #sparseTreeRoots = new Set()
78
77
  #linkedActualForDiff = null
@@ -305,7 +304,6 @@ module.exports = cls => class Reifier extends cls {
305
304
  ]],
306
305
  [_rollbackCreateSparseTree, [
307
306
  _createSparseTree,
308
- _loadShrinkwrapsAndUpdateTrees,
309
307
  _loadBundlesAndUpdateTrees,
310
308
  _submitQuickAudit,
311
309
  _unpackNewModules,
@@ -465,7 +463,6 @@ module.exports = cls => class Reifier extends cls {
465
463
  // and ideal trees.
466
464
  this.diff = Diff.calculate({
467
465
  omit: this.#omit,
468
- shrinkwrapInflated: this.#shrinkwrapInflated,
469
466
  filterNodes,
470
467
  actual: this.#linkedActualForDiff || this.actualTree,
471
468
  ideal: this.idealTree,
@@ -616,39 +613,6 @@ module.exports = cls => class Reifier extends cls {
616
613
  .then(() => this[_rollbackRetireShallowNodes](er))
617
614
  }
618
615
 
619
- // shrinkwrap nodes define their dependency branches with a file, so
620
- // we need to unpack them, read that shrinkwrap file, and then update
621
- // the tree by calling loadVirtual with the node as the root.
622
- [_loadShrinkwrapsAndUpdateTrees] () {
623
- const seen = this.#shrinkwrapInflated
624
- const shrinkwraps = this.diff.leaves
625
- .filter(d => (d.action === 'CHANGE' || d.action === 'ADD' || !d.action) &&
626
- d.ideal.hasShrinkwrap && !seen.has(d.ideal) &&
627
- !this[_trashList].has(d.ideal.path))
628
-
629
- if (!shrinkwraps.length) {
630
- return
631
- }
632
-
633
- const timeEnd = time.start('reify:loadShrinkwraps')
634
-
635
- const Arborist = this.constructor
636
- return promiseAllRejectLate(shrinkwraps.map(diff => {
637
- const node = diff.ideal
638
- seen.add(node)
639
- return diff.action ? this[_reifyNode](node) : node
640
- }))
641
- .then(nodes => promiseAllRejectLate(nodes.map(node => new Arborist({
642
- ...this.options,
643
- path: node.path,
644
- }).loadVirtual({ root: node }))))
645
- // reload the diff and sparse tree because the ideal tree changed
646
- .then(() => this[_diffTrees]())
647
- .then(() => this[_createSparseTree]())
648
- .then(() => this[_loadShrinkwrapsAndUpdateTrees]())
649
- .then(timeEnd)
650
- }
651
-
652
616
  // create a symlink for Links, extract for Nodes
653
617
  // return the node object, since we usually want that
654
618
  // handle optional dep failures here
@@ -741,7 +705,14 @@ module.exports = cls => class Reifier extends cls {
741
705
  ...this.options,
742
706
  resolved: node.resolved,
743
707
  integrity: node.integrity,
744
- _isRoot: node.parent?.isProjectRoot || node.parent?.isWorkspace,
708
+ // A node counts as "root" for allow-* enforcement if it satisfies at least one valid dependency edge declared by the project root or a workspace.
709
+ // node.parent is unsafe here: after hoisting, transitive packages can have the project root as their tree parent.
710
+ _isRoot: [...node.edgesIn].some(e =>
711
+ e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
712
+ ),
713
+ // pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs.
714
+ // Override only when we can prove the URL is registry-mediated; see #isRegistryResolvedTarball.
715
+ ...(this.#isRegistryResolvedTarball(node) ? { allowRemote: 'all' } : {}),
745
716
  })
746
717
  // store nodes don't use Node class so node.package doesn't get updated
747
718
  if (node.isInStore) {
@@ -865,6 +836,24 @@ module.exports = cls => class Reifier extends cls {
865
836
  return wrapper
866
837
  }
867
838
 
839
+ // When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
840
+ // pacote re-parses that with npa and gets spec.type === 'remote', so without an override the allow-remote gate would fire on every registry tarball (both =none and =root mis-fire).
841
+ // Returns true only when we are confident this is a registry-mediated install: the node's inbound edges must all be registry-typed (no exotic spec smuggled the URL in) AND the resolved URL's host must match the registry npm-registry-fetch selected for this spec, so a tampered lockfile pointing at an attacker host still hits the gate.
842
+ #isRegistryResolvedTarball (node) {
843
+ if (!node.resolved || !node.isRegistryDependency) {
844
+ return false
845
+ }
846
+ try {
847
+ // Hostnames are case-insensitive; lowercase both sides for safety even though WHATWG URL already normalizes.
848
+ const resolvedHost = new URL(node.resolved).hostname.toLowerCase()
849
+ // pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
850
+ const registryHost = new URL(pickRegistry(npa(node.name), this.options)).hostname.toLowerCase()
851
+ return resolvedHost === registryHost
852
+ } catch {
853
+ return false
854
+ }
855
+ }
856
+
868
857
  #registryResolved (resolved) {
869
858
  // the default registry url is a magic value meaning "the currently
870
859
  // configured registry".
@@ -1150,7 +1139,6 @@ module.exports = cls => class Reifier extends cls {
1150
1139
 
1151
1140
  const node = diff.ideal
1152
1141
  const bd = this.#bundleUnpacked.has(node)
1153
- const sw = this.#shrinkwrapInflated.has(node)
1154
1142
  const bundleMissing = this.#bundleMissing.has(node)
1155
1143
 
1156
1144
  // check whether we still need to unpack this one.
@@ -1160,8 +1148,6 @@ module.exports = cls => class Reifier extends cls {
1160
1148
  !node.isRoot &&
1161
1149
  // already unpacked to read bundle
1162
1150
  !bd &&
1163
- // already unpacked to read sw
1164
- !sw &&
1165
1151
  // already unpacked by another dep's bundle
1166
1152
  (bundleMissing || !node.inDepBundle)
1167
1153
 
package/lib/diff.js CHANGED
@@ -11,10 +11,9 @@ const { existsSync } = require('node:fs')
11
11
  const ssri = require('ssri')
12
12
 
13
13
  class Diff {
14
- constructor ({ actual, ideal, filterSet, shrinkwrapInflated, omit }) {
14
+ constructor ({ actual, ideal, filterSet, omit }) {
15
15
  this.omit = omit
16
16
  this.filterSet = filterSet
17
- this.shrinkwrapInflated = shrinkwrapInflated
18
17
  this.children = []
19
18
  this.actual = actual
20
19
  this.ideal = ideal
@@ -36,7 +35,6 @@ class Diff {
36
35
  actual,
37
36
  ideal,
38
37
  filterNodes = [],
39
- shrinkwrapInflated = new Set(),
40
38
  omit = new Set(),
41
39
  }) {
42
40
  // if there's a filterNode, then:
@@ -102,7 +100,7 @@ class Diff {
102
100
  }
103
101
 
104
102
  return depth({
105
- tree: new Diff({ actual, ideal, filterSet, shrinkwrapInflated, omit }),
103
+ tree: new Diff({ actual, ideal, filterSet, omit }),
106
104
  getChildren,
107
105
  leave,
108
106
  })
@@ -191,26 +189,16 @@ const getChildren = diff => {
191
189
  unchanged,
192
190
  removed,
193
191
  filterSet,
194
- shrinkwrapInflated,
195
192
  omit,
196
193
  } = diff
197
194
 
198
195
  // Note: we DON'T diff fsChildren themselves, because they are either
199
- // included in the package contents, or part of some other project, and
200
- // will never appear in legacy shrinkwraps anyway. but we _do_ include the
201
- // child nodes of fsChildren, because those are nodes that we are typically
202
- // responsible for installing.
196
+ // included in the package contents, or part of some other project.
197
+ // But we _do_ include the child nodes of fsChildren, because those are
198
+ // nodes that we are typically responsible for installing.
203
199
  const actualKids = allChildren(actual)
204
200
  const idealKids = allChildren(ideal)
205
201
 
206
- if (ideal && ideal.hasShrinkwrap && !shrinkwrapInflated.has(ideal)) {
207
- // Guaranteed to get a diff.leaves here, because we always
208
- // be called with a proper Diff object when ideal has a shrinkwrap
209
- // that has not been inflated.
210
- diff.leaves.push(diff)
211
- return children
212
- }
213
-
214
202
  const paths = new Set([...actualKids.keys(), ...idealKids.keys()])
215
203
  for (const path of paths) {
216
204
  const actual = actualKids.get(path)
@@ -222,7 +210,6 @@ const getChildren = diff => {
222
210
  unchanged,
223
211
  removed,
224
212
  filterSet,
225
- shrinkwrapInflated,
226
213
  omit,
227
214
  })
228
215
  }
@@ -241,7 +228,6 @@ const diffNode = ({
241
228
  unchanged,
242
229
  removed,
243
230
  filterSet,
244
- shrinkwrapInflated,
245
231
  omit,
246
232
  }) => {
247
233
  if (filterSet.size && !(filterSet.has(ideal) || filterSet.has(actual))) {
@@ -264,11 +250,11 @@ const diffNode = ({
264
250
 
265
251
  // if it's a match, then get its children
266
252
  // otherwise, this is the child diff node
267
- if (action || (!shrinkwrapInflated.has(ideal) && ideal.hasShrinkwrap)) {
253
+ if (action) {
268
254
  if (action === 'REMOVE') {
269
255
  removed.push(actual)
270
256
  }
271
- children.push(new Diff({ actual, ideal, filterSet, shrinkwrapInflated, omit }))
257
+ children.push(new Diff({ actual, ideal, filterSet, omit }))
272
258
  } else {
273
259
  unchanged.push(ideal)
274
260
  // !*! Weird dirty hack warning !*!
@@ -307,7 +293,6 @@ const diffNode = ({
307
293
  unchanged,
308
294
  removed,
309
295
  filterSet,
310
- shrinkwrapInflated,
311
296
  omit,
312
297
  }))
313
298
  }
package/lib/edge.js CHANGED
@@ -109,8 +109,8 @@ class Edge {
109
109
  }
110
110
 
111
111
  // NOTE: this condition means we explicitly do not support overriding
112
- // bundled or shrinkwrapped dependencies
113
- if (node.hasShrinkwrap || node.inShrinkwrap || node.inBundle) {
112
+ // bundled dependencies
113
+ if (node.inDepBundle) {
114
114
  return depValid(node, this.rawSpec, this.#accept, this.#from)
115
115
  }
116
116
 
@@ -16,7 +16,6 @@ class IsolatedNode {
16
16
  edgesIn = new Set()
17
17
  edgesOut = new CaseInsensitiveMap()
18
18
  fsChildren = new Set()
19
- hasShrinkwrap = false
20
19
  integrity = null
21
20
  inventory = new IsolatedInventory()
22
21
  isInStore = false
package/lib/link.js CHANGED
@@ -109,12 +109,24 @@ class Link extends Node {
109
109
  // so this is a no-op
110
110
  [_loadDeps] () {}
111
111
 
112
- // When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut.
113
- // Without this, overrides stop at the Link and never reach the target's dependency edges.
112
+ // When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut — but only when the OverrideSet has at least one rule that names a dep the target actually depends on.
113
+ // Without this scope, the link forwards a generic ancestor OverrideSet that has no real effect on the target's edges, but still flips the target to "has overrides", which changes downstream `canReplaceWith` / placement decisions and causes `npm ci` to re-resolve lockfile-pinned edges from the registry.
114
+ // See npm/cli#9357.
114
115
  recalculateOutEdgesOverrides () {
115
- if (this.target) {
116
- this.target.updateOverridesEdgeInAdded(this.overrides)
116
+ if (!this.target || !this.overrides) {
117
+ return
118
+ }
119
+ let hasMatchingRule = false
120
+ for (const rule of this.overrides.ruleset.values()) {
121
+ if (this.target.edgesOut.has(rule.name)) {
122
+ hasMatchingRule = true
123
+ break
124
+ }
125
+ }
126
+ if (!hasMatchingRule) {
127
+ return
117
128
  }
129
+ this.target.updateOverridesEdgeInAdded(this.overrides)
118
130
  }
119
131
 
120
132
  // links can't have children, only their targets can
package/lib/node.js CHANGED
@@ -82,7 +82,6 @@ class Node {
82
82
  fsChildren,
83
83
  fsParent,
84
84
  global = false,
85
- hasShrinkwrap,
86
85
  inert = false,
87
86
  installLinks = false,
88
87
  integrity,
@@ -170,7 +169,6 @@ class Node {
170
169
  }
171
170
  }
172
171
  this.integrity = integrity || this.package._integrity || null
173
- this.hasShrinkwrap = hasShrinkwrap || this.package._hasShrinkwrap || false
174
172
  this.installLinks = installLinks
175
173
  this.legacyPeerDeps = legacyPeerDeps
176
174
 
@@ -1101,8 +1099,8 @@ class Node {
1101
1099
  // is depending on it would be fine with the thing that they would resolve
1102
1100
  // to if it was removed, or nothing is depending on it in the first place.
1103
1101
  canDedupe (preferDedupe = false, explicitRequest = false) {
1104
- // not allowed to mess with shrinkwraps or bundles
1105
- if (this.inDepBundle || this.inShrinkwrap) {
1102
+ // not allowed to mess with bundles
1103
+ if (this.inDepBundle) {
1106
1104
  return false
1107
1105
  }
1108
1106
 
@@ -1249,11 +1247,6 @@ class Node {
1249
1247
  treeCheck(this)
1250
1248
  }
1251
1249
 
1252
- get inShrinkwrap () {
1253
- return this.parent &&
1254
- (this.parent.hasShrinkwrap || this.parent.inShrinkwrap)
1255
- }
1256
-
1257
1250
  get parent () {
1258
1251
  // setter prevents _parent from being this
1259
1252
  return this[_parent]
package/lib/printable.js CHANGED
@@ -52,11 +52,6 @@ class ArboristNode {
52
52
  if (bd && bd.length) {
53
53
  this.bundleDependencies = bd
54
54
  }
55
- if (tree.inShrinkwrap) {
56
- this.inShrinkwrap = true
57
- } else if (tree.hasShrinkwrap) {
58
- this.hasShrinkwrap = true
59
- }
60
55
  if (tree.error) {
61
56
  this.error = treeError(tree.error)
62
57
  }
package/lib/shrinkwrap.js CHANGED
@@ -1,5 +1,4 @@
1
- // a module that manages a shrinkwrap file (npm-shrinkwrap.json or
2
- // package-lock.json).
1
+ // a module that manages a lockfile (package-lock.json).
3
2
 
4
3
  // Increment whenever the lockfile version updates
5
4
  // v1 - npm <=6
@@ -98,7 +97,6 @@ const pkgMetaKeys = [
98
97
  'libc',
99
98
  '_integrity',
100
99
  'license',
101
- '_hasShrinkwrap',
102
100
  'hasInstallScript',
103
101
  'bin',
104
102
  'deprecated',
@@ -108,7 +106,6 @@ const pkgMetaKeys = [
108
106
  const nodeMetaKeys = [
109
107
  'integrity',
110
108
  'inBundle',
111
- 'hasShrinkwrap',
112
109
  'hasInstallScript',
113
110
  ]
114
111
 
@@ -199,17 +196,14 @@ class Shrinkwrap {
199
196
  const s = new Shrinkwrap(options)
200
197
  s.reset()
201
198
 
202
- const [sw, lock] = await s.resetFiles
199
+ const [lock] = await s.resetFiles
203
200
 
204
- // XXX this is duplicated in this.load(), but using loadFiles instead of resetFiles
205
201
  if (s.hiddenLockfile) {
206
202
  s.filename = resolve(s.path, 'node_modules/.package-lock.json')
207
- } else if (s.shrinkwrapOnly || sw) {
208
- s.filename = resolve(s.path, 'npm-shrinkwrap.json')
209
203
  } else {
210
204
  s.filename = resolve(s.path, 'package-lock.json')
211
205
  }
212
- s.loadedFromDisk = !!(sw || lock)
206
+ s.loadedFromDisk = !!lock
213
207
  // TODO what uses this?
214
208
  s.type = basename(s.filename)
215
209
 
@@ -286,7 +280,6 @@ class Shrinkwrap {
286
280
  path,
287
281
  indent = 2,
288
282
  newline = '\n',
289
- shrinkwrapOnly = false,
290
283
  hiddenLockfile = false,
291
284
  lockfileVersion,
292
285
  resolveOptions = {},
@@ -312,8 +305,6 @@ class Shrinkwrap {
312
305
  this.hiddenLockfile = hiddenLockfile
313
306
  this.loadingError = null
314
307
  this.resolveOptions = resolveOptions
315
- // only load npm-shrinkwrap.json in dep trees, not package-lock
316
- this.shrinkwrapOnly = shrinkwrapOnly
317
308
  }
318
309
 
319
310
  // check to see if a spec is present in the yarn.lock file, and if so,
@@ -369,14 +360,10 @@ class Shrinkwrap {
369
360
 
370
361
  // files to potentially read from and write to, in order of priority
371
362
  get #filenameSet () {
372
- if (this.shrinkwrapOnly) {
373
- return [`${this.path}/npm-shrinkwrap.json`]
374
- }
375
363
  if (this.hiddenLockfile) {
376
364
  return [`${this.path}/node_modules/.package-lock.json`]
377
365
  }
378
366
  return [
379
- `${this.path}/npm-shrinkwrap.json`,
380
367
  `${this.path}/package-lock.json`,
381
368
  `${this.path}/yarn.lock`,
382
369
  ]
@@ -396,9 +383,9 @@ class Shrinkwrap {
396
383
  }
397
384
 
398
385
  get resetFiles () {
399
- // slice out yarn, we only care about lock or shrinkwrap when checking
386
+ // slice out yarn, we only care about the package-lock when checking
400
387
  // this way, since we're not actually loading the full lock metadata
401
- return Promise.all(this.#filenameSet.slice(0, 2)
388
+ return Promise.all(this.#filenameSet.slice(0, 1)
402
389
  .map(file => file && stat(file).then(st => st.isFile(), er => {
403
390
  /* istanbul ignore else - can't test without breaking module itself */
404
391
  if (er.code === 'ENOENT') {
@@ -425,25 +412,18 @@ class Shrinkwrap {
425
412
  }
426
413
 
427
414
  async load () {
428
- // we don't need to load package-lock.json except for top of tree nodes,
429
- // only npm-shrinkwrap.json.
430
415
  let data
431
416
  try {
432
- const [sw, lock, yarn] = await this.loadFiles
433
- data = sw || lock || '{}'
417
+ const [lock, yarn] = await this.loadFiles
418
+ data = lock || '{}'
434
419
 
435
- // use shrinkwrap only for deps; otherwise, prefer package-lock
436
- // and ignore npm-shrinkwrap if both are present.
437
- // TODO: emit a warning here or something if both are present.
438
420
  if (this.hiddenLockfile) {
439
421
  this.filename = resolve(this.path, 'node_modules/.package-lock.json')
440
- } else if (this.shrinkwrapOnly || sw) {
441
- this.filename = resolve(this.path, 'npm-shrinkwrap.json')
442
422
  } else {
443
423
  this.filename = resolve(this.path, 'package-lock.json')
444
424
  }
445
425
  this.type = basename(this.filename)
446
- this.loadedFromDisk = Boolean(sw || lock)
426
+ this.loadedFromDisk = Boolean(lock)
447
427
 
448
428
  if (yarn) {
449
429
  this.yarnLock = new YarnLock()
@@ -809,7 +789,6 @@ class Shrinkwrap {
809
789
  const {
810
790
  resolved,
811
791
  integrity,
812
- hasShrinkwrap,
813
792
  version,
814
793
  } = this.get(node.path)
815
794
 
@@ -836,17 +815,14 @@ class Shrinkwrap {
836
815
  if (allOk) {
837
816
  node.resolved = node.resolved || pathFixed || null
838
817
  node.integrity = node.integrity || integrity || null
839
- node.hasShrinkwrap = node.hasShrinkwrap || hasShrinkwrap || false
840
818
  } else {
841
819
  // try to read off the package or node itself
842
820
  const {
843
821
  resolved,
844
822
  integrity,
845
- hasShrinkwrap,
846
823
  } = Shrinkwrap.metaFromNode(node, this.path, this.resolveOptions)
847
824
  node.resolved = node.resolved || resolved || null
848
825
  node.integrity = node.integrity || integrity || null
849
- node.hasShrinkwrap = node.hasShrinkwrap || hasShrinkwrap || false
850
826
  }
851
827
  }
852
828
  this.#awaitingUpdate.set(loc, node)
@@ -929,10 +905,24 @@ class Shrinkwrap {
929
905
  continue
930
906
  }
931
907
  const loc = relpath(this.path, node.path)
932
- this.data.packages[loc] = Shrinkwrap.metaFromNode(
908
+ // Drop lockfile entries for extraneous nodes outside node_modules. These are stale workspace entries: the workspace was removed from package.json or its directory was deleted, so it should not be tracked in package-lock.json.
909
+ if (node.extraneous && !/(^|\/)node_modules\//.test(loc) && loc !== 'node_modules') {
910
+ continue
911
+ }
912
+ const meta = Shrinkwrap.metaFromNode(
933
913
  node,
934
914
  this.path,
935
915
  this.resolveOptions)
916
+ // Skip inert nodes — these are optional deps that failed to load
917
+ // (e.g. 404 from a proxy registry that hasn't cached the package,
918
+ // or incomplete manifest missing version field).
919
+ // #pruneFailedOptional marks them inert so they won't be reified;
920
+ // writing them to the lockfile produces invalid entries like
921
+ // {"optional": true} that cause "Invalid Version:" errors.
922
+ if (node.inert && !node.package.version) {
923
+ continue
924
+ }
925
+ this.data.packages[loc] = meta
936
926
  }
937
927
  } else if (this.#awaitingUpdate.size > 0) {
938
928
  for (const loc of this.#awaitingUpdate.keys()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.5.0",
3
+ "version": "10.0.0-pre.0.0",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@gar/promise-retry": "^1.0.0",
package/bin/shrinkwrap.js DELETED
@@ -1,7 +0,0 @@
1
- const Shrinkwrap = require('../lib/shrinkwrap.js')
2
-
3
- module.exports = (options, time) => Shrinkwrap
4
- .load(options)
5
- .then((s) => s.commit())
6
- .then(time)
7
- .then(({ result: s }) => JSON.stringify(s, 0, 2))