diff --git a/lib/commands/ls.js b/lib/commands/ls.js index 0ea8704a50573..9d6177413ad8d 100644 --- a/lib/commands/ls.js +++ b/lib/commands/ls.js @@ -57,6 +57,7 @@ class LS extends ArboristWorkspaceCmd { const unicode = this.npm.config.get('unicode') const packageLockOnly = this.npm.config.get('package-lock-only') const workspacesEnabled = this.npm.flatOptions.workspacesEnabled + const installStrategy = this.npm.flatOptions.installStrategy const path = global ? resolve(this.npm.globalDir, '..') : this.npm.prefix @@ -136,6 +137,9 @@ class LS extends ArboristWorkspaceCmd { link, omit, }) : () => true) + .filter(installStrategy === 'linked' + ? filterLinkedStrategyEdges({ node, currentDepth }) + : () => true) .map(mapEdgesToNodes({ seenPaths })) .concat(appendExtraneousChildren({ node, seenPaths })) .sort(sortAlphabetically) @@ -403,6 +407,34 @@ const getJsonOutputItem = (node, { global, long }) => { return augmentItemWithIncludeMetadata(node, item) } +// In linked strategy, two types of edges produce false UNMET DEPENDENCYs: +// 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. +// 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. +const filterLinkedStrategyEdges = ({ node, currentDepth }) => { + const declaredDeps = new Set(Object.keys(Object.assign({}, + node.target.package.dependencies, + node.target.package.devDependencies, + node.target.package.optionalDependencies, + node.target.package.peerDependencies + ))) + + return (edge) => { + // Skip workspace edges for undeclared workspaces at root level + if (currentDepth === 0 && edge.type === 'workspace' && edge.missing) { + if (!declaredDeps.has(edge.name)) { + return false + } + } + + // Skip dev edges for non-root packages (store packages) + if (currentDepth > 0 && edge.dev) { + return false + } + + return true + } +} + const filterByEdgesTypes = ({ link, omit }) => (edge) => { for (const omitType of omit) { if (edge[omitType]) { diff --git a/test/lib/commands/ls.js b/test/lib/commands/ls.js index 1de85b8cfd095..ab98773bc68e5 100644 --- a/test/lib/commands/ls.js +++ b/test/lib/commands/ls.js @@ -5301,3 +5301,107 @@ t.test('completion', async t => { const res = await ls.completion({ conf: { argv: { remain: ['npm', 'ls'] } } }) t.type(res, Array) }) + +t.test('ls --install-strategy=linked', async t => { + t.test('should not report undeclared workspaces as UNMET DEPENDENCY', async t => { + const { result, ls } = await mockLs(t, { + config: { + 'install-strategy': 'linked', + }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-linked-ws', + version: '1.0.0', + workspaces: ['packages/*'], + dependencies: { 'workspace-a': '*' }, + }), + packages: { + 'workspace-a': { + 'package.json': JSON.stringify({ + name: 'workspace-a', + version: '1.0.0', + }), + }, + 'workspace-b': { + 'package.json': JSON.stringify({ + name: 'workspace-b', + version: '1.0.0', + }), + }, + }, + node_modules: { + 'workspace-a': t.fixture('symlink', '../packages/workspace-a'), + // workspace-b intentionally NOT linked (undeclared in dependencies) + }, + }, + }) + await ls.exec([]) + const output = cleanCwd(result()) + t.notMatch(output, /UNMET DEPENDENCY/, 'should not report undeclared workspace as UNMET DEPENDENCY') + t.match(output, /workspace-a/, 'should list declared workspace') + }) + + t.test('should not report devDeps of store packages as UNMET DEPENDENCY', async t => { + const { result, ls } = await mockLs(t, { + config: { + 'install-strategy': 'linked', + }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-linked-store', + version: '1.0.0', + dependencies: { nopt: '^1.0.0' }, + }), + node_modules: { + nopt: t.fixture('symlink', '.store/nopt@1.0.0/node_modules/nopt'), + '.store': { + 'nopt@1.0.0': { + node_modules: { + nopt: { + 'package.json': JSON.stringify({ + name: 'nopt', + version: '1.0.0', + devDependencies: { tap: '^16.0.0' }, + }), + }, + }, + }, + }, + }, + }, + }) + await ls.exec([]) + const output = cleanCwd(result()) + t.notMatch(output, /UNMET DEPENDENCY/, 'should not report devDeps of store packages') + t.match(output, /nopt/, 'should list the dependency') + }) + + t.test('should still report declared workspace as UNMET DEPENDENCY when missing', async t => { + const { ls } = await mockLs(t, { + config: { + 'install-strategy': 'linked', + }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'test-linked-ws-missing', + version: '1.0.0', + workspaces: ['packages/*'], + dependencies: { 'workspace-a': '*' }, + }), + packages: { + 'workspace-a': { + 'package.json': JSON.stringify({ + name: 'workspace-a', + version: '1.0.0', + }), + }, + }, + node_modules: { + // workspace-a is declared but its symlink is missing + }, + }, + }) + await t.rejects(ls.exec([]), { code: 'ELSPROBLEMS' }, + 'should report declared workspace as UNMET DEPENDENCY') + }) +})