@npmcli/arborist 9.4.2 → 9.5.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 +57 -63
- package/lib/arborist/index.js +10 -0
- package/lib/arborist/isolated-reifier.js +6 -1
- package/lib/arborist/reify.js +156 -11
- package/lib/link.js +8 -0
- package/lib/optional-set.js +1 -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,
|
|
@@ -294,10 +293,6 @@ module.exports = cls => class IdealTreeBuilder extends cls {
|
|
|
294
293
|
}).then(meta => Object.assign(root, { meta }))
|
|
295
294
|
} else {
|
|
296
295
|
return this.loadVirtual({ root })
|
|
297
|
-
.then(tree => {
|
|
298
|
-
this.#applyRootOverridesToWorkspaces(tree)
|
|
299
|
-
return tree
|
|
300
|
-
})
|
|
301
296
|
}
|
|
302
297
|
})
|
|
303
298
|
|
|
@@ -406,6 +401,7 @@ module.exports = cls => class IdealTreeBuilder extends cls {
|
|
|
406
401
|
global: this.options.global,
|
|
407
402
|
installLinks: this.installLinks,
|
|
408
403
|
legacyPeerDeps: this.legacyPeerDeps,
|
|
404
|
+
loadOverrides: true,
|
|
409
405
|
root,
|
|
410
406
|
})
|
|
411
407
|
}
|
|
@@ -450,6 +446,11 @@ module.exports = cls => class IdealTreeBuilder extends cls {
|
|
|
450
446
|
const paths = await readdirScoped(nm).catch(() => [])
|
|
451
447
|
for (const p of paths) {
|
|
452
448
|
const name = p.replace(/\\/g, '/')
|
|
449
|
+
// Match loadActual behavior: hidden entries and retired scoped package
|
|
450
|
+
// folders are not installed global packages.
|
|
451
|
+
if (/^(@[^/]+\/)?\./.test(name)) {
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
453
454
|
const updateName = this[_updateNames].includes(name)
|
|
454
455
|
if (this[_updateAll] || updateName) {
|
|
455
456
|
if (updateName) {
|
|
@@ -913,8 +914,21 @@ This is a one-time fix-up, please be patient...
|
|
|
913
914
|
// be forced to agree on a version of z.
|
|
914
915
|
const required = new Set([edge.from])
|
|
915
916
|
const parent = edge.peer ? virtualRoot : null
|
|
916
|
-
|
|
917
|
-
|
|
917
|
+
let dep = vrDep && vrDep.satisfies(edge) ? vrDep : null
|
|
918
|
+
|
|
919
|
+
// 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.
|
|
920
|
+
// 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.
|
|
921
|
+
const skipExistingShortcut = this[_updateNames].includes(edge.name)
|
|
922
|
+
|| this.#explicitRequests.has(edge)
|
|
923
|
+
|| (edge.to && this.auditReport?.isVulnerable(edge.to))
|
|
924
|
+
if (!dep && edge.type === 'peerOptional' && !skipExistingShortcut) {
|
|
925
|
+
dep = this.#findHoistableNode(
|
|
926
|
+
/* istanbul ignore next - resolveParent is always set for non-root nodes */
|
|
927
|
+
edge.from.resolveParent || edge.from, edge)
|
|
928
|
+
}
|
|
929
|
+
if (!dep) {
|
|
930
|
+
dep = await this.#nodeFromEdge(edge, parent, null, required)
|
|
931
|
+
}
|
|
918
932
|
|
|
919
933
|
/* istanbul ignore next */
|
|
920
934
|
debug(() => {
|
|
@@ -950,7 +964,7 @@ This is a one-time fix-up, please be patient...
|
|
|
950
964
|
tree: pd,
|
|
951
965
|
getChildren: pd => pd.children,
|
|
952
966
|
visit: pd => {
|
|
953
|
-
const { placed, edge, canPlace: cpd } = pd
|
|
967
|
+
const { placed, edge, canPlace: cpd, parent } = pd
|
|
954
968
|
// if we didn't place anything, nothing to do here
|
|
955
969
|
if (!placed) {
|
|
956
970
|
return
|
|
@@ -1011,8 +1025,7 @@ This is a one-time fix-up, please be patient...
|
|
|
1011
1025
|
return
|
|
1012
1026
|
}
|
|
1013
1027
|
|
|
1014
|
-
// lastly, also check for the missing deps of the node we placed,
|
|
1015
|
-
// and any holes created by pruning out conflicted peer sets.
|
|
1028
|
+
// lastly, also check for the missing deps of the node we placed, and any holes created by pruning out conflicted peer sets.
|
|
1016
1029
|
this.#depsQueue.push(placed)
|
|
1017
1030
|
for (const dep of pd.needEvaluation) {
|
|
1018
1031
|
this.#depsSeen.delete(dep)
|
|
@@ -1020,16 +1033,14 @@ This is a one-time fix-up, please be patient...
|
|
|
1020
1033
|
}
|
|
1021
1034
|
|
|
1022
1035
|
// pre-fetch any problem edges, since we'll need these soon
|
|
1023
|
-
// if it fails at this point, though, don't worry because it
|
|
1024
|
-
//
|
|
1025
|
-
// fail later anyway.
|
|
1036
|
+
// if it fails at this point, though, don't worry because it may well be an optional dep that has gone missing
|
|
1037
|
+
// it'll fail later anyway
|
|
1026
1038
|
for (const e of this.#problemEdges(placed)) {
|
|
1027
|
-
// XXX This is somehow load bearing. This makes tests that print
|
|
1028
|
-
//
|
|
1029
|
-
// can't be changed or removed till we figure out why
|
|
1039
|
+
// XXX This is somehow load bearing. This makes tests that print the ideal tree of a tree with tarball dependencies fail
|
|
1040
|
+
// This can't be changed or removed till we figure out why
|
|
1030
1041
|
// The test is named "tarball deps with transitive tarball deps"
|
|
1031
1042
|
promises.push(() =>
|
|
1032
|
-
this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)))
|
|
1043
|
+
this.#fetchManifest(npa.resolve(e.name, e.spec, fromPath(placed, e)), parent)
|
|
1033
1044
|
.catch(() => null)
|
|
1034
1045
|
)
|
|
1035
1046
|
}
|
|
@@ -1047,26 +1058,36 @@ This is a one-time fix-up, please be patient...
|
|
|
1047
1058
|
return this.#buildDepStep()
|
|
1048
1059
|
}
|
|
1049
1060
|
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1061
|
+
// BFS descendants of `root` for a node satisfying `edge`.
|
|
1062
|
+
// Prefers nodes closer to root. Skips bundled nodes.
|
|
1063
|
+
#findHoistableNode (root, edge) {
|
|
1064
|
+
const queue = [...root.children.values()]
|
|
1065
|
+
while (queue.length) {
|
|
1066
|
+
const node = queue.shift()
|
|
1067
|
+
if (node.name === edge.name
|
|
1068
|
+
&& !node.inDepBundle
|
|
1069
|
+
&& node.satisfies(edge)) {
|
|
1070
|
+
return node
|
|
1071
|
+
}
|
|
1072
|
+
for (const child of node.children.values()) {
|
|
1073
|
+
queue.push(child)
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return null
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// 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.
|
|
1052
1080
|
async #nodeFromEdge (edge, parent_, secondEdge, required) {
|
|
1053
|
-
// create a virtual root node with the same deps as the node that
|
|
1054
|
-
//
|
|
1055
|
-
// a context where they're likely to be resolvable.
|
|
1056
|
-
// Note that the virtual root will also have virtual copies of the
|
|
1057
|
-
// targets of any child Links, so that they resolve appropriately.
|
|
1081
|
+
// 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.
|
|
1082
|
+
// Note that the virtual root will also have virtual copies of the targets of any child Links, so that they resolve appropriately.
|
|
1058
1083
|
const parent = parent_ || this.#virtualRoot(edge.from)
|
|
1059
1084
|
|
|
1060
1085
|
const spec = npa.resolve(edge.name, edge.spec, edge.from.path)
|
|
1061
1086
|
const first = await this.#nodeFromSpec(edge.name, spec, parent, edge)
|
|
1062
1087
|
|
|
1063
|
-
// we might have a case where the parent has a peer dependency on
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
// we're unnecessarily triggering an ERESOLVE.
|
|
1067
|
-
// If we have a second edge to worry about, and it's not satisfied
|
|
1068
|
-
// by the first node, try a second and see if that satisfies the
|
|
1069
|
-
// original edge here.
|
|
1088
|
+
// we might have a case where the parent has a peer dependency on `foo@*` which resolves to v2, but another dep in the set has a peerDependency on `foo@1`.
|
|
1089
|
+
// In that case, if we force it to be v2, we're unnecessarily triggering an ERESOLVE.
|
|
1090
|
+
// If we have a second edge to worry about, and it's not satisfied by the first node, try a second and see if that satisfies the original edge here.
|
|
1070
1091
|
const spec2 = secondEdge && npa.resolve(
|
|
1071
1092
|
edge.name,
|
|
1072
1093
|
secondEdge.spec,
|
|
@@ -1210,11 +1231,12 @@ This is a one-time fix-up, please be patient...
|
|
|
1210
1231
|
return problems
|
|
1211
1232
|
}
|
|
1212
1233
|
|
|
1213
|
-
async #fetchManifest (spec) {
|
|
1234
|
+
async #fetchManifest (spec, parent) {
|
|
1214
1235
|
const options = {
|
|
1215
1236
|
...this.options,
|
|
1216
1237
|
avoid: this.#avoidRange(spec.name),
|
|
1217
1238
|
fullMetadata: true,
|
|
1239
|
+
_isRoot: parent?.isProjectRoot || parent?.isWorkspace,
|
|
1218
1240
|
}
|
|
1219
1241
|
// get the intended spec and stored metadata from yarn.lock file,
|
|
1220
1242
|
// if available and valid.
|
|
@@ -1231,10 +1253,8 @@ This is a one-time fix-up, please be patient...
|
|
|
1231
1253
|
}
|
|
1232
1254
|
|
|
1233
1255
|
async #nodeFromSpec (name, spec, parent, edge) {
|
|
1234
|
-
// pacote will slap integrity on its options, so we have to clone
|
|
1235
|
-
// the
|
|
1236
|
-
// Don't bother to load the manifest for link deps, because the target
|
|
1237
|
-
// might be within another package that doesn't exist yet.
|
|
1256
|
+
// pacote will slap integrity on its options, so we have to clone the object so it doesn't get mutated.
|
|
1257
|
+
// Don't bother to load the manifest for link deps, because the target might be within another package that doesn't exist yet.
|
|
1238
1258
|
const { installLinks, legacyPeerDeps } = this
|
|
1239
1259
|
const isWorkspace = this.idealTree.workspaces && this.idealTree.workspaces.has(spec.name)
|
|
1240
1260
|
|
|
@@ -1287,7 +1307,7 @@ This is a one-time fix-up, please be patient...
|
|
|
1287
1307
|
|
|
1288
1308
|
// spec isn't a directory, and either isn't a workspace or the workspace we have
|
|
1289
1309
|
// doesn't satisfy the edge. try to fetch a manifest and build a node from that.
|
|
1290
|
-
return this.#fetchManifest(spec)
|
|
1310
|
+
return this.#fetchManifest(spec, parent)
|
|
1291
1311
|
.then(pkg => new Node({ name, pkg, parent, installLinks, legacyPeerDeps }), error => {
|
|
1292
1312
|
error.requiredBy = edge.from.location || '.'
|
|
1293
1313
|
|
|
@@ -1519,32 +1539,6 @@ This is a one-time fix-up, please be patient...
|
|
|
1519
1539
|
timeEnd()
|
|
1520
1540
|
}
|
|
1521
1541
|
|
|
1522
|
-
#applyRootOverridesToWorkspaces (tree) {
|
|
1523
|
-
const rootOverrides = tree.root.package.overrides || {}
|
|
1524
|
-
|
|
1525
|
-
for (const node of tree.root.inventory.values()) {
|
|
1526
|
-
if (!node.isWorkspace) {
|
|
1527
|
-
continue
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
for (const depName of Object.keys(rootOverrides)) {
|
|
1531
|
-
const edge = node.edgesOut.get(depName)
|
|
1532
|
-
const rootNode = tree.root.children.get(depName)
|
|
1533
|
-
|
|
1534
|
-
// safely skip if either edge or rootNode doesn't exist yet
|
|
1535
|
-
if (!edge || !rootNode) {
|
|
1536
|
-
continue
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
const resolvedRootVersion = rootNode.package.version
|
|
1540
|
-
if (!semver.satisfies(resolvedRootVersion, edge.spec)) {
|
|
1541
|
-
edge.detach()
|
|
1542
|
-
node.children.delete(depName)
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
1542
|
#idealTreePrune () {
|
|
1549
1543
|
for (const node of this.idealTree.inventory.values()) {
|
|
1550
1544
|
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.
|
|
@@ -113,7 +113,11 @@ module.exports = cls => class IsolatedReifier extends cls {
|
|
|
113
113
|
})
|
|
114
114
|
// local `file:` deps are in fsChildren but are not workspaces.
|
|
115
115
|
// they are already handled as workspace-like proxies above and should not go through the external/store extraction path.
|
|
116
|
-
|
|
116
|
+
// Links with file: resolved paths (from `npm link`) should also be treated as local dependencies and symlinked directly instead of being extracted into the store.
|
|
117
|
+
const isLocalFileDep = next.isLink && next.resolved?.startsWith('file:')
|
|
118
|
+
if (isLocalFileDep && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
|
|
119
|
+
this.idealGraph.workspaces.push(await this.#workspaceProxy(next.target))
|
|
120
|
+
} else if (!next.isProjectRoot && !next.isWorkspace && !next.inert && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
|
|
117
121
|
this.idealGraph.external.push(await this.#externalProxy(next))
|
|
118
122
|
}
|
|
119
123
|
}
|
|
@@ -157,6 +161,7 @@ module.exports = cls => class IsolatedReifier extends cls {
|
|
|
157
161
|
...this.options,
|
|
158
162
|
resolved: node.resolved,
|
|
159
163
|
integrity: node.integrity,
|
|
164
|
+
// TODO _isRoot
|
|
160
165
|
})
|
|
161
166
|
const Arborist = this.constructor
|
|
162
167
|
const arb = new Arborist({ ...this.options, path: dir })
|
package/lib/arborist/reify.js
CHANGED
|
@@ -11,7 +11,7 @@ const { depth: dfwalk } = require('treeverse')
|
|
|
11
11
|
const { dirname, resolve, relative, join, sep } = require('node:path')
|
|
12
12
|
const { log, time } = require('proc-log')
|
|
13
13
|
const { existsSync } = require('node:fs')
|
|
14
|
-
const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises')
|
|
14
|
+
const { lstat, mkdir, readdir, readlink, rm, symlink } = require('node:fs/promises')
|
|
15
15
|
const { moveFile } = require('@npmcli/fs')
|
|
16
16
|
const { subset, intersects } = require('semver')
|
|
17
17
|
const { walkUp } = require('walk-up-path')
|
|
@@ -126,7 +126,11 @@ module.exports = cls => class Reifier extends cls {
|
|
|
126
126
|
await this[_diffTrees]()
|
|
127
127
|
await this.#reifyPackages()
|
|
128
128
|
if (linked) {
|
|
129
|
-
|
|
129
|
+
// The sweep mutates node_modules on disk, so skip it for dry runs and lockfile-only installs (those modes also short-circuit #reifyPackages).
|
|
130
|
+
// The sweep itself scopes to in-filter workspaces when a filter is active, so it's safe to run for filtered installs too.
|
|
131
|
+
if (!this.options.dryRun && !this.options.packageLockOnly) {
|
|
132
|
+
await this.#cleanOrphanedStoreEntries()
|
|
133
|
+
}
|
|
130
134
|
// swap back in the idealTree
|
|
131
135
|
// so that the lockfile is preserved
|
|
132
136
|
this.idealTree = oldTree
|
|
@@ -737,6 +741,7 @@ module.exports = cls => class Reifier extends cls {
|
|
|
737
741
|
...this.options,
|
|
738
742
|
resolved: node.resolved,
|
|
739
743
|
integrity: node.integrity,
|
|
744
|
+
_isRoot: node.parent?.isProjectRoot || node.parent?.isWorkspace,
|
|
740
745
|
})
|
|
741
746
|
// store nodes don't use Node class so node.package doesn't get updated
|
|
742
747
|
if (node.isInStore) {
|
|
@@ -1320,35 +1325,175 @@ module.exports = cls => class Reifier extends cls {
|
|
|
1320
1325
|
|
|
1321
1326
|
// After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
|
|
1322
1327
|
// Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
|
|
1328
|
+
// 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.
|
|
1323
1329
|
async #cleanOrphanedStoreEntries () {
|
|
1324
|
-
const
|
|
1330
|
+
const nmDir = resolve(this.path, 'node_modules')
|
|
1331
|
+
const storeDir = resolve(nmDir, '.store')
|
|
1332
|
+
|
|
1325
1333
|
let entries
|
|
1326
1334
|
try {
|
|
1327
1335
|
entries = await readdir(storeDir)
|
|
1328
1336
|
} catch {
|
|
1329
|
-
|
|
1337
|
+
entries = null
|
|
1330
1338
|
}
|
|
1331
1339
|
|
|
1332
|
-
// Collect valid store keys
|
|
1340
|
+
// Collect valid store keys and valid top-level links per node_modules directory.
|
|
1341
|
+
// Store entries have location node_modules/.store/{key}/node_modules/{pkg}.
|
|
1342
|
+
// 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.
|
|
1343
|
+
// Locations are normalized to forward slashes here because IsolatedNode/IsolatedLink locations are built with path.join, which uses backslashes on Windows.
|
|
1333
1344
|
const validKeys = new Set()
|
|
1345
|
+
const nmDirs = new Map()
|
|
1346
|
+
const NM_PREFIX = 'node_modules/'
|
|
1347
|
+
const STORE_MARKER = '/.store/'
|
|
1334
1348
|
for (const child of this.idealTree.children.values()) {
|
|
1349
|
+
const loc = child.location.replace(/\\/g, '/')
|
|
1335
1350
|
if (child.isInStore) {
|
|
1336
|
-
const key =
|
|
1351
|
+
const key = loc.split('/')[2]
|
|
1337
1352
|
validKeys.add(key)
|
|
1353
|
+
continue
|
|
1354
|
+
}
|
|
1355
|
+
if (!child.isLink) {
|
|
1356
|
+
continue
|
|
1357
|
+
}
|
|
1358
|
+
const nmIdx = loc.lastIndexOf(NM_PREFIX)
|
|
1359
|
+
if (nmIdx === -1 || loc.includes(STORE_MARKER)) {
|
|
1360
|
+
continue
|
|
1361
|
+
}
|
|
1362
|
+
const prefix = loc.slice(0, nmIdx)
|
|
1363
|
+
const dir = resolve(this.path, prefix, 'node_modules')
|
|
1364
|
+
const rest = loc.slice(nmIdx + NM_PREFIX.length)
|
|
1365
|
+
let entry
|
|
1366
|
+
if (rest.startsWith('@')) {
|
|
1367
|
+
const [scope, name] = rest.split('/')
|
|
1368
|
+
entry = `${scope}${sep}${name}`
|
|
1369
|
+
} else {
|
|
1370
|
+
entry = rest.split('/')[0]
|
|
1371
|
+
}
|
|
1372
|
+
let set = nmDirs.get(dir)
|
|
1373
|
+
if (!set) {
|
|
1374
|
+
set = new Set()
|
|
1375
|
+
nmDirs.set(dir, set)
|
|
1376
|
+
}
|
|
1377
|
+
set.add(entry)
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Determine which node_modules directories to sweep.
|
|
1381
|
+
// 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).
|
|
1382
|
+
// 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.
|
|
1383
|
+
// When --include-workspace-root is set, the filter scope pulls in root deps too, so the root node_modules is included in the sweep.
|
|
1384
|
+
const filteredNames = this.options.workspaces
|
|
1385
|
+
const isFiltered = Array.isArray(filteredNames) && filteredNames.length > 0
|
|
1386
|
+
if (isFiltered) {
|
|
1387
|
+
const allowedDirs = new Set()
|
|
1388
|
+
for (const ws of this.idealTree.fsChildren) {
|
|
1389
|
+
if (filteredNames.includes(ws.packageName) || filteredNames.includes(ws.name)) {
|
|
1390
|
+
allowedDirs.add(resolve(ws.path, 'node_modules'))
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (this.options.includeWorkspaceRoot) {
|
|
1394
|
+
allowedDirs.add(nmDir)
|
|
1395
|
+
}
|
|
1396
|
+
for (const dir of [...nmDirs.keys()]) {
|
|
1397
|
+
if (!allowedDirs.has(dir)) {
|
|
1398
|
+
nmDirs.delete(dir)
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
for (const dir of allowedDirs) {
|
|
1402
|
+
if (!nmDirs.has(dir)) {
|
|
1403
|
+
nmDirs.set(dir, new Set())
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
} else {
|
|
1407
|
+
if (!nmDirs.has(nmDir)) {
|
|
1408
|
+
nmDirs.set(nmDir, new Set())
|
|
1409
|
+
}
|
|
1410
|
+
for (const ws of this.idealTree.fsChildren) {
|
|
1411
|
+
const wsNmDir = resolve(ws.path, 'node_modules')
|
|
1412
|
+
if (!nmDirs.has(wsNmDir)) {
|
|
1413
|
+
nmDirs.set(wsNmDir, new Set())
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (entries) {
|
|
1419
|
+
const orphaned = entries.filter(e => !validKeys.has(e))
|
|
1420
|
+
if (orphaned.length) {
|
|
1421
|
+
log.silly('reify', 'cleaning orphaned store entries', orphaned)
|
|
1422
|
+
await promiseAllRejectLate(
|
|
1423
|
+
orphaned.map(e =>
|
|
1424
|
+
rm(resolve(storeDir, e), { recursive: true, force: true })
|
|
1425
|
+
.catch(/* istanbul ignore next -- rm with force rarely fails */
|
|
1426
|
+
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
|
|
1427
|
+
)
|
|
1428
|
+
)
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
for (const [dir, valid] of nmDirs) {
|
|
1433
|
+
await this.#cleanOrphanedTopLevelLinks(dir, valid)
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Remove node_modules/ entries that aren't represented in the ideal tree.
|
|
1438
|
+
// Run for the project root and each workspace's node_modules.
|
|
1439
|
+
// The linked diff path can't see these because #buildLinkedActualForDiff derives the actual tree from the ideal, so removed deps are never compared.
|
|
1440
|
+
// 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.
|
|
1441
|
+
// 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.
|
|
1442
|
+
async #cleanOrphanedTopLevelLinks (nmDir, validTopLevel) {
|
|
1443
|
+
const projectPrefix = resolve(this.path) + sep
|
|
1444
|
+
let dirents
|
|
1445
|
+
try {
|
|
1446
|
+
dirents = await readdir(nmDir, { withFileTypes: true })
|
|
1447
|
+
} catch {
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const isOurOrphan = async (linkPath) => {
|
|
1452
|
+
let target
|
|
1453
|
+
try {
|
|
1454
|
+
target = await readlink(linkPath)
|
|
1455
|
+
} catch {
|
|
1456
|
+
/* istanbul ignore next -- readlink of an entry we just listed as a symlink should not fail */
|
|
1457
|
+
return false
|
|
1458
|
+
}
|
|
1459
|
+
return resolve(dirname(linkPath), target).startsWith(projectPrefix)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const orphaned = []
|
|
1463
|
+
for (const ent of dirents) {
|
|
1464
|
+
// skip npm-managed entries (.bin, .store, .package-lock.json, etc)
|
|
1465
|
+
if (ent.name.startsWith('.')) {
|
|
1466
|
+
continue
|
|
1467
|
+
}
|
|
1468
|
+
if (ent.name.startsWith('@')) {
|
|
1469
|
+
let scoped
|
|
1470
|
+
try {
|
|
1471
|
+
scoped = await readdir(resolve(nmDir, ent.name), { withFileTypes: true })
|
|
1472
|
+
} catch {
|
|
1473
|
+
/* istanbul ignore next -- readdir of an entry we just listed should not fail */
|
|
1474
|
+
continue
|
|
1475
|
+
}
|
|
1476
|
+
for (const pkgEnt of scoped) {
|
|
1477
|
+
const key = `${ent.name}${sep}${pkgEnt.name}`
|
|
1478
|
+
if (!validTopLevel.has(key) && pkgEnt.isSymbolicLink() && await isOurOrphan(resolve(nmDir, key))) {
|
|
1479
|
+
orphaned.push(key)
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
} else if (!validTopLevel.has(ent.name) && ent.isSymbolicLink() && await isOurOrphan(resolve(nmDir, ent.name))) {
|
|
1483
|
+
orphaned.push(ent.name)
|
|
1338
1484
|
}
|
|
1339
1485
|
}
|
|
1340
1486
|
|
|
1341
|
-
const orphaned = entries.filter(e => !validKeys.has(e))
|
|
1342
1487
|
if (!orphaned.length) {
|
|
1343
1488
|
return
|
|
1344
1489
|
}
|
|
1345
1490
|
|
|
1346
|
-
log.silly('reify', 'cleaning orphaned
|
|
1491
|
+
log.silly('reify', 'cleaning orphaned top-level links', orphaned)
|
|
1347
1492
|
await promiseAllRejectLate(
|
|
1348
|
-
orphaned.map(
|
|
1349
|
-
rm(resolve(
|
|
1493
|
+
orphaned.map(name =>
|
|
1494
|
+
rm(resolve(nmDir, name), { recursive: true, force: true })
|
|
1350
1495
|
.catch(/* istanbul ignore next -- rm with force rarely fails */
|
|
1351
|
-
er => log.warn('cleanup', `Failed to remove orphaned
|
|
1496
|
+
er => log.warn('cleanup', `Failed to remove orphaned link ${name}`, er))
|
|
1352
1497
|
)
|
|
1353
1498
|
)
|
|
1354
1499
|
}
|
package/lib/link.js
CHANGED
|
@@ -109,6 +109,14 @@ 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.
|
|
114
|
+
recalculateOutEdgesOverrides () {
|
|
115
|
+
if (this.target) {
|
|
116
|
+
this.target.updateOverridesEdgeInAdded(this.overrides)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
112
120
|
// links can't have children, only their targets can
|
|
113
121
|
// fix it to an empty list so that we can still call
|
|
114
122
|
// things that iterate over them, just as a no-op
|
package/lib/optional-set.js
CHANGED
|
@@ -26,7 +26,7 @@ const optionalSet = node => {
|
|
|
26
26
|
|
|
27
27
|
// now that we've hit the boundary, gather the rest of the nodes in
|
|
28
28
|
// the optional section that don't have dependents outside the set.
|
|
29
|
-
return gatherDepSet(set, edge => !set.has(edge.to))
|
|
29
|
+
return gatherDepSet(set, edge => !set.has(edge.to) && !edge.from?.inert)
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
module.exports = optionalSet
|