/** * Changeset-driven module versioning (ISS-0151 / openspec/changes/module-version-semantics/proposal.md). * * For `version_source: { kind: changeset }` modules, the payload version is * authored via `.changeset/*.md` files — the same on-disk format the changesets * CLI uses, but keyed by the MODULE id (Option A) rather than an npm package, so * a celilo module needs no package.json. `celilo module version` reads those * files, computes the next `manifest.yml#version`, and writes a CHANGELOG. * * This file is the pure core: parsing + semver math, no filesystem. The command * (module-version.ts) does the I/O. */ export type BumpType = 'major' | 'minor' | 'patch'; const BUMP_RANK: Record = { patch: 1, minor: 2, major: 3 }; export function isBumpType(s: string): s is BumpType { return s === 'major' || s === 'minor' || s === 'patch'; } export interface ParsedChangeset { /** module id → declared bump, from the `---` frontmatter. */ bumps: Record; /** the markdown body after the frontmatter, trimmed. */ summary: string; } /** * Parse one changeset `.md`: a YAML-ish frontmatter block fenced by `---` * mapping `"": ` (quotes optional), followed by a prose body. * * Throws on a malformed frontmatter fence or an invalid bump keyword — a * changeset that can't be understood must fail loudly, not silently no-op. */ export function parseChangeset(content: string): ParsedChangeset { const normalized = content.replace(/\r\n/g, '\n'); const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) { throw new Error('changeset has no `---` frontmatter block'); } const [, frontmatter, body] = match; const bumps: Record = {}; for (const rawLine of frontmatter.split('\n')) { const line = rawLine.trim(); if (!line) continue; const sep = line.indexOf(':'); if (sep === -1) { throw new Error(`changeset frontmatter line is not "id: bump": ${line}`); } const id = line .slice(0, sep) .trim() .replace(/^['"]|['"]$/g, ''); const bump = line .slice(sep + 1) .trim() .replace(/^['"]|['"]$/g, ''); if (!id) throw new Error(`changeset frontmatter has an empty module id: ${line}`); if (!isBumpType(bump)) { throw new Error(`changeset bump for "${id}" must be major|minor|patch, got "${bump}"`); } bumps[id] = bump; } return { bumps, summary: body.trim() }; } /** Highest-ranked bump in the list, or null if empty. */ export function maxBump(bumps: BumpType[]): BumpType | null { let best: BumpType | null = null; for (const b of bumps) { if (best === null || BUMP_RANK[b] > BUMP_RANK[best]) best = b; } return best; } /** Apply a semver bump to an `x.y.z` string. Throws if `version` isn't `x.y.z`. */ export function applyBump(version: string, bump: BumpType): string { const m = version.match(/^(\d+)\.(\d+)\.(\d+)$/); if (!m) throw new Error(`version must be x.y.z, got "${version}"`); const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]; switch (bump) { case 'major': return `${major + 1}.0.0`; case 'minor': return `${major}.${minor + 1}.0`; case 'patch': return `${major}.${minor}.${patch + 1}`; } } export interface ModuleVersionPlan { current: string; next: string; bump: BumpType; /** summaries of the changesets that target this module, for the CHANGELOG. */ entries: string[]; } /** * Compute the next version for `moduleId` from the parsed changesets. Returns * null when no changeset targets this module — "nothing to version" is a clean * no-op, not an error (e.g. a docs-only PR added a changeset for another module, * or none at all). */ export function planModuleVersion( current: string, moduleId: string, changesets: ParsedChangeset[], ): ModuleVersionPlan | null { const relevant = changesets.filter((c) => moduleId in c.bumps); if (relevant.length === 0) return null; const bump = maxBump(relevant.map((c) => c.bumps[moduleId])); // relevant is non-empty, so maxBump is non-null. if (bump === null) return null; const entries = relevant.map((c) => c.summary).filter((s) => s.length > 0); return { current, next: applyBump(current, bump), bump, entries }; } /** * Render a CHANGELOG section for a computed plan (newest-on-top convention). * The command prepends this below the file-level `# ` header. */ export function renderChangelogSection(plan: ModuleVersionPlan): string { const heading: Record = { major: 'Major Changes', minor: 'Minor Changes', patch: 'Patch Changes', }; const lines = [`## ${plan.next}`, '', `### ${heading[plan.bump]}`, '']; const bullets = plan.entries.length > 0 ? plan.entries : ['Version bump.']; for (const e of bullets) lines.push(`- ${e.replace(/\n+/g, ' ').trim()}`); lines.push(''); return lines.join('\n'); }