refactor: use semver package for version ordering (#105944)

* refactor: use semver package for version ordering

* refactor: centralize semver validation

* chore: keep release notes in PR body

* refactor: update semver LOC ratchet
This commit is contained in:
Peter Steinberger
2026-07-12 23:02:25 -07:00
committed by GitHub
parent 2454511480
commit a5883c33d1
23 changed files with 258 additions and 358 deletions
+13
View File
@@ -9,6 +9,7 @@
"version": "2026.7.2",
"dependencies": {
"@openai/codex": "0.144.1",
"semver": "7.8.5",
"smol-toml": "1.7.0",
"typebox": "1.3.3",
"ws": "8.21.0",
@@ -137,6 +138,18 @@
"node": ">=16"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/smol-toml": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
+1
View File
@@ -9,6 +9,7 @@
"type": "module",
"dependencies": {
"@openai/codex": "0.144.1",
"semver": "7.8.5",
"smol-toml": "1.7.0",
"typebox": "1.3.3",
"ws": "8.21.0",
+15 -29
View File
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { compare as compareSemver, parse as parseSemver } from "semver";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import {
type CodexAppServerRequestMethod,
@@ -943,43 +944,28 @@ function readCodexVersionFromUserAgent(userAgent: string | undefined): string |
/** Compares stable Codex app-server versions for protocol floor checks. */
function compareCodexAppServerVersions(left: string, right: string): number {
const leftVersion = parseVersionForComparison(left);
const rightVersion = parseVersionForComparison(right);
const leftParts = leftVersion.parts;
const rightParts = rightVersion.parts;
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart !== rightPart) {
return leftPart < rightPart ? -1 : 1;
}
const leftVersion = parseSemver(left);
const rightVersion = parseSemver(right);
if (!leftVersion || !rightVersion) {
// Invalid detected versions fail below a valid protocol floor instead of bypassing it.
return leftVersion ? 1 : rightVersion ? -1 : 0;
}
if (leftVersion.unstableSuffix && !rightVersion.unstableSuffix) {
const precedence = compareSemver(leftVersion, rightVersion);
if (precedence !== 0) {
return precedence;
}
// Build metadata has no SemVer precedence, but custom Codex builds must not satisfy a stable floor.
const leftUnstable = leftVersion.prerelease.length > 0 || leftVersion.build.length > 0;
const rightUnstable = rightVersion.prerelease.length > 0 || rightVersion.build.length > 0;
if (leftUnstable && !rightUnstable) {
return -1;
}
if (!leftVersion.unstableSuffix && rightVersion.unstableSuffix) {
if (!leftUnstable && rightUnstable) {
return 1;
}
return 0;
}
function parseVersionForComparison(version: string): { parts: number[]; unstableSuffix: boolean } {
// Same-version prerelease or build-suffixed versions do not satisfy a stable
// protocol floor because important app-server contract changes can land
// between alpha cuts and custom builds.
const hasBuildMetadata = version.includes("+");
const [withoutBuild = version] = version.split("+", 1);
const prereleaseIndex = withoutBuild.indexOf("-");
const numeric = prereleaseIndex >= 0 ? withoutBuild.slice(0, prereleaseIndex) : withoutBuild;
return {
parts: numeric
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0)),
unstableSuffix: prereleaseIndex >= 0 || hasBuildMetadata,
};
}
function redactCodexAppServerLinePreview(value: string): string {
const compact = value.replace(/\s+/g, " ").trim();
const redacted = compact
+1
View File
@@ -8,6 +8,7 @@
"@copilotkit/aimock": "1.35.0",
"@modelcontextprotocol/sdk": "1.29.0",
"playwright-core": "1.61.1",
"semver": "7.8.5",
"yaml": "2.9.0",
"zod": "4.4.3"
},
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { coerce as coerceSemver } from "semver";
const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([
"image-generation-core",
@@ -25,41 +26,7 @@ function assertSafeQaBundledPluginId(pluginId: string) {
}
function parseStableSemverFloor(value: string | undefined) {
if (!value) {
return null;
}
const match = value.trim().match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return {
major: Number.parseInt(match[1] ?? "", 10),
minor: Number.parseInt(match[2] ?? "", 10),
patch: Number.parseInt(match[3] ?? "", 10),
label: `${match[1]}.${match[2]}.${match[3]}`,
};
}
function compareSemverFloors(
left: ReturnType<typeof parseStableSemverFloor>,
right: ReturnType<typeof parseStableSemverFloor>,
) {
if (!left && !right) {
return 0;
}
if (!left) {
return -1;
}
if (!right) {
return 1;
}
if (left.major !== right.major) {
return left.major - right.major;
}
if (left.minor !== right.minor) {
return left.minor - right.minor;
}
return left.patch - right.patch;
return value ? coerceSemver(value) : null;
}
function isQaOpenAiResponsesProviderConfig(config: ModelProviderConfig) {
@@ -392,12 +359,12 @@ export async function resolveQaRuntimeHostVersion(params: {
};
};
const candidate = parseStableSemverFloor(packageJson.openclaw?.install?.minHostVersion);
if (compareSemverFloors(candidate, selected) > 0) {
if (candidate && (!selected || candidate.compare(selected) > 0)) {
selected = candidate;
}
}
return selected?.label;
return selected?.version;
}
export async function createQaBundledPluginsDir(params: {
@@ -97,6 +97,21 @@ describe("codex plugin lifecycle: pinned-old codex plugin with new OpenClaw", ()
'Codex plugin version 2026.5.19 is older than OpenClaw 2026.5.21. Run "openclaw plugins update codex" or unpin codex, then rerun "openclaw doctor --fix".',
);
});
it("treats an equal-base prerelease plugin as older than the stable host", async () => {
const agentDir = await createAgentDir("qa-codex-plugin-prerelease-");
await seedCodexPluginAt("2026.5.21-beta.1", agentDir);
await seedAuthProfiles("oauth-only", agentDir);
const result = evaluateCodexPluginLifecycle({
plugin: await snapshotCodexPluginState(agentDir),
auth: await snapshotAuthProfiles(agentDir),
hostVersion: "2026.5.21",
});
expect(result.status).toBe("blocked");
expect(result.remediation).toContain("is older than OpenClaw 2026.5.21");
});
});
describe("codex plugin lifecycle: pinned-new codex plugin with old OpenClaw", () => {
@@ -116,6 +131,21 @@ describe("codex plugin lifecycle: pinned-new codex plugin with old OpenClaw", ()
"Codex plugin version 2026.5.22 requires a newer OpenClaw host than 2026.5.21. Upgrade OpenClaw or install a codex plugin version pinned to 2026.5.21.",
);
});
it("orders a numeric correction plugin after the base stable host", async () => {
const agentDir = await createAgentDir("qa-codex-plugin-correction-");
await seedCodexPluginAt("2026.5.21-1", agentDir);
await seedAuthProfiles("oauth-only", agentDir);
const result = evaluateCodexPluginLifecycle({
plugin: await snapshotCodexPluginState(agentDir),
auth: await snapshotAuthProfiles(agentDir),
hostVersion: "2026.5.21",
});
expect(result.status).toBe("blocked");
expect(result.remediation).toContain("requires a newer OpenClaw host than 2026.5.21");
});
});
describe("codex plugin lifecycle: install racing first agent turn", () => {
+31 -29
View File
@@ -1,6 +1,7 @@
// Qa Lab plugin module implements codex plugin.fixture behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { compare as compareSemver, parse as parseSemver } from "semver";
import { resolveCodexAuthProfile, type QaAuthProfileSnapshot } from "./auth-profile.fixture.js";
export const CODEX_PLUGIN_CURRENT_VERSION = "2026.5.21";
@@ -43,12 +44,6 @@ type CodexPluginPackageJson = {
};
};
type ComparableVersion = {
major: number;
minor: number;
patch: number;
};
type CodexPluginInstallGateResult = {
text: string;
inputTokens: number;
@@ -81,34 +76,38 @@ function buildPackageJson(version: string): CodexPluginPackageJson {
};
}
function parseComparableVersion(value: string | undefined): ComparableVersion | null {
function parseComparableVersion(value: string | undefined) {
if (!value || value === CODEX_PLUGIN_HEAD_VERSION) {
return parseComparableVersion(CODEX_PLUGIN_CURRENT_VERSION);
}
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return {
major: Number.parseInt(match[1] ?? "0", 10),
minor: Number.parseInt(match[2] ?? "0", 10),
patch: Number.parseInt(match[3] ?? "0", 10),
};
return parseSemver(value.trim());
}
function compareVersions(left: string | undefined, right: string): number {
const leftVersion = parseComparableVersion(left);
const rightVersion = parseComparableVersion(right);
if (!leftVersion || !rightVersion) {
return 0;
type ParsedSemver = NonNullable<ReturnType<typeof parseSemver>>;
function compareCodexPluginVersions(left: ParsedSemver, right: ParsedSemver): number {
const sameCore =
left.major === right.major && left.minor === right.minor && left.patch === right.patch;
const leftCorrection =
left.prerelease.length === 1 && typeof left.prerelease[0] === "number"
? left.prerelease[0]
: null;
const rightCorrection =
right.prerelease.length === 1 && typeof right.prerelease[0] === "number"
? right.prerelease[0]
: null;
if (sameCore && (leftCorrection !== null || rightCorrection !== null)) {
// OpenClaw numeric suffixes are correction releases after stable, unlike SemVer prereleases.
const leftRank = leftCorrection !== null ? 2 : left.prerelease.length === 0 ? 1 : 0;
const rightRank = rightCorrection !== null ? 2 : right.prerelease.length === 0 ? 1 : 0;
if (leftRank !== rightRank) {
return leftRank < rightRank ? -1 : 1;
}
if (leftCorrection !== null && rightCorrection !== null) {
return Math.sign(leftCorrection - rightCorrection);
}
}
if (leftVersion.major !== rightVersion.major) {
return leftVersion.major - rightVersion.major;
}
if (leftVersion.minor !== rightVersion.minor) {
return leftVersion.minor - rightVersion.minor;
}
return leftVersion.patch - rightVersion.patch;
return compareSemver(left, right);
}
function formatPinnedOldRemediation(pluginVersion: string, hostVersion: string) {
@@ -219,7 +218,10 @@ export function evaluateCodexPluginLifecycle(params: {
};
}
const versionDelta = compareVersions(params.plugin.version, params.hostVersion);
const pluginVersion = parseComparableVersion(params.plugin.version);
const hostVersion = parseComparableVersion(params.hostVersion);
const versionDelta =
pluginVersion && hostVersion ? compareCodexPluginVersions(pluginVersion, hostVersion) : 0;
if (versionDelta < 0 && params.plugin.version) {
return {
status: "blocked",
+13
View File
@@ -56,6 +56,7 @@
"qrcode": "1.5.4",
"quickjs-wasi": "3.0.2",
"rastermill": "0.3.1",
"semver": "7.8.5",
"tar": "7.5.19",
"tree-sitter-bash": "0.25.1",
"tslog": "4.10.2",
@@ -2900,6 +2901,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+2
View File
@@ -2049,6 +2049,7 @@
"qrcode": "1.5.4",
"quickjs-wasi": "3.0.2",
"rastermill": "0.3.1",
"semver": "7.8.5",
"tar": "7.5.19",
"tree-sitter-bash": "0.25.1",
"tslog": "4.10.2",
@@ -2076,6 +2077,7 @@
"@types/markdown-it": "14.1.2",
"@types/node": "26.1.0",
"@types/proper-lockfile": "4.1.4",
"@types/semver": "7.7.1",
"@types/ws": "8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"@vitest/coverage-v8": "4.1.9",
+17
View File
@@ -184,6 +184,9 @@ importers:
rastermill:
specifier: 0.3.1
version: 0.3.1
semver:
specifier: 7.8.5
version: 7.8.5
tar:
specifier: 7.5.19
version: 7.5.19
@@ -260,6 +263,9 @@ importers:
'@types/proper-lockfile':
specifier: 4.1.4
version: 4.1.4
'@types/semver':
specifier: 7.7.1
version: 7.7.1
'@types/ws':
specifier: 8.18.1
version: 8.18.1
@@ -539,6 +545,9 @@ importers:
'@openai/codex':
specifier: 0.144.1
version: 0.144.1
semver:
specifier: 7.8.5
version: 7.8.5
smol-toml:
specifier: 1.7.0
version: 1.7.0
@@ -1396,6 +1405,9 @@ importers:
playwright-core:
specifier: 1.61.1
version: 1.61.1
semver:
specifier: 7.8.5
version: 7.8.5
yaml:
specifier: 2.9.0
version: 2.9.0
@@ -4543,6 +4555,9 @@ packages:
'@types/sarif@2.1.7':
resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -10620,6 +10635,8 @@ snapshots:
'@types/sarif@2.1.7': {}
'@types/semver@7.7.1': {}
'@types/send@1.2.1':
dependencies:
'@types/node': 26.1.0
+4 -4
View File
@@ -40,7 +40,7 @@
"extensions/codex/src/app-server/attempt-turn-watches.ts": 517,
"extensions/codex/src/app-server/auth-bridge.ts": 1011,
"extensions/codex/src/app-server/bounded-turn.ts": 535,
"extensions/codex/src/app-server/client.ts": 1061,
"extensions/codex/src/app-server/client.ts": 1047,
"extensions/codex/src/app-server/compact.ts": 916,
"extensions/codex/src/app-server/computer-use.ts": 1264,
"extensions/codex/src/app-server/config.ts": 2492,
@@ -961,7 +961,7 @@
"src/infra/backup-create.ts": 976,
"src/infra/bonjour-discovery.ts": 629,
"src/infra/clawhub-install-trust.ts": 1098,
"src/infra/clawhub.ts": 2001,
"src/infra/clawhub.ts": 1999,
"src/infra/command-analysis/inline-eval.ts": 526,
"src/infra/command-explainer/extract.ts": 1391,
"src/infra/device-bootstrap.ts": 509,
@@ -1000,7 +1000,7 @@
"src/infra/state-migrations.debug-proxy.ts": 578,
"src/infra/state-migrations.ts": 6443,
"src/infra/unhandled-rejections.ts": 566,
"src/infra/update-check.ts": 746,
"src/infra/update-check.ts": 745,
"src/infra/update-global.ts": 1015,
"src/infra/update-managed-service-handoff.ts": 830,
"src/infra/update-runner.ts": 1874,
@@ -1098,7 +1098,7 @@
"src/plugins/tools.ts": 1573,
"src/plugins/types.ts": 3071,
"src/plugins/uninstall.ts": 780,
"src/plugins/update.ts": 2745,
"src/plugins/update.ts": 2742,
"src/process/command-queue.ts": 700,
"src/process/exec.ts": 773,
"src/proxy-capture/runtime.ts": 668,
+1
View File
@@ -53,6 +53,7 @@ describe("compareOpenClawVersions", () => {
});
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);
expect(compareOpenClawVersions("2026.3.23-beta.2", "2026.3.23-beta.1")).toBe(1);
expect(compareOpenClawVersions("2026.3.23.beta.1", "2026.3.23-beta.2")).toBe(-1);
+42 -43
View File
@@ -1,9 +1,6 @@
import { expectDefined } from "@openclaw/normalization-core";
// Normalizes config version metadata and compatibility comparisons.
import {
comparePrereleaseIdentifiers,
normalizeLegacyDotBetaVersion,
} from "../infra/semver-compare.js";
import { compare as compareSemver, parse as parseSemver } from "semver";
import { normalizeLegacyDotBetaVersion } from "../infra/semver.js";
type OpenClawVersion = {
major: number;
@@ -13,26 +10,29 @@ type OpenClawVersion = {
prerelease: string[] | null;
};
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
/** Parses stable, prerelease, and legacy dot-beta OpenClaw versions. */
export function parseOpenClawVersion(raw: string | null | undefined): OpenClawVersion | null {
if (!raw) {
return null;
}
const normalized = normalizeLegacyDotBetaVersion(raw.trim());
const match = normalized.match(VERSION_RE);
if (!match) {
const parsed = parseSemver(normalized);
if (!parsed) {
return null;
}
const [, major, minor, patch, suffix] = match;
const revision = suffix && /^[0-9]+$/.test(suffix) ? Number.parseInt(suffix, 10) : null;
const revision =
parsed.prerelease.length === 1 && typeof parsed.prerelease[0] === "number"
? parsed.prerelease[0]
: null;
return {
major: Number.parseInt(expectDefined(major, "version major"), 10),
minor: Number.parseInt(expectDefined(minor, "version minor"), 10),
patch: Number.parseInt(expectDefined(patch, "version patch"), 10),
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch,
revision,
prerelease: suffix && revision == null ? suffix.split(".").filter(Boolean) : null,
prerelease:
parsed.prerelease.length > 0 && revision == null
? parsed.prerelease.map((part) => String(part))
: null,
};
}
@@ -72,35 +72,26 @@ export function compareOpenClawVersions(
if (!parsedA || !parsedB) {
return null;
}
if (parsedA.major !== parsedB.major) {
return parsedA.major < parsedB.major ? -1 : 1;
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;
}
}
if (parsedA.minor !== parsedB.minor) {
return parsedA.minor < parsedB.minor ? -1 : 1;
}
if (parsedA.patch !== parsedB.patch) {
return parsedA.patch < parsedB.patch ? -1 : 1;
}
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;
}
if (parsedA.prerelease || parsedB.prerelease) {
return comparePrereleaseIdentifiers(parsedA.prerelease, parsedB.prerelease);
}
return 0;
return compareSemver(formatComparableVersion(parsedA), formatComparableVersion(parsedB));
}
export function shouldWarnOnTouchedVersion(
@@ -136,3 +127,11 @@ function releaseRank(version: OpenClawVersion): number {
}
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;
}
+1 -1
View File
@@ -222,7 +222,7 @@ describe("clawhub helpers", () => {
).toBe("1.2.2");
});
it("checks plugin api ranges without semver dependency", () => {
it("checks plugin api ranges with semver precedence", () => {
expect(satisfiesPluginApiRange("1.2.3", "^1.2.0")).toBe(true);
expect(satisfiesPluginApiRange("1.9.0", ">=1.2.0 <2.0.0")).toBe(true);
expect(satisfiesPluginApiRange("2.0.0", "^1.2.0")).toBe(false);
+8 -10
View File
@@ -9,6 +9,7 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { parse as parseSemverVersion, prerelease as parseSemverPrerelease } from "semver";
import { retryClawHubRead } from "./clawhub-retry.js";
import { sha256Base64, sha256Hex as digestSha256Hex } from "./crypto-digest.js";
import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js";
@@ -18,7 +19,7 @@ import {
parseStrictPositiveInteger,
} from "./parse-finite-number.js";
import { isAtLeast, parseSemver } from "./runtime-guard.js";
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
import { compareValidSemver } from "./semver.js";
import { createTempDownloadTarget } from "./temp-download.js";
export { parseClawHubPluginSpec } from "./clawhub-spec.js";
@@ -549,14 +550,13 @@ function normalizePartialComparableVersion(version: string): {
}
function compareSemver(left: string, right: string): number | null {
return compareComparableSemver(
parseComparableSemver(normalizePartialComparableVersion(left).version),
parseComparableSemver(normalizePartialComparableVersion(right).version),
);
const normalizedLeft = normalizePartialComparableVersion(left).version;
const normalizedRight = normalizePartialComparableVersion(right).version;
return compareValidSemver(normalizedLeft, normalizedRight);
}
function upperBoundForCaret(version: string): string | null {
const parsed = parseComparableSemver(normalizePartialComparableVersion(version).version);
const parsed = parseSemverVersion(normalizePartialComparableVersion(version).version);
if (!parsed) {
return null;
}
@@ -579,9 +579,7 @@ function matchWildcardComparator(token: string): "any" | "none" | null {
}
function shouldPreservePluginApiPrereleaseFloor(target: string): boolean {
return Boolean(
parseComparableSemver(normalizePartialComparableVersion(target).version)?.prerelease?.length,
);
return Boolean(parseSemverPrerelease(normalizePartialComparableVersion(target).version));
}
function normalizePluginApiVersionForComparator(version: string, target: string): string {
@@ -601,7 +599,7 @@ function satisfiesComparator(version: string, token: string): boolean {
}
const wildcard = matchWildcardComparator(trimmed);
if (wildcard) {
return wildcard === "any" && parseComparableSemver(version) != null;
return wildcard === "any" && parseSemverVersion(version) != null;
}
if (trimmed.startsWith("^")) {
const base = trimmed.slice(1).trim();
+37 -53
View File
@@ -1,15 +1,12 @@
// Parses npm registry specs into package, version, and tag references.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
parse as parseSemver,
prerelease as parseSemverPrerelease,
valid as validSemver,
} from "semver";
const EXACT_SEMVER_VERSION_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
const OPENCLAW_STABLE_CORRECTION_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-(?<correction>[1-9]\d*)$/;
const OPENCLAW_STABLE_VERSION_RE = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)$/;
const OPENCLAW_ALPHA_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-alpha\.(?<alpha>[1-9]\d*)$/;
const OPENCLAW_BETA_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-beta\.(?<beta>[1-9]\d*)$/;
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. */
@@ -91,8 +88,8 @@ function parseRegistryNpmSpecInternal(
if (/[\\/]/.test(selector)) {
return { ok: false, error: "unsupported npm spec: invalid version/tag" };
}
const exactVersionMatch = EXACT_SEMVER_VERSION_RE.exec(selector);
if (exactVersionMatch) {
const exactVersion = validSemver(selector);
if (exactVersion) {
return {
ok: true,
parsed: {
@@ -101,7 +98,8 @@ function parseRegistryNpmSpecInternal(
selector,
selectorKind: "exact-version",
selectorIsPrerelease:
Boolean(exactVersionMatch[4]) && !isOpenClawStableCorrectionVersion(selector),
parseSemverPrerelease(exactVersion) !== null &&
!isOpenClawStableCorrectionVersion(selector),
},
};
}
@@ -143,60 +141,47 @@ export function validateRegistryNpmSpec(rawSpec: string): string | null {
/** Returns whether a value is an exact semver selector, with optional leading `v`. */
export function isExactSemverVersion(value: string): boolean {
return EXACT_SEMVER_VERSION_RE.test(value.trim());
return validSemver(value.trim()) !== null;
}
/** Parses OpenClaw's monthly patch stable/alpha/beta/correction version format. */
function parseOpenClawReleaseVersion(value: string): OpenClawReleaseVersion | null {
const trimmed = value.trim();
const candidates = [
{ match: OPENCLAW_STABLE_VERSION_RE.exec(trimmed), channel: "stable" as const },
{ match: OPENCLAW_STABLE_CORRECTION_VERSION_RE.exec(trimmed), channel: "stable" as const },
{ match: OPENCLAW_ALPHA_VERSION_RE.exec(trimmed), channel: "alpha" as const },
{ match: OPENCLAW_BETA_VERSION_RE.exec(trimmed), channel: "beta" as const },
];
const candidate = candidates.find((entry) => entry.match?.groups);
if (!candidate?.match?.groups) {
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) {
return null;
}
const year = Number.parseInt(candidate.match.groups.year ?? "", 10);
const month = Number.parseInt(candidate.match.groups.month ?? "", 10);
const patch = Number.parseInt(candidate.match.groups.patch ?? "", 10);
if (
!Number.isInteger(year) ||
!Number.isInteger(month) ||
!Number.isInteger(patch) ||
month < 1 ||
month > 12 ||
patch < 1
) {
const [label, sequence] = parsed.prerelease;
const isStable = parsed.prerelease.length === 0;
const isCorrection = parsed.prerelease.length === 1 && typeof label === "number" && label > 0;
const isAlpha =
parsed.prerelease.length === 2 &&
label === "alpha" &&
typeof sequence === "number" &&
sequence > 0;
const isBeta =
parsed.prerelease.length === 2 &&
label === "beta" &&
typeof sequence === "number" &&
sequence > 0;
if (!isStable && !isCorrection && !isAlpha && !isBeta) {
return null;
}
const correctionNumber =
candidate.channel === "stable" && candidate.match.groups.correction
? Number.parseInt(candidate.match.groups.correction, 10)
: undefined;
// Stable correction releases share the stable channel rank; the optional
// correction number is compared later so base stable sorts before fixes.
const alphaNumber =
candidate.channel === "alpha"
? Number.parseInt(candidate.match.groups.alpha ?? "", 10)
: undefined;
const betaNumber =
candidate.channel === "beta"
? Number.parseInt(candidate.match.groups.beta ?? "", 10)
: undefined;
const channel = isAlpha ? "alpha" : isBeta ? "beta" : "stable";
return {
channel: candidate.channel,
channel,
year,
month,
patch,
correctionNumber,
alphaNumber,
betaNumber,
correctionNumber: isCorrection ? label : undefined,
alphaNumber: isAlpha ? sequence : undefined,
betaNumber: isBeta ? sequence : undefined,
};
}
@@ -238,8 +223,7 @@ export function compareOpenClawReleaseVersions(left: string, right: string): num
/** Returns whether an exact semver value is a prerelease, excluding stable correction releases. */
export function isPrereleaseSemverVersion(value: string): boolean {
const trimmed = value.trim();
const match = EXACT_SEMVER_VERSION_RE.exec(trimmed);
return Boolean(match?.[4]) && !isOpenClawStableCorrectionVersion(trimmed);
return parseSemverPrerelease(trimmed) !== null && !isOpenClawStableCorrectionVersion(trimmed);
}
/**
-125
View File
@@ -1,125 +0,0 @@
// Compares semantic versions for package and runtime compatibility checks.
type ComparableSemver = {
major: number;
minor: number;
patch: number;
prerelease: string[] | null;
};
/**
* Converts legacy OpenClaw `1.2.3.beta.N` tags into semver-compatible `1.2.3-beta.N`.
*/
export function normalizeLegacyDotBetaVersion(version: string): string {
const trimmed = version.trim();
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed);
if (!dotBetaMatch) {
return trimmed;
}
const base = dotBetaMatch[1];
const suffix = dotBetaMatch[2];
return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
}
/**
* Parses an exact semver-like version into fields used by update and plugin version ordering.
*/
export function parseComparableSemver(
version: string | null | undefined,
options?: { normalizeLegacyDotBeta?: boolean },
): ComparableSemver | null {
if (!version) {
return null;
}
const normalized = options?.normalizeLegacyDotBeta
? normalizeLegacyDotBetaVersion(version)
: version.trim();
const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
normalized,
);
if (!match) {
return null;
}
const [, major, minor, patch, prereleaseRaw] = match;
if (!major || !minor || !patch) {
return null;
}
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null,
};
}
/**
* Compares semver prerelease identifiers using numeric-before-string semver precedence rules.
*/
export function comparePrereleaseIdentifiers(a: string[] | null, b: string[] | null): number {
if (!a?.length && !b?.length) {
return 0;
}
// A stable release has higher precedence than any prerelease for the same major/minor/patch.
if (!a?.length) {
return 1;
}
if (!b?.length) {
return -1;
}
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const ai = a[i];
const bi = b[i];
if (ai == null && bi == null) {
return 0;
}
if (ai == null) {
return -1;
}
if (bi == null) {
return 1;
}
if (ai === bi) {
continue;
}
const aiNumeric = /^[0-9]+$/.test(ai);
const biNumeric = /^[0-9]+$/.test(bi);
if (aiNumeric && biNumeric) {
const aiNum = Number.parseInt(ai, 10);
const biNum = Number.parseInt(bi, 10);
return aiNum < biNum ? -1 : 1;
}
if (aiNumeric && !biNumeric) {
return -1;
}
if (!aiNumeric && biNumeric) {
return 1;
}
return ai < bi ? -1 : 1;
}
return 0;
}
/**
* Compares parsed semver values, returning null when either side could not be parsed.
*/
export function compareComparableSemver(
a: ComparableSemver | null,
b: ComparableSemver | null,
): number | null {
if (!a || !b) {
return null;
}
if (a.major !== b.major) {
return a.major < b.major ? -1 : 1;
}
if (a.minor !== b.minor) {
return a.minor < b.minor ? -1 : 1;
}
if (a.patch !== b.patch) {
return a.patch < b.patch ? -1 : 1;
}
return comparePrereleaseIdentifiers(a.prerelease, b.prerelease);
}
+19
View File
@@ -0,0 +1,19 @@
import { compare, valid } 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;
}
/** Converts legacy OpenClaw `1.2.3.beta.N` tags into valid SemVer prereleases. */
export function normalizeLegacyDotBetaVersion(version: string): string {
const trimmed = version.trim();
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed);
if (!dotBetaMatch) {
return trimmed;
}
const base = dotBetaMatch[1];
const suffix = dotBetaMatch[2];
return suffix ? `${base}-beta.${suffix}` : `${base}-beta`;
}
+4 -3
View File
@@ -1,6 +1,7 @@
// Resolves OpenClaw update channels from config, tags, and versions.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { parseComparableSemver } from "./semver-compare.js";
import { parse as parseSemver } from "semver";
import { normalizeLegacyDotBetaVersion } from "./semver.js";
/** Release stream used to choose registry tags and update policy defaults. */
export type UpdateChannel = "stable" | "extended-stable" | "beta" | "dev";
@@ -67,9 +68,9 @@ export function isBetaTag(tag: string): boolean {
/** Detects prerelease tags, including legacy dot-beta tags and named prerelease channels. */
export function isPrereleaseTag(tag: string): boolean {
const parsed = parseComparableSemver(tag, { normalizeLegacyDotBeta: true });
const parsed = parseSemver(normalizeLegacyDotBetaVersion(tag));
if (parsed) {
return Boolean(parsed.prerelease?.some((part) => !/^[0-9]+$/.test(part)));
return parsed.prerelease.some((part) => typeof part === "string");
}
return /(?:^|[.-])(alpha|beta|rc|pre|preview|canary|dev|next|nightly|experimental)(?:[.-]|$)/i.test(
tag,
+6 -11
View File
@@ -27,17 +27,12 @@ afterEach(() => {
});
describe("compareSemverStrings", () => {
it("handles stable and prerelease precedence for both legacy and beta formats", () => {
expect(compareSemverStrings("1.0.0", "1.0.0")).toBe(0);
expect(compareSemverStrings("v1.0.0", "1.0.0")).toBe(0);
expect(compareSemverStrings("1.0.0", "1.0.0-beta.1")).toBe(1);
expect(compareSemverStrings("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1);
expect(compareSemverStrings("1.0.0-2", "1.0.0-1")).toBe(1);
expect(compareSemverStrings("1.0.0-1", "1.0.0-beta.1")).toBe(-1);
expect(compareSemverStrings("1.0.0.beta.2", "1.0.0-beta.1")).toBe(1);
expect(compareSemverStrings("1.0.0", "1.0.0.beta.1")).toBe(1);
it("orders real stable, prerelease, and legacy dot-beta versions", () => {
expect(compareSemverStrings("2026.6.5", "2026.6.6-beta.1")).toBe(-1);
expect(compareSemverStrings("2026.6.6", "2026.6.6-beta.1")).toBe(1);
expect(compareSemverStrings("2026.6.6-beta.2", "2026.6.6-beta.1")).toBe(1);
expect(compareSemverStrings("v2026.6.6", "2026.6.6")).toBe(0);
expect(compareSemverStrings("2026.6.6.beta.2", "2026.6.6-beta.1")).toBe(1);
});
it("treats OpenClaw stable correction releases as newer than their base release", () => {
+4 -5
View File
@@ -6,7 +6,7 @@ import { runCommandWithTimeout } from "../process/exec.js";
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js";
import { compareOpenClawReleaseVersions } from "./npm-registry-spec.js";
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
import { compareValidSemver, normalizeLegacyDotBetaVersion } from "./semver.js";
import { channelToNpmTag, type UpdateChannel } from "./update-channels.js";
export type PackageManager = "pnpm" | "bun" | "npm" | "unknown";
@@ -677,10 +677,9 @@ export function compareSemverStrings(a: string | null, b: string | null): number
return openClawReleaseCmp;
}
}
return compareComparableSemver(
parseComparableSemver(a, { normalizeLegacyDotBeta: true }),
parseComparableSemver(b, { normalizeLegacyDotBeta: true }),
);
const normalizedA = a ? normalizeLegacyDotBetaVersion(a) : null;
const normalizedB = b ? normalizeLegacyDotBetaVersion(b) : null;
return normalizedA && normalizedB ? compareValidSemver(normalizedA, normalizedB) : null;
}
export async function checkUpdateStatus(params: {
+2 -2
View File
@@ -46,7 +46,7 @@ import {
createSafeNpmInstallArgs,
createSafeNpmInstallEnv,
} from "../infra/safe-package-install.js";
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
import { compareValidSemver } from "../infra/semver.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { InstallPolicySource } from "../security/install-policy.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
@@ -317,7 +317,7 @@ function compareNpmSemver(a: string, b: string): number {
if (releaseCmp !== null) {
return releaseCmp;
}
return compareComparableSemver(parseComparableSemver(a), parseComparableSemver(b)) ?? 0;
return compareValidSemver(a, b) ?? 0;
}
type TrustedOfficialPrereleaseResolution =
+3 -6
View File
@@ -23,7 +23,7 @@ import {
readInstalledPackagePeerDependencies,
readInstalledPackageVersion,
} from "../infra/package-update-utils.js";
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
import { compareValidSemver } from "../infra/semver.js";
import type { UpdateChannel } from "../infra/update-channels.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
@@ -397,7 +397,7 @@ function compareNpmSemverForUpdate(left: string, right: string): number {
if (releaseCmp !== null) {
return releaseCmp;
}
return compareComparableSemver(parseComparableSemver(left), parseComparableSemver(right)) ?? 0;
return compareValidSemver(left, right) ?? 0;
}
async function resolveNewerExactPinnedNpmDefaultLine(params: {
@@ -584,10 +584,7 @@ function isBundledVersionNewer(bundledVersion: string, installedVersion: string)
if (releaseCmp !== null) {
return releaseCmp > 0;
}
const bundled = parseComparableSemver(bundledVersion);
const installed = parseComparableSemver(installedVersion);
const cmp = compareComparableSemver(bundled, installed);
return cmp !== null && cmp > 0;
return (compareValidSemver(bundledVersion, installedVersion) ?? 0) > 0;
}
function pathsEqual(