Skip to content

Commit b56986a

Browse files
fix(ls): suppress false UNMET DEPENDENCYs in linked strategy (#9095)
1 parent c7702d0 commit b56986a

File tree

2 files changed

+136
-0
lines changed

2 files changed

+136
-0
lines changed

lib/commands/ls.js

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

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

@@ -136,6 +137,9 @@ class LS extends ArboristWorkspaceCmd {
136137
link,
137138
omit,
138139
}) : () => true)
140+
.filter(installStrategy === 'linked'
141+
? filterLinkedStrategyEdges({ node, currentDepth })
142+
: () => true)
139143
.map(mapEdgesToNodes({ seenPaths }))
140144
.concat(appendExtraneousChildren({ node, seenPaths }))
141145
.sort(sortAlphabetically)
@@ -403,6 +407,34 @@ const getJsonOutputItem = (node, { global, long }) => {
403407
return augmentItemWithIncludeMetadata(node, item)
404408
}
405409

410+
// In linked strategy, two types of edges produce false UNMET DEPENDENCYs:
411+
// 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.
412+
// 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.
413+
const filterLinkedStrategyEdges = ({ node, currentDepth }) => {
414+
const declaredDeps = new Set(Object.keys(Object.assign({},
415+
node.target.package.dependencies,
416+
node.target.package.devDependencies,
417+
node.target.package.optionalDependencies,
418+
node.target.package.peerDependencies
419+
)))
420+
421+
return (edge) => {
422+
// Skip workspace edges for undeclared workspaces at root level
423+
if (currentDepth === 0 && edge.type === 'workspace' && edge.missing) {
424+
if (!declaredDeps.has(edge.name)) {
425+
return false
426+
}
427+
}
428+
429+
// Skip dev edges for non-root packages (store packages)
430+
if (currentDepth > 0 && edge.dev) {
431+
return false
432+
}
433+
434+
return true
435+
}
436+
}
437+
406438
const filterByEdgesTypes = ({ link, omit }) => (edge) => {
407439
for (const omitType of omit) {
408440
if (edge[omitType]) {

test/lib/commands/ls.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5301,3 +5301,107 @@ t.test('completion', async t => {
53015301
const res = await ls.completion({ conf: { argv: { remain: ['npm', 'ls'] } } })
53025302
t.type(res, Array)
53035303
})
5304+
5305+
t.test('ls --install-strategy=linked', async t => {
5306+
t.test('should not report undeclared workspaces as UNMET DEPENDENCY', async t => {
5307+
const { result, ls } = await mockLs(t, {
5308+
config: {
5309+
'install-strategy': 'linked',
5310+
},
5311+
prefixDir: {
5312+
'package.json': JSON.stringify({
5313+
name: 'test-linked-ws',
5314+
version: '1.0.0',
5315+
workspaces: ['packages/*'],
5316+
dependencies: { 'workspace-a': '*' },
5317+
}),
5318+
packages: {
5319+
'workspace-a': {
5320+
'package.json': JSON.stringify({
5321+
name: 'workspace-a',
5322+
version: '1.0.0',
5323+
}),
5324+
},
5325+
'workspace-b': {
5326+
'package.json': JSON.stringify({
5327+
name: 'workspace-b',
5328+
version: '1.0.0',
5329+
}),
5330+
},
5331+
},
5332+
node_modules: {
5333+
'workspace-a': t.fixture('symlink', '../packages/workspace-a'),
5334+
// workspace-b intentionally NOT linked (undeclared in dependencies)
5335+
},
5336+
},
5337+
})
5338+
await ls.exec([])
5339+
const output = cleanCwd(result())
5340+
t.notMatch(output, /UNMET DEPENDENCY/, 'should not report undeclared workspace as UNMET DEPENDENCY')
5341+
t.match(output, /workspace-a/, 'should list declared workspace')
5342+
})
5343+
5344+
t.test('should not report devDeps of store packages as UNMET DEPENDENCY', async t => {
5345+
const { result, ls } = await mockLs(t, {
5346+
config: {
5347+
'install-strategy': 'linked',
5348+
},
5349+
prefixDir: {
5350+
'package.json': JSON.stringify({
5351+
name: 'test-linked-store',
5352+
version: '1.0.0',
5353+
dependencies: { nopt: '^1.0.0' },
5354+
}),
5355+
node_modules: {
5356+
nopt: t.fixture('symlink', '.store/nopt@1.0.0/node_modules/nopt'),
5357+
'.store': {
5358+
'nopt@1.0.0': {
5359+
node_modules: {
5360+
nopt: {
5361+
'package.json': JSON.stringify({
5362+
name: 'nopt',
5363+
version: '1.0.0',
5364+
devDependencies: { tap: '^16.0.0' },
5365+
}),
5366+
},
5367+
},
5368+
},
5369+
},
5370+
},
5371+
},
5372+
})
5373+
await ls.exec([])
5374+
const output = cleanCwd(result())
5375+
t.notMatch(output, /UNMET DEPENDENCY/, 'should not report devDeps of store packages')
5376+
t.match(output, /nopt/, 'should list the dependency')
5377+
})
5378+
5379+
t.test('should still report declared workspace as UNMET DEPENDENCY when missing', async t => {
5380+
const { ls } = await mockLs(t, {
5381+
config: {
5382+
'install-strategy': 'linked',
5383+
},
5384+
prefixDir: {
5385+
'package.json': JSON.stringify({
5386+
name: 'test-linked-ws-missing',
5387+
version: '1.0.0',
5388+
workspaces: ['packages/*'],
5389+
dependencies: { 'workspace-a': '*' },
5390+
}),
5391+
packages: {
5392+
'workspace-a': {
5393+
'package.json': JSON.stringify({
5394+
name: 'workspace-a',
5395+
version: '1.0.0',
5396+
}),
5397+
},
5398+
},
5399+
node_modules: {
5400+
// workspace-a is declared but its symlink is missing
5401+
},
5402+
},
5403+
})
5404+
await t.rejects(ls.exec([]), { code: 'ELSPROBLEMS' },
5405+
'should report declared workspace as UNMET DEPENDENCY')
5406+
})
5407+
})

0 commit comments

Comments
 (0)