diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index cddb25235d24..03774a0f2478 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -79,6 +79,13 @@ interface NpmListDependency { * @param logger An optional logger instance. * @returns A map of package names to their installed package details. */ +interface NpmListProject { + name?: string; + dependencies?: Record; + devDependencies?: Record; + unsavedDependencies?: Record; +} + export function parseNpmLikeDependencies( stdout: string, logger?: Logger, @@ -94,26 +101,55 @@ export function parseNpmLikeDependencies( return dependencies; } - let data = JSON.parse(stdout); - if (Array.isArray(data)) { - // pnpm returns an array of projects. - data = data[0]; - } + const data = JSON.parse(stdout); + const workspacePackageName = options?.workspacePackageName; - const dependencyMaps = [data.dependencies, data.devDependencies, data.unsavedDependencies].filter( - (d) => !!d, - ); + let rootProject: NpmListProject | undefined; + let subProject: NpmListProject | undefined; - if (dependencyMaps.length === 0) { - logger?.debug(' `dependencies` property not found. No dependencies found.'); + if (Array.isArray(data)) { + rootProject = data[0]; + if (workspacePackageName) { + subProject = data.find( + (project) => (project as NpmListProject | undefined)?.name === workspacePackageName, + ); + } + } else { + rootProject = data; + } + if (!rootProject || typeof rootProject !== 'object') { return dependencies; } - const workspacePackageName = options?.workspacePackageName; - - if (workspacePackageName) { - for (const dependencyMap of dependencyMaps) { + // 1. Extract subproject dependencies first to prioritize them + if (subProject) { + const subprojectMaps = [ + subProject.dependencies, + subProject.devDependencies, + subProject.unsavedDependencies, + ].filter((d) => !!d) as Record[]; + + for (const subprojectMap of subprojectMaps) { + for (const [name, info] of Object.entries(subprojectMap)) { + if (info && typeof info === 'object' && info.version) { + dependencies.set(name, { + name, + version: info.version, + path: info.path, + }); + } + } + } + } else if (workspacePackageName) { + // Fallback to npm-like nested lookup inside the root project + const rootDependencyMaps = [ + rootProject.dependencies, + rootProject.devDependencies, + rootProject.unsavedDependencies, + ].filter((d) => !!d) as Record[]; + + for (const dependencyMap of rootDependencyMaps) { const info = dependencyMap[workspacePackageName]; if (info && typeof info === 'object') { const nestedMaps = [ @@ -137,11 +173,15 @@ export function parseNpmLikeDependencies( } } - // Extract top-level dependencies (root), without overwriting subproject dependencies - for (const dependencyMap of dependencyMaps) { - for (const [name, info] of Object.entries( - dependencyMap as Record, - )) { + // 2. Supplement with hoisted/root dependencies (without overwriting subproject dependencies) + const rootDependencyMaps = [ + rootProject.dependencies, + rootProject.devDependencies, + rootProject.unsavedDependencies, + ].filter((d) => !!d) as Record[]; + + for (const dependencyMap of rootDependencyMaps) { + for (const [name, info] of Object.entries(dependencyMap)) { if (!info || typeof info !== 'object') { continue; } diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 563420c3cc13..dff0adfc2726 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -64,6 +64,113 @@ describe('parsers', () => { path: undefined, }); }); + + it('should parse dependencies from array when workspacePackageName matches a project', () => { + const stdout = JSON.stringify([ + { + name: 'monorepo-root', + version: '1.0.0', + path: '/workspace', + dependencies: { + typescript: { + version: '5.9.3', + }, + }, + }, + { + name: 'app', + version: '1.0.0', + path: '/workspace/apps/test-app', + dependencies: { + rxjs: { + version: '7.8.2', + }, + }, + }, + ]); + + const deps = parseNpmLikeDependencies(stdout, undefined, { workspacePackageName: 'app' }); + expect(deps.size).toBe(2); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2', path: undefined }); + expect(deps.get('typescript')).toEqual({ + name: 'typescript', + version: '5.9.3', + path: undefined, + }); + }); + + it('should parse dependencies from array when workspacePackageName matches the root project', () => { + const stdout = JSON.stringify([ + { + name: 'monorepo-root', + version: '1.0.0', + path: '/workspace', + dependencies: { + typescript: { + version: '5.9.3', + }, + }, + }, + { + name: 'other-app', + version: '1.0.0', + path: '/workspace/apps/other-app', + dependencies: { + rxjs: { + version: '7.8.2', + }, + }, + }, + ]); + + const deps = parseNpmLikeDependencies(stdout, undefined, { + workspacePackageName: 'monorepo-root', + }); + expect(deps.size).toBe(1); + expect(deps.get('typescript')).toEqual({ + name: 'typescript', + version: '5.9.3', + path: undefined, + }); + }); + + it('should parse nested dependencies and supplement with hoisted root dependencies in npm workspaces', () => { + const stdout = JSON.stringify({ + name: 'monorepo-root', + version: '1.0.0', + dependencies: { + app: { + version: '1.0.0', + resolved: 'file:apps/test-app', + dependencies: { + rxjs: { + version: '7.8.2', + }, + }, + }, + typescript: { + version: '5.9.3', + }, + }, + }); + + const deps = parseNpmLikeDependencies(stdout, undefined, { + workspacePackageName: 'app', + }); + expect(deps.size).toBe(2); + expect(deps.get('rxjs')).toEqual({ name: 'rxjs', version: '7.8.2', path: undefined }); + expect(deps.get('typescript')).toEqual({ + name: 'typescript', + version: '5.9.3', + path: undefined, + }); + }); + + it('should return empty map and not throw on empty array or non-object json', () => { + expect(parseNpmLikeDependencies('[]').size).toBe(0); + expect(parseNpmLikeDependencies('null').size).toBe(0); + expect(parseNpmLikeDependencies('"not-an-object"').size).toBe(0); + }); }); describe('parseNpmLikeError', () => {