@npmcli/arborist 9.4.3 → 9.6.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/lib/arborist/build-ideal-tree.js +120 -53
- package/lib/arborist/index.js +10 -0
- package/lib/arborist/isolated-reifier.js +3 -1
- package/lib/arborist/reify.js +182 -12
- package/lib/link.js +20 -0
- package/lib/shrinkwrap.js +15 -1
- package/package.json +1 -1
|
@@ -13,7 +13,6 @@ 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')
|
|
17
16
|
|
|
18
17
|
const {
|
|
19
18
|
OK,
|
|
@@ -29,6 +28,15 @@ const Shrinkwrap = require('../shrinkwrap.js')
|
|
|
29
28
|
const { defaultLockfileVersion } = Shrinkwrap
|
|
30
29
|
const Node = require('../node.js')
|
|
31
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
|
+
}
|
|
32
40
|
const addRmPkgDeps = require('../add-rm-pkg-deps.js')
|
|
33
41
|
const optionalSet = require('../optional-set.js')
|
|
34
42
|
const { checkEngine, checkPlatform } = require('npm-install-checks')
|
|
@@ -294,10 +302,6 @@ module.exports = cls => class IdealTreeBuilder extends cls {
|
|
|
294
302
|
}).then(meta => Object.assign(root, { meta }))
|
|
295
303
|
} else {
|
|
296
304
|
return this.loadVirtual({ root })
|
|
297
|
-
.then(tree => {
|
|
298
|
-
this.#applyRootOverridesToWorkspaces(tree)
|
|
299
|
-
return tree
|
|
300
|
-
})
|
|
301
305
|
}
|
|
302
306
|
})
|
|
303
307
|
|
|
@@ -406,6 +410,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
|
|
|
406
410
|
global: this.options.global,
|
|
407
411
|
installLinks: this.installLinks,
|
|
408
412
|
legacyPeerDeps: this.legacyPeerDeps,
|
|
413
|
+
loadOverrides: true,
|
|
409
414
|
root,
|
|
410
415
|
})
|
|
411
416
|
}
|
|
@@ -450,6 +455,11 @@ module.exports = cls => class IdealTreeBuilder extends cls {
|
|
|
450
455
|
const paths = await readdirScoped(nm).catch(() => [])
|
|
451
456
|
for (const p of paths) {
|
|
452
457
|
const name = p.replace(/\\/g, '/')
|
|
458
|
+
// Match loadActual behavior: hidden entries and retired scoped package
|
|
459
|
+
// folders are not installed global packages.
|
|
460
|
+
if (/^(@[^/]+\/)?\./.test(name)) {
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
453
463
|
const updateName = this[_updateNames].includes(name)
|
|
454
464
|
if (this[_updateAll] || updateName) {
|
|
455
465
|
if (updateName) {
|
|
@@ -648,6 +658,45 @@ module.exports = cls => class IdealTreeBuilder extends cls {
|
|
|
648
658
|
return vuln.range
|
|
649
659
|
}
|
|
650
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
|
+
|
|
651
700
|
#queueNamedUpdates () {
|
|
652
701
|
// ignore top nodes, since they are not loaded the same way, and
|
|
653
702
|
// probably have their own project associated with them.
|
|
@@ -913,8 +962,21 @@ This is a one-time fix-up, please be patient...
|
|
|
913
962
|
// be forced to agree on a version of z.
|
|
914
963
|
const required = new Set([edge.from])
|
|
915
964
|
const parent = edge.peer ? virtualRoot : null
|
|
916
|
-
|
|
917
|
-
|
|
965
|
+
let dep = vrDep && vrDep.satisfies(edge) ? vrDep : null
|
|
966
|
+
|
|
967
|
+
// A peerOptional conflict can be resolved by finding an existing node in the tree that satisfies the edge, avoiding a registry fetch that may introduce an extraneous package. See npm/cli#9249.
|
|
968
|
+
// Skip the shortcut when the user has signaled an explicit re-fetch intent (npm update by name, explicit request, or audit fix), so we honor those signals rather than silently keeping the existing node.
|
|
969
|
+
const skipExistingShortcut = this[_updateNames].includes(edge.name)
|
|
970
|
+
|| this.#explicitRequests.has(edge)
|
|
971
|
+
|| (edge.to && this.auditReport?.isVulnerable(edge.to))
|
|
972
|
+
if (!dep && edge.type === 'peerOptional' && !skipExistingShortcut) {
|
|
973
|
+
dep = this.#findHoistableNode(
|
|
974
|
+
/* istanbul ignore next - resolveParent is always set for non-root nodes */
|
|
975
|
+
edge.from.resolveParent || edge.from, edge)
|
|
976
|
+
}
|
|
977
|
+
if (!dep) {
|
|
978
|
+
dep = await this.#nodeFromEdge(edge, parent, null, required)
|
|
979
|
+
}
|
|
918
980
|
|
|
919
981
|
/* istanbul ignore next */
|
|
920
982
|
debug(() => {
|
|
@@ -1026,7 +1088,7 @@ This is a one-time fix-up, please be patient...
|
|
|
1026
1088
|
// This can't be changed or removed till we figure out why
|
|
1027
1089
|
// The test is named "tarball deps with transitive tarball deps"
|
|
1028
1090
|
promises.push(() =>
|
|
1029
|
-
this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)), parent)
|
|
1091
|
+
this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)), parent, e)
|
|
1030
1092
|
.catch(() => null)
|
|
1031
1093
|
)
|
|
1032
1094
|
}
|
|
@@ -1044,6 +1106,24 @@ This is a one-time fix-up, please be patient...
|
|
|
1044
1106
|
return this.#buildDepStep()
|
|
1045
1107
|
}
|
|
1046
1108
|
|
|
1109
|
+
// BFS descendants of `root` for a node satisfying `edge`.
|
|
1110
|
+
// Prefers nodes closer to root. Skips bundled nodes.
|
|
1111
|
+
#findHoistableNode (root, edge) {
|
|
1112
|
+
const queue = [...root.children.values()]
|
|
1113
|
+
while (queue.length) {
|
|
1114
|
+
const node = queue.shift()
|
|
1115
|
+
if (node.name === edge.name
|
|
1116
|
+
&& !node.inDepBundle
|
|
1117
|
+
&& node.satisfies(edge)) {
|
|
1118
|
+
return node
|
|
1119
|
+
}
|
|
1120
|
+
for (const child of node.children.values()) {
|
|
1121
|
+
queue.push(child)
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return null
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1047
1127
|
// loads a node from an edge, and then loads its peer deps (and their peer deps, on down the line) into a virtual root parent.
|
|
1048
1128
|
async #nodeFromEdge (edge, parent_, secondEdge, required) {
|
|
1049
1129
|
// create a virtual root node with the same deps as the node that is requesting this one, so that we can get all the peer deps in a context where they're likely to be resolvable.
|
|
@@ -1199,12 +1279,14 @@ This is a one-time fix-up, please be patient...
|
|
|
1199
1279
|
return problems
|
|
1200
1280
|
}
|
|
1201
1281
|
|
|
1202
|
-
async #fetchManifest (spec, parent) {
|
|
1282
|
+
async #fetchManifest (spec, parent, edge) {
|
|
1283
|
+
// Enforce allow-* gates before consulting the manifest cache so a cached entry from a different edge cannot bypass the policy.
|
|
1284
|
+
this.#checkAllow(spec, edge)
|
|
1203
1285
|
const options = {
|
|
1204
1286
|
...this.options,
|
|
1205
1287
|
avoid: this.#avoidRange(spec.name),
|
|
1206
1288
|
fullMetadata: true,
|
|
1207
|
-
_isRoot:
|
|
1289
|
+
_isRoot: !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace),
|
|
1208
1290
|
}
|
|
1209
1291
|
// get the intended spec and stored metadata from yarn.lock file,
|
|
1210
1292
|
// if available and valid.
|
|
@@ -1221,6 +1303,14 @@ This is a one-time fix-up, please be patient...
|
|
|
1221
1303
|
}
|
|
1222
1304
|
|
|
1223
1305
|
async #nodeFromSpec (name, spec, parent, edge) {
|
|
1306
|
+
// 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.
|
|
1307
|
+
// 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).
|
|
1308
|
+
try {
|
|
1309
|
+
this.#checkAllow(spec, edge)
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
return this.#failureNode(name, parent, error, edge)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1224
1314
|
// pacote will slap integrity on its options, so we have to clone the object so it doesn't get mutated.
|
|
1225
1315
|
// Don't bother to load the manifest for link deps, because the target might be within another package that doesn't exist yet.
|
|
1226
1316
|
const { installLinks, legacyPeerDeps } = this
|
|
@@ -1275,23 +1365,26 @@ This is a one-time fix-up, please be patient...
|
|
|
1275
1365
|
|
|
1276
1366
|
// spec isn't a directory, and either isn't a workspace or the workspace we have
|
|
1277
1367
|
// doesn't satisfy the edge. try to fetch a manifest and build a node from that.
|
|
1278
|
-
return this.#fetchManifest(spec, parent)
|
|
1279
|
-
.then(
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1368
|
+
return this.#fetchManifest(spec, parent, edge)
|
|
1369
|
+
.then(
|
|
1370
|
+
pkg => {
|
|
1371
|
+
// When a proxy/upstream registry returns an incomplete manifest
|
|
1372
|
+
// (e.g. missing version field for platform-specific packages it
|
|
1373
|
+
// hasn't cached), treat it as a load failure so that optional deps
|
|
1374
|
+
// are properly pruned instead of written to the lockfile without
|
|
1375
|
+
// version metadata. Only apply to registry specs — file: deps
|
|
1376
|
+
// legitimately omit version.
|
|
1377
|
+
if (!pkg.version && spec.registry) {
|
|
1378
|
+
const error = Object.assign(
|
|
1379
|
+
new Error(`incomplete manifest for ${name}, missing version`),
|
|
1380
|
+
{ code: 'EINCOMPLETEMANIFEST' }
|
|
1381
|
+
)
|
|
1382
|
+
return this.#failureNode(name, parent, error, edge)
|
|
1383
|
+
}
|
|
1384
|
+
return new Node({ name, pkg, parent, installLinks, legacyPeerDeps })
|
|
1385
|
+
},
|
|
1386
|
+
error => this.#failureNode(name, parent, error, edge)
|
|
1387
|
+
)
|
|
1295
1388
|
}
|
|
1296
1389
|
|
|
1297
1390
|
// load all peer deps and meta-peer deps into the node's parent
|
|
@@ -1507,32 +1600,6 @@ This is a one-time fix-up, please be patient...
|
|
|
1507
1600
|
timeEnd()
|
|
1508
1601
|
}
|
|
1509
1602
|
|
|
1510
|
-
#applyRootOverridesToWorkspaces (tree) {
|
|
1511
|
-
const rootOverrides = tree.root.package.overrides || {}
|
|
1512
|
-
|
|
1513
|
-
for (const node of tree.root.inventory.values()) {
|
|
1514
|
-
if (!node.isWorkspace) {
|
|
1515
|
-
continue
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
for (const depName of Object.keys(rootOverrides)) {
|
|
1519
|
-
const edge = node.edgesOut.get(depName)
|
|
1520
|
-
const rootNode = tree.root.children.get(depName)
|
|
1521
|
-
|
|
1522
|
-
// safely skip if either edge or rootNode doesn't exist yet
|
|
1523
|
-
if (!edge || !rootNode) {
|
|
1524
|
-
continue
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
const resolvedRootVersion = rootNode.package.version
|
|
1528
|
-
if (!semver.satisfies(resolvedRootVersion, edge.spec)) {
|
|
1529
|
-
edge.detach()
|
|
1530
|
-
node.children.delete(depName)
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
1603
|
#idealTreePrune () {
|
|
1537
1604
|
for (const node of this.idealTree.inventory.values()) {
|
|
1538
1605
|
if (node.extraneous) {
|
package/lib/arborist/index.js
CHANGED
|
@@ -288,6 +288,16 @@ class Arborist extends Base {
|
|
|
288
288
|
return ret
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// Build an ideal tree (or reuse an already-built one) and return the
|
|
292
|
+
// resulting lockfile contents as a string, without writing to disk.
|
|
293
|
+
// Useful for callers that want to inspect, diff, or store a lockfile
|
|
294
|
+
// somewhere other than the project's `package-lock.json`.
|
|
295
|
+
async lockfileString (options = {}) {
|
|
296
|
+
await this.buildIdealTree(options)
|
|
297
|
+
|
|
298
|
+
return this.idealTree.meta.toString(options)
|
|
299
|
+
}
|
|
300
|
+
|
|
291
301
|
async dedupe (options = {}) {
|
|
292
302
|
// allow the user to set options on the ctor as well.
|
|
293
303
|
// XXX: deprecate separate method options objects.
|
|
@@ -97,7 +97,9 @@ module.exports = cls => class IsolatedReifier extends cls {
|
|
|
97
97
|
}
|
|
98
98
|
this.counter = 0
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
// 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.
|
|
101
|
+
const fsChildren = Array.from(idealTree.fsChildren.values()).filter(w => !w.extraneous)
|
|
102
|
+
this.idealGraph.workspaces = await Promise.all(fsChildren.map(w => this.#workspaceProxy(w)))
|
|
101
103
|
const processed = new Set()
|
|
102
104
|
const queue = [idealTree, ...idealTree.fsChildren]
|
|
103
105
|
while (queue.length !== 0) {
|
package/lib/arborist/reify.js
CHANGED
|
@@ -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')
|
|
@@ -11,7 +12,7 @@ const { depth: dfwalk } = require('treeverse')
|
|
|
11
12
|
const { dirname, resolve, relative, join, sep } = require('node:path')
|
|
12
13
|
const { log, time } = require('proc-log')
|
|
13
14
|
const { existsSync } = require('node:fs')
|
|
14
|
-
const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises')
|
|
15
|
+
const { lstat, mkdir, readdir, readlink, rm, symlink } = require('node:fs/promises')
|
|
15
16
|
const { moveFile } = require('@npmcli/fs')
|
|
16
17
|
const { subset, intersects } = require('semver')
|
|
17
18
|
const { walkUp } = require('walk-up-path')
|
|
@@ -126,7 +127,11 @@ module.exports = cls => class Reifier extends cls {
|
|
|
126
127
|
await this[_diffTrees]()
|
|
127
128
|
await this.#reifyPackages()
|
|
128
129
|
if (linked) {
|
|
129
|
-
|
|
130
|
+
// The sweep mutates node_modules on disk, so skip it for dry runs and lockfile-only installs (those modes also short-circuit #reifyPackages).
|
|
131
|
+
// The sweep itself scopes to in-filter workspaces when a filter is active, so it's safe to run for filtered installs too.
|
|
132
|
+
if (!this.options.dryRun && !this.options.packageLockOnly) {
|
|
133
|
+
await this.#cleanOrphanedStoreEntries()
|
|
134
|
+
}
|
|
130
135
|
// swap back in the idealTree
|
|
131
136
|
// so that the lockfile is preserved
|
|
132
137
|
this.idealTree = oldTree
|
|
@@ -737,7 +742,14 @@ module.exports = cls => class Reifier extends cls {
|
|
|
737
742
|
...this.options,
|
|
738
743
|
resolved: node.resolved,
|
|
739
744
|
integrity: node.integrity,
|
|
740
|
-
|
|
745
|
+
// 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.
|
|
746
|
+
// node.parent is unsafe here: after hoisting, transitive packages can have the project root as their tree parent.
|
|
747
|
+
_isRoot: [...node.edgesIn].some(e =>
|
|
748
|
+
e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
|
|
749
|
+
),
|
|
750
|
+
// pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs.
|
|
751
|
+
// Override only when we can prove the URL is registry-mediated; see #isRegistryResolvedTarball.
|
|
752
|
+
...(this.#isRegistryResolvedTarball(node) ? { allowRemote: 'all' } : {}),
|
|
741
753
|
})
|
|
742
754
|
// store nodes don't use Node class so node.package doesn't get updated
|
|
743
755
|
if (node.isInStore) {
|
|
@@ -861,6 +873,24 @@ module.exports = cls => class Reifier extends cls {
|
|
|
861
873
|
return wrapper
|
|
862
874
|
}
|
|
863
875
|
|
|
876
|
+
// When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
|
|
877
|
+
// 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).
|
|
878
|
+
// 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.
|
|
879
|
+
#isRegistryResolvedTarball (node) {
|
|
880
|
+
if (!node.resolved || !node.isRegistryDependency) {
|
|
881
|
+
return false
|
|
882
|
+
}
|
|
883
|
+
try {
|
|
884
|
+
// Hostnames are case-insensitive; lowercase both sides for safety even though WHATWG URL already normalizes.
|
|
885
|
+
const resolvedHost = new URL(node.resolved).hostname.toLowerCase()
|
|
886
|
+
// pickRegistry only consults spec.scope, so a bare-name (tag) parse is sufficient and avoids a node.version dependency.
|
|
887
|
+
const registryHost = new URL(pickRegistry(npa(node.name), this.options)).hostname.toLowerCase()
|
|
888
|
+
return resolvedHost === registryHost
|
|
889
|
+
} catch {
|
|
890
|
+
return false
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
864
894
|
#registryResolved (resolved) {
|
|
865
895
|
// the default registry url is a magic value meaning "the currently
|
|
866
896
|
// configured registry".
|
|
@@ -1321,35 +1351,175 @@ module.exports = cls => class Reifier extends cls {
|
|
|
1321
1351
|
|
|
1322
1352
|
// After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
|
|
1323
1353
|
// Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
|
|
1354
|
+
// Then sweep the top-level node_modules/ for orphaned symlinks (e.g. an uninstalled dep whose store entry was just removed) so we don't leave dangling links.
|
|
1324
1355
|
async #cleanOrphanedStoreEntries () {
|
|
1325
|
-
const
|
|
1356
|
+
const nmDir = resolve(this.path, 'node_modules')
|
|
1357
|
+
const storeDir = resolve(nmDir, '.store')
|
|
1358
|
+
|
|
1326
1359
|
let entries
|
|
1327
1360
|
try {
|
|
1328
1361
|
entries = await readdir(storeDir)
|
|
1329
1362
|
} catch {
|
|
1330
|
-
|
|
1363
|
+
entries = null
|
|
1331
1364
|
}
|
|
1332
1365
|
|
|
1333
|
-
// Collect valid store keys
|
|
1366
|
+
// Collect valid store keys and valid top-level links per node_modules directory.
|
|
1367
|
+
// Store entries have location node_modules/.store/{key}/node_modules/{pkg}.
|
|
1368
|
+
// Top-level links have location {prefix}/node_modules/{pkg} or {prefix}/node_modules/@scope/{pkg}, where {prefix} is empty for the root project and the workspace's localLocation for workspace deps.
|
|
1369
|
+
// Locations are normalized to forward slashes here because IsolatedNode/IsolatedLink locations are built with path.join, which uses backslashes on Windows.
|
|
1334
1370
|
const validKeys = new Set()
|
|
1371
|
+
const nmDirs = new Map()
|
|
1372
|
+
const NM_PREFIX = 'node_modules/'
|
|
1373
|
+
const STORE_MARKER = '/.store/'
|
|
1335
1374
|
for (const child of this.idealTree.children.values()) {
|
|
1375
|
+
const loc = child.location.replace(/\\/g, '/')
|
|
1336
1376
|
if (child.isInStore) {
|
|
1337
|
-
const key =
|
|
1377
|
+
const key = loc.split('/')[2]
|
|
1338
1378
|
validKeys.add(key)
|
|
1379
|
+
continue
|
|
1380
|
+
}
|
|
1381
|
+
if (!child.isLink) {
|
|
1382
|
+
continue
|
|
1383
|
+
}
|
|
1384
|
+
const nmIdx = loc.lastIndexOf(NM_PREFIX)
|
|
1385
|
+
if (nmIdx === -1 || loc.includes(STORE_MARKER)) {
|
|
1386
|
+
continue
|
|
1387
|
+
}
|
|
1388
|
+
const prefix = loc.slice(0, nmIdx)
|
|
1389
|
+
const dir = resolve(this.path, prefix, 'node_modules')
|
|
1390
|
+
const rest = loc.slice(nmIdx + NM_PREFIX.length)
|
|
1391
|
+
let entry
|
|
1392
|
+
if (rest.startsWith('@')) {
|
|
1393
|
+
const [scope, name] = rest.split('/')
|
|
1394
|
+
entry = `${scope}${sep}${name}`
|
|
1395
|
+
} else {
|
|
1396
|
+
entry = rest.split('/')[0]
|
|
1397
|
+
}
|
|
1398
|
+
let set = nmDirs.get(dir)
|
|
1399
|
+
if (!set) {
|
|
1400
|
+
set = new Set()
|
|
1401
|
+
nmDirs.set(dir, set)
|
|
1402
|
+
}
|
|
1403
|
+
set.add(entry)
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Determine which node_modules directories to sweep.
|
|
1407
|
+
// For an unfiltered install, sweep the project root and every workspace's node_modules even if no top-level links remain (e.g. last dep was just uninstalled).
|
|
1408
|
+
// For a filtered install (npm install -w <ws>), restrict the sweep to the in-scope workspaces so out-of-scope workspaces are left untouched, mirroring what the diff would do.
|
|
1409
|
+
// When --include-workspace-root is set, the filter scope pulls in root deps too, so the root node_modules is included in the sweep.
|
|
1410
|
+
const filteredNames = this.options.workspaces
|
|
1411
|
+
const isFiltered = Array.isArray(filteredNames) && filteredNames.length > 0
|
|
1412
|
+
if (isFiltered) {
|
|
1413
|
+
const allowedDirs = new Set()
|
|
1414
|
+
for (const ws of this.idealTree.fsChildren) {
|
|
1415
|
+
if (filteredNames.includes(ws.packageName) || filteredNames.includes(ws.name)) {
|
|
1416
|
+
allowedDirs.add(resolve(ws.path, 'node_modules'))
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
if (this.options.includeWorkspaceRoot) {
|
|
1420
|
+
allowedDirs.add(nmDir)
|
|
1421
|
+
}
|
|
1422
|
+
for (const dir of [...nmDirs.keys()]) {
|
|
1423
|
+
if (!allowedDirs.has(dir)) {
|
|
1424
|
+
nmDirs.delete(dir)
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
for (const dir of allowedDirs) {
|
|
1428
|
+
if (!nmDirs.has(dir)) {
|
|
1429
|
+
nmDirs.set(dir, new Set())
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
} else {
|
|
1433
|
+
if (!nmDirs.has(nmDir)) {
|
|
1434
|
+
nmDirs.set(nmDir, new Set())
|
|
1435
|
+
}
|
|
1436
|
+
for (const ws of this.idealTree.fsChildren) {
|
|
1437
|
+
const wsNmDir = resolve(ws.path, 'node_modules')
|
|
1438
|
+
if (!nmDirs.has(wsNmDir)) {
|
|
1439
|
+
nmDirs.set(wsNmDir, new Set())
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (entries) {
|
|
1445
|
+
const orphaned = entries.filter(e => !validKeys.has(e))
|
|
1446
|
+
if (orphaned.length) {
|
|
1447
|
+
log.silly('reify', 'cleaning orphaned store entries', orphaned)
|
|
1448
|
+
await promiseAllRejectLate(
|
|
1449
|
+
orphaned.map(e =>
|
|
1450
|
+
rm(resolve(storeDir, e), { recursive: true, force: true })
|
|
1451
|
+
.catch(/* istanbul ignore next -- rm with force rarely fails */
|
|
1452
|
+
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
|
|
1453
|
+
)
|
|
1454
|
+
)
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
for (const [dir, valid] of nmDirs) {
|
|
1459
|
+
await this.#cleanOrphanedTopLevelLinks(dir, valid)
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Remove node_modules/ entries that aren't represented in the ideal tree.
|
|
1464
|
+
// Run for the project root and each workspace's node_modules.
|
|
1465
|
+
// The linked diff path can't see these because #buildLinkedActualForDiff derives the actual tree from the ideal, so removed deps are never compared.
|
|
1466
|
+
// Only symlinks whose target resolves inside the project root are removed — that covers store links (node_modules/.store/...) and workspace self-links (e.g. node_modules/<ws> -> ../packages/<ws>) that npm itself created.
|
|
1467
|
+
// Symlinks pointing outside the project (e.g. `npm link foo` without --save targeting the global prefix, or hand-made `ln -s` to an external path) and real directories are preserved.
|
|
1468
|
+
async #cleanOrphanedTopLevelLinks (nmDir, validTopLevel) {
|
|
1469
|
+
const projectPrefix = resolve(this.path) + sep
|
|
1470
|
+
let dirents
|
|
1471
|
+
try {
|
|
1472
|
+
dirents = await readdir(nmDir, { withFileTypes: true })
|
|
1473
|
+
} catch {
|
|
1474
|
+
return
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const isOurOrphan = async (linkPath) => {
|
|
1478
|
+
let target
|
|
1479
|
+
try {
|
|
1480
|
+
target = await readlink(linkPath)
|
|
1481
|
+
} catch {
|
|
1482
|
+
/* istanbul ignore next -- readlink of an entry we just listed as a symlink should not fail */
|
|
1483
|
+
return false
|
|
1484
|
+
}
|
|
1485
|
+
return resolve(dirname(linkPath), target).startsWith(projectPrefix)
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const orphaned = []
|
|
1489
|
+
for (const ent of dirents) {
|
|
1490
|
+
// skip npm-managed entries (.bin, .store, .package-lock.json, etc)
|
|
1491
|
+
if (ent.name.startsWith('.')) {
|
|
1492
|
+
continue
|
|
1493
|
+
}
|
|
1494
|
+
if (ent.name.startsWith('@')) {
|
|
1495
|
+
let scoped
|
|
1496
|
+
try {
|
|
1497
|
+
scoped = await readdir(resolve(nmDir, ent.name), { withFileTypes: true })
|
|
1498
|
+
} catch {
|
|
1499
|
+
/* istanbul ignore next -- readdir of an entry we just listed should not fail */
|
|
1500
|
+
continue
|
|
1501
|
+
}
|
|
1502
|
+
for (const pkgEnt of scoped) {
|
|
1503
|
+
const key = `${ent.name}${sep}${pkgEnt.name}`
|
|
1504
|
+
if (!validTopLevel.has(key) && pkgEnt.isSymbolicLink() && await isOurOrphan(resolve(nmDir, key))) {
|
|
1505
|
+
orphaned.push(key)
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
} else if (!validTopLevel.has(ent.name) && ent.isSymbolicLink() && await isOurOrphan(resolve(nmDir, ent.name))) {
|
|
1509
|
+
orphaned.push(ent.name)
|
|
1339
1510
|
}
|
|
1340
1511
|
}
|
|
1341
1512
|
|
|
1342
|
-
const orphaned = entries.filter(e => !validKeys.has(e))
|
|
1343
1513
|
if (!orphaned.length) {
|
|
1344
1514
|
return
|
|
1345
1515
|
}
|
|
1346
1516
|
|
|
1347
|
-
log.silly('reify', 'cleaning orphaned
|
|
1517
|
+
log.silly('reify', 'cleaning orphaned top-level links', orphaned)
|
|
1348
1518
|
await promiseAllRejectLate(
|
|
1349
|
-
orphaned.map(
|
|
1350
|
-
rm(resolve(
|
|
1519
|
+
orphaned.map(name =>
|
|
1520
|
+
rm(resolve(nmDir, name), { recursive: true, force: true })
|
|
1351
1521
|
.catch(/* istanbul ignore next -- rm with force rarely fails */
|
|
1352
|
-
er => log.warn('cleanup', `Failed to remove orphaned
|
|
1522
|
+
er => log.warn('cleanup', `Failed to remove orphaned link ${name}`, er))
|
|
1353
1523
|
)
|
|
1354
1524
|
)
|
|
1355
1525
|
}
|
package/lib/link.js
CHANGED
|
@@ -109,6 +109,26 @@ 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 — 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.
|
|
115
|
+
recalculateOutEdgesOverrides () {
|
|
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
|
|
128
|
+
}
|
|
129
|
+
this.target.updateOverridesEdgeInAdded(this.overrides)
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
// links can't have children, only their targets can
|
|
113
133
|
// fix it to an empty list so that we can still call
|
|
114
134
|
// things that iterate over them, just as a no-op
|
package/lib/shrinkwrap.js
CHANGED
|
@@ -929,10 +929,24 @@ class Shrinkwrap {
|
|
|
929
929
|
continue
|
|
930
930
|
}
|
|
931
931
|
const loc = relpath(this.path, node.path)
|
|
932
|
-
|
|
932
|
+
// 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.
|
|
933
|
+
if (node.extraneous && !/(^|\/)node_modules\//.test(loc) && loc !== 'node_modules') {
|
|
934
|
+
continue
|
|
935
|
+
}
|
|
936
|
+
const meta = Shrinkwrap.metaFromNode(
|
|
933
937
|
node,
|
|
934
938
|
this.path,
|
|
935
939
|
this.resolveOptions)
|
|
940
|
+
// Skip inert nodes — these are optional deps that failed to load
|
|
941
|
+
// (e.g. 404 from a proxy registry that hasn't cached the package,
|
|
942
|
+
// or incomplete manifest missing version field).
|
|
943
|
+
// #pruneFailedOptional marks them inert so they won't be reified;
|
|
944
|
+
// writing them to the lockfile produces invalid entries like
|
|
945
|
+
// {"optional": true} that cause "Invalid Version:" errors.
|
|
946
|
+
if (node.inert && !node.package.version) {
|
|
947
|
+
continue
|
|
948
|
+
}
|
|
949
|
+
this.data.packages[loc] = meta
|
|
936
950
|
}
|
|
937
951
|
} else if (this.#awaitingUpdate.size > 0) {
|
|
938
952
|
for (const loc of this.#awaitingUpdate.keys()) {
|