From 8532c3b97f74f5276d7164dce7e42af50df2743d Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:54:30 -0400 Subject: [PATCH] fix(@angular/cli): recursively collect nested workspace dependencies in npm When running the update command in an npm workspace repository from within a workspace subdirectory, the CLI currently fails to detect hoisted dependencies. This occurs because npm list structures its workspace dependency output as nested items inside their respective top-level workspace entry, rather than as top-level items. The current parser was only reading the top-level items and consequently missed nested workspace dependencies. This change updates the dependency parser to perform a breadth-first traversal of the JSON tree output of the package list command. By iteratively traversing the nested dependencies, the CLI can successfully resolve all installed packages within an npm workspaces monorepo. --- .../cli/src/package-managers/parsers.ts | 33 ++++++++++++---- .../cli/src/package-managers/parsers_spec.ts | 38 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index c9f7fb235087..509e7d16e8fe 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -108,13 +108,32 @@ export function parseNpmLikeDependencies( return dependencies; } - for (const dependencyMap of dependencyMaps) { - for (const [name, info] of Object.entries(dependencyMap as Record)) { - dependencies.set(name, { - name, - version: info.version, - path: info.path, - }); + // Perform a breadth-first traversal to collect dependencies. + // The queue size is bounded because `npm list` is executed with `--depth=0`, + // which limits the traversal to top-level dependencies of the workspaces. + const queue = [...dependencyMaps]; + let index = 0; + while (index < queue.length) { + const currentMap = queue[index++] as Record | undefined; + if (!currentMap) { + continue; + } + for (const [name, info] of Object.entries(currentMap)) { + if (info && typeof info === 'object') { + if (info.version && !dependencies.has(name)) { + dependencies.set(name, { + name, + version: info.version, + path: info.path, + }); + } + const nestedMaps = [ + info.dependencies, + info.devDependencies, + info.unsavedDependencies, + ].filter((d) => !!d); + queue.push(...nestedMaps); + } } } diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 2fa8abdc1e32..802402f76f5d 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -8,6 +8,7 @@ import { parseBunDependencies, + parseNpmLikeDependencies, parseNpmLikeError, parseNpmLikeManifest, parseYarnClassicDependencies, @@ -16,6 +17,43 @@ import { } from './parsers'; describe('parsers', () => { + describe('parseNpmLikeDependencies', () => { + it('should parse simple dependencies', () => { + const stdout = JSON.stringify({ + dependencies: { + rxjs: { + version: '7.8.2', + }, + }, + }); + const deps = parseNpmLikeDependencies(stdout); + expect(deps.size).toBe(1); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2', path: undefined }); + }); + + it('should parse nested workspace dependencies for npm workspaces', () => { + const stdout = JSON.stringify({ + version: '1.0.0', + name: 'npm-workspace-test', + dependencies: { + app: { + version: '1.0.0', + resolved: 'file:../packages/app', + dependencies: { + rxjs: { + version: '7.8.1', + }, + }, + }, + }, + }); + const deps = parseNpmLikeDependencies(stdout); + expect(deps.size).toBe(2); + expect(deps.get('app')).toEqual({ name: 'app', version: '1.0.0', path: undefined }); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.1', path: undefined }); + }); + }); + describe('parseNpmLikeError', () => { it('should parse a structured JSON error from modern yarn', () => { const stdout = JSON.stringify({