refactor(version): use native SemVer ordering

This commit is contained in:
Peter Steinberger
2026-07-14 04:12:06 +01:00
parent 661cf9644d
commit 6efec6d28c
5 changed files with 53 additions and 153 deletions
+7
View File
@@ -9,6 +9,13 @@ describe("compareOpenClawVersions", () => {
expect(compareOpenClawVersions("2026.3.23-2", "2026.3.23-1")).toBe(1);
});
it("preserves numeric correction and build-metadata edge cases", () => {
expect(compareOpenClawVersions("2026.3.23", "2026.3.23-0")).toBe(-1);
expect(compareOpenClawVersions("2026.3.23", "2026.3.23-1.2")).toBe(1);
expect(compareOpenClawVersions("2026.3.23+first", "2026.3.23+second")).toBe(0);
expect(compareOpenClawVersions("2026.3.23-1+first", "2026.3.23-1+second")).toBe(0);
});
it("treats stable as newer than beta and compares beta identifiers", () => {
expect(compareOpenClawVersions("2026.6.5", "2026.6.6-beta.1")).toBe(-1);
expect(compareOpenClawVersions("2026.3.23", "2026.3.23-beta.1")).toBe(1);
+14 -99
View File
@@ -1,39 +1,18 @@
// Normalizes config version metadata and compatibility comparisons.
import { compare as compareSemver, parse as parseSemver } from "semver";
import { normalizeLegacyDotBetaVersion } from "../infra/semver.js";
type OpenClawVersion = {
major: number;
minor: number;
patch: number;
revision: number | null;
prerelease: string[] | null;
};
import { parse as parseSemver, type SemVer } from "semver";
import {
compareOpenClawSemver,
isOpenClawCorrectionSemver,
normalizeLegacyDotBetaVersion,
} from "../infra/semver.js";
/** Parses stable, prerelease, and legacy dot-beta OpenClaw versions. */
function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null {
function parseOpenClawVersion(raw: string | null | undefined): SemVer | null {
if (!raw) {
return null;
}
const normalized = normalizeLegacyDotBetaVersion(raw.trim());
const parsed = parseSemver(normalized);
if (!parsed) {
return null;
}
const revision =
parsed.prerelease.length === 1 && typeof parsed.prerelease[0] === "number"
? parsed.prerelease[0]
: null;
return {
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch,
revision,
prerelease:
parsed.prerelease.length > 0 && revision == null
? parsed.prerelease.map((part) => String(part))
: null,
};
return parseSemver(normalized);
}
export function normalizeOpenClawVersionBase(raw: string | null | undefined): string | null {
@@ -44,25 +23,6 @@ export function normalizeOpenClawVersionBase(raw: string | null | undefined): st
return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
}
function isSameOpenClawStableFamily(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const parsedA = parseOpenClawVersion(a);
const parsedB = parseOpenClawVersion(b);
if (!parsedA || !parsedB) {
return false;
}
if (parsedA.prerelease?.length || parsedB.prerelease?.length) {
return false;
}
return (
parsedA.major === parsedB.major &&
parsedA.minor === parsedB.minor &&
parsedA.patch === parsedB.patch
);
}
export function compareOpenClawVersions(
a: string | null | undefined,
b: string | null | undefined,
@@ -72,26 +32,7 @@ export function compareOpenClawVersions(
if (!parsedA || !parsedB) {
return null;
}
const sameCore =
parsedA.major === parsedB.major &&
parsedA.minor === parsedB.minor &&
parsedA.patch === parsedB.patch;
// Numeric suffixes are shipped OpenClaw correction releases, ordered after the base stable.
if (sameCore && (parsedA.revision != null || parsedB.revision != null)) {
const rankA = releaseRank(parsedA);
const rankB = releaseRank(parsedB);
if (rankA !== rankB) {
return rankA < rankB ? -1 : 1;
}
if (
parsedA.revision != null &&
parsedB.revision != null &&
parsedA.revision !== parsedB.revision
) {
return parsedA.revision < parsedB.revision ? -1 : 1;
}
}
return compareSemver(formatComparableVersion(parsedA), formatComparableVersion(parsedB));
return compareOpenClawSemver(parsedA, parsedB);
}
export function shouldWarnOnTouchedVersion(
@@ -100,38 +41,12 @@ export function shouldWarnOnTouchedVersion(
): boolean {
const parsedCurrent = parseOpenClawVersion(current);
const parsedTouched = parseOpenClawVersion(touched);
if (
parsedCurrent &&
parsedTouched &&
parsedCurrent.major === parsedTouched.major &&
parsedCurrent.minor === parsedTouched.minor &&
parsedCurrent.patch === parsedTouched.patch
) {
if (!parsedTouched.prerelease?.length) {
if (parsedCurrent && parsedTouched && parsedCurrent.compareMain(parsedTouched) === 0) {
if (parsedTouched.prerelease.length === 0 || isOpenClawCorrectionSemver(parsedTouched)) {
return false;
}
}
if (isSameOpenClawStableFamily(current, touched)) {
return false;
}
const cmp = compareOpenClawVersions(current, touched);
return cmp !== null && cmp < 0;
}
function releaseRank(version: OpenClawVersion): number {
if (version.prerelease?.length) {
return 0;
}
if (version.revision != null) {
return 2;
}
return 1;
}
function formatComparableVersion(version: OpenClawVersion): string {
const base = `${version.major}.${version.minor}.${version.patch}`;
if (version.revision != null) {
return `${base}-${version.revision}`;
}
return version.prerelease?.length ? `${base}-${version.prerelease.join(".")}` : base;
return parsedCurrent !== null && parsedTouched !== null
? compareOpenClawSemver(parsedCurrent, parsedTouched) < 0
: false;
}
+3
View File
@@ -139,6 +139,9 @@ describe("npm registry spec parsing helpers", () => {
{ left: "2026.5.3-2", right: "2026.5.3-1", expected: 1 },
{ left: "2026.5.3", right: "2026.5.3-beta.3", expected: 1 },
{ left: "2026.5.3-beta.3", right: "2026.5.3-alpha.9", expected: 1 },
{ left: "2026.5.3-alpha.10", right: "2026.5.3-alpha.2", expected: 1 },
{ left: "2026.5.3-0", right: "2026.5.3", expected: null },
{ left: "2026.5.3+build", right: "2026.5.3", expected: null },
{ left: "1.2.3-1", right: "1.2.3", expected: null },
])("compares OpenClaw release versions for %s and %s", ({ left, right, expected }) => {
expect(compareOpenClawReleaseVersions(left, right)).toBe(expected);
+8 -50
View File
@@ -3,23 +3,14 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
import {
parse as parseSemver,
prerelease as parseSemverPrerelease,
type SemVer,
valid as validSemver,
} from "semver";
import { compareOpenClawSemver, isOpenClawCorrectionSemver } from "./semver.js";
const OPENCLAW_RELEASE_PREFIX_RE = /^\d{4}\./;
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** Parsed monthly patch OpenClaw release version used for channel-aware ordering. */
type OpenClawReleaseVersion = {
channel: "alpha" | "beta" | "stable";
year: number;
month: number;
patch: number;
alphaNumber?: number;
betaNumber?: number;
correctionNumber?: number;
};
/**
* Parsed registry-only npm spec accepted by plugin install flows.
* Selectors are limited to exact versions and dist-tags; URL/git/file specs
@@ -145,20 +136,19 @@ export function isExactSemverVersion(value: string): boolean {
}
/** Parses OpenClaw's monthly patch stable/alpha/beta/correction version format. */
function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | null {
function parseOpenClawReleaseVersion(value: string): SemVer | null {
const trimmed = value.trim();
const parsed = OPENCLAW_RELEASE_PREFIX_RE.test(trimmed) ? parseSemver(trimmed) : null;
if (!parsed || parsed.build.length > 0) {
return null;
}
const { major: year, minor: month, patch } = parsed;
if (month < 1 || month > 12 || patch < 1) {
if (parsed.minor < 1 || parsed.minor > 12 || parsed.patch < 1) {
return null;
}
const [label, sequence] = parsed.prerelease;
const isStable = parsed.prerelease.length === 0;
const isCorrection = parsed.prerelease.length === 1 && typeof label === "number" && label > 0;
const isCorrection = isOpenClawCorrectionSemver(parsed) && typeof label === "number" && label > 0;
const isAlpha =
parsed.prerelease.length === 2 &&
label === "alpha" &&
@@ -172,52 +162,20 @@ function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | nu
if (!isStable && !isCorrection && !isAlpha && !isBeta) {
return null;
}
const channel = isAlpha ? "alpha" : isBeta ? "beta" : "stable";
return {
channel,
year,
month,
patch,
correctionNumber: isCorrection ? label : undefined,
alphaNumber: isAlpha ? sequence : undefined,
betaNumber: isBeta ? sequence : undefined,
};
return parsed;
}
/** Returns whether a version is an OpenClaw monthly patch stable correction release. */
function isOpenClawStableCorrectionVersion(value: string): boolean {
const parsed = parseOpenClawReleaseVersion(value);
return parsed?.channel === "stable" && parsed.correctionNumber !== undefined;
return parsed !== null && isOpenClawCorrectionSemver(parsed);
}
/** Compares OpenClaw monthly patch release versions across alpha, beta, stable, and corrections. */
export function compareOpenClawReleaseVersions(left: string, right: string): number | null {
const parsedLeft = parseOpenClawReleaseVersion(left);
const parsedRight = parseOpenClawReleaseVersion(right);
if (!parsedLeft || !parsedRight) {
return null;
}
if (parsedLeft.year !== parsedRight.year) {
return parsedLeft.year < parsedRight.year ? -1 : 1;
}
if (parsedLeft.month !== parsedRight.month) {
return parsedLeft.month < parsedRight.month ? -1 : 1;
}
if (parsedLeft.patch !== parsedRight.patch) {
return parsedLeft.patch < parsedRight.patch ? -1 : 1;
}
if (parsedLeft.channel !== parsedRight.channel) {
const rank = { alpha: 0, beta: 1, stable: 2 };
return rank[parsedLeft.channel] < rank[parsedRight.channel] ? -1 : 1;
}
if (parsedLeft.channel === "alpha") {
return Math.sign((parsedLeft.alphaNumber ?? 0) - (parsedRight.alphaNumber ?? 0));
}
if (parsedLeft.channel === "beta") {
return Math.sign((parsedLeft.betaNumber ?? 0) - (parsedRight.betaNumber ?? 0));
}
return Math.sign((parsedLeft.correctionNumber ?? 0) - (parsedRight.correctionNumber ?? 0));
return parsedLeft && parsedRight ? compareOpenClawSemver(parsedLeft, parsedRight) : null;
}
/** Returns whether an exact semver value is a prerelease, excluding stable correction releases. */
+21 -4
View File
@@ -1,9 +1,26 @@
import { compare, valid } from "semver";
import { compareBuild, parse, type SemVer } from "semver";
export function compareValidSemver(left: string, right: string): number | null {
const validLeft = valid(left);
const validRight = valid(right);
return validLeft && validRight ? compare(validLeft, validRight) : null;
const parsedLeft = parse(left);
const parsedRight = parse(right);
return parsedLeft && parsedRight ? parsedLeft.compare(parsedRight) : null;
}
export function isOpenClawCorrectionSemver(version: SemVer): boolean {
return version.prerelease.length === 1 && typeof version.prerelease[0] === "number";
}
function toOpenClawComparableVersion(version: SemVer): string {
if (isOpenClawCorrectionSemver(version)) {
return `${version.major}.${version.minor}.${version.patch}+${version.prerelease[0]}`;
}
// SemVer.version excludes build metadata, which remains precedence-neutral.
return version.version;
}
/** Compares prereleases, stable releases, then OpenClaw numeric corrections. */
export function compareOpenClawSemver(left: SemVer, right: SemVer): number {
return compareBuild(toOpenClawComparableVersion(left), toOpenClawComparableVersion(right));
}
/** Converts legacy OpenClaw `1.2.3.beta.N` tags into valid SemVer prereleases. */