From 5c1db4eb55dbc80081058232a85df3483486e5fa Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 7 May 2026 09:50:28 -0400 Subject: [PATCH 1/2] fix(@angular/cli): robustly parse npm manifest from array Update `parseNpmLikeManifest` to find the manifest with the highest version instead of assuming the last item in the array is the correct one. This makes the parsing robust against out-of-order results from `npm view` or similar commands without incurring performance penalties. This change ensures that the latest relevant version is always selected when a range is queried and multiple versions are returned, improving reliability in edge cases where registry output might not be sorted. --- .../cli/src/package-managers/parsers.ts | 22 ++++++++++++++++++- .../cli/src/package-managers/parsers_spec.ts | 8 +++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 0fe498c12331..7b78890e54ff 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -16,6 +16,7 @@ import { ErrorInfo } from './error'; import { Logger } from './logger'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; +import { compare } from 'semver'; const MAX_LOG_LENGTH = 1024; @@ -234,7 +235,26 @@ export function parseNpmLikeManifest(stdout: string, logger?: Logger): PackageMa const result = JSON.parse(stdout); - return Array.isArray(result) ? result[result.length - 1] : result; + // npm view returns an array of manifests if the query matches multiple versions + // (e.g. when using a version range). We find the highest version to ensure + // we get the latest relevant manifest, even if the output is not sorted. + if (Array.isArray(result)) { + let maxManifest: PackageManifest | null = null; + + for (const manifest of result) { + if (!manifest || typeof manifest.version !== 'string') { + continue; + } + + if (!maxManifest || compare(manifest.version, maxManifest.version) > 0) { + maxManifest = manifest; + } + } + + return maxManifest; + } + + return result; } /** diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 6d21300c7009..a0b00f42585e 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -136,6 +136,14 @@ describe('parsers', () => { expect(parseNpmLikeManifest(stdout)).toEqual({ name: 'foo', version: '1.1.0' }); }); + it('should return the highest version manifest from an unsorted array', () => { + const stdout = JSON.stringify([ + { name: 'foo', version: '1.1.0' }, + { name: 'foo', version: '1.0.0' }, + ]); + expect(parseNpmLikeManifest(stdout)).toEqual({ name: 'foo', version: '1.1.0' }); + }); + it('should return null for empty stdout', () => { expect(parseNpmLikeManifest('')).toBeNull(); }); From 4a78ddc6584ff63c898b8859878280f93331d683 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 7 May 2026 10:04:54 -0400 Subject: [PATCH 2/2] refactor(@angular/cli): add validation and logging to npm manifest parsing Introduce a reusable `isValidManifest` type guard to ensure that parsed manifests contain both `name` and `version` strings. This applies to both single object responses and elements within an array response from the package manager. --- .../cli/src/package-managers/parsers.ts | 39 +++++++++++- .../cli/src/package-managers/parsers_spec.ts | 62 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 7b78890e54ff..6e4fbcf83028 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -12,11 +12,11 @@ * into their own file improves modularity and allows for focused testing. */ +import { compare, valid } from 'semver'; import { ErrorInfo } from './error'; import { Logger } from './logger'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; -import { compare } from 'semver'; const MAX_LOG_LENGTH = 1024; @@ -217,6 +217,18 @@ export function parseYarnClassicDependencies( return dependencies; } +function isValidManifest(obj: unknown): obj is PackageManifest { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const record = obj as Record; + const name = record.name; + const version = record.version; + + return typeof name === 'string' && typeof version === 'string' && valid(version) !== null; +} + /** * Parses the output of `npm view` or a compatible command to get a package manifest. * @param stdout The standard output of the command. @@ -242,7 +254,10 @@ export function parseNpmLikeManifest(stdout: string, logger?: Logger): PackageMa let maxManifest: PackageManifest | null = null; for (const manifest of result) { - if (!manifest || typeof manifest.version !== 'string') { + if (!isValidManifest(manifest)) { + logger?.debug( + ' Skipping invalid manifest in array (missing name, version, or invalid SemVer).', + ); continue; } @@ -251,9 +266,21 @@ export function parseNpmLikeManifest(stdout: string, logger?: Logger): PackageMa } } + if (!maxManifest) { + logger?.debug(' No valid manifests found in the array.'); + } + return maxManifest; } + if (!isValidManifest(result)) { + logger?.debug( + ' Parsed JSON is not a valid manifest (missing name, version, or invalid SemVer).', + ); + + return null; + } + return result; } @@ -329,6 +356,14 @@ export function parseYarnClassicManifest(stdout: string, logger?: Logger): Packa manifest['ng-add'].save ??= false; } + if (!isValidManifest(manifest)) { + logger?.debug( + ' Parsed JSON is not a valid manifest (missing name, version, or invalid SemVer).', + ); + + return null; + } + return manifest; } diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index a0b00f42585e..d8fac05c3700 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -13,6 +13,7 @@ import { parseNpmLikeManifest, parseYarnClassicDependencies, parseYarnClassicError, + parseYarnClassicManifest, parseYarnModernDependencies, } from './parsers'; @@ -144,11 +145,72 @@ describe('parsers', () => { expect(parseNpmLikeManifest(stdout)).toEqual({ name: 'foo', version: '1.1.0' }); }); + it('should skip invalid manifests in an array', () => { + const stdout = JSON.stringify([ + { name: 'foo', version: '1.0.0' }, + { name: 'foo' }, // Missing version + { version: '1.2.0' }, // Missing name + null, + 'invalid', + ]); + expect(parseNpmLikeManifest(stdout)).toEqual({ name: 'foo', version: '1.0.0' }); + }); + + it('should return null if no valid manifests found in the array', () => { + const stdout = JSON.stringify([{ name: 'foo' }, { version: '1.2.0' }]); + expect(parseNpmLikeManifest(stdout)).toBeNull(); + }); + + it('should return null for invalid single object', () => { + const stdout = JSON.stringify({ name: 'foo' }); // Missing version + expect(parseNpmLikeManifest(stdout)).toBeNull(); + }); + + it('should skip manifests with invalid semver versions in an array', () => { + const stdout = JSON.stringify([ + { name: 'foo', version: '1.0.0' }, + { name: 'foo', version: 'invalid-version' }, + { name: 'foo', version: '1.0' }, + ]); + expect(parseNpmLikeManifest(stdout)).toEqual({ name: 'foo', version: '1.0.0' }); + }); + + it('should return null for single object with invalid semver version', () => { + const stdout = JSON.stringify({ name: 'foo', version: 'invalid-version' }); + expect(parseNpmLikeManifest(stdout)).toBeNull(); + }); + it('should return null for empty stdout', () => { expect(parseNpmLikeManifest('')).toBeNull(); }); }); + describe('parseYarnClassicManifest', () => { + it('should parse a valid manifest', () => { + const stdout = JSON.stringify({ + type: 'inspect', + data: { name: 'foo', version: '1.0.0' }, + }); + expect(parseYarnClassicManifest(stdout)).toEqual({ name: 'foo', version: '1.0.0' }); + }); + + it('should return null for invalid manifest', () => { + const stdout = JSON.stringify({ + type: 'inspect', + data: { name: 'foo' }, + }); + expect(parseYarnClassicManifest(stdout)).toBeNull(); + }); + + it('should return null if no inspect type found', () => { + const stdout = JSON.stringify({ + type: 'other', + data: { name: 'foo', version: '1.0.0' }, + }); + expect(parseYarnClassicManifest(stdout)).toBeNull(); + }); + }); + describe('parseYarnClassicError', () => { it('should parse a 404 from verbose logs', () => { const stdout =