mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
refactor(version): use native SemVer ordering
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user