Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 59 additions & 19 deletions packages/angular/cli/src/package-managers/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, NpmListDependency>;
devDependencies?: Record<string, NpmListDependency>;
unsavedDependencies?: Record<string, NpmListDependency>;
}

export function parseNpmLikeDependencies(
stdout: string,
logger?: Logger,
Expand All @@ -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<string, NpmListDependency>[];

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<string, NpmListDependency>[];

for (const dependencyMap of rootDependencyMaps) {
const info = dependencyMap[workspacePackageName];
if (info && typeof info === 'object') {
const nestedMaps = [
Expand All @@ -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<string, NpmListDependency & { resolved?: string }>,
)) {
// 2. Supplement with hoisted/root dependencies (without overwriting subproject dependencies)
const rootDependencyMaps = [
rootProject.dependencies,
rootProject.devDependencies,
rootProject.unsavedDependencies,
].filter((d) => !!d) as Record<string, NpmListDependency & { resolved?: string }>[];

for (const dependencyMap of rootDependencyMaps) {
for (const [name, info] of Object.entries(dependencyMap)) {
if (!info || typeof info !== 'object') {
continue;
}
Expand Down
107 changes: 107 additions & 0 deletions packages/angular/cli/src/package-managers/parsers_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading