diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 0fe498c12331..6e4fbcf83028 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -12,6 +12,7 @@ * 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'; @@ -216,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. @@ -234,7 +247,41 @@ 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 (!isValidManifest(manifest)) { + logger?.debug( + ' Skipping invalid manifest in array (missing name, version, or invalid SemVer).', + ); + continue; + } + + if (!maxManifest || compare(manifest.version, maxManifest.version) > 0) { + maxManifest = manifest; + } + } + + 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; } /** @@ -309,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 6d21300c7009..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'; @@ -136,11 +137,80 @@ 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 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 =