@npmcli/arborist 9.1.2 → 9.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ const pacote = require('pacote')
6
6
  const cacache = require('cacache')
7
7
  const { callLimit: promiseCallLimit } = require('promise-call-limit')
8
8
  const realpath = require('../../lib/realpath.js')
9
- const { resolve, dirname } = require('node:path')
9
+ const { resolve, dirname, sep } = require('node:path')
10
10
  const treeCheck = require('../tree-check.js')
11
11
  const { readdirScoped } = require('@npmcli/fs')
12
12
  const { lstat, readlink } = require('node:fs/promises')
@@ -192,9 +192,11 @@ module.exports = cls => class IdealTreeBuilder extends cls {
192
192
  }
193
193
 
194
194
  async #checkEngineAndPlatform () {
195
- const { engineStrict, npmVersion, nodeVersion } = this.options
195
+ const { engineStrict, npmVersion, nodeVersion, omit = [] } = this.options
196
+ const omitSet = new Set(omit)
197
+
196
198
  for (const node of this.idealTree.inventory.values()) {
197
- if (!node.optional) {
199
+ if (!node.optional && !node.shouldOmit(omitSet)) {
198
200
  try {
199
201
  // if devEngines is present in the root node we ignore the engines check
200
202
  if (!(node.isRoot && node.package.devEngines)) {
@@ -1224,15 +1226,31 @@ This is a one-time fix-up, please be patient...
1224
1226
  const { installLinks, legacyPeerDeps } = this
1225
1227
  const isWorkspace = this.idealTree.workspaces && this.idealTree.workspaces.has(spec.name)
1226
1228
 
1227
- // spec is a directory, link it unless installLinks is set or it's a workspace
1229
+ // spec is a directory, link it if:
1230
+ // - it's a workspace, OR
1231
+ // - it's a project-internal file: dependency (always linked), OR
1232
+ // - it's external and installLinks is false
1228
1233
  // TODO post arborist refactor, will need to check for installStrategy=linked
1229
- if (spec.type === 'directory' && (isWorkspace || !installLinks)) {
1234
+ let isProjectInternalFileSpec = false
1235
+ if (edge?.rawSpec.startsWith('file:../') || edge?.rawSpec.startsWith('file:./')) {
1236
+ const targetPath = resolve(parent.realpath, edge.rawSpec.slice(5))
1237
+ const resolvedProjectRoot = resolve(this.idealTree.realpath)
1238
+ // Check if the target is within the project root
1239
+ isProjectInternalFileSpec = targetPath.startsWith(resolvedProjectRoot + sep) || targetPath === resolvedProjectRoot
1240
+ }
1241
+
1242
+ // When using --install-links, we need to handle transitive file dependencies specially
1243
+ // If the parent was installed (not linked) due to --install-links, and this is a file: dep, we should also install it rather than link it
1244
+ const parentWasInstalled = parent && !parent.isLink && parent.resolved?.startsWith('file:')
1245
+ const isTransitiveFileDep = spec.type === 'directory' && parentWasInstalled && installLinks
1246
+
1247
+ // Decide whether to link or copy the dependency
1248
+ const shouldLink = (isWorkspace || isProjectInternalFileSpec || !installLinks) && !isTransitiveFileDep
1249
+ if (spec.type === 'directory' && shouldLink) {
1230
1250
  return this.#linkFromSpec(name, spec, parent, edge)
1231
1251
  }
1232
1252
 
1233
- // if the spec matches a workspace name, then see if the workspace node will
1234
- // satisfy the edge. if it does, we return the workspace node to make sure it
1235
- // takes priority.
1253
+ // 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.
1236
1254
  if (isWorkspace) {
1237
1255
  const existingNode = this.idealTree.edgesOut.get(spec.name).to
1238
1256
  if (existingNode && existingNode.isWorkspace && existingNode.satisfies(edge)) {
@@ -1240,6 +1258,15 @@ This is a one-time fix-up, please be patient...
1240
1258
  }
1241
1259
  }
1242
1260
 
1261
+ // For file: dependencies that we're installing (not linking), ensure proper resolution
1262
+ if (isTransitiveFileDep && edge) {
1263
+ // For transitive file deps, resolve relative to the parent's original source location
1264
+ const parentOriginalPath = parent.resolved.slice(5) // Remove 'file:' prefix
1265
+ const relativePath = edge.rawSpec.slice(5) // Remove 'file:' prefix
1266
+ const absolutePath = resolve(parentOriginalPath, relativePath)
1267
+ spec = npa.resolve(name, `file:${absolutePath}`)
1268
+ }
1269
+
1243
1270
  // spec isn't a directory, and either isn't a workspace or the workspace we have
1244
1271
  // doesn't satisfy the edge. try to fetch a manifest and build a node from that.
1245
1272
  return this.#fetchManifest(spec)
@@ -1292,6 +1319,12 @@ This is a one-time fix-up, please be patient...
1292
1319
  .sort(({ name: a }, { name: b }) => localeCompare(a, b))
1293
1320
 
1294
1321
  for (const edge of peerEdges) {
1322
+ // node.parent gets mutated during loop execution due to recursive #nodeFromEdge calls.
1323
+ // When a compatible peer is found (e.g. a@1.1.0 replaces a@1.2.0), the original node loses its parent.
1324
+ // if node is detached/removed from the tree, or has no parent, so no need to check remaining edgesOut for that node.
1325
+ if (!node.parent) {
1326
+ break
1327
+ }
1295
1328
  // already placed this one, and we're happy with it.
1296
1329
  if (edge.valid && edge.to) {
1297
1330
  continue
@@ -1476,11 +1509,6 @@ This is a one-time fix-up, please be patient...
1476
1509
  const needPrune = metaFromDisk && (mutateTree || flagsSuspect)
1477
1510
  if (this.#prune && needPrune) {
1478
1511
  this.#idealTreePrune()
1479
- for (const node of this.idealTree.inventory.values()) {
1480
- if (node.extraneous) {
1481
- node.parent = null
1482
- }
1483
- }
1484
1512
  }
1485
1513
 
1486
1514
  timeEnd()
@@ -1514,7 +1542,12 @@ This is a one-time fix-up, please be patient...
1514
1542
 
1515
1543
  #idealTreePrune () {
1516
1544
  for (const node of this.idealTree.inventory.values()) {
1517
- if (node.extraneous) {
1545
+ // optional peer dependencies are meant to be added to the tree
1546
+ // through an explicit required dependency (most commonly in the
1547
+ // root package.json), at which point they won't be optional so
1548
+ // any dependencies still marked as both optional and peer at
1549
+ // this point can be pruned as a special kind of extraneous
1550
+ if (node.extraneous || (node.peer && node.optional)) {
1518
1551
  node.parent = null
1519
1552
  }
1520
1553
  }
@@ -84,9 +84,7 @@ module.exports = cls => class Reifier extends cls {
84
84
  #bundleUnpacked = new Set() // the nodes we unpack to read their bundles
85
85
  #dryRun
86
86
  #nmValidated = new Set()
87
- #omitDev
88
- #omitPeer
89
- #omitOptional
87
+ #omit
90
88
  #retiredPaths = {}
91
89
  #retiredUnchanged = {}
92
90
  #savePrefix
@@ -110,10 +108,7 @@ module.exports = cls => class Reifier extends cls {
110
108
  throw er
111
109
  }
112
110
 
113
- const omit = new Set(options.omit || [])
114
- this.#omitDev = omit.has('dev')
115
- this.#omitOptional = omit.has('optional')
116
- this.#omitPeer = omit.has('peer')
111
+ this.#omit = new Set(options.omit)
117
112
 
118
113
  // start tracker block
119
114
  this.addTracker('reify')
@@ -562,12 +557,11 @@ module.exports = cls => class Reifier extends cls {
562
557
  // adding to the trash list will skip reifying, and delete them
563
558
  // if they are currently in the tree and otherwise untouched.
564
559
  [_addOmitsToTrashList] () {
565
- if (!this.#omitDev && !this.#omitOptional && !this.#omitPeer) {
560
+ if (!this.#omit.size) {
566
561
  return
567
562
  }
568
563
 
569
564
  const timeEnd = time.start('reify:trashOmits')
570
-
571
565
  for (const node of this.idealTree.inventory.values()) {
572
566
  const { top } = node
573
567
 
@@ -583,12 +577,7 @@ module.exports = cls => class Reifier extends cls {
583
577
  }
584
578
 
585
579
  // omit node if the dep type matches any omit flags that were set
586
- if (
587
- node.peer && this.#omitPeer ||
588
- node.dev && this.#omitDev ||
589
- node.optional && this.#omitOptional ||
590
- node.devOptional && this.#omitOptional && this.#omitDev
591
- ) {
580
+ if (node.shouldOmit(this.#omit)) {
592
581
  this[_addNodeToTrashList](node)
593
582
  }
594
583
  }
@@ -896,6 +885,7 @@ module.exports = cls => class Reifier extends cls {
896
885
  // Replace the host with the registry host while keeping the path intact
897
886
  resolvedURL.hostname = registryURL.hostname
898
887
  resolvedURL.port = registryURL.port
888
+ resolvedURL.protocol = registryURL.protocol
899
889
 
900
890
  // Make sure we don't double-include the path if it's already there
901
891
  const registryPath = registryURL.pathname.replace(/\/$/, '')
@@ -1,5 +1,4 @@
1
1
  // an object representing the set of vulnerabilities in a tree
2
- /* eslint camelcase: "off" */
3
2
 
4
3
  const localeCompare = require('@isaacs/string-locale-compare')('en')
5
4
  const npa = require('npm-package-arg')
@@ -8,16 +7,15 @@ const pickManifest = require('npm-pick-manifest')
8
7
  const Vuln = require('./vuln.js')
9
8
  const Calculator = require('@npmcli/metavuln-calculator')
10
9
 
11
- const _getReport = Symbol('getReport')
12
- const _fixAvailable = Symbol('fixAvailable')
13
- const _checkTopNode = Symbol('checkTopNode')
14
- const _init = Symbol('init')
15
- const _omit = Symbol('omit')
16
10
  const { log, time } = require('proc-log')
17
11
 
18
12
  const npmFetch = require('npm-registry-fetch')
19
13
 
20
14
  class AuditReport extends Map {
15
+ #omit
16
+ error = null
17
+ topVulns = new Map()
18
+
21
19
  static load (tree, opts) {
22
20
  return new AuditReport(tree, opts).run()
23
21
  }
@@ -91,22 +89,18 @@ class AuditReport extends Map {
91
89
 
92
90
  constructor (tree, opts = {}) {
93
91
  super()
94
- const { omit } = opts
95
- this[_omit] = new Set(omit || [])
96
- this.topVulns = new Map()
97
-
92
+ this.#omit = new Set(opts.omit || [])
98
93
  this.calculator = new Calculator(opts)
99
- this.error = null
100
94
  this.options = opts
101
95
  this.tree = tree
102
96
  this.filterSet = opts.filterSet
103
97
  }
104
98
 
105
99
  async run () {
106
- this.report = await this[_getReport]()
100
+ this.report = await this.#getReport()
107
101
  log.silly('audit report', this.report)
108
102
  if (this.report) {
109
- await this[_init]()
103
+ await this.#init()
110
104
  }
111
105
  return this
112
106
  }
@@ -116,7 +110,7 @@ class AuditReport extends Map {
116
110
  return !!(vuln && vuln.isVulnerable(node))
117
111
  }
118
112
 
119
- async [_init] () {
113
+ async #init () {
120
114
  const timeEnd = time.start('auditReport:init')
121
115
 
122
116
  const promises = []
@@ -148,7 +142,7 @@ class AuditReport extends Map {
148
142
  if (!seen.has(k)) {
149
143
  const p = []
150
144
  for (const node of this.tree.inventory.query('packageName', name)) {
151
- if (!shouldAudit(node, this[_omit], this.filterSet)) {
145
+ if (!this.shouldAudit(node)) {
152
146
  continue
153
147
  }
154
148
 
@@ -171,7 +165,15 @@ class AuditReport extends Map {
171
165
  vuln.nodes.add(node)
172
166
  for (const { from: dep, spec } of node.edgesIn) {
173
167
  if (dep.isTop && !vuln.topNodes.has(dep)) {
174
- this[_checkTopNode](dep, vuln, spec)
168
+ vuln.fixAvailable = this.#fixAvailable(vuln, spec)
169
+ if (vuln.fixAvailable !== true) {
170
+ // now we know the top node is vulnerable, and cannot be
171
+ // upgraded out of the bad place without --force. But, there's
172
+ // no need to add it to the actual vulns list, because nothing
173
+ // depends on root.
174
+ this.topVulns.set(vuln.name, vuln)
175
+ vuln.topNodes.add(dep)
176
+ }
175
177
  } else {
176
178
  // calculate a metavuln, if necessary
177
179
  const calc = this.calculator.calculate(dep.packageName, advisory)
@@ -214,33 +216,14 @@ class AuditReport extends Map {
214
216
  timeEnd()
215
217
  }
216
218
 
217
- [_checkTopNode] (topNode, vuln, spec) {
218
- vuln.fixAvailable = this[_fixAvailable](topNode, vuln, spec)
219
-
220
- if (vuln.fixAvailable !== true) {
221
- // now we know the top node is vulnerable, and cannot be
222
- // upgraded out of the bad place without --force. But, there's
223
- // no need to add it to the actual vulns list, because nothing
224
- // depends on root.
225
- this.topVulns.set(vuln.name, vuln)
226
- vuln.topNodes.add(topNode)
227
- }
228
- }
229
-
230
- // check whether the top node is vulnerable.
231
- // check whether we can get out of the bad place with --force, and if
232
- // so, whether that update is SemVer Major
233
- [_fixAvailable] (topNode, vuln, spec) {
234
- // this will always be set to at least {name, versions:{}}
235
- const paku = vuln.packument
236
-
219
+ // given the spec, see if there is a fix available at all, and note whether or not it's a semver major fix or not (i.e. will need --force)
220
+ #fixAvailable (vuln, spec) {
221
+ // TODO we return true, false, OR an object here. this is probably a bad pattern.
237
222
  if (!vuln.testSpec(spec)) {
238
223
  return true
239
224
  }
240
225
 
241
- // similarly, even if we HAVE a packument, but we're looking for it
242
- // somewhere other than the registry, and we got something vulnerable,
243
- // then we're stuck with it.
226
+ // even if we HAVE a packument, if we're looking for it somewhere other than the registry and we have something vulnerable then we're stuck with it.
244
227
  const specObj = npa(spec)
245
228
  if (!specObj.registry) {
246
229
  return false
@@ -250,15 +233,13 @@ class AuditReport extends Map {
250
233
  spec = specObj.subSpec.rawSpec
251
234
  }
252
235
 
253
- // We don't provide fixes for top nodes other than root, but we
254
- // still check to see if the node is fixable with a different version,
255
- // and if that is a semver major bump.
236
+ // we don't provide fixes for top nodes other than root, but we still check to see if the node is fixable with a different version, and note if that is a semver major bump.
256
237
  try {
257
238
  const {
258
239
  _isSemVerMajor: isSemVerMajor,
259
240
  version,
260
241
  name,
261
- } = pickManifest(paku, spec, {
242
+ } = pickManifest(vuln.packument, spec, {
262
243
  ...this.options,
263
244
  before: null,
264
245
  avoid: vuln.range,
@@ -274,7 +255,7 @@ class AuditReport extends Map {
274
255
  throw new Error('do not call AuditReport.set() directly')
275
256
  }
276
257
 
277
- async [_getReport] () {
258
+ async #getReport () {
278
259
  // if we're not auditing, just return false
279
260
  if (this.options.audit === false || this.options.offline === true || this.tree.inventory.size === 1) {
280
261
  return null
@@ -282,7 +263,7 @@ class AuditReport extends Map {
282
263
 
283
264
  const timeEnd = time.start('auditReport:getReport')
284
265
  try {
285
- const body = prepareBulkData(this.tree, this[_omit], this.filterSet)
266
+ const body = this.prepareBulkData()
286
267
  log.silly('audit', 'bulk request', body)
287
268
 
288
269
  // no sense asking if we don't have anything to audit,
@@ -309,37 +290,39 @@ class AuditReport extends Map {
309
290
  timeEnd()
310
291
  }
311
292
  }
312
- }
313
-
314
- // return true if we should audit this one
315
- const shouldAudit = (node, omit, filterSet) =>
316
- !node.version ? false
317
- : node.isRoot ? false
318
- : filterSet && filterSet.size !== 0 && !filterSet.has(node) ? false
319
- : omit.size === 0 ? true
320
- : !( // otherwise, just ensure we're not omitting this one
321
- node.dev && omit.has('dev') ||
322
- node.optional && omit.has('optional') ||
323
- node.devOptional && omit.has('dev') && omit.has('optional') ||
324
- node.peer && omit.has('peer')
325
- )
326
-
327
- const prepareBulkData = (tree, omit, filterSet) => {
328
- const payload = {}
329
- for (const name of tree.inventory.query('packageName')) {
330
- const set = new Set()
331
- for (const node of tree.inventory.query('packageName', name)) {
332
- if (!shouldAudit(node, omit, filterSet)) {
333
- continue
334
- }
335
293
 
336
- set.add(node.version)
294
+ // return true if we should audit this one
295
+ shouldAudit (node) {
296
+ if (
297
+ !node.version ||
298
+ node.isRoot ||
299
+ (this.filterSet && this.filterSet?.size !== 0 && !this.filterSet?.has(node))
300
+ ) {
301
+ return false
337
302
  }
338
- if (set.size) {
339
- payload[name] = [...set]
303
+ if (this.#omit.size === 0) {
304
+ return true
305
+ }
306
+ return !node.shouldOmit(this.#omit)
307
+ }
308
+
309
+ prepareBulkData () {
310
+ const payload = {}
311
+ for (const name of this.tree.inventory.query('packageName')) {
312
+ const set = new Set()
313
+ for (const node of this.tree.inventory.query('packageName', name)) {
314
+ if (!this.shouldAudit(node)) {
315
+ continue
316
+ }
317
+
318
+ set.add(node.version)
319
+ }
320
+ if (set.size) {
321
+ payload[name] = [...set]
322
+ }
340
323
  }
324
+ return payload
341
325
  }
342
- return payload
343
326
  }
344
327
 
345
328
  module.exports = AuditReport
package/lib/node.js CHANGED
@@ -489,6 +489,15 @@ class Node {
489
489
  return false
490
490
  }
491
491
 
492
+ shouldOmit (omitSet) {
493
+ return (
494
+ this.peer && omitSet.has('peer') ||
495
+ this.dev && omitSet.has('dev') ||
496
+ this.optional && omitSet.has('optional') ||
497
+ this.devOptional && omitSet.has('optional') && omitSet.has('dev')
498
+ )
499
+ }
500
+
492
501
  getBundler (path = []) {
493
502
  // made a cycle, definitely not bundled!
494
503
  if (path.includes(this)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "9.1.2",
3
+ "version": "9.1.4",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@isaacs/string-locale-compare": "^1.1.0",
@@ -41,7 +41,7 @@
41
41
  "devDependencies": {
42
42
  "@npmcli/eslint-config": "^5.0.1",
43
43
  "@npmcli/mock-registry": "^1.0.0",
44
- "@npmcli/template-oss": "4.23.6",
44
+ "@npmcli/template-oss": "4.24.4",
45
45
  "benchmark": "^2.1.4",
46
46
  "minify-registry-metadata": "^4.0.0",
47
47
  "nock": "^13.3.3",
@@ -93,7 +93,7 @@
93
93
  },
94
94
  "templateOSS": {
95
95
  "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
96
- "version": "4.23.6",
96
+ "version": "4.24.4",
97
97
  "content": "../../scripts/template-oss/index.js"
98
98
  }
99
99
  }