From 153c9de406574bec19ee80ffbd6579a9514c6c4e Mon Sep 17 00:00:00 2001 From: lukaskolafa <lukas.kolafa@gmail.com> Date: Fri, 25 Aug 2023 14:16:25 +0200 Subject: [PATCH] fix(versioning/composer): support patch suffixes (#23842) Co-authored-by: Lukas Kolafa <lukas.kolafa@arvato-scs.com> Co-authored-by: Michael Kriese <michael.kriese@visualon.de> --- lib/modules/versioning/composer/index.spec.ts | 35 +++++-- lib/modules/versioning/composer/index.ts | 97 +++++++++++++++---- lib/modules/versioning/composer/readme.md | 3 + 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/lib/modules/versioning/composer/index.spec.ts b/lib/modules/versioning/composer/index.spec.ts index e1bcbd602e..ba67d2f3c3 100644 --- a/lib/modules/versioning/composer/index.spec.ts +++ b/lib/modules/versioning/composer/index.spec.ts @@ -15,15 +15,22 @@ describe('modules/versioning/composer/index', () => { ${'1.0@alpha3'} | ${'1.0.0-alpha.3'} | ${true} ${'1.0@beta'} | ${'1.0.0-beta'} | ${true} ${'1.0@rc2'} | ${'1.0.0-rc.2'} | ${true} + ${'1.0.0'} | ${'1.0.0-p1'} | ${false} `('equals("$a", "$b") === $expected', ({ a, b, expected }) => { expect(semver.equals(a, b)).toBe(expected); }); it.each` - a | b | expected - ${'1.2.0'} | ${'v1.2'} | ${false} - ${'v1.0.1'} | ${'1'} | ${true} - ${'1'} | ${'1.1'} | ${false} + a | b | expected + ${'1.2.0'} | ${'v1.2'} | ${false} + ${'v1.0.1'} | ${'1'} | ${true} + ${'1'} | ${'1.1'} | ${false} + ${'1.0.0'} | ${'1.0.0-p1'} | ${false} + ${'1.0.0-p1'} | ${'1.0.0'} | ${true} + ${'1.0.0-p1'} | ${'1.0.0-p2'} | ${false} + ${'1.0.0-p2'} | ${'1.0.0-p1'} | ${true} + ${'1'} | ${'1.0-p1'} | ${false} + ${'1.0-p1'} | ${'1'} | ${true} `('isGreaterThan("$a", "$b") === $expected', ({ a, b, expected }) => { expect(semver.isGreaterThan(a, b)).toBe(expected); }); @@ -37,8 +44,12 @@ describe('modules/versioning/composer/index', () => { }); it.each` - version | expected - ${'v1.2'} | ${true} + version | expected + ${'v1.2'} | ${true} + ${'v1.2.4-p2'} | ${true} + ${'v1.2.4-p12'} | ${true} + ${'v1.2.4-beta5'} | ${false} + ${null} | ${false} `('isStable("$version") === $expected', ({ version, expected }) => { const res = !!semver.isStable(version); expect(res).toBe(expected); @@ -71,6 +82,7 @@ describe('modules/versioning/composer/index', () => { ${'~1.0 || ~2.0'} | ${true} ${'<8.0-DEV'} | ${true} ${'<8-DEV'} | ${true} + ${'1.2.3-p1'} | ${true} `('isValid("$version") === $expected', ({ version, expected }) => { const res = !!semver.isValid(version); expect(res).toBe(expected); @@ -91,6 +103,7 @@ describe('modules/versioning/composer/index', () => { ${['v0.4.0', 'v0.5.0', 'v4.0.0', 'v4.2.0', 'v5.0.0']} | ${'~4'} | ${'v4.2.0'} ${['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0']} | ${'~0.4'} | ${'0.5.0'} ${['0.4.0', '0.5.0', '4.0.0-beta1', '4.0.0-beta2', '4.2.0-beta1', '4.2.0-beta2', '5.0.0']} | ${'~4@beta'} | ${'4.0.0-beta2'} + ${['4.0.0', '4.2.0', '5.0.0', '4.2.0-p2', '4.2.0-p12']} | ${'~4'} | ${'4.2.0-p12'} `( 'getSatisfyingVersion($versions, "$range") === $expected', ({ versions, range, expected }) => { @@ -105,6 +118,8 @@ describe('modules/versioning/composer/index', () => { ${['v0.4.0', 'v0.5.0', 'v4.0.0', 'v4.2.0', 'v5.0.0']} | ${'~4'} | ${'v4.0.0'} ${['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0']} | ${'~0.4'} | ${'0.4.0'} ${['0.4.0', '0.5.0', '4.0.0-beta1', '4.0.0', '4.2.0-beta1', '4.2.0-beta2', '5.0.0']} | ${'~4@beta'} | ${'4.0.0-beta1'} + ${['0.4.0', '0.5.0', '4.0.0-p1', '4.0.0', '4.2.0-p1', '4.2.0-p2', '5.0.0']} | ${'~4'} | ${'4.0.0'} + ${['0.4.0', '0.5.0', '4.0.0-p1', '4.2.0-p1', '4.2.0-p2', '5.0.0']} | ${'~4'} | ${'4.0.0-p1'} `( 'minSatisfyingVersion($versions, "$range") === $expected', ({ versions, range, expected }) => { @@ -203,13 +218,17 @@ describe('modules/versioning/composer/index', () => { it.each` versions | expected ${['1.2.3-beta', '1.0.0-alpha24', '2.0.1', '1.3.4', '1.0.0-alpha9', '1.2.3']} | ${['1.0.0-alpha9', '1.0.0-alpha24', '1.2.3-beta', '1.2.3', '1.3.4', '2.0.1']} + ${['1.2.3-p1', '1.2.3-p2', '1.2.3']} | ${['1.2.3', '1.2.3-p1', '1.2.3-p2']} + ${['1.2.3-p1', '1.2.2']} | ${['1.2.2', '1.2.3-p1']} + ${['1.0-p1', '1']} | ${['1', '1.0-p1']} `('$versions -> sortVersions -> $expected ', ({ versions, expected }) => { expect(versions.sort(semver.sortVersions)).toEqual(expected); }); it.each` - version | expected - ${'1.2.0'} | ${true} + version | expected + ${'1.2.0'} | ${true} + ${'1.2.0-p1'} | ${true} `('isCompatible("$version") === $expected', ({ version, expected }) => { expect(semver.isCompatible(version)).toBe(expected); }); diff --git a/lib/modules/versioning/composer/index.ts b/lib/modules/versioning/composer/index.ts index f3de82dcd1..a176856e35 100644 --- a/lib/modules/versioning/composer/index.ts +++ b/lib/modules/versioning/composer/index.ts @@ -67,6 +67,57 @@ function normalizeVersion(input: string): string { return convertStabilityModifier(output); } +/** + * @param versions Version list in any format, it recognizes the specific patch format x.x.x-pXX + * @param range Range in composer format + * @param minMode If true, it will calculate minSatisfyingVersion, if false, it calculates the maxSatisfyingVersion + * @returns min or max satisfyingVersion from the input + */ +function calculateSatisfyingVersionIntenal( + versions: string[], + range: string, + minMode: boolean +): string | null { + // Because composer -p versions are considered stable, we have to remove the suffix for the npm.XXX functions. + const versionsMapped = versions.map((x) => { + return { + origianl: x, + cleaned: removeComposerSpecificPatchPart(x), + npmVariant: composer2npm(removeComposerSpecificPatchPart(x)[0]), + }; + }); + + const npmVersions = versionsMapped.map((x) => x.npmVariant); + const npmVersion = minMode + ? npm.minSatisfyingVersion(npmVersions, composer2npm(range)) + : npm.getSatisfyingVersion(npmVersions, composer2npm(range)); + + if (!npmVersion) { + return null; + } + + // After we find the npm versions, we select from them back in the mapping the possible patches. + const candidates = versionsMapped + .filter((x) => x.npmVariant === npmVersion) + .sort((a, b) => (minMode ? 1 : -1) * sortVersions(a.origianl, b.origianl)); + + return candidates[0].origianl; +} + +/** + * @param intput Version in any format, it recognizes the specific patch format x.x.x-pXX + * @returns If input contains the specific patch, it returns the input with removed the patch and true, otherwise it retunrs the same string and false. + */ +function removeComposerSpecificPatchPart(input: string): [string, boolean] { + // the regex is based on the original from composer implementation https://github.com/composer/semver/blob/fa1ec24f0ab1efe642671ec15c51a3ab879f59bf/src/VersionParser.php#L137 + const pattern = /^v?\d+(\.\d+(\.\d+(\.\d+)?)?)?(?<suffix>-p[1-9]\d*)$/gi; + const match = pattern.exec(input); + + return match + ? [input.replace(match.groups!.suffix, ''), true] + : [input, false]; +} + function composer2npm(input: string): string { return input .split(regEx(/\s*\|\|?\s*/g)) @@ -119,11 +170,14 @@ function getMinor(version: string): number | null { function getPatch(version: string): number | null { const semverVersion = semver.coerce(composer2npm(version)); + + // This returns only the numbers without the optional `-pXX` patch version supported by composer. Fixing that would require a bigger + // refactoring, because the API supports only numbers. return semverVersion ? npm.getPatch(semverVersion) : null; } function isGreaterThan(a: string, b: string): boolean { - return npm.isGreaterThan(composer2npm(a), composer2npm(b)); + return sortVersions(a, b) === 1; } function isLessThanRange(version: string, range: string): boolean { @@ -135,7 +189,14 @@ function isSingleVersion(input: string): boolean { } function isStable(version: string): boolean { - return !!(version && npm.isStable(composer2npm(version))); + if (version) { + // Composer considers patches `-pXX` as stable: https://github.com/composer/semver/blob/fa1ec24f0ab1efe642671ec15c51a3ab879f59bf/src/VersionParser.php#L568 but npm not. + // In order to be able to use the standard npm.isStable function, we remove the potential patch version for the check. + const [withoutPatch] = removeComposerSpecificPatchPart(version); + return npm.isStable(composer2npm(withoutPatch)); + } + + return false; } export function isValid(input: string): boolean { @@ -154,26 +215,14 @@ function getSatisfyingVersion( versions: string[], range: string ): string | null { - const npmVersions = versions.map(composer2npm); - const npmVersion = npm.getSatisfyingVersion(npmVersions, composer2npm(range)); - if (!npmVersion) { - return null; - } - // get index of npmVersion in npmVersions - return versions[npmVersions.indexOf(npmVersion)] ?? npmVersion; + return calculateSatisfyingVersionIntenal(versions, range, false); } function minSatisfyingVersion( versions: string[], range: string ): string | null { - const npmVersions = versions.map(composer2npm); - const npmVersion = npm.minSatisfyingVersion(npmVersions, composer2npm(range)); - if (!npmVersion) { - return null; - } - // get index of npmVersion in npmVersions - return versions[npmVersions.indexOf(npmVersion)] ?? npmVersion; + return calculateSatisfyingVersionIntenal(versions, range, true); } function subset(subRange: string, superRange: string): boolean | undefined { @@ -305,7 +354,21 @@ function getNewValue({ } function sortVersions(a: string, b: string): number { - return npm.sortVersions(composer2npm(a), composer2npm(b)); + const [aWithoutPatch, aContainsPatch] = removeComposerSpecificPatchPart(a); + const [bWithoutPatch, bContainsPatch] = removeComposerSpecificPatchPart(b); + + if (aContainsPatch === bContainsPatch) { + // If both [a and b] contain patch version or both [a and b] do not contain patch version, then npm comparison deliveres correct results + return npm.sortVersions(composer2npm(a), composer2npm(b)); + } else if ( + npm.equals(composer2npm(aWithoutPatch), composer2npm(bWithoutPatch)) + ) { + // If only one [a or b] contains patch version and the parts without patch versions are equal, then the version with patch is greater (this is the case where npm comparison fails) + return aContainsPatch ? 1 : -1; + } else { + // All other cases can be compared correctly by npm + return npm.sortVersions(composer2npm(a), composer2npm(b)); + } } function isCompatible(version: string): boolean { diff --git a/lib/modules/versioning/composer/readme.md b/lib/modules/versioning/composer/readme.md index dc8fd52f9b..88e43441d5 100644 --- a/lib/modules/versioning/composer/readme.md +++ b/lib/modules/versioning/composer/readme.md @@ -7,3 +7,6 @@ Tilde ranges with "short" versions are different to npm. e.g. `~4` is equivalent to `^4` in npm `~4.1` is equivalent to `^4.1` in npm `~0.4` is equivalent to `>=0.4 <1` in npm + +Composer supports patches in the version numbers, which are considered stable. E.g., `1.2.3-p1` is recognized by npm as unstable, Renovate +implements additional logic to support correct sorting and stability checks on composer patch versions syntax. -- GitLab