Skip to content

Commit 6968015

Browse files
fix(arborist): record the linked .store layout in the hidden lockfile (#9630)
In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](WordPress/gutenberg#75814), which powers the WordPress Block Editor. Under `install-strategy=linked`, the hidden lockfile `node_modules/.package-lock.json` recorded the hoisted logical layout (`node_modules/<pkg>`) instead of the actual on-disk `.store`/symlink layout. The hidden lockfile is meant to cache what `loadActual()` finds on disk so the actual tree can be validated cheaply, but because it recorded the wrong layout it was rejected on every reload, so it never served as a cache and misrepresented the installed layout. ## Why A linked reify swaps `idealTree` for the isolated tree, materializes the `.store`/symlink layout, then swaps the logical tree back before saving. The hidden lockfile was serialized from that logical tree, so it listed packages at their hoisted paths. On the next load, `assertNoNewer()` walked the real `node_modules` (the root symlink plus `.store/`) and could not reconcile it with the hoisted entries, throwing `missing from lockfile`, so `loadActual()` always fell back to a full filesystem scan. ## How `reify.js` serializes the hidden lockfile from the isolated tree, which mirrors the on-disk layout, while `package-lock.json` still comes from the logical tree. It records every store package directory and symlink, adds an entry for each `.store/<key>` container directory (these are the fsParents `loadVirtual()` needs so a store package can resolve its sibling deps), includes the workspace directories, and skips tree-only undeclared-workspace self-links that are never materialized on disk. `assertNoNewer()` additionally validates the directories the plain `node_modules` walk cannot reach under the linked strategy: a store package's deps live as symlinked siblings under `.store/<key>/node_modules` (and `.store` is skipped as a dot-dir), and an undeclared workspace is not symlinked into the root `node_modules` at all. These directories are derived from the lockfile entries. A workspace directory is only walked when it is not the target of a link entry, so the hoisted strategy keeps its existing, stricter validation unchanged — a stale workspace symlink that points at the wrong target still surfaces as a missing entry and rejects the cache. ## References Fixes #9612 Part of #9608
1 parent 2aa1c7c commit 6968015

4 files changed

Lines changed: 206 additions & 8 deletions

File tree

workspaces/arborist/lib/arborist/reify.js

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ const { applyPatchToDir, patchIntegrity } = require('../patch.js')
2828
const { readFile } = require('node:fs/promises')
2929
const retirePath = require('../retire-path.js')
3030
const treeCheck = require('../tree-check.js')
31-
const { defaultLockfileVersion } = require('../shrinkwrap.js')
31+
const Shrinkwrap = require('../shrinkwrap.js')
32+
const { defaultLockfileVersion } = Shrinkwrap
3233
const { saveTypeMap, hasSubKey } = require('../add-rm-pkg-deps.js')
3334
const { IsolatedNode, IsolatedLink } = require('../isolated-classes.js')
3435

@@ -115,12 +116,15 @@ module.exports = cls => class Reifier extends cls {
115116
await this[_loadTrees](options)
116117

117118
const oldTree = this.idealTree
119+
// Kept to serialize the hidden lockfile from the on-disk .store/symlink layout.
120+
let isolatedTree = null
118121
if (linked) {
119122
// swap out the tree with the isolated tree
120123
// this is currently technical debt which will be resolved in a refactor
121124
// of Node/Link trees
122125
log.warn('reify', 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.')
123126
this.idealTree = await this.createIsolatedTree()
127+
isolatedTree = this.idealTree
124128
if (this.actualTree) {
125129
this.#linkedActualForDiff = this.#buildLinkedActualForDiff(
126130
this.idealTree, this.actualTree
@@ -241,17 +245,24 @@ module.exports = cls => class Reifier extends cls {
241245
calcDepFlags(this.idealTree)
242246
}
243247

244-
// save the ideal's meta as a hidden lockfile after we actualize it
245-
this.idealTree.meta.filename =
246-
this.idealTree.realpath + '/node_modules/.package-lock.json'
247-
this.idealTree.meta.hiddenLockfile = true
248-
this.idealTree.meta.lockfileVersion = defaultLockfileVersion
248+
// save the ideal's meta as a hidden lockfile after we actualize it.
249+
// Under linked the logical tree is the hoisted layout, so the hidden lockfile is serialized from the isolated tree instead.
250+
if (!linked) {
251+
this.idealTree.meta.filename =
252+
this.idealTree.realpath + '/node_modules/.package-lock.json'
253+
this.idealTree.meta.hiddenLockfile = true
254+
this.idealTree.meta.lockfileVersion = defaultLockfileVersion
255+
}
249256

250257
this.actualTree = this.idealTree
251258
this.idealTree = null
252259

253260
if (!this.options.global && !this.options.dryRun) {
254-
await this.actualTree.meta.save()
261+
if (linked) {
262+
await this.#saveLinkedHiddenLockfile(isolatedTree)
263+
} else {
264+
await this.actualTree.meta.save()
265+
}
255266
const ignoreScripts = !!this.options.ignoreScripts
256267
// if we aren't doing a dry run or ignoring scripts and we actually made changes to the dep
257268
// tree, then run the dependencies scripts
@@ -851,6 +862,47 @@ module.exports = cls => class Reifier extends cls {
851862
return join(filePath)
852863
}
853864

865+
// Serialize the hidden lockfile from the isolated tree, which mirrors the on-disk .store/symlink layout.
866+
// Its children are every materialized node_modules entry: store package dirs and all symlinks.
867+
async #saveLinkedHiddenLockfile (isolatedTree) {
868+
const path = isolatedTree.realpath
869+
const meta = new Shrinkwrap({
870+
path,
871+
hiddenLockfile: true,
872+
lockfileVersion: defaultLockfileVersion,
873+
resolveOptions: this.options,
874+
})
875+
meta.reset()
876+
meta.filename = resolve(path, 'node_modules/.package-lock.json')
877+
const storeRe = /^(.*\/\.store\/.+?)\/node_modules\//
878+
const containers = new Set()
879+
const nodes = new Set()
880+
for (const node of isolatedTree.children.values()) {
881+
// Tree-only undeclared workspace self-links aren't on disk.
882+
if (node.isUndeclaredWorkspaceLink) {
883+
continue
884+
}
885+
nodes.add(node)
886+
// Record the enclosing .store/<key> dir so loadVirtual can resolve a store package's sibling deps.
887+
// node.location uses the platform separator; lockfile keys are posix.
888+
const m = node.location.replace(/\\/g, '/').match(storeRe)
889+
if (m) {
890+
containers.add(m[1])
891+
}
892+
}
893+
// Workspace dirs hold their own dep symlinks; record them so the cache can validate those subtrees.
894+
for (const ws of isolatedTree.fsChildren) {
895+
nodes.add(ws)
896+
}
897+
for (const node of nodes) {
898+
meta.add(node)
899+
}
900+
for (const loc of containers) {
901+
meta.data.packages[loc] = {}
902+
}
903+
await meta.save()
904+
}
905+
854906
// Build a flat actual tree wrapper for linked installs so the diff can correctly match store entries that already exist on disk.
855907
// The proxy tree from createIsolatedTree() is flat (all children on root), but loadActual() produces a nested tree where store entries are deep link targets.
856908
// This wrapper surfaces them at the root level for comparison.

workspaces/arborist/lib/shrinkwrap.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,35 @@ const assertNoNewer = async (path, data, lockTime, dir, seen) => {
178178
return
179179
}
180180

181+
// The walk above can't reach two linked-strategy layouts: a store package's sibling deps under .store/<key>/node_modules (.store is a skipped dot-dir), and an undeclared workspace not symlinked into root node_modules. Walk those dirs, derived from the lockfile entries.
182+
// A dir reachable through a link entry is skipped: it was already reached (or, if the symlink is stale, correctly stays unseen so the lockfile is rejected). This keeps the hoisted strategy's stale-symlink detection intact, since there every workspace is a link target.
183+
const linkTargets = new Set()
184+
for (const loc in data.packages) {
185+
const { link, resolved } = data.packages[loc]
186+
if (link && resolved) {
187+
linkTargets.add(resolved.replace(/\\/g, '/'))
188+
}
189+
}
190+
const extraDirs = new Set()
191+
for (const loc in data.packages) {
192+
const store = loc.match(/^(.*\/\.store\/.+?)\/node_modules\//)
193+
if (store) {
194+
// .store/<key> is never walked but has no entry, so mark it seen.
195+
seen.add(store[1])
196+
extraDirs.add(`${store[1]}/node_modules`)
197+
continue
198+
}
199+
// A workspace/fsChild dir outside node_modules, e.g. packages/a.
200+
const i = loc.indexOf('/node_modules/')
201+
const root = i === -1 ? loc : loc.slice(0, i)
202+
if (root && !/(^|\/)node_modules(\/|$)/.test(root) && !linkTargets.has(root)) {
203+
extraDirs.add(root)
204+
}
205+
}
206+
for (const rel of extraDirs) {
207+
await assertNoNewer(path, data, lockTime, resolve(path, rel), seen)
208+
}
209+
181210
// assert that all the entries in the lockfile were seen
182211
for (const loc in data.packages) {
183212
if (!seen.has(loc)) {

workspaces/arborist/test/arborist/reify-npm-extension.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,14 @@ t.test('a project with no .npm-extension installs normally and records no state'
215215
t.test('provenance round-trips under install-strategy=linked', async t => {
216216
const dir = await setup(t)
217217
await newArb(dir, { installStrategy: 'linked' }).reify()
218-
// a second linked reify rescans the store and links, re-deriving provenance on both
218+
// the hidden lockfile carries the provenance forward on a second linked reify
219219
const tree = await newArb(dir, { installStrategy: 'linked' }).reify()
220220
const foo = [...tree.inventory.values()].find(n => n.name === 'foo')
221221
t.ok(foo.npmExtensionApplied || foo.target?.npmExtensionApplied, 'provenance present on the linked node or its target')
222+
// a full rescan (no cache) re-derives provenance on the store node and mirrors it onto the link
223+
const actual = await newArb(dir, { installStrategy: 'linked' }).loadActual({ forceActual: true })
224+
const fooLink = [...actual.inventory.values()].find(n => n.name === 'foo' && n.isLink)
225+
t.ok(fooLink?.npmExtensionApplied, 'a rescan mirrors provenance onto the linked location')
222226
})
223227

224228
t.test('loadActual re-derives provenance only for transformed installed deps', async t => {

workspaces/arborist/test/arborist/reify.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4220,6 +4220,119 @@ t.test('install strategy linked', async (t) => {
42204220
t.ok(abbrev.isSymbolicLink(), 'abbrev got installed')
42214221
})
42224222

4223+
t.test('hidden lockfile records the linked .store layout and round-trips', async t => {
4224+
// Regression for #9612: the hidden lockfile must record the on-disk .store/symlink layout so it round-trips as a valid cache.
4225+
const Shrinkwrap = require('../../lib/shrinkwrap.js')
4226+
const path = t.testdir({
4227+
'package.json': JSON.stringify({
4228+
name: 'root',
4229+
version: '1.0.0',
4230+
// once depends on wrappy, so the store has a transitive symlink to validate
4231+
dependencies: { once: '1.4.0' },
4232+
}),
4233+
})
4234+
4235+
createRegistry(t, true)
4236+
await reify(path, { installStrategy: 'linked' })
4237+
4238+
const hidden = require(resolve(path, 'node_modules/.package-lock.json'))
4239+
const locs = Object.keys(hidden.packages)
4240+
// the layout is recorded at .store paths, not hoisted node_modules/<name>
4241+
t.ok(locs.some(l => /^node_modules\/\.store\/once@/.test(l)),
4242+
'once is recorded under .store')
4243+
t.ok(locs.some(l => /^node_modules\/\.store\/.*\/node_modules\/wrappy$/.test(l)),
4244+
'the transitive wrappy symlink is recorded inside the store')
4245+
t.notOk(locs.includes('node_modules/wrappy'),
4246+
'wrappy is not recorded at a hoisted path')
4247+
4248+
// the cache is accepted on reload: assertNoNewer matches it against the real disk layout
4249+
const meta = await Shrinkwrap.load({ path, hiddenLockfile: true })
4250+
t.equal(meta.loadedFromDisk, true, 'hidden lockfile is a valid cache of the disk layout')
4251+
4252+
// loadActual must reconstruct the tree from the cache with once->wrappy resolved through the store.
4253+
const actual = await newArb({ path, installStrategy: 'linked' }).loadActual()
4254+
const onceNode = [...actual.inventory.values()].find(n => n.name === 'once' && !n.isLink)
4255+
t.ok(onceNode, 'once is in the cached actual tree')
4256+
const wrappyEdge = onceNode.edgesOut.get('wrappy')
4257+
t.ok(wrappyEdge && !wrappyEdge.missing, 'once resolves its wrappy dep through the cached store layout')
4258+
})
4259+
4260+
t.test('hidden lockfile round-trips with an undeclared workspace', async t => {
4261+
// Regression for #9612: an undeclared workspace materializes deps in its own node_modules but isn't linked into root, and the cache must still validate that subtree.
4262+
const Shrinkwrap = require('../../lib/shrinkwrap.js')
4263+
const path = t.testdir({
4264+
'package.json': JSON.stringify({
4265+
name: 'host',
4266+
version: '1.0.0',
4267+
workspaces: ['packages/a'],
4268+
// root does not depend on the workspace, so it stays undeclared
4269+
}),
4270+
packages: {
4271+
a: {
4272+
'package.json': JSON.stringify({
4273+
name: 'a',
4274+
version: '1.0.0',
4275+
dependencies: { once: '1.4.0' },
4276+
}),
4277+
},
4278+
},
4279+
})
4280+
4281+
createRegistry(t, true)
4282+
await reify(path, { installStrategy: 'linked' })
4283+
4284+
// the workspace's dep is materialized under its own node_modules, not the root's
4285+
t.ok(fs.lstatSync(resolve(path, 'packages/a/node_modules/once')).isSymbolicLink(),
4286+
'once is symlinked into the workspace node_modules')
4287+
t.notOk(fs.existsSync(resolve(path, 'node_modules/a')),
4288+
'the undeclared workspace is not symlinked into the root node_modules')
4289+
4290+
const meta = await Shrinkwrap.load({ path, hiddenLockfile: true })
4291+
t.equal(meta.loadedFromDisk, true, 'hidden lockfile validates the undeclared workspace subtree')
4292+
})
4293+
4294+
t.test('hidden lockfile round-trips with an undeclared workspace and no store entries', async t => {
4295+
// Regression for #9612: with only local deps there is no .store, so the cache must still walk the undeclared workspace subtree to validate it.
4296+
const Shrinkwrap = require('../../lib/shrinkwrap.js')
4297+
const path = t.testdir({
4298+
'package.json': JSON.stringify({
4299+
name: 'host',
4300+
version: '1.0.0',
4301+
workspaces: ['packages/w', 'packages/a', 'packages/b'],
4302+
// only w is declared; a and b stay undeclared, and a depends on b locally
4303+
dependencies: { w: '1.0.0' },
4304+
}),
4305+
packages: {
4306+
w: { 'package.json': JSON.stringify({ name: 'w', version: '1.0.0' }) },
4307+
a: {
4308+
'package.json': JSON.stringify({
4309+
name: 'a',
4310+
version: '1.0.0',
4311+
dependencies: { b: '1.0.0' },
4312+
}),
4313+
},
4314+
b: { 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }) },
4315+
},
4316+
})
4317+
4318+
createRegistry(t, false)
4319+
await reify(path, { installStrategy: 'linked' })
4320+
4321+
t.notOk(fs.existsSync(resolve(path, 'node_modules/.store')),
4322+
'no store is created for an all-local graph')
4323+
t.ok(fs.lstatSync(resolve(path, 'packages/a/node_modules/b')).isSymbolicLink(),
4324+
'the undeclared workspace links its local dep')
4325+
4326+
const meta = await Shrinkwrap.load({ path, hiddenLockfile: true })
4327+
t.equal(meta.loadedFromDisk, true, 'hidden lockfile validates the subtree without any store entry')
4328+
4329+
// loadActual must reconstruct the undeclared workspace from the cache with its local dep resolved.
4330+
const actual = await newArb({ path, installStrategy: 'linked' }).loadActual()
4331+
const aNode = [...actual.inventory.values()].find(n => n.name === 'a' && !n.isLink)
4332+
const bEdge = aNode && aNode.edgesOut.get('b')
4333+
t.ok(bEdge && !bEdge.missing, 'the undeclared workspace resolves its local dep through the cache')
4334+
})
4335+
42234336
t.test('does not re-create a workspace dir removed from manifest', async t => {
42244337
// Regression test for https://github.com/npm/cli/issues/9331
42254338
const path = t.testdir({

0 commit comments

Comments
 (0)