diff --git a/contracts/fixtures/lifecycle-semver-v1.json b/contracts/fixtures/lifecycle-semver-v1.json new file mode 100644 index 00000000..77f99fbe --- /dev/null +++ b/contracts/fixtures/lifecycle-semver-v1.json @@ -0,0 +1,269 @@ +{ + "contract": "clawsec.lifecycle-semver.fixtures/v1", + "valid_versions": [ + { + "input": "0.0.0", + "core": ["0", "0", "0"], + "prerelease": [], + "build": [] + }, + { + "input": "0.1.0-beta.1", + "core": ["0", "1", "0"], + "prerelease": ["beta", "1"], + "build": [] + }, + { + "input": "1.2.3-01alpha.7+build.001.sha-1", + "core": ["1", "2", "3"], + "prerelease": ["01alpha", "7"], + "build": ["build", "001", "sha-1"] + }, + { + "input": "18446744073709551616.99999999999999999999.0", + "core": ["18446744073709551616", "99999999999999999999", "0"], + "prerelease": [], + "build": [] + }, + { + "input": "1.0.0-x-y-z.--+meta", + "core": ["1", "0", "0"], + "prerelease": ["x-y-z", "--"], + "build": ["meta"] + } + ], + "invalid_versions": [ + "", + "1", + "1.2", + "1.2.3.4", + "01.2.3", + "1.02.3", + "1.2.03", + "1.2.3-", + "1.2.3-alpha..1", + "1.2.3-alpha_1", + "1.2.3-01", + "1.2.3+", + "1.2.3+build..1", + "v1.2.3", + " 1.2.3", + "1.2.3 ", + "1.2.3\n", + "1.2.3-β", + null, + 123, + [], + {} + ], + "precedence_chains": [ + [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0" + ], + [ + "0.1.0-beta.1", + "0.1.0-beta.2", + "0.1.0-rc.1", + "0.1.0" + ], + [ + "1.99999999999999999999.0", + "2.0.0", + "18446744073709551615.0.0", + "18446744073709551616.0.0" + ], + [ + "1.0.0-99999999999999999999", + "1.0.0-100000000000000000000", + "1.0.0-alpha" + ] + ], + "equal_precedence": [ + ["1.0.0+build.1", "1.0.0+build.2"], + ["1.0.0-alpha+one", "1.0.0-alpha+two"] + ], + "package_tags": { + "valid": [ + { + "input": "clawsec-core-openclaw-v0.1.0", + "package_name": "clawsec-core-openclaw", + "version": "0.1.0" + }, + { + "input": "agent-v-tools-v1.2.3-rc.1", + "package_name": "agent-v-tools", + "version": "1.2.3-rc.1" + }, + { + "input": "clawsec-v1.2.3+build.7", + "package_name": "clawsec", + "version": "1.2.3+build.7" + } + ], + "invalid": [ + "", + "clawsec-1.2.3", + "-v1.2.3", + "ClawSec-v1.2.3", + "clawsec--core-v1.2.3", + "clawsec-vv1.2.3", + "clawsec-v1.2", + "clawsec-v01.2.3", + null, + 123 + ] + }, + "format_tags": { + "valid": [ + { + "package_name": "clawsec-core-hermes", + "version": "0.1.0-beta.1", + "expected": "clawsec-core-hermes-v0.1.0-beta.1" + }, + { + "package_name": "agent-v-tools", + "version": "1.2.3+build.1", + "expected": "agent-v-tools-v1.2.3+build.1" + } + ], + "invalid_package_names": ["", "ClawSec", "clawsec_kit", "-clawsec", "clawsec-", "clawsec--core", "clawsec\n"] + }, + "classifications": [ + {"version": "0.1.0-beta.0", "expected": "beta"}, + {"version": "0.1.0-beta.12", "expected": "beta"}, + {"version": "0.1.0-rc.1", "expected": "rc"}, + {"version": "0.1.0", "expected": "final"}, + {"version": "0.1.0+build.1", "expected": "final"}, + {"version": "0.0.1-beta5", "expected": "legacy_prerelease"}, + {"version": "0.1.0-rc1", "expected": "legacy_prerelease"}, + {"version": "0.1.0-alpha.1", "expected": "legacy_prerelease"}, + {"version": "0.1.0-preview", "expected": "legacy_prerelease"} + ], + "publication_evaluations": [ + { + "version": "0.1.0", + "lifecycle_class": "final", + "eligible": true, + "reason_code": "final_version_eligible_not_authorized" + }, + { + "version": "0.1.0-beta.1", + "lifecycle_class": "beta", + "eligible": false, + "reason_code": "candidate_prerelease_lab_only" + }, + { + "version": "0.1.0-rc.1", + "lifecycle_class": "rc", + "eligible": false, + "reason_code": "candidate_prerelease_lab_only" + }, + { + "version": "0.1.0-beta5", + "lifecycle_class": "legacy_prerelease", + "eligible": false, + "reason_code": "legacy_prerelease_non_authorized" + }, + { + "version": "0.1.0+build.1", + "lifecycle_class": "final", + "eligible": false, + "reason_code": "build_metadata_disallowed" + } + ], + "lifecycle_bindings": { + "valid": [ + { + "stage": "beta", + "artifactVersion": "0.1.0-beta.1", + "intendedVersion": "0.1.0", + "activeCatalogAuthorized": false + }, + { + "stage": "rc", + "artifactVersion": "0.1.0-rc.2", + "intendedVersion": "0.1.0", + "activeCatalogAuthorized": false + }, + { + "stage": "stable_intent", + "artifactVersion": "0.1.0", + "intendedVersion": "0.1.0", + "activeCatalogAuthorized": false + }, + { + "stage": "stable", + "artifactVersion": "0.1.0", + "intendedVersion": "0.1.0", + "activeCatalogAuthorized": true + } + ], + "invalid": [ + { + "input": { + "stage": "beta", + "artifactVersion": "0.1.0-beta5", + "intendedVersion": "0.1.0" + }, + "error": "canonical beta.N" + }, + { + "input": { + "stage": "rc", + "artifactVersion": "0.1.0-rc.1", + "intendedVersion": "0.2.0" + }, + "error": "same core version" + }, + { + "input": { + "stage": "stable_intent", + "artifactVersion": "0.1.0-rc.1", + "intendedVersion": "0.1.0" + }, + "error": "equal the final intendedVersion" + }, + { + "input": { + "stage": "stable_intent", + "artifactVersion": "0.1.0", + "intendedVersion": "0.1.0", + "activeCatalogAuthorized": true + }, + "error": "outside the active public catalog" + }, + { + "input": { + "stage": "stable", + "artifactVersion": "0.1.0", + "intendedVersion": "0.1.0", + "activeCatalogAuthorized": false + }, + "error": "active signed-catalog authorization" + }, + { + "input": { + "stage": "stable", + "artifactVersion": "0.1.0+build.1", + "intendedVersion": "0.1.0" + }, + "error": "must not contain build metadata" + }, + { + "input": { + "stage": "preview", + "artifactVersion": "0.1.0-preview.1", + "intendedVersion": "0.1.0" + }, + "error": "Unknown lifecycle stage" + } + ] + } +} diff --git a/contracts/lifecycle-semver-v1.json b/contracts/lifecycle-semver-v1.json new file mode 100644 index 00000000..57901bd4 --- /dev/null +++ b/contracts/lifecycle-semver-v1.json @@ -0,0 +1,63 @@ +{ + "contract": "clawsec.lifecycle-semver/v1", + "contract_version": 1, + "purpose": "Define strict SemVer syntax, package-qualified tags, lifecycle version classes, and publication eligibility without performing release or catalog writes.", + "semver": { + "standard": "Semantic Versioning 2.0.0", + "strict_input": true, + "numeric_representation": "unbounded decimal string", + "build_metadata_is_valid_syntax": true, + "build_metadata_affects_precedence": false, + "build_metadata_allowed_for_clawsec_publication_identity": false + }, + "package_tag": { + "grammar": "-v", + "delimiter_rule": "Use the terminal -v suffix whose remainder is a valid SemVer version.", + "example": "clawsec-core-openclaw-v0.1.0" + }, + "version_classes": { + "beta": "An exact -beta.N prerelease, where N is a canonical non-negative decimal identifier.", + "rc": "An exact -rc.N prerelease, where N is a canonical non-negative decimal identifier.", + "final": "A SemVer version with no prerelease identifiers; this does not itself prove stable status.", + "legacy_prerelease": "Any other valid SemVer prerelease, including beta5, rc1, alpha, and preview forms." + }, + "lifecycle_stages": { + "beta": { + "artifact_version_class": "beta", + "distribution": "authenticated private lab only", + "active_catalog_authorized": false + }, + "rc": { + "artifact_version_class": "rc", + "distribution": "authenticated private lab only", + "active_catalog_authorized": false + }, + "stable_intent": { + "artifact_version_class": "final", + "artifact_version_equals_intended_version": true, + "distribution": "authenticated private lab only", + "active_catalog_authorized": false + }, + "stable": { + "artifact_version_class": "final", + "artifact_version_equals_intended_version": true, + "requires_active_signed_catalog_authorization": true + } + }, + "publication_policy": { + "syntactic_validity_implies_publication_eligibility": false, + "eligible_version_class": "final", + "eligibility_is_stable_authorization": false, + "reason_codes": [ + "final_version_eligible_not_authorized", + "candidate_prerelease_lab_only", + "legacy_prerelease_non_authorized", + "build_metadata_disallowed" + ] + }, + "security_boundaries": [ + "This contract performs no signature, catalog, cutover-policy, release, store, tag, or discovery verification.", + "A stable assertion requires an authorization decision supplied by a future verified active-catalog implementation.", + "A final-version artifact may be publication-eligible while remaining published-but-not-authorized and therefore not stable." + ] +} diff --git a/scripts/ci/lifecycle_semver.mjs b/scripts/ci/lifecycle_semver.mjs new file mode 100644 index 00000000..c73f0cc4 --- /dev/null +++ b/scripts/ci/lifecycle_semver.mjs @@ -0,0 +1,287 @@ +const CORE_IDENTIFIER_PATTERN = /^(?:0|[1-9]\d*)$/; +const VERSION_IDENTIFIER_PATTERN = /^[0-9A-Za-z-]+$/; +const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/; +const PACKAGE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const LIFECYCLE_STAGES = new Set(["beta", "rc", "stable_intent", "stable"]); + +function invalidSemver(version) { + return new Error(`Invalid SemVer 2.0 version: ${String(version)}`); +} + +function compareDecimalStrings(left, right) { + if (left.length !== right.length) { + return left.length < right.length ? -1 : 1; + } + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function comparePrereleaseIdentifiers(left, right) { + const leftIsNumeric = matchesEntire(NUMERIC_IDENTIFIER_PATTERN, left); + const rightIsNumeric = matchesEntire(NUMERIC_IDENTIFIER_PATTERN, right); + + if (leftIsNumeric && rightIsNumeric) { + return compareDecimalStrings(left, right); + } + if (leftIsNumeric) return -1; + if (rightIsNumeric) return 1; + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function matchesEntire(pattern, value) { + const match = value.match(pattern); + return match?.[0] === value; +} + +function isValidPackageName(packageName) { + if (typeof packageName !== "string") return false; + return matchesEntire(PACKAGE_NAME_PATTERN, packageName); +} + +function assertPackageName(packageName) { + if (!isValidPackageName(packageName)) { + throw new Error(`Invalid package name: ${String(packageName)}`); + } +} + +function hasBuildMetadata(parsedVersion) { + return parsedVersion.build.length > 0; +} + +function assertNoBuildMetadata(parsedVersion, label) { + if (hasBuildMetadata(parsedVersion)) { + throw new Error(`${label} must not contain build metadata: ${parsedVersion.raw}`); + } +} + +function assertFinalVersion(parsedVersion, label) { + if (parsedVersion.prerelease.length > 0) { + throw new Error(`${label} must be a final SemVer version: ${parsedVersion.raw}`); + } + assertNoBuildMetadata(parsedVersion, label); +} + +function sameCore(left, right) { + return left.core.every((identifier, index) => identifier === right.core[index]); +} + +export function parseSemverV2(version) { + if (typeof version !== "string") { + throw invalidSemver(version); + } + + const plusIndex = version.indexOf("+"); + if (plusIndex !== -1 && version.indexOf("+", plusIndex + 1) !== -1) { + throw invalidSemver(version); + } + + const coreAndPrerelease = plusIndex === -1 ? version : version.slice(0, plusIndex); + const buildText = plusIndex === -1 ? null : version.slice(plusIndex + 1); + const dashIndex = coreAndPrerelease.indexOf("-"); + const coreText = dashIndex === -1 + ? coreAndPrerelease + : coreAndPrerelease.slice(0, dashIndex); + const prereleaseText = dashIndex === -1 + ? null + : coreAndPrerelease.slice(dashIndex + 1); + + const core = coreText.split("."); + if (core.length !== 3 || core.some((identifier) => ( + !matchesEntire(CORE_IDENTIFIER_PATTERN, identifier) + ))) { + throw invalidSemver(version); + } + + const prerelease = prereleaseText === null ? [] : prereleaseText.split("."); + if (prereleaseText === "" || prerelease.some((identifier) => ( + !matchesEntire(VERSION_IDENTIFIER_PATTERN, identifier) + || ( + matchesEntire(NUMERIC_IDENTIFIER_PATTERN, identifier) + && identifier.length > 1 + && identifier.startsWith("0") + ) + ))) { + throw invalidSemver(version); + } + + const build = buildText === null ? [] : buildText.split("."); + if (buildText === "" || build.some((identifier) => ( + !matchesEntire(VERSION_IDENTIFIER_PATTERN, identifier) + ))) { + throw invalidSemver(version); + } + + return { + raw: version, + core, + prerelease, + build, + }; +} + +export function compareSemverV2(leftVersion, rightVersion) { + const left = parseSemverV2(leftVersion); + const right = parseSemverV2(rightVersion); + + for (let index = 0; index < left.core.length; index += 1) { + const comparison = compareDecimalStrings(left.core[index], right.core[index]); + if (comparison !== 0) return comparison; + } + + if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0; + if (left.prerelease.length === 0) return 1; + if (right.prerelease.length === 0) return -1; + + const length = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + if (leftIdentifier === undefined) return -1; + if (rightIdentifier === undefined) return 1; + + const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier); + if (comparison !== 0) return comparison; + } + + return 0; +} + +export function parsePackageTag(tag) { + if (typeof tag !== "string") { + throw new Error(`Invalid package-qualified tag: ${String(tag)}`); + } + + const firstDotIndex = tag.indexOf("."); + const delimiterIndex = firstDotIndex === -1 + ? -1 + : tag.lastIndexOf("-v", firstDotIndex); + if (delimiterIndex <= 0) { + throw new Error(`Invalid package-qualified tag: ${tag}`); + } + + const packageName = tag.slice(0, delimiterIndex); + const version = tag.slice(delimiterIndex + 2); + if (!isValidPackageName(packageName)) { + throw new Error(`Invalid package-qualified tag: ${tag}`); + } + + try { + const parsedVersion = parseSemverV2(version); + return { raw: tag, packageName, version, parsedVersion }; + } catch { + throw new Error(`Invalid package-qualified tag: ${tag}`); + } +} + +export function formatPackageTag(packageName, version) { + assertPackageName(packageName); + const parsedVersion = parseSemverV2(version); + return `${packageName}-v${parsedVersion.raw}`; +} + +export function classifyLifecycleVersion(version) { + const parsed = parseSemverV2(version); + if (parsed.prerelease.length === 0) return "final"; + + if ( + parsed.prerelease.length === 2 + && (parsed.prerelease[0] === "beta" || parsed.prerelease[0] === "rc") + && matchesEntire(NUMERIC_IDENTIFIER_PATTERN, parsed.prerelease[1]) + ) { + return parsed.prerelease[0]; + } + + return "legacy_prerelease"; +} + +export function evaluatePublicationVersion(version) { + const parsed = parseSemverV2(version); + const lifecycleClass = classifyLifecycleVersion(version); + + if (hasBuildMetadata(parsed)) { + return { + version, + lifecycleClass, + publicPublicationEligible: false, + reasonCode: "build_metadata_disallowed", + }; + } + + if (lifecycleClass === "final") { + return { + version, + lifecycleClass, + publicPublicationEligible: true, + reasonCode: "final_version_eligible_not_authorized", + }; + } + + if (lifecycleClass === "beta" || lifecycleClass === "rc") { + return { + version, + lifecycleClass, + publicPublicationEligible: false, + reasonCode: "candidate_prerelease_lab_only", + }; + } + + return { + version, + lifecycleClass, + publicPublicationEligible: false, + reasonCode: "legacy_prerelease_non_authorized", + }; +} + +export function assertLifecycleBinding({ + stage, + artifactVersion, + intendedVersion, + activeCatalogAuthorized = false, +} = {}) { + if (!LIFECYCLE_STAGES.has(stage)) { + throw new Error(`Unknown lifecycle stage: ${String(stage)}`); + } + if (typeof activeCatalogAuthorized !== "boolean") { + throw new Error("activeCatalogAuthorized must be a boolean"); + } + + const artifact = parseSemverV2(artifactVersion); + const intended = parseSemverV2(intendedVersion); + assertNoBuildMetadata(artifact, "artifactVersion"); + assertFinalVersion(intended, "intendedVersion"); + + if (!sameCore(artifact, intended)) { + throw new Error( + `artifactVersion and intendedVersion must share the same core version: ${artifact.raw} != ${intended.raw}`, + ); + } + + const lifecycleClass = classifyLifecycleVersion(artifactVersion); + if (stage === "beta" || stage === "rc") { + if (lifecycleClass !== stage) { + throw new Error(`${stage} requires a canonical ${stage}.N artifactVersion`); + } + if (activeCatalogAuthorized) { + throw new Error(`${stage} candidates cannot be authorized by the active public catalog`); + } + return true; + } + + if (lifecycleClass !== "final" || artifact.raw !== intended.raw) { + throw new Error(`${stage} requires artifactVersion to equal the final intendedVersion`); + } + + if (stage === "stable_intent") { + if (activeCatalogAuthorized) { + throw new Error("stable_intent must remain outside the active public catalog"); + } + return true; + } + + if (!activeCatalogAuthorized) { + throw new Error("stable requires active signed-catalog authorization"); + } + return true; +} diff --git a/scripts/ci/semver_increment.mjs b/scripts/ci/semver_increment.mjs index 9074ca6d..d14576a8 100644 --- a/scripts/ci/semver_increment.mjs +++ b/scripts/ci/semver_increment.mjs @@ -2,6 +2,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; +import { compareSemverV2, parseSemverV2 } from "./lifecycle_semver.mjs"; function parseIdentifier(identifier) { if (/^\d+$/.test(identifier)) { @@ -13,51 +14,55 @@ function parseIdentifier(identifier) { return identifier; } +function incrementDecimalString(value) { + const normalized = value.replace(/^0+(?=\d)/, ""); + const digits = normalized.split(""); + let carry = 1; + + for (let index = digits.length - 1; index >= 0 && carry === 1; index -= 1) { + if (digits[index] === "9") { + digits[index] = "0"; + } else { + digits[index] = String(Number(digits[index]) + 1); + carry = 0; + } + } + + if (carry === 1) digits.unshift("1"); + return digits.join(""); +} + export function parseSemver(version) { const normalized = String(version ?? "").trim(); - const match = normalized.match( + const legacyShape = normalized.match( /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/, ); - if (!match) { + if (legacyShape?.[4]) { + for (const identifier of legacyShape[4].split(".")) { + parseIdentifier(identifier); + } + } + + let parsed; + try { + parsed = parseSemverV2(normalized); + } catch { + throw new Error(`Invalid semantic version: ${version}`); + } + if (parsed.build.length > 0) { throw new Error(`Invalid semantic version: ${version}`); } return { raw: normalized, - core: [Number(match[1]), Number(match[2]), Number(match[3])], - prerelease: match[4] ? match[4].split(".").map(parseIdentifier) : [], + core: parsed.core.map(Number), + prerelease: parsed.prerelease.map(parseIdentifier), }; } export function compareSemver(leftVersion, rightVersion) { const left = parseSemver(leftVersion); const right = parseSemver(rightVersion); - - for (let index = 0; index < left.core.length; index += 1) { - if (left.core[index] !== right.core[index]) { - return left.core[index] < right.core[index] ? -1 : 1; - } - } - - if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0; - if (left.prerelease.length === 0) return 1; - if (right.prerelease.length === 0) return -1; - - const length = Math.max(left.prerelease.length, right.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = left.prerelease[index]; - const rightIdentifier = right.prerelease[index]; - if (leftIdentifier === undefined) return -1; - if (rightIdentifier === undefined) return 1; - if (leftIdentifier === rightIdentifier) continue; - if (typeof leftIdentifier === "number" && typeof rightIdentifier === "number") { - return leftIdentifier < rightIdentifier ? -1 : 1; - } - if (typeof leftIdentifier === "number") return -1; - if (typeof rightIdentifier === "number") return 1; - return leftIdentifier < rightIdentifier ? -1 : 1; - } - - return 0; + return compareSemverV2(left.raw, right.raw); } export function assertSemverIncrement(baseVersion, nextVersion) { @@ -72,11 +77,9 @@ export function assertSemverIncrement(baseVersion, nextVersion) { return true; } - const coreComparison = next.core.findIndex( - (identifier, index) => identifier !== base.core[index], - ); - const coreIncreases = coreComparison !== -1 - && next.core[coreComparison] > base.core[coreComparison]; + const baseCoreVersion = parseSemverV2(base.raw).core.join("."); + const nextCoreVersion = parseSemverV2(next.raw).core.join("."); + const coreIncreases = compareSemverV2(nextCoreVersion, baseCoreVersion) > 0; if (!coreIncreases) { throw new Error( `Skill core version must increase by at least a patch: base=${baseVersion}, next=${nextVersion}`, @@ -87,15 +90,26 @@ export function assertSemverIncrement(baseVersion, nextVersion) { export function nextSimulatedReleaseVersion(version) { const parsed = parseSemver(version); - const [major, minor, patch] = parsed.core; + const strict = parseSemverV2(parsed.raw); + const [major, minor, patch] = strict.core; if (parsed.prerelease.length === 0) { - return `${major}.${minor}.${patch + 1}`; + return `${major}.${minor}.${incrementDecimalString(patch)}`; } const rawPrerelease = parsed.raw.slice(parsed.raw.indexOf("-") + 1); - const numberedPrerelease = rawPrerelease.match(/^(.*?)(\d+)$/); - if (numberedPrerelease) { - return `${major}.${minor}.${patch}-${numberedPrerelease[1]}${Number(numberedPrerelease[2]) + 1}`; + let trailingDigitsStart = rawPrerelease.length; + while ( + trailingDigitsStart > 0 + && rawPrerelease[trailingDigitsStart - 1] >= "0" + && rawPrerelease[trailingDigitsStart - 1] <= "9" + ) { + trailingDigitsStart -= 1; + } + + if (trailingDigitsStart < rawPrerelease.length) { + const prefix = rawPrerelease.slice(0, trailingDigitsStart); + const digits = rawPrerelease.slice(trailingDigitsStart); + return `${major}.${minor}.${patch}-${prefix}${incrementDecimalString(digits)}`; } return `${major}.${minor}.${patch}-${rawPrerelease}1`; } diff --git a/scripts/test-skill-lifecycle-semver.mjs b/scripts/test-skill-lifecycle-semver.mjs new file mode 100644 index 00000000..8f792e8b --- /dev/null +++ b/scripts/test-skill-lifecycle-semver.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { performance } from "node:perf_hooks"; +import { + assertLifecycleBinding, + classifyLifecycleVersion, + compareSemverV2, + evaluatePublicationVersion, + formatPackageTag, + parsePackageTag, + parseSemverV2, +} from "./ci/lifecycle_semver.mjs"; +import { + assertSemverIncrement, + nextSimulatedReleaseVersion, + parseSemver as parseLegacySemver, +} from "./ci/semver_increment.mjs"; + +function readJson(relativePath) { + return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), "utf8")); +} + +const contract = readJson("../contracts/lifecycle-semver-v1.json"); +const fixtures = readJson("../contracts/fixtures/lifecycle-semver-v1.json"); + +assert.equal(contract.contract, "clawsec.lifecycle-semver/v1"); +assert.equal(contract.contract_version, 1); +assert.deepEqual(Object.keys(contract.lifecycle_stages), ["beta", "rc", "stable_intent", "stable"]); +assert.equal(contract.lifecycle_stages.stable.requires_active_signed_catalog_authorization, true); +assert.equal(contract.publication_policy.syntactic_validity_implies_publication_eligibility, false); +assert.equal(contract.publication_policy.eligibility_is_stable_authorization, false); + +for (const fixture of fixtures.valid_versions) { + assert.deepEqual(parseSemverV2(fixture.input), { + raw: fixture.input, + core: fixture.core, + prerelease: fixture.prerelease, + build: fixture.build, + }); +} + +for (const invalidVersion of fixtures.invalid_versions) { + assert.throws( + () => parseSemverV2(invalidVersion), + /Invalid SemVer 2\.0 version/, + `expected invalid SemVer: ${JSON.stringify(invalidVersion)}`, + ); +} + +for (const chain of fixtures.precedence_chains) { + for (let index = 1; index < chain.length; index += 1) { + const lower = chain[index - 1]; + const higher = chain[index]; + assert.equal(compareSemverV2(lower, higher), -1, `${lower} must precede ${higher}`); + assert.equal(compareSemverV2(higher, lower), 1, `${higher} must follow ${lower}`); + } +} + +for (const [left, right] of fixtures.equal_precedence) { + assert.equal(compareSemverV2(left, right), 0, `${left} and ${right} must have equal precedence`); + assert.equal(compareSemverV2(right, left), 0, `${right} and ${left} must have equal precedence`); +} + +for (const fixture of fixtures.package_tags.valid) { + const parsed = parsePackageTag(fixture.input); + assert.equal(parsed.raw, fixture.input); + assert.equal(parsed.packageName, fixture.package_name); + assert.equal(parsed.version, fixture.version); + assert.deepEqual(parsed.parsedVersion, parseSemverV2(fixture.version)); +} + +for (const invalidTag of fixtures.package_tags.invalid) { + assert.throws( + () => parsePackageTag(invalidTag), + /Invalid package-qualified tag/, + `expected invalid package tag: ${JSON.stringify(invalidTag)}`, + ); +} + +for (const fixture of fixtures.format_tags.valid) { + assert.equal(formatPackageTag(fixture.package_name, fixture.version), fixture.expected); +} + +for (const packageName of fixtures.format_tags.invalid_package_names) { + assert.throws(() => formatPackageTag(packageName, "1.2.3"), /Invalid package name/); +} +assert.throws(() => formatPackageTag("clawsec", "1.2"), /Invalid SemVer 2\.0 version/); + +for (const fixture of fixtures.classifications) { + assert.equal(classifyLifecycleVersion(fixture.version), fixture.expected); +} + +for (const fixture of fixtures.publication_evaluations) { + assert.deepEqual(evaluatePublicationVersion(fixture.version), { + version: fixture.version, + lifecycleClass: fixture.lifecycle_class, + publicPublicationEligible: fixture.eligible, + reasonCode: fixture.reason_code, + }); +} + +for (const binding of fixtures.lifecycle_bindings.valid) { + assert.equal(assertLifecycleBinding(binding), true); +} + +for (const fixture of fixtures.lifecycle_bindings.invalid) { + assert.throws( + () => assertLifecycleBinding(fixture.input), + new RegExp(fixture.error), + `expected invalid lifecycle binding: ${JSON.stringify(fixture.input)}`, + ); +} + +assert.throws(() => assertLifecycleBinding(), /Unknown lifecycle stage/); +assert.throws( + () => assertLifecycleBinding({ + stage: "stable", + artifactVersion: "1.0.0", + intendedVersion: "1.0.0", + activeCatalogAuthorized: "yes", + }), + /must be a boolean/, +); + +assert.deepEqual(parseLegacySemver(" 0.0.1-beta5 "), { + raw: "0.0.1-beta5", + core: [0, 0, 1], + prerelease: ["beta5"], +}); +assert.throws(() => parseLegacySemver("1.2.3+build.1"), /Invalid semantic version/); +assert.throws( + () => parseLegacySemver("1.2.3-beta.01"), + /Invalid numeric prerelease identifier with leading zero: 01/, +); +assert.equal(nextSimulatedReleaseVersion("0.0.1-beta5"), "0.0.1-beta6"); +assert.equal(nextSimulatedReleaseVersion("0.0.1-beta.5"), "0.0.1-beta.6"); +assert.equal(nextSimulatedReleaseVersion("0.0.1-preview"), "0.0.1-preview1"); +assert.equal( + nextSimulatedReleaseVersion("1.2.9007199254740992"), + "1.2.9007199254740993", +); +assert.equal( + nextSimulatedReleaseVersion("1.2.3-beta.9007199254740992"), + "1.2.3-beta.9007199254740993", +); +assert.equal( + nextSimulatedReleaseVersion(`1.2.${"9".repeat(400)}`), + `1.2.1${"0".repeat(400)}`, +); +assert.equal( + assertSemverIncrement("9007199254740992.0.0", "9007199254740993.0.0"), + true, +); + +const longMalformedVersion = `1.2.3-${"a".repeat(50_000)}_`; +const longMalformedStart = performance.now(); +assert.throws(() => parseSemverV2(longMalformedVersion), /Invalid SemVer 2\.0 version/); +const longMalformedDurationMs = performance.now() - longMalformedStart; +assert.ok( + longMalformedDurationMs < 1_000, + `long malformed SemVer must be rejected in bounded time; took ${longMalformedDurationMs}ms`, +); + +const longMalformedTag = `a${"-v".repeat(50_000)}not-a-version`; +const longMalformedTagStart = performance.now(); +assert.throws(() => parsePackageTag(longMalformedTag), /Invalid package-qualified tag/); +const longMalformedTagDurationMs = performance.now() - longMalformedTagStart; +assert.ok( + longMalformedTagDurationMs < 1_000, + `long malformed package tag must be rejected in bounded time; took ${longMalformedTagDurationMs}ms`, +); + +const longNonnumericPrerelease = `1.2.3-${"1".repeat(50_000)}a`; +const longSimulationStart = performance.now(); +assert.equal( + nextSimulatedReleaseVersion(longNonnumericPrerelease), + `${longNonnumericPrerelease}1`, +); +const longSimulationDurationMs = performance.now() - longSimulationStart; +assert.ok( + longSimulationDurationMs < 1_000, + `long prerelease simulation must complete in bounded time; took ${longSimulationDurationMs}ms`, +);