Skip to content

Commit f9c977c

Browse files
fix(arborist): re-apply packageExtensions to the linked actual tree (#9569)
Follow-up of #9496 Under `install-strategy=linked`, a root `packageExtensions` rule that adds a missing dependency installs and works at runtime, but every command that reads the actual tree loses the edge: - `npm ls --all --json` omits the extension-created dependency and its provenance. - `npm explain <dep>` fails with `No dependencies found matching <dep>`. - `npm patch add <dep>` fails with `EPATCHNOTINSTALLED`. Hoisted installs and normally-declared transitive deps are unaffected. ## References Fixes #9568
1 parent ce7681f commit f9c977c

2 files changed

Lines changed: 78 additions & 0 deletions

File tree

workspaces/arborist/lib/arborist/load-actual.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const calcDepFlags = require('../calc-dep-flags.js')
1313
const Node = require('../node.js')
1414
const Link = require('../link.js')
1515
const realpath = require('../realpath.js')
16+
const PackageExtensions = require('../package-extensions.js')
1617

1718
// public symbols
1819
const _changePath = Symbol.for('_changePath')
@@ -173,6 +174,8 @@ module.exports = cls => class ActualLoader extends cls {
173174
await Promise.all(promises)
174175
}
175176

177+
this.#applyPackageExtensions()
178+
176179
if (!ignoreMissing) {
177180
await this.#findMissingEdges()
178181
}
@@ -352,6 +355,34 @@ module.exports = cls => class ActualLoader extends cls {
352355
}
353356
}
354357

358+
// packageExtensions never rewrite a package's package.json, so a filesystem-scanned actual tree lacks the extension-created edges and provenance.
359+
// Re-derive them from the root rule set, as buildIdealTree does.
360+
// This is always required under the linked strategy, whose store layout forces the filesystem-scan path.
361+
#applyPackageExtensions () {
362+
const rootPkg = this.#actualTree.target?.package
363+
const pe = new PackageExtensions(rootPkg?.packageExtensions)
364+
if (!pe.present || !pe.selectors.length) {
365+
return
366+
}
367+
for (const node of this.#actualTree.inventory.values()) {
368+
// only installed dependencies are extended, never the root or a workspace
369+
if (node.isLink || node.isProjectRoot || !node.name || !node.inNodeModules()) {
370+
continue
371+
}
372+
const res = pe.apply(node.package)
373+
if (res) {
374+
node.package = res.pkg
375+
node.packageExtensionsApplied = res.applied
376+
}
377+
}
378+
// mirror the provenance onto links so the logical tree location reports it too
379+
for (const node of this.#actualTree.inventory.values()) {
380+
if (node.isLink && node.target?.packageExtensionsApplied) {
381+
node.packageExtensionsApplied = node.target.packageExtensionsApplied
382+
}
383+
}
384+
}
385+
355386
async #findMissingEdges () {
356387
// try to resolve any missing edges by walking up the directory tree,
357388
// checking for the package in each node_modules folder. stop at the

workspaces/arborist/test/arborist/load-actual.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,3 +498,50 @@ t.test('loading a workspace maintains overrides', async t => {
498498
const fooEdge = tree.edgesOut.get('foo')
499499
t.equal(tree.overrides, fooEdge.overrides, 'foo edge got the correct overrides')
500500
})
501+
502+
t.test('applies root packageExtensions to a linked actual tree', async t => {
503+
// packageExtensions never rewrite a package's package.json, so the extension edge lives only in lockfile metadata.
504+
// The linked store layout forces loadActual onto the filesystem-scan path, where the edge must be re-derived from the root rule set.
505+
const path = t.testdir({
506+
'package.json': JSON.stringify({
507+
name: 'root',
508+
version: '1.0.0',
509+
dependencies: { broken: '1.0.0', safe: '1.0.0' },
510+
packageExtensions: { 'broken@1': { dependencies: { missing: '^1.0.0' } } },
511+
}),
512+
node_modules: {
513+
broken: t.fixture('symlink', '.store/broken@1.0.0/node_modules/broken'),
514+
// safe matches no selector, exercising the non-extended path
515+
safe: t.fixture('symlink', '.store/safe@1.0.0/node_modules/safe'),
516+
'.store': {
517+
'broken@1.0.0': {
518+
node_modules: {
519+
// physical manifest deliberately omits the extension-added dependency
520+
broken: { 'package.json': JSON.stringify({ name: 'broken', version: '1.0.0' }) },
521+
missing: t.fixture('symlink', '../../missing@1.0.0/node_modules/missing'),
522+
},
523+
},
524+
'missing@1.0.0': {
525+
node_modules: {
526+
missing: { 'package.json': JSON.stringify({ name: 'missing', version: '1.0.0' }) },
527+
},
528+
},
529+
'safe@1.0.0': {
530+
node_modules: {
531+
safe: { 'package.json': JSON.stringify({ name: 'safe', version: '1.0.0' }) },
532+
},
533+
},
534+
},
535+
},
536+
})
537+
538+
const tree = await loadActual(path)
539+
const brokenLink = tree.children.get('broken')
540+
const broken = brokenLink.target
541+
const edge = broken.edgesOut.get('missing')
542+
t.ok(edge && !edge.error, 'extension-added edge is present and resolves')
543+
t.equal(edge.to.name, 'missing', 'edge resolves to the installed package')
544+
const applied = { selector: 'broken@1', dependencies: ['missing'] }
545+
t.strictSame(broken.packageExtensionsApplied, applied, 'provenance recorded on the store node')
546+
t.strictSame(brokenLink.packageExtensionsApplied, applied, 'provenance mirrored onto the link')
547+
})

0 commit comments

Comments
 (0)