Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ module.exports = cls => class IsolatedReifier extends cls {
}

binNames.forEach(bn => {
target.binPaths.push(join(from.realpath, 'node_modules', '.bin', bn))
target.binPaths.push(join(dep.root.localPath, nmFolder, '.bin', bn))
})

const link = {
Expand All @@ -465,7 +465,10 @@ module.exports = cls => class IsolatedReifier extends cls {
path: join(dep.root.localPath, nmFolder, dep.name),
realpath: target.path,
name: toKey,
resolved: dep.resolved,
version: dep.version,
resolved: external
? `file:.store/${toKey}/node_modules/${dep.packageName}`
: dep.resolved,
top: { path: dep.root.localPath },
children: new Map(),
fsChildren: new Set(),
Expand Down
162 changes: 156 additions & 6 deletions workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ const promiseAllRejectLate = require('promise-all-reject-late')
const runScript = require('@npmcli/run-script')
const { callLimit: promiseCallLimit } = require('promise-call-limit')
const { depth: dfwalk } = require('treeverse')
const { dirname, resolve, relative, join } = require('node:path')
const { dirname, resolve, relative, join, sep } = require('node:path')
const { log, time } = require('proc-log')
const { lstat, mkdir, rm, symlink } = require('node:fs/promises')
const { existsSync } = require('node:fs')
const { lstat, mkdir, readdir, rm, symlink } = require('node:fs/promises')
const { moveFile } = require('@npmcli/fs')
const { subset, intersects } = require('semver')
const { walkUp } = require('walk-up-path')
Expand Down Expand Up @@ -75,6 +76,7 @@ module.exports = cls => class Reifier extends cls {
#shrinkwrapInflated = new Set()
#sparseTreeDirs = new Set()
#sparseTreeRoots = new Set()
#linkedActualForDiff = null

constructor (options) {
super(options)
Expand Down Expand Up @@ -116,15 +118,20 @@ module.exports = cls => class Reifier extends cls {
// of Node/Link trees
log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
this.idealTree = await this[_createIsolatedTree]()
this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
this.idealTree, this.actualTree
)
}
await this[_diffTrees]()
await this.#reifyPackages()
if (linked) {
await this.#cleanOrphanedStoreEntries()
// swap back in the idealTree
// so that the lockfile is preserved
this.idealTree = oldTree
}
await this[_saveIdealTree](options)
this.#linkedActualForDiff = null
// clean inert
for (const node of this.idealTree.inventory.values()) {
if (node.inert) {
Expand Down Expand Up @@ -426,9 +433,15 @@ module.exports = cls => class Reifier extends cls {
if (ideal) {
filterNodes.push(ideal)
}
const actual = this.actualTree.children.get(ws)
if (actual) {
filterNodes.push(actual)
// Skip actual-side filterNodes when using the linked diff wrapper —
// those nodes have root===actualTree, not root===linkedActualForDiff,
// and Diff.calculate requires filterNode.root to match actual.
// The ideal filterNode alone is sufficient to scope the workspace diff.
if (!this.#linkedActualForDiff) {
const actual = this.actualTree.children.get(ws)
if (actual) {
filterNodes.push(actual)
}
}
}
}
Expand All @@ -450,7 +463,7 @@ module.exports = cls => class Reifier extends cls {
omit: this.#omit,
shrinkwrapInflated: this.#shrinkwrapInflated,
filterNodes,
actual: this.actualTree,
actual: this.#linkedActualForDiff || this.actualTree,
ideal: this.idealTree,
})

Expand Down Expand Up @@ -573,6 +586,7 @@ module.exports = cls => class Reifier extends cls {
// if the directory already exists, made will be undefined. if that's the case
// we don't want to remove it because we aren't the ones who created it so we
// omit it from the #sparseTreeRoots
/* istanbul ignore next -- mkdir returns path only when dir is new */
if (made) {
this.#sparseTreeRoots.add(made)
}
Expand Down Expand Up @@ -789,6 +803,104 @@ module.exports = cls => class Reifier extends cls {
return join(filePath)
}

// Build a flat actual tree wrapper for linked installs so the diff can
// correctly match store entries that already exist on disk. The proxy
// tree from _createIsolatedTree() is flat (all children on root), but
// loadActual() produces a nested tree where store entries are deep link
// targets. This wrapper surfaces them at the root level for comparison.
#buildLinkedActualForDiff (idealTree, actualTree) {
// Combined Map keyed by path (how allChildren() in diff.js keys)
const combined = new Map()

// Add actual tree's children (the top-level symlinks)
for (const child of actualTree.children.values()) {
combined.set(child.path, child)
}

// Add synthetic entries for store nodes and store links that exist on disk.
// The proxy tree is flat — all store entries (isInStore) and store links
// (isStoreLink) are direct children of root. The actual tree only has
// top-level links as root children, so store entries and store links
// need synthetic actual entries for the diff to match them.
for (const child of idealTree.children.values()) {
if (!combined.has(child.path) && (child.isInStore || child.isStoreLink) &&
existsSync(child.path)) {
const entry = {
global: false,
globalTop: false,
isProjectRoot: false,
isTop: false,
location: child.location,
name: child.name,
optional: child.optional,
top: child.top,
children: [],
edgesIn: new Set(),
edgesOut: new Map(),
binPaths: [],
fsChildren: [],
/* istanbul ignore next -- emulate Node */
getBundler () {
return null
},
hasShrinkwrap: false,
inDepBundle: false,
integrity: null,
isLink: Boolean(child.isLink),
isRoot: false,
isInStore: Boolean(child.isInStore),
path: child.path,
realpath: child.realpath,
resolved: child.resolved,
version: child.version,
package: child.package,
}
entry.target = entry
if (child.isLink && combined.has(child.realpath)) {
entry.target = combined.get(child.realpath)
}
combined.set(child.path, entry)
}
}

// Proxy .get(name) to original actual tree for filterNodes compatibility
// (scoped workspace installs use .get(name), allChildren uses .values())
const origGet = actualTree.children.get.bind(actualTree.children)
const combinedGet = combined.get.bind(combined)
/* istanbul ignore next -- only reached during scoped workspace installs */
combined.get = (key) => combinedGet(key) || origGet(key)

const wrapper = {
isRoot: true,
isLink: actualTree.isLink,
target: actualTree.target,
fsChildren: actualTree.fsChildren,
path: actualTree.path,
realpath: actualTree.realpath,
edgesOut: actualTree.edgesOut,
inventory: actualTree.inventory,
package: actualTree.package,
resolved: actualTree.resolved,
version: actualTree.version,
integrity: actualTree.integrity,
binPaths: actualTree.binPaths,
hasShrinkwrap: false,
inDepBundle: false,
parent: null,
children: combined,
}

// Set parent/root on synthetic entries for consistency
for (const child of combined.values()) {
if (!child.parent) {
child.parent = wrapper
child.root = wrapper
}
}

return wrapper
}

#registryResolved (resolved) {
// the default registry url is a magic value meaning "the currently
// configured registry".
Expand Down Expand Up @@ -1247,6 +1359,44 @@ module.exports = cls => class Reifier extends cls {
timeEnd()
}

// After a linked install, scan node_modules/.store/ and remove any
// directories that are not referenced by the current ideal tree.
// Store entries become orphaned when dependencies are updated or
// removed, because the diff never sees the old store keys.
async #cleanOrphanedStoreEntries () {
const storeDir = resolve(this.path, 'node_modules', '.store')
let entries
try {
entries = await readdir(storeDir)
} catch {
return
}

// Collect valid store keys from the isolated ideal tree.
// Store entries have location: node_modules/.store/{key}/node_modules/{pkg}
const validKeys = new Set()
for (const child of this.idealTree.children.values()) {
if (child.isInStore) {
const key = child.location.split(sep)[2]
validKeys.add(key)
}
}

const orphaned = entries.filter(e => !validKeys.has(e))
if (!orphaned.length) {
return
}

log.silly('reify', 'cleaning orphaned store entries', orphaned)
await promiseAllRejectLate(
orphaned.map(e =>
rm(resolve(storeDir, e), { recursive: true, force: true })
.catch(/* istanbul ignore next -- rm with force rarely fails */
er => log.warn('cleanup', `Failed to remove orphaned store entry ${e}`, er))
)
)
}

// last but not least, we save the ideal tree metadata to the package-lock
// or shrinkwrap file, and any additions or removals to package.json
async [_saveIdealTree] (options) {
Expand Down
Loading
Loading