import { readJson } from "../utils/files" import type { BumpLevel, BumpOverride, ComponentDecision, ParsedReleaseIntent, ReleaseComponent, ReleasePreview, } from "./types" const RELEASE_COMPONENTS: ReleaseComponent[] = [ "cli", "compound-engineering", "coding-tutor", "marketplace", ] const FILE_COMPONENT_MAP: Array<{ component: ReleaseComponent; prefixes: string[] }> = [ { component: "cli", prefixes: ["src/", "package.json", "bun.lock", "tests/cli.test.ts"], }, { component: "compound-engineering", prefixes: ["plugins/compound-engineering/"], }, { component: "coding-tutor", prefixes: ["plugins/coding-tutor/"], }, { component: "marketplace", prefixes: [".claude-plugin/marketplace.json", ".cursor-plugin/marketplace.json"], }, ] const SCOPES_TO_COMPONENTS: Record = { cli: "cli", compound: "compound-engineering", "compound-engineering": "compound-engineering", "coding-tutor": "coding-tutor", marketplace: "marketplace", } const NON_RELEASABLE_TYPES = new Set(["docs", "chore", "test", "ci", "build", "style"]) const PATCH_TYPES = new Set(["fix", "perf", "refactor", "revert"]) type VersionSources = Record type RootPackageJson = { version: string } type PluginManifest = { version: string } type MarketplaceManifest = { metadata: { version: string } } export function parseReleaseIntent(rawTitle: string): ParsedReleaseIntent { const trimmed = rawTitle.trim() const match = /^(?[a-z]+)(?:\((?[^)]+)\))?(?!)?:\s+(?.+)$/.exec(trimmed) if (!match?.groups) { return { raw: rawTitle, type: null, scope: null, description: null, breaking: false, } } return { raw: rawTitle, type: match.groups.type ?? null, scope: match.groups.scope ?? null, description: match.groups.description ?? null, breaking: match.groups.bang === "!", } } export function inferBumpFromIntent(intent: ParsedReleaseIntent): BumpLevel | null { if (intent.breaking) return "major" if (!intent.type) return null if (intent.type === "feat") return "minor" if (PATCH_TYPES.has(intent.type)) return "patch" if (NON_RELEASABLE_TYPES.has(intent.type)) return null return null } export function detectComponentsFromFiles(files: string[]): Map { const componentFiles = new Map() for (const component of RELEASE_COMPONENTS) { componentFiles.set(component, []) } for (const file of files) { for (const mapping of FILE_COMPONENT_MAP) { if (mapping.prefixes.some((prefix) => file === prefix || file.startsWith(prefix))) { componentFiles.get(mapping.component)!.push(file) } } } for (const [component, matchedFiles] of componentFiles.entries()) { if (component === "cli" && matchedFiles.length === 0) continue if (component !== "cli" && matchedFiles.length === 0) continue } return componentFiles } export function resolveComponentWarnings( intent: ParsedReleaseIntent, detectedComponents: ReleaseComponent[], ): string[] { const warnings: string[] = [] if (!intent.type) { warnings.push("Title does not match the expected conventional format: (optional-scope): description") return warnings } if (intent.scope) { const normalized = intent.scope.trim().toLowerCase() const expected = SCOPES_TO_COMPONENTS[normalized] if (expected && detectedComponents.length > 0 && !detectedComponents.includes(expected)) { warnings.push( `Optional scope "${intent.scope}" does not match the detected component set: ${detectedComponents.join(", ")}`, ) } } if (detectedComponents.length === 0 && inferBumpFromIntent(intent) !== null) { warnings.push("No releasable component files were detected for this change") } return warnings } export function applyOverride( inferred: BumpLevel | null, override: BumpOverride, ): BumpLevel | null { if (override === "auto") return inferred return override } export function bumpVersion(version: string, bump: BumpLevel | null): string | null { if (!bump) return null const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version) if (!match) { throw new Error(`Unsupported version format: ${version}`) } const major = Number(match[1]) const minor = Number(match[2]) const patch = Number(match[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 async function loadCurrentVersions(cwd = process.cwd()): Promise { const root = await readJson(`${cwd}/package.json`) const ce = await readJson(`${cwd}/plugins/compound-engineering/.claude-plugin/plugin.json`) const codingTutor = await readJson(`${cwd}/plugins/coding-tutor/.claude-plugin/plugin.json`) const marketplace = await readJson(`${cwd}/.claude-plugin/marketplace.json`) return { cli: root.version, "compound-engineering": ce.version, "coding-tutor": codingTutor.version, marketplace: marketplace.metadata.version, } } export async function buildReleasePreview(options: { title: string files: string[] overrides?: Partial> cwd?: string }): Promise { const intent = parseReleaseIntent(options.title) const inferredBump = inferBumpFromIntent(intent) const componentFilesMap = detectComponentsFromFiles(options.files) const currentVersions = await loadCurrentVersions(options.cwd) const detectedComponents = RELEASE_COMPONENTS.filter( (component) => (componentFilesMap.get(component) ?? []).length > 0, ) const warnings = resolveComponentWarnings(intent, detectedComponents) const components: ComponentDecision[] = detectedComponents.map((component) => { const override = options.overrides?.[component] ?? "auto" const effectiveBump = applyOverride(inferredBump, override) const currentVersion = currentVersions[component] return { component, files: componentFilesMap.get(component) ?? [], currentVersion, inferredBump, effectiveBump, override, nextVersion: bumpVersion(currentVersion, effectiveBump), } }) return { intent, warnings, components, } }