diff --git a/src/config/version.test.ts b/src/config/version.test.ts index 7532a4b191c0..bea8ebd0d5fd 100644 --- a/src/config/version.test.ts +++ b/src/config/version.test.ts @@ -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); diff --git a/src/config/version.ts b/src/config/version.ts index d975355a5b67..bb23ff19759a 100644 --- a/src/config/version.ts +++ b/src/config/version.ts @@ -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; } diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index 36c2a0535046..f4f6504578d9 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -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); diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts index 690bee8d7b82..7bb26d74f342 100644 --- a/src/infra/npm-registry-spec.ts +++ b/src/infra/npm-registry-spec.ts @@ -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. */ diff --git a/src/infra/semver.ts b/src/infra/semver.ts index b76d17046bf4..17a5b9971a52 100644 --- a/src/infra/semver.ts +++ b/src/infra/semver.ts @@ -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. */