From 2f805f4b7d746ffbe0c6509be592ae0df24979f4 Mon Sep 17 00:00:00 2001 From: Janus Troelsen Date: Fri, 6 Dec 2024 04:21:07 -0600 Subject: [PATCH] feat(versioning): add PVP versioning scheme (#32298) Co-authored-by: Sebastian Poxhofer Co-authored-by: Michael Kriese --- lib/modules/versioning/api.ts | 2 + lib/modules/versioning/pvp/index.spec.ts | 265 +++++++++++++++++++++++ lib/modules/versioning/pvp/index.ts | 257 ++++++++++++++++++++++ lib/modules/versioning/pvp/range.spec.ts | 12 + lib/modules/versioning/pvp/range.ts | 21 ++ lib/modules/versioning/pvp/readme.md | 18 ++ lib/modules/versioning/pvp/types.ts | 9 + lib/modules/versioning/pvp/util.spec.ts | 23 ++ lib/modules/versioning/pvp/util.ts | 54 +++++ 9 files changed, 661 insertions(+) create mode 100644 lib/modules/versioning/pvp/index.spec.ts create mode 100644 lib/modules/versioning/pvp/index.ts create mode 100644 lib/modules/versioning/pvp/range.spec.ts create mode 100644 lib/modules/versioning/pvp/range.ts create mode 100644 lib/modules/versioning/pvp/readme.md create mode 100644 lib/modules/versioning/pvp/types.ts create mode 100644 lib/modules/versioning/pvp/util.spec.ts create mode 100644 lib/modules/versioning/pvp/util.ts diff --git a/lib/modules/versioning/api.ts b/lib/modules/versioning/api.ts index 764529bf40c69f..211c3f8740fe29 100644 --- a/lib/modules/versioning/api.ts +++ b/lib/modules/versioning/api.ts @@ -26,6 +26,7 @@ import * as nuget from './nuget'; import * as pep440 from './pep440'; import * as perl from './perl'; import * as poetry from './poetry'; +import * as pvp from './pvp'; import * as python from './python'; import * as redhat from './redhat'; import * as regex from './regex'; @@ -71,6 +72,7 @@ api.set(nuget.id, nuget.api); api.set(pep440.id, pep440.api); api.set(perl.id, perl.api); api.set(poetry.id, poetry.api); +api.set(pvp.id, pvp.api); api.set(python.id, python.api); api.set(redhat.id, redhat.api); api.set(regex.id, regex.api); diff --git a/lib/modules/versioning/pvp/index.spec.ts b/lib/modules/versioning/pvp/index.spec.ts new file mode 100644 index 00000000000000..900baa3ae7ef8e --- /dev/null +++ b/lib/modules/versioning/pvp/index.spec.ts @@ -0,0 +1,265 @@ +import pvp from '.'; + +describe('modules/versioning/pvp/index', () => { + describe('.isGreaterThan(version, other)', () => { + it.each` + first | second | expected + ${'1.23.1'} | ${'1.9.6'} | ${true} + ${'4.0.0'} | ${'3.0.0'} | ${true} + ${'3.0.1'} | ${'3.0.0'} | ${true} + ${'4.10'} | ${'4.1'} | ${true} + ${'1.0.0'} | ${'1.0'} | ${true} + ${'2.0.2'} | ${'3.1.0'} | ${false} + ${'3.0.0'} | ${'3.0.0'} | ${false} + ${'4.1'} | ${'4.10'} | ${false} + ${'1.0'} | ${'1.0.0'} | ${false} + ${''} | ${'1.0'} | ${false} + ${'1.0'} | ${''} | ${false} + `('pvp.isGreaterThan($first, $second)', ({ first, second, expected }) => { + expect(pvp.isGreaterThan(first, second)).toBe(expected); + }); + }); + + describe('.getMajor(version)', () => { + it.each` + version | expected + ${'1.0.0'} | ${1.0} + ${'1.0.1'} | ${1.0} + ${'1.1.1'} | ${1.1} + ${''} | ${null} + `('pvp.getMajor("$version") === $expected', ({ version, expected }) => { + expect(pvp.getMajor(version)).toBe(expected); + }); + }); + + describe('.getMinor(version)', () => { + it.each` + version | expected + ${'1.0'} | ${null} + ${'1.0.0'} | ${0} + ${'1.0.1'} | ${1} + ${'1.1.2'} | ${2} + `('pvp.getMinor("$version") === $expected', ({ version, expected }) => { + expect(pvp.getMinor(version)).toBe(expected); + }); + }); + + describe('.getPatch(version)', () => { + it.each` + version | expected + ${'1.0.0'} | ${null} + ${'1.0.0.5.1'} | ${5.1} + ${'1.0.1.6'} | ${6} + ${'1.1.2.7'} | ${7} + ${'0.0.0.0.1'} | ${0.1} + ${'0.0.0.0.10'} | ${0.1} + `('pvp.getPatch("$version") === $expected', ({ version, expected }) => { + expect(pvp.getPatch(version)).toBe(expected); + }); + }); + + describe('.matches(version, range)', () => { + it.each` + version | range | expected + ${'1.0.1'} | ${'>=1.0 && <1.1'} | ${true} + ${'4.1'} | ${'>=4.0 && <4.10'} | ${true} + ${'4.1'} | ${'>=4.1 && <4.10'} | ${true} + ${'4.1.0'} | ${'>=4.1 && <4.10'} | ${true} + ${'4.1.0'} | ${'<4.10 && >=4.1'} | ${true} + ${'4.10'} | ${'>=4.1 && <4.10.0'} | ${true} + ${'4.10'} | ${'>=4.0 && <4.10.1'} | ${true} + ${'1.0.0'} | ${'>=2.0 && <2.1'} | ${false} + ${'4'} | ${'>=4.0 && <4.10'} | ${false} + ${'4.10'} | ${'>=4.1 && <4.10'} | ${false} + ${'4'} | ${'gibberish'} | ${false} + ${''} | ${'>=1.0 && <1.1'} | ${false} + `( + 'pvp.matches("$version", "$range") === $expected', + ({ version, range, expected }) => { + expect(pvp.matches(version, range)).toBe(expected); + }, + ); + }); + + describe('.getSatisfyingVersion(versions, range)', () => { + it.each` + versions | range | expected + ${['1.0.0', '1.0.4', '1.3.0', '2.0.0']} | ${'>=1.0 && <1.1'} | ${'1.0.4'} + ${['2.0.0', '1.0.0', '1.0.4', '1.3.0']} | ${'>=1.0 && <1.1'} | ${'1.0.4'} + ${['1.0.0', '1.0.4', '1.3.0', '2.0.0']} | ${'>=3.0 && <4.0'} | ${null} + `( + 'pvp.getSatisfyingVersion($versions, "$range") === $expected', + ({ versions, range, expected }) => { + expect(pvp.getSatisfyingVersion(versions, range)).toBe(expected); + }, + ); + }); + + describe('.minSatisfyingVersion(versions, range)', () => { + it('should return min satisfying version in range', () => { + expect( + pvp.minSatisfyingVersion( + ['0.9', '1.0.0', '1.0.4', '1.3.0', '2.0.0'], + '>=1.0 && <1.1', + ), + ).toBe('1.0.0'); + }); + }); + + describe('.isLessThanRange(version, range)', () => { + it.each` + version | range | expected + ${'2.0.2'} | ${'>=3.0 && <3.1'} | ${true} + ${'3'} | ${'>=3.0 && <3.1'} | ${true} + ${'3'} | ${'>=3 && <3.1'} | ${false} + ${'3.0'} | ${'>=3.0 && <3.1'} | ${false} + ${'3.0.0'} | ${'>=3.0 && <3.1'} | ${false} + ${'4.0.0'} | ${'>=3.0 && <3.1'} | ${false} + ${'3.1.0'} | ${'>=3.0 && <3.1'} | ${false} + ${'3'} | ${'gibberish'} | ${false} + ${''} | ${'>=3.0 && <3.1'} | ${false} + `( + 'pvp.isLessThanRange?.("$version", "$range") === $expected', + ({ version, range, expected }) => { + expect(pvp.isLessThanRange?.(version, range)).toBe(expected); + }, + ); + }); + + describe('.isValid(version)', () => { + it.each` + version | expected + ${''} | ${false} + ${'1.0.0.0'} | ${true} + ${'1.0'} | ${true} + ${'>=1.0 && <1.1'} | ${true} + `('pvp.isValid("$version") === $expected', ({ version, expected }) => { + expect(pvp.isValid(version)).toBe(expected); + }); + }); + + describe('.getNewValue(newValueConfig)', () => { + it.each` + currentValue | newVersion | rangeStrategy | expected + ${'>=1.0 && <1.1'} | ${'1.1'} | ${'auto'} | ${'>=1.0 && <1.2'} + ${'>=1.2 && <1.3'} | ${'1.2.3'} | ${'auto'} | ${null} + ${'>=1.0 && <1.1'} | ${'1.2.3'} | ${'update-lockfile'} | ${null} + ${'gibberish'} | ${'1.2.3'} | ${'auto'} | ${null} + ${'>=1.0 && <1.1'} | ${'0.9'} | ${'auto'} | ${null} + ${'>=1.0 && <1.1'} | ${''} | ${'auto'} | ${null} + `( + 'pvp.getNewValue({currentValue: "$currentValue", newVersion: "$newVersion", rangeStrategy: "$rangeStrategy"}) === $expected', + ({ currentValue, newVersion, rangeStrategy, expected }) => { + expect( + pvp.getNewValue({ currentValue, newVersion, rangeStrategy }), + ).toBe(expected); + }, + ); + }); + + describe('.isSame(...)', () => { + it.each` + type | a | b | expected + ${'major'} | ${'4.10'} | ${'4.1'} | ${false} + ${'major'} | ${'4.1.0'} | ${'5.1.0'} | ${false} + ${'major'} | ${'4.1'} | ${'5.1'} | ${false} + ${'major'} | ${'0'} | ${'1'} | ${false} + ${'major'} | ${'4.1'} | ${'4.1.0'} | ${true} + ${'major'} | ${'4.1.1'} | ${'4.1.2'} | ${true} + ${'major'} | ${'0'} | ${'0'} | ${true} + ${'minor'} | ${'4.1.0'} | ${'5.1.0'} | ${true} + ${'minor'} | ${'4.1'} | ${'4.1'} | ${true} + ${'minor'} | ${'4.1'} | ${'5.1'} | ${true} + ${'minor'} | ${'4.1.0'} | ${'4.1.1'} | ${false} + ${'minor'} | ${''} | ${'0'} | ${false} + ${'patch'} | ${'1.0.0.0'} | ${'1.0.0.0'} | ${true} + ${'patch'} | ${'1.0.0.0'} | ${'2.0.0.0'} | ${true} + ${'patch'} | ${'1.0.0.0'} | ${'1.0.0.1'} | ${false} + ${'patch'} | ${'0.0.0.0.1'} | ${'0.0.0.0.10'} | ${false} + `( + 'pvp.isSame("$type", "$a", "$b") === $expected', + ({ type, a, b, expected }) => { + expect(pvp.isSame?.(type, a, b)).toBe(expected); + }, + ); + }); + + describe('.isVersion(maybeRange)', () => { + it.each` + version | expected + ${'1.0'} | ${true} + ${'>=1.0 && <1.1'} | ${false} + `('pvp.isVersion("$version") === $expected', ({ version, expected }) => { + expect(pvp.isVersion(version)).toBe(expected); + }); + }); + + describe('.equals(a, b)', () => { + it.each` + a | b | expected + ${'1.01'} | ${'1.1'} | ${true} + ${'1.01'} | ${'1.0'} | ${false} + ${''} | ${'1.0'} | ${false} + ${'1.0'} | ${''} | ${false} + `('pvp.equals("$a", "$b") === $expected', ({ a, b, expected }) => { + expect(pvp.equals(a, b)).toBe(expected); + }); + }); + + describe('.isSingleVersion(range)', () => { + it.each` + version | expected + ${'==1.0'} | ${true} + ${'>=1.0 && <1.1'} | ${false} + `( + 'pvp.isSingleVersion("$version") === $expected', + ({ version, expected }) => { + expect(pvp.isSingleVersion(version)).toBe(expected); + }, + ); + }); + + describe('.subset(subRange, superRange)', () => { + it.each` + subRange | superRange | expected + ${'>=1.0 && <1.1'} | ${'>=1.0 && <2.0'} | ${true} + ${'>=1.0 && <2.0'} | ${'>=1.0 && <2.0'} | ${true} + ${'>=1.0 && <2.1'} | ${'>=1.0 && <2.0'} | ${false} + ${'>=0.9 && <2.1'} | ${'>=1.0 && <2.0'} | ${false} + ${'gibberish'} | ${''} | ${undefined} + ${'>=. && <.'} | ${'>=. && <.'} | ${undefined} + `( + 'pvp.subbet("$subRange", "$superRange") === $expected', + ({ subRange, superRange, expected }) => { + expect(pvp.subset?.(subRange, superRange)).toBe(expected); + }, + ); + }); + + describe('.sortVersions()', () => { + it.each` + a | b | expected + ${'1.0'} | ${'1.1'} | ${-1} + ${'1.1'} | ${'1.0'} | ${1} + ${'1.0'} | ${'1.0'} | ${0} + `('pvp.sortVersions("$a", "$b") === $expected', ({ a, b, expected }) => { + expect(pvp.sortVersions(a, b)).toBe(expected); + }); + }); + + describe('.isStable()', () => { + it('should consider 0.0.0 stable', () => { + // in PVP, stability is not conveyed in the version number + // so we consider all versions stable + expect(pvp.isStable('0.0.0')).toBeTrue(); + }); + }); + + describe('.isCompatible()', () => { + it('should consider 0.0.0 compatible', () => { + // in PVP, there is no extra information besides the numbers + // so we consider all versions compatible + expect(pvp.isCompatible('0.0.0')).toBeTrue(); + }); + }); +}); diff --git a/lib/modules/versioning/pvp/index.ts b/lib/modules/versioning/pvp/index.ts new file mode 100644 index 00000000000000..59e8b3026ab91c --- /dev/null +++ b/lib/modules/versioning/pvp/index.ts @@ -0,0 +1,257 @@ +import { logger } from '../../../logger'; +import type { RangeStrategy } from '../../../types/versioning'; +import { regEx } from '../../../util/regex'; +import type { NewValueConfig, VersioningApi } from '../types'; +import { parseRange } from './range'; +import { compareIntArray, extractAllParts, getParts, plusOne } from './util'; + +export const id = 'pvp'; +export const displayName = 'Package Versioning Policy (Haskell)'; +export const urls = ['https://pvp.haskell.org']; +export const supportsRanges = true; +export const supportedRangeStrategies: RangeStrategy[] = ['auto']; + +const digitsAndDots = regEx(/^[\d.]+$/); + +function isGreaterThan(version: string, other: string): boolean { + const versionIntMajor = extractAllParts(version); + const otherIntMajor = extractAllParts(other); + if (versionIntMajor === null || otherIntMajor === null) { + return false; + } + return compareIntArray(versionIntMajor, otherIntMajor) === 'gt'; +} + +function getMajor(version: string): number | null { + // This basically can't be implemented correctly, since + // 1.1 and 1.10 become equal when converted to float. + // Consumers should use isSame instead. + const parts = getParts(version); + if (parts === null) { + return null; + } + return Number(parts.major.join('.')); +} + +function getMinor(version: string): number | null { + const parts = getParts(version); + if (parts === null || parts.minor.length === 0) { + return null; + } + return Number(parts.minor.join('.')); +} + +function getPatch(version: string): number | null { + const parts = getParts(version); + if (parts === null || parts.patch.length === 0) { + return null; + } + return Number(parts.patch[0] + '.' + parts.patch.slice(1).join('')); +} + +function matches(version: string, range: string): boolean { + const parsed = parseRange(range); + if (parsed === null) { + return false; + } + const ver = extractAllParts(version); + const lower = extractAllParts(parsed.lower); + const upper = extractAllParts(parsed.upper); + if (ver === null || lower === null || upper === null) { + return false; + } + return ( + 'gt' === compareIntArray(upper, ver) && + ['eq', 'lt'].includes(compareIntArray(lower, ver)) + ); +} + +function satisfyingVersion( + versions: string[], + range: string, + reverse: boolean, +): string | null { + const copy = versions.slice(0); + copy.sort((a, b) => { + const multiplier = reverse ? 1 : -1; + return sortVersions(a, b) * multiplier; + }); + const result = copy.find((v) => matches(v, range)); + return result ?? null; +} + +function getSatisfyingVersion( + versions: string[], + range: string, +): string | null { + return satisfyingVersion(versions, range, false); +} + +function minSatisfyingVersion( + versions: string[], + range: string, +): string | null { + return satisfyingVersion(versions, range, true); +} + +function isLessThanRange(version: string, range: string): boolean { + const parsed = parseRange(range); + if (parsed === null) { + return false; + } + const compos = extractAllParts(version); + const lower = extractAllParts(parsed.lower); + if (compos === null || lower === null) { + return false; + } + return 'lt' === compareIntArray(compos, lower); +} + +function getNewValue({ + currentValue, + newVersion, + rangeStrategy, +}: NewValueConfig): string | null { + if (rangeStrategy !== 'auto') { + logger.info( + { rangeStrategy, currentValue, newVersion }, + `PVP can't handle this range strategy.`, + ); + return null; + } + const parsed = parseRange(currentValue); + if (parsed === null) { + logger.info( + { currentValue, newVersion }, + 'could not parse PVP version range', + ); + return null; + } + if (isLessThanRange(newVersion, currentValue)) { + // ignore new releases in old release series + return null; + } + if (matches(newVersion, currentValue)) { + // the upper bound is already high enough + return null; + } + const compos = getParts(newVersion); + if (compos === null) { + return null; + } + const majorPlusOne = plusOne(compos.major); + // istanbul ignore next: since all versions that can be parsed, can also be bumped, this can never happen + if (!matches(newVersion, `>=${parsed.lower} && <${majorPlusOne}`)) { + logger.warn( + { newVersion }, + "Even though the major bound was bumped, the newVersion still isn't accepted.", + ); + return null; + } + return `>=${parsed.lower} && <${majorPlusOne}`; +} + +function isSame( + type: 'major' | 'minor' | 'patch', + a: string, + b: string, +): boolean { + const aParts = getParts(a); + const bParts = getParts(b); + if (aParts === null || bParts === null) { + return false; + } + if (type === 'major') { + return 'eq' === compareIntArray(aParts.major, bParts.major); + } else if (type === 'minor') { + return 'eq' === compareIntArray(aParts.minor, bParts.minor); + } else { + return 'eq' === compareIntArray(aParts.patch, bParts.patch); + } +} + +function subset(subRange: string, superRange: string): boolean | undefined { + const sub = parseRange(subRange); + const sup = parseRange(superRange); + if (sub === null || sup === null) { + return undefined; + } + const subLower = extractAllParts(sub.lower); + const subUpper = extractAllParts(sub.upper); + const supLower = extractAllParts(sup.lower); + const supUpper = extractAllParts(sup.upper); + if ( + subLower === null || + subUpper === null || + supLower === null || + supUpper === null + ) { + return undefined; + } + if ('lt' === compareIntArray(subLower, supLower)) { + return false; + } + if ('gt' === compareIntArray(subUpper, supUpper)) { + return false; + } + return true; +} + +function isVersion(maybeRange: string | undefined | null): boolean { + return typeof maybeRange === 'string' && parseRange(maybeRange) === null; +} + +function isValid(ver: string): boolean { + return extractAllParts(ver) !== null || parseRange(ver) !== null; +} + +function isSingleVersion(range: string): boolean { + const noSpaces = range.trim(); + return noSpaces.startsWith('==') && digitsAndDots.test(noSpaces.slice(2)); +} + +function equals(a: string, b: string): boolean { + const aParts = extractAllParts(a); + const bParts = extractAllParts(b); + if (aParts === null || bParts === null) { + return false; + } + return 'eq' === compareIntArray(aParts, bParts); +} + +function sortVersions(a: string, b: string): number { + if (equals(a, b)) { + return 0; + } + return isGreaterThan(a, b) ? 1 : -1; +} + +function isStable(version: string): boolean { + return true; +} + +function isCompatible(version: string): boolean { + return true; +} + +export const api: VersioningApi = { + isValid, + isVersion, + isStable, + isCompatible, + getMajor, + getMinor, + getPatch, + isSingleVersion, + sortVersions, + equals, + matches, + getSatisfyingVersion, + minSatisfyingVersion, + isLessThanRange, + isGreaterThan, + getNewValue, + isSame, + subset, +}; +export default api; diff --git a/lib/modules/versioning/pvp/range.spec.ts b/lib/modules/versioning/pvp/range.spec.ts new file mode 100644 index 00000000000000..52b9128d8843cf --- /dev/null +++ b/lib/modules/versioning/pvp/range.spec.ts @@ -0,0 +1,12 @@ +import { parseRange } from './range'; + +describe('modules/versioning/pvp/range', () => { + describe('.parseRange(range)', () => { + it('should parse >=1.0 && <1.1', () => { + const parsed = parseRange('>=1.0 && <1.1'); + expect(parsed).not.toBeNull(); + expect(parsed!.lower).toBe('1.0'); + expect(parsed!.upper).toBe('1.1'); + }); + }); +}); diff --git a/lib/modules/versioning/pvp/range.ts b/lib/modules/versioning/pvp/range.ts new file mode 100644 index 00000000000000..7bf77b2c43758f --- /dev/null +++ b/lib/modules/versioning/pvp/range.ts @@ -0,0 +1,21 @@ +import { regEx } from '../../../util/regex'; +import type { Range } from './types'; + +// This range format was chosen because it is common in the ecosystem +const gteAndLtRange = regEx(/>=(?[\d.]+)&&<(?[\d.]+)/); +const ltAndGteRange = regEx(/<(?[\d.]+)&&>=(?[\d.]+)/); + +export function parseRange(input: string): Range | null { + const noSpaces = input.replaceAll(' ', ''); + let m = gteAndLtRange.exec(noSpaces); + if (!m?.groups) { + m = ltAndGteRange.exec(noSpaces); + if (!m?.groups) { + return null; + } + } + return { + lower: m.groups['lower'], + upper: m.groups['upper'], + }; +} diff --git a/lib/modules/versioning/pvp/readme.md b/lib/modules/versioning/pvp/readme.md new file mode 100644 index 00000000000000..c542f9c7d64414 --- /dev/null +++ b/lib/modules/versioning/pvp/readme.md @@ -0,0 +1,18 @@ +[Package Versioning Policy](https://pvp.haskell.org/) is used with Haskell. +It's like semver, except that the first _two_ parts are of the major +version. That is, in `A.B.C`: + +- `A.B`: major version +- `C`: minor + +The remaining parts are all considered of the patch version, and +they will be concatenated to form a `number`, i.e. IEEE 754 double. This means +that both `0.0.0.0.1` and `0.0.0.0.10` have patch version `0.1`. + +The range syntax comes from Cabal, specifically the [build-depends +section](https://cabal.readthedocs.io/en/3.10/cabal-package.html). + +This module is considered experimental since it only supports ranges of forms: + +- `>=W.X && =W.X` diff --git a/lib/modules/versioning/pvp/types.ts b/lib/modules/versioning/pvp/types.ts new file mode 100644 index 00000000000000..d2ab5e550debb4 --- /dev/null +++ b/lib/modules/versioning/pvp/types.ts @@ -0,0 +1,9 @@ +export interface Range { + lower: string; + upper: string; +} +export interface Parts { + major: number[]; + minor: number[]; + patch: number[]; +} diff --git a/lib/modules/versioning/pvp/util.spec.ts b/lib/modules/versioning/pvp/util.spec.ts new file mode 100644 index 00000000000000..c8f46e91e599bc --- /dev/null +++ b/lib/modules/versioning/pvp/util.spec.ts @@ -0,0 +1,23 @@ +import { extractAllParts, getParts } from './util'; + +describe('modules/versioning/pvp/util', () => { + describe('.extractAllParts(version)', () => { + it('should return null when there are no numbers', () => { + expect(extractAllParts('')).toBeNull(); + }); + + it('should parse 3.0', () => { + expect(extractAllParts('3.0')).toEqual([3, 0]); + }); + }); + + describe('.getParts(...)', () => { + it('"0" is valid major version', () => { + expect(getParts('0')?.major).toEqual([0]); + }); + + it('returns null when no parts could be extracted', () => { + expect(getParts('')).toBeNull(); + }); + }); +}); diff --git a/lib/modules/versioning/pvp/util.ts b/lib/modules/versioning/pvp/util.ts new file mode 100644 index 00000000000000..d4e4cfe8ac6ad3 --- /dev/null +++ b/lib/modules/versioning/pvp/util.ts @@ -0,0 +1,54 @@ +import type { Parts } from './types'; + +export function extractAllParts(version: string): number[] | null { + const parts = version.split('.').map((x) => parseInt(x, 10)); + const ret: number[] = []; + for (const l of parts) { + if (l < 0 || !isFinite(l)) { + return null; + } + ret.push(l); + } + return ret; +} + +export function getParts(splitOne: string): Parts | null { + const c = extractAllParts(splitOne); + if (c === null) { + return null; + } + return { + major: c.slice(0, 2), + minor: c.slice(2, 3), + patch: c.slice(3), + }; +} + +export function plusOne(majorOne: number[]): string { + return `${majorOne[0]}.${majorOne[1] + 1}`; +} + +export function compareIntArray( + versionPartsInt: number[], + otherPartsInt: number[], +): 'lt' | 'eq' | 'gt' { + for ( + let i = 0; + i < Math.min(versionPartsInt.length, otherPartsInt.length); + i++ + ) { + if (versionPartsInt[i] > otherPartsInt[i]) { + return 'gt'; + } + if (versionPartsInt[i] < otherPartsInt[i]) { + return 'lt'; + } + } + if (versionPartsInt.length === otherPartsInt.length) { + return 'eq'; + } + if (versionPartsInt.length > otherPartsInt.length) { + return 'gt'; + } + return 'lt'; +}