Skip to content

Commit d6fe671

Browse files
fix(arborist): v10 - backport multiple fixes for linked install (#9098)
This is a cherry-pick of - c7702d0 - #9094 - b56986a - #9095 - 3b70a9d - #9097 - 5b7c0cc - #9096
1 parent ebd09c3 commit d6fe671

7 files changed

Lines changed: 307 additions & 25 deletions

File tree

lib/commands/ls.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class LS extends ArboristWorkspaceCmd {
5959
const unicode = this.npm.config.get('unicode')
6060
const packageLockOnly = this.npm.config.get('package-lock-only')
6161
const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
62+
const installStrategy = this.npm.flatOptions.installStrategy
6263

6364
const path = global ? resolve(this.npm.globalDir, '..') : this.npm.prefix
6465

@@ -138,6 +139,9 @@ class LS extends ArboristWorkspaceCmd {
138139
link,
139140
omit,
140141
}) : () => true)
142+
.filter(installStrategy === 'linked'
143+
? filterLinkedStrategyEdges({ node, currentDepth })
144+
: () => true)
141145
.map(mapEdgesToNodes({ seenPaths }))
142146
.concat(appendExtraneousChildren({ node, seenPaths }))
143147
.sort(sortAlphabetically)
@@ -405,6 +409,34 @@ const getJsonOutputItem = (node, { global, long }) => {
405409
return augmentItemWithIncludeMetadata(node, item)
406410
}
407411

412+
// In linked strategy, two types of edges produce false UNMET DEPENDENCYs:
413+
// 1. Workspace edges for undeclared workspaces: the lockfile records edges from root to ALL workspaces, but only declared workspaces are hoisted to root/node_modules in linked mode. Undeclared ones are intentionally absent.
414+
// 2. Dev edges on non-root packages: store package link targets have no parent in the node tree, so they are treated as "top" nodes and their devDependencies are loaded as edges. Those devDeps are never installed.
415+
const filterLinkedStrategyEdges = ({ node, currentDepth }) => {
416+
const declaredDeps = new Set(Object.keys(Object.assign({},
417+
node.target.package.dependencies,
418+
node.target.package.devDependencies,
419+
node.target.package.optionalDependencies,
420+
node.target.package.peerDependencies
421+
)))
422+
423+
return (edge) => {
424+
// Skip workspace edges for undeclared workspaces at root level
425+
if (currentDepth === 0 && edge.type === 'workspace' && edge.missing) {
426+
if (!declaredDeps.has(edge.name)) {
427+
return false
428+
}
429+
}
430+
431+
// Skip dev edges for non-root packages (store packages)
432+
if (currentDepth > 0 && edge.dev) {
433+
return false
434+
}
435+
436+
return true
437+
}
438+
}
439+
408440
const filterByEdgesTypes = ({ link, omit }) => (edge) => {
409441
for (const omitType of omit) {
410442
if (edge[omitType]) {

test/lib/commands/ls.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5325,3 +5325,123 @@ t.test('ls --package-lock-only', async t => {
53255325
})
53265326
})
53275327
})
5328+
5329+
t.test('completion', async t => {
5330+
const { ls } = await mockNpm(t, {
5331+
command: 'ls',
5332+
prefixDir: {
5333+
node_modules: {
5334+
foo: {
5335+
'package.json': JSON.stringify({ name: 'foo', version: '1.0.0' }),
5336+
},
5337+
},
5338+
'package.json': JSON.stringify({ name: 'project', version: '1.0.0' }),
5339+
},
5340+
})
5341+
const res = await ls.completion({ conf: { argv: { remain: ['npm', 'ls'] } } })
5342+
t.type(res, Array)
5343+
})
5344+
5345+
t.test('ls --install-strategy=linked', async t => {
5346+
t.test('should not report undeclared workspaces as UNMET DEPENDENCY', async t => {
5347+
const { result, ls } = await mockLs(t, {
5348+
config: {
5349+
'install-strategy': 'linked',
5350+
},
5351+
prefixDir: {
5352+
'package.json': JSON.stringify({
5353+
name: 'test-linked-ws',
5354+
version: '1.0.0',
5355+
workspaces: ['packages/*'],
5356+
dependencies: { 'workspace-a': '*' },
5357+
}),
5358+
packages: {
5359+
'workspace-a': {
5360+
'package.json': JSON.stringify({
5361+
name: 'workspace-a',
5362+
version: '1.0.0',
5363+
}),
5364+
},
5365+
'workspace-b': {
5366+
'package.json': JSON.stringify({
5367+
name: 'workspace-b',
5368+
version: '1.0.0',
5369+
}),
5370+
},
5371+
},
5372+
node_modules: {
5373+
'workspace-a': t.fixture('symlink', '../packages/workspace-a'),
5374+
// workspace-b intentionally NOT linked (undeclared in dependencies)
5375+
},
5376+
},
5377+
})
5378+
await ls.exec([])
5379+
const output = cleanCwd(result())
5380+
t.notMatch(output, /UNMET DEPENDENCY/, 'should not report undeclared workspace as UNMET DEPENDENCY')
5381+
t.match(output, /workspace-a/, 'should list declared workspace')
5382+
})
5383+
5384+
t.test('should not report devDeps of store packages as UNMET DEPENDENCY', async t => {
5385+
const { result, ls } = await mockLs(t, {
5386+
config: {
5387+
'install-strategy': 'linked',
5388+
},
5389+
prefixDir: {
5390+
'package.json': JSON.stringify({
5391+
name: 'test-linked-store',
5392+
version: '1.0.0',
5393+
dependencies: { nopt: '^1.0.0' },
5394+
}),
5395+
node_modules: {
5396+
nopt: t.fixture('symlink', '.store/[email protected]/node_modules/nopt'),
5397+
'.store': {
5398+
5399+
node_modules: {
5400+
nopt: {
5401+
'package.json': JSON.stringify({
5402+
name: 'nopt',
5403+
version: '1.0.0',
5404+
devDependencies: { tap: '^16.0.0' },
5405+
}),
5406+
},
5407+
},
5408+
},
5409+
},
5410+
},
5411+
},
5412+
})
5413+
await ls.exec([])
5414+
const output = cleanCwd(result())
5415+
t.notMatch(output, /UNMET DEPENDENCY/, 'should not report devDeps of store packages')
5416+
t.match(output, /nopt/, 'should list the dependency')
5417+
})
5418+
5419+
t.test('should still report declared workspace as UNMET DEPENDENCY when missing', async t => {
5420+
const { ls } = await mockLs(t, {
5421+
config: {
5422+
'install-strategy': 'linked',
5423+
},
5424+
prefixDir: {
5425+
'package.json': JSON.stringify({
5426+
name: 'test-linked-ws-missing',
5427+
version: '1.0.0',
5428+
workspaces: ['packages/*'],
5429+
dependencies: { 'workspace-a': '*' },
5430+
}),
5431+
packages: {
5432+
'workspace-a': {
5433+
'package.json': JSON.stringify({
5434+
name: 'workspace-a',
5435+
version: '1.0.0',
5436+
}),
5437+
},
5438+
},
5439+
node_modules: {
5440+
// workspace-a is declared but its symlink is missing
5441+
},
5442+
},
5443+
})
5444+
await t.rejects(ls.exec([]), { code: 'ELSPROBLEMS' },
5445+
'should report declared workspace as UNMET DEPENDENCY')
5446+
})
5447+
})

workspaces/arborist/lib/arborist/isolated-reifier.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ module.exports = cls => class IsolatedReifier extends cls {
8080
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
8181
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
8282
const rootPkg = idealTree.package
83-
this.#rootDeclaredDeps = new Set([
84-
...Object.keys(rootPkg.dependencies || {}),
85-
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
86-
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
87-
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
88-
])
83+
this.#rootDeclaredDeps = new Set(Object.keys(Object.assign({},
84+
rootPkg.dependencies,
85+
(!omit.has('dev') && rootPkg.devDependencies),
86+
(!omit.has('optional') && rootPkg.optionalDependencies),
87+
(!omit.has('peer') && rootPkg.peerDependencies)
88+
)))
8989

9090
// XXX this sometimes acts like a node too
9191
this.idealGraph = {

workspaces/arborist/lib/arborist/reify.js

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -842,24 +842,23 @@ module.exports = cls => class Reifier extends cls {
842842
#buildLinkedActualForDiff (idealTree, actualTree) {
843843
const combined = new Map()
844844

845-
for (const child of actualTree.children.values()) {
846-
combined.set(child.path, child)
847-
}
848-
845+
// Create synthetic actual entries for ALL ideal children that exist on disk.
846+
// The isolated ideal tree is flat (all entries as root children), but loadActual() produces a nested tree where workspace deps are under fsChildren and store entries are deep link targets.
847+
// Synthetic entries ensure the diff compares matching resolved/integrity values (e.g. workspace links have resolved=undefined in the ideal tree but resolved="file:../packages/..." in the actual tree).
849848
for (const child of idealTree.children.values()) {
850-
if (!combined.has(child.path) && (child.isInStore || child.isStoreLink) &&
851-
existsSync(child.path)) {
852-
let entry
853-
if (child.isLink) {
854-
entry = new IsolatedLink(child)
855-
} else {
856-
entry = new IsolatedNode(child)
857-
}
858-
if (child.isLink && combined.has(child.realpath)) {
859-
entry.target = combined.get(child.realpath)
860-
}
861-
combined.set(child.path, entry)
849+
if (combined.has(child.path) || !existsSync(child.path)) {
850+
continue
851+
}
852+
let entry
853+
if (child.isLink) {
854+
entry = new IsolatedLink(child)
855+
} else {
856+
entry = new IsolatedNode(child)
857+
}
858+
if (child.isLink && combined.has(child.realpath)) {
859+
entry.target = combined.get(child.realpath)
862860
}
861+
combined.set(child.path, entry)
863862
}
864863

865864
const origGet = actualTree.children.get.bind(actualTree.children)
@@ -878,7 +877,9 @@ module.exports = cls => class Reifier extends cls {
878877
wrapper.binPaths = actualTree.binPaths
879878
wrapper.children = combined
880879
wrapper.edgesOut = actualTree.edgesOut
881-
wrapper.fsChildren = actualTree.fsChildren
880+
// Use empty fsChildren so that allChildren() only picks up entries from the combined map.
881+
// The actual fsChildren have real children with different resolved values (e.g. file:../../../node_modules/.store/... vs file:.store/...) that would overwrite our synthetic entries in allChildren().
882+
wrapper.fsChildren = new Set()
882883
wrapper.integrity = actualTree.integrity
883884
wrapper.inventory = actualTree.inventory
884885

workspaces/arborist/lib/query-selector-all.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -785,9 +785,14 @@ const hasParent = (node, compareNodes) => {
785785
compareNode = compareNode.target
786786
}
787787

788-
// follows logical parent for link anscestors
788+
// Follows logical parent for link ancestors (e.g. workspaces whose target lives outside node_modules).
789+
// Only match if the node has a link whose parent is the compareNode. Without this check, nodes deep in the store (linked strategy) would incorrectly match as children of root via their fsParent chain.
789790
if (node.isTop && (node.resolveParent === compareNode)) {
790-
return true
791+
for (const link of node.linksIn) {
792+
if (link.parent === compareNode) {
793+
return true
794+
}
795+
}
791796
}
792797
// follows edges-in to check if they match a possible parent
793798
for (const edge of node.edgesIn) {

workspaces/arborist/test/isolated-mode.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,6 +1863,43 @@ tap.test('workspace links are not affected by store resolved fix', async t => {
18631863
t.ok(arb2.diff.unchanged.length > 0, 'second install should have unchanged nodes')
18641864
})
18651865

1866+
tap.test('idempotent install with cross-workspace deps (diamond pattern)', async t => {
1867+
// Regression: when workspace-x depends on workspace-y (and both share a registry dep), the second install would report "changed N packages" because (1) workspace link resolved values didn't match between ideal and actual trees, and (2) actual fsChildren overwrote synthetic store entries in the diff proxy.
1868+
const graph = {
1869+
registry: [
1870+
{ name: 'abbrev', version: '2.0.0' },
1871+
],
1872+
root: {
1873+
name: 'myproject',
1874+
version: '1.0.0',
1875+
dependencies: { 'workspace-x': '*', 'workspace-y': '*' },
1876+
},
1877+
workspaces: [
1878+
{ name: 'workspace-x', version: '1.0.0', dependencies: { abbrev: '2.0.0', 'workspace-y': '*' } },
1879+
{ name: 'workspace-y', version: '1.0.0', dependencies: { abbrev: '2.0.0' } },
1880+
],
1881+
}
1882+
const { dir, registry } = await getRepo(graph)
1883+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1884+
1885+
// First install
1886+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1887+
await arb1.reify({ installStrategy: 'linked' })
1888+
1889+
// Second install — should detect everything is up-to-date
1890+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1891+
await arb2.reify({ installStrategy: 'linked' })
1892+
1893+
const leaves = arb2.diff?.leaves || []
1894+
const actions = leaves.filter(l => l.action)
1895+
t.equal(actions.length, 0, 'second install should have no diff actions')
1896+
t.ok(arb2.diff.unchanged.length > 0, 'second install should have unchanged nodes')
1897+
1898+
// Verify packages are still correctly installed (abbrev is a workspace dep, not root)
1899+
t.ok(setupRequire(path.join(dir, 'packages', 'workspace-y'))('abbrev'),
1900+
'abbrev is requireable from workspace after second install')
1901+
})
1902+
18661903
tap.test('orphaned store entries are cleaned up on dependency update', async t => {
18671904
const graph = {
18681905
registry: [

workspaces/arborist/test/query-selector-all.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,3 +1019,90 @@ t.test('query-selector-all', async t => {
10191019
10201020
])
10211021
})
1022+
1023+
// Simulates the linked install strategy layout where packages live in node_modules/.store/ and are symlinked from node_modules/.
1024+
t.test('linked strategy: :root > * excludes transitive deps and store nodes', async t => {
1025+
/*
1026+
fixture tree (linked strategy layout):
1027+
1028+
1029+
├── [email protected] (symlink -> .store/nopt-hash/node_modules/nopt)
1030+
├── [email protected] (symlink -> .store/ini-hash/node_modules/ini)
1031+
└── .store/
1032+
├── nopt-hash/
1033+
│ └── node_modules/
1034+
│ ├── [email protected] (actual)
1035+
│ └── abbrev (symlink -> ../../abbrev-hash/node_modules/abbrev)
1036+
├── ini-hash/
1037+
│ └── node_modules/
1038+
│ └── [email protected] (actual)
1039+
└── abbrev-hash/
1040+
└── node_modules/
1041+
└── [email protected] (actual)
1042+
*/
1043+
1044+
const path = t.testdir({
1045+
node_modules: {
1046+
nopt: t.fixture('symlink', '.store/nopt-hash/node_modules/nopt'),
1047+
ini: t.fixture('symlink', '.store/ini-hash/node_modules/ini'),
1048+
'.store': {
1049+
'nopt-hash': {
1050+
node_modules: {
1051+
nopt: {
1052+
'package.json': JSON.stringify({
1053+
name: 'nopt',
1054+
version: '7.2.1',
1055+
dependencies: { abbrev: '^2.0.0' },
1056+
}),
1057+
},
1058+
abbrev: t.fixture('symlink', '../../abbrev-hash/node_modules/abbrev'),
1059+
},
1060+
},
1061+
'ini-hash': {
1062+
node_modules: {
1063+
ini: {
1064+
'package.json': JSON.stringify({
1065+
name: 'ini',
1066+
version: '4.1.3',
1067+
}),
1068+
},
1069+
},
1070+
},
1071+
'abbrev-hash': {
1072+
node_modules: {
1073+
abbrev: {
1074+
'package.json': JSON.stringify({
1075+
name: 'abbrev',
1076+
version: '2.0.0',
1077+
}),
1078+
},
1079+
},
1080+
},
1081+
},
1082+
},
1083+
'package.json': JSON.stringify({
1084+
name: 'linked-test',
1085+
version: '1.0.0',
1086+
dependencies: {
1087+
nopt: '^7.0.0',
1088+
ini: '^4.0.0',
1089+
},
1090+
}),
1091+
})
1092+
1093+
const arb = new Arborist({ path })
1094+
const tree = await arb.loadActual()
1095+
1096+
const rootChildren = await querySelectorAll(tree, ':root > *')
1097+
t.same(rootChildren, [
1098+
1099+
1100+
], ':root > * should only return direct dependencies, not transitive deps or store nodes')
1101+
1102+
const rootDescendants = await querySelectorAll(tree, ':root *')
1103+
t.same(rootDescendants, [
1104+
1105+
1106+
1107+
], ':root * should return all descendants including transitive deps')
1108+
})

0 commit comments

Comments
 (0)