#!/usr/bin/env bun import { $, Glob } from "bun"; import * as path from "node:path"; const CHANGELOG_GLOB = "packages/*/CHANGELOG.md"; const ORDERED_SECTION_TITLES = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const; interface NumberedLine { text: string; lineNumber: number; } interface Subsection { title: string; lines: NumberedLine[]; } interface ReleaseSection { heading: string; title: string; leadingLines: NumberedLine[]; subsections: Subsection[]; } interface ChangelogDocument { prefixLines: NumberedLine[]; sections: ReleaseSection[]; } interface ParsedItem { startLine: number; endLine: number; lines: string[]; } interface FixCounters { promotedItems: number; mergedDuplicateHeadings: number; removedEmptyHeadings: number; droppedReleasedDuplicates: number; } export interface FixChangelogContentResult extends FixCounters { content: string; } interface HunkRef { path: string; index: number; } interface AddedItemCandidate { path: string; lineNumber: number; text: string; hunk: HunkRef; pairedWithRemoval: boolean; } interface RemovedItemOccurrence { path: string; text: string; hunk: HunkRef; pairedWithAddition: boolean; } export interface ChangedChangelogSummary extends FixCounters { path: string; } export interface RunChangelogFixerOptions { repoRoot?: string; since?: string; write?: boolean; recover?: boolean; } export interface RunChangelogFixerResult { since: string; changedFiles: ChangedChangelogSummary[]; } interface CliOptions { mode: "write" | "dry-run" | "check"; repoRoot?: string; since?: string; recover: boolean; help: boolean; } interface HistoricalReleaseRecovery { itemKeys: Set; sectionsByTitle: Map; } function isReleaseHeading(line: string): boolean { return /^## \[[^\]]+\]/.test(line); } function isSubsectionHeading(line: string): boolean { return /^###\s+\S/.test(line); } function parseReleaseTitle(heading: string): string { const match = heading.match(/^## \[([^\]]+)\]/); return match?.[1] ?? heading.replace(/^##\s+/, "").trim(); } function parseSubsectionTitle(heading: string): string { return heading.replace(/^###\s+/, "").trim(); } function isListItemLine(line: string): boolean { return line.trimStart().startsWith("- "); } function normalizeItemText(text: string): string { return text.trim(); } function splitContentLines(content: string): string[] { const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); if (normalized.endsWith("\n")) { return normalized.slice(0, -1).split("\n"); } return normalized.split("\n"); } function createNumberedLine(text: string, lineNumber: number): NumberedLine { return { text, lineNumber }; } function parseChangelog(content: string): ChangelogDocument { const lines = splitContentLines(content); const numberedLines = lines.map((text, index) => createNumberedLine(text, index + 1)); const prefixLines: NumberedLine[] = []; const sections: ReleaseSection[] = []; let index = 0; while (index < numberedLines.length && !isReleaseHeading(numberedLines[index]?.text ?? "")) { const line = numberedLines[index]; if (line) prefixLines.push(line); index++; } while (index < numberedLines.length) { const headingLine = numberedLines[index]; if (!headingLine) break; index++; const bodyLines: NumberedLine[] = []; while (index < numberedLines.length && !isReleaseHeading(numberedLines[index]?.text ?? "")) { const line = numberedLines[index]; if (line) bodyLines.push(line); index++; } sections.push(parseReleaseSection(headingLine.text, bodyLines)); } return { prefixLines, sections }; } function parseReleaseSection(heading: string, bodyLines: readonly NumberedLine[]): ReleaseSection { const leadingLines: NumberedLine[] = []; const subsections: Subsection[] = []; let index = 0; while (index < bodyLines.length && !isSubsectionHeading(bodyLines[index]?.text ?? "")) { const line = bodyLines[index]; if (line) leadingLines.push(line); index++; } while (index < bodyLines.length) { const headingLine = bodyLines[index]; if (!headingLine) break; index++; const lines: NumberedLine[] = []; while (index < bodyLines.length && !isSubsectionHeading(bodyLines[index]?.text ?? "")) { const line = bodyLines[index]; if (line) lines.push(line); index++; } subsections.push({ title: parseSubsectionTitle(headingLine.text), lines }); } return { heading, title: parseReleaseTitle(heading), leadingLines, subsections, }; } function trimBlankLines(lines: readonly string[]): string[] { let start = 0; let end = lines.length; while (start < end && lines[start]?.trim() === "") start++; while (end > start && lines[end - 1]?.trim() === "") end--; return lines.slice(start, end); } function numberedText(lines: readonly NumberedLine[]): string[] { return lines.map(line => line.text); } function syntheticLines(lines: readonly string[]): NumberedLine[] { return lines.map(text => ({ text, lineNumber: 0 })); } function appendSubsectionLines(target: Subsection, sourceLines: readonly string[]): void { const trimmedSource = trimBlankLines(sourceLines); if (trimmedSource.length === 0) return; const existing = trimBlankLines(numberedText(target.lines)); if (existing.length === 0) { target.lines = syntheticLines(trimmedSource); return; } const lastExisting = existing[existing.length - 1] ?? ""; const firstSource = trimmedSource[0] ?? ""; const separator = isListItemLine(lastExisting) && isListItemLine(firstSource) ? [] : [""]; target.lines = syntheticLines([...existing, ...separator, ...trimmedSource]); } function parseItems(lines: readonly NumberedLine[]): ParsedItem[] { const items: ParsedItem[] = []; let index = 0; while (index < lines.length) { const line = lines[index]; if (!line || !isListItemLine(line.text)) { index++; continue; } const start = index; index++; while (index < lines.length && !isListItemLine(lines[index]?.text ?? "")) { index++; } const itemLines = lines.slice(start, index); const firstLine = itemLines[0]; const lastLine = itemLines[itemLines.length - 1]; if (firstLine && lastLine) { items.push({ startLine: firstLine.lineNumber, endLine: lastLine.lineNumber, lines: trimBlankLines(numberedText(itemLines)), }); } } return items; } function lineRangeSet(items: readonly ParsedItem[]): Set { const lines = new Set(); for (const item of items) { for (let line = item.startLine; line <= item.endLine; line++) { lines.add(line); } } return lines; } function itemTextKey(itemLines: readonly string[]): string { return trimBlankLines(itemLines).join("\n"); } function subsectionHasItem(subsection: Subsection, itemLines: readonly string[]): boolean { const wanted = itemTextKey(itemLines); if (!wanted) return true; for (const item of parseItems(subsection.lines)) { if (itemTextKey(item.lines) === wanted) return true; } return false; } function collectReleasedItemKeys(document: ChangelogDocument): Set { const keys = new Set(); for (const section of document.sections) { if (section.title === "Unreleased") continue; for (const subsection of section.subsections) { for (const item of parseItems(subsection.lines)) { const key = itemTextKey(item.lines); if (key) keys.add(key); } } } return keys; } /** * Drop items from [Unreleased] that already appear verbatim in a released * section — the residue of a release that copied [Unreleased] into the new * version section without clearing it. The released copy is authoritative, so * the Unreleased duplicate is removed. Runs before promotion (while parse line * numbers are still real) and only ever mutates the Unreleased section. */ function dropUnreleasedDuplicatesOfReleased( document: ChangelogDocument, historicalReleasedItemKeys: ReadonlySet = new Set(), ): number { const unreleased = document.sections.find(section => section.title === "Unreleased"); if (!unreleased) return 0; const releasedKeys = collectReleasedItemKeys(document); for (const key of historicalReleasedItemKeys) releasedKeys.add(key); if (releasedKeys.size === 0) return 0; let dropped = 0; for (const subsection of unreleased.subsections) { const duplicates = parseItems(subsection.lines).filter(item => releasedKeys.has(itemTextKey(item.lines))); if (duplicates.length === 0) continue; const linesToRemove = lineRangeSet(duplicates); subsection.lines = subsection.lines.filter(line => !linesToRemove.has(line.lineNumber)); dropped += duplicates.length; } return dropped; } function getOrCreateUnreleasedSection(document: ChangelogDocument): ReleaseSection { const existing = document.sections.find(section => section.title === "Unreleased"); if (existing) return existing; const section: ReleaseSection = { heading: "## [Unreleased]", title: "Unreleased", leadingLines: [], subsections: [], }; document.sections.unshift(section); return section; } function getOrCreateSubsection(section: ReleaseSection, title: string): Subsection { const existing = section.subsections.findLast(subsection => subsection.title === title); if (existing) return existing; const subsection: Subsection = { title, lines: [] }; section.subsections.push(subsection); return subsection; } function titleOrder(title: string): number { const index = ORDERED_SECTION_TITLES.indexOf(title as (typeof ORDERED_SECTION_TITLES)[number]); return index === -1 ? ORDERED_SECTION_TITLES.length : index; } function compactAdjacentListSpacing(lines: readonly string[]): string[] { const trimmedLines = trimBlankLines(lines); if (trimmedLines.length === 0) return []; const parsedItems = parseItems(syntheticLines(trimmedLines)); if (parsedItems.length === 0) return [...trimmedLines]; const flattenedItems = parsedItems.flatMap(item => item.lines); const nonBlankOriginal = trimmedLines.filter(line => line.trim() !== ""); const nonBlankFlattened = flattenedItems.filter(line => line.trim() !== ""); if ( nonBlankOriginal.length !== nonBlankFlattened.length || !nonBlankOriginal.every((line, index) => line === nonBlankFlattened[index]) ) { return [...trimmedLines]; } return flattenedItems; } function normalizeSection(section: ReleaseSection): FixCounters { const counters: FixCounters = { promotedItems: 0, mergedDuplicateHeadings: 0, removedEmptyHeadings: 0, droppedReleasedDuplicates: 0, }; const subsectionByTitle = new Map(); const normalizedSubsections: Subsection[] = []; for (const subsection of section.subsections) { const trimmedLines = compactAdjacentListSpacing(trimBlankLines(numberedText(subsection.lines))); if (trimmedLines.length === 0) { counters.removedEmptyHeadings++; continue; } const existing = subsectionByTitle.get(subsection.title); if (existing) { appendSubsectionLines(existing, trimmedLines); counters.mergedDuplicateHeadings++; continue; } const normalized: Subsection = { title: subsection.title, lines: syntheticLines(trimmedLines), }; subsectionByTitle.set(subsection.title, normalized); normalizedSubsections.push(normalized); } if (section.title === "Unreleased") { normalizedSubsections.sort((a, b) => titleOrder(a.title) - titleOrder(b.title)); } section.leadingLines = syntheticLines(trimBlankLines(numberedText(section.leadingLines))); section.subsections = normalizedSubsections; return counters; } function cloneReleaseSection(section: ReleaseSection): ReleaseSection { return { heading: section.heading, title: section.title, leadingLines: syntheticLines(trimBlankLines(numberedText(section.leadingLines))), subsections: section.subsections.map(subsection => ({ title: subsection.title, lines: syntheticLines(trimBlankLines(numberedText(subsection.lines))), })), }; } function sectionHasContent(section: ReleaseSection): boolean { if (trimBlankLines(numberedText(section.leadingLines)).length > 0) return true; return section.subsections.some(subsection => trimBlankLines(numberedText(subsection.lines)).length > 0); } function compareVersionTitlesDesc(left: string, right: string): number { const leftParts = left.split(".").map(part => Number.parseInt(part, 10)); const rightParts = right.split(".").map(part => Number.parseInt(part, 10)); const limit = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < limit; index++) { const difference = (rightParts[index] ?? 0) - (leftParts[index] ?? 0); if (difference !== 0) return difference; } return 0; } function sortReleaseSections(document: ChangelogDocument): void { const unreleasedSections = document.sections.filter(section => section.title === "Unreleased"); const releasedSections = document.sections .filter(section => section.title !== "Unreleased") .sort((left, right) => compareVersionTitlesDesc(left.title, right.title)); document.sections = [...unreleasedSections, ...releasedSections]; } function rebuildReleasedSectionsFromHistory( content: string, historicalSectionsByTitle: ReadonlyMap, ): string { if (historicalSectionsByTitle.size === 0) return content; const document = parseChangelog(content); const unreleasedSections: ReleaseSection[] = []; const releasedSections: ReleaseSection[] = []; const seenTitles = new Set(); for (const section of document.sections) { if (section.title === "Unreleased") { unreleasedSections.push(section); continue; } if (seenTitles.has(section.title)) continue; seenTitles.add(section.title); const historical = historicalSectionsByTitle.get(section.title); if (historical) { releasedSections.push({ heading: section.heading, title: section.title, leadingLines: syntheticLines(trimBlankLines(numberedText(historical.leadingLines))), subsections: historical.subsections.map(subsection => ({ title: subsection.title, lines: syntheticLines(trimBlankLines(numberedText(subsection.lines))), })), }); continue; } releasedSections.push(section); } for (const [title, section] of historicalSectionsByTitle) { if (seenTitles.has(title)) continue; releasedSections.push(cloneReleaseSection(section)); } document.sections = [...unreleasedSections, ...releasedSections]; sortReleaseSections(document); return renderChangelog(document); } function renderChangelog(document: ChangelogDocument): string { const output: string[] = []; const prefix = trimBlankLines(numberedText(document.prefixLines)); if (prefix.length > 0) { output.push(...prefix, ""); } for (const section of document.sections) { output.push(section.heading); const leading = trimBlankLines(numberedText(section.leadingLines)); if (leading.length > 0) { output.push("", ...leading); } for (const subsection of section.subsections) { const lines = trimBlankLines(numberedText(subsection.lines)); if (lines.length === 0) continue; output.push("", `### ${subsection.title}`, "", ...lines); } output.push(""); } while (output.length > 0 && output[output.length - 1] === "") { output.pop(); } return `${output.join("\n")}\n`; } export function fixChangelogContent( content: string, promotableAddedItemStartLines: ReadonlySet, historicalReleasedItemKeys: ReadonlySet = new Set(), ): FixChangelogContentResult { const document = parseChangelog(content); let unreleased = document.sections.find(section => section.title === "Unreleased"); let promotedItems = 0; const droppedReleasedDuplicates = dropUnreleasedDuplicatesOfReleased(document, historicalReleasedItemKeys); for (const section of document.sections) { if (section.title === "Unreleased") continue; for (const subsection of section.subsections) { const items = parseItems(subsection.lines).filter(item => promotableAddedItemStartLines.has(item.startLine)); if (items.length === 0) continue; const linesToRemove = lineRangeSet(items); subsection.lines = subsection.lines.filter(line => !linesToRemove.has(line.lineNumber)); unreleased ??= getOrCreateUnreleasedSection(document); const targetSubsection = getOrCreateSubsection(unreleased, subsection.title); for (const item of items) { if (!subsectionHasItem(targetSubsection, item.lines)) { appendSubsectionLines(targetSubsection, item.lines); } promotedItems++; } } } let mergedDuplicateHeadings = 0; let removedEmptyHeadings = 0; for (const section of document.sections) { const counters = normalizeSection(section); mergedDuplicateHeadings += counters.mergedDuplicateHeadings; removedEmptyHeadings += counters.removedEmptyHeadings; } sortReleaseSections(document); const renderedContent = renderChangelog(document); return { content: renderedContent, promotedItems, mergedDuplicateHeadings, removedEmptyHeadings, droppedReleasedDuplicates, }; } function hunkKey(hunk: HunkRef): string { return `${hunk.path}\0${hunk.index}`; } function isAddedReleaseHeadingLine(line: string): boolean { return line.startsWith("+## ["); } function itemKey(pathName: string, text: string): string { return `${pathName}\0${normalizeItemText(text)}`; } export function collectPromotableAddedItemLines(diffText: string): Map> { const candidates: AddedItemCandidate[] = []; const removals: RemovedItemOccurrence[] = []; const addedReleaseHeadingHunks = new Set(); let currentPath = ""; let oldLine = 0; let newLine = 0; let hunkIndex = -1; for (const rawLine of diffText.replace(/\r\n/g, "\n").split("\n")) { if (rawLine.startsWith("+++ b/")) { currentPath = rawLine.slice("+++ b/".length); continue; } if (rawLine.startsWith("diff --git ")) { currentPath = ""; hunkIndex = -1; continue; } const hunkMatch = rawLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (hunkMatch) { oldLine = Number(hunkMatch[1]); newLine = Number(hunkMatch[2]); hunkIndex++; continue; } if (!currentPath || hunkIndex < 0 || rawLine.length === 0) continue; const marker = rawLine[0]; const text = rawLine.slice(1); const hunk = { path: currentPath, index: hunkIndex }; if (marker === "+") { const hunkKeyValue = hunkKey(hunk); if (isAddedReleaseHeadingLine(rawLine)) { addedReleaseHeadingHunks.add(hunkKeyValue); } if (isListItemLine(text)) { candidates.push({ path: currentPath, lineNumber: newLine, text, hunk, pairedWithRemoval: false, }); } newLine++; continue; } if (marker === "-") { if (isListItemLine(text)) { removals.push({ path: currentPath, text, hunk, pairedWithAddition: false, }); } oldLine++; continue; } if (marker === " ") { oldLine++; newLine++; } } const removalsByItem = new Map(); for (const removal of removals) { const key = itemKey(removal.path, removal.text); const existing = removalsByItem.get(key); if (existing) { existing.push(removal); } else { removalsByItem.set(key, [removal]); } } for (const candidate of candidates) { const sameItemRemovals = removalsByItem.get(itemKey(candidate.path, candidate.text)); const matchingRemoval = sameItemRemovals?.find(removal => !removal.pairedWithAddition); if (matchingRemoval) { matchingRemoval.pairedWithAddition = true; candidate.pairedWithRemoval = true; } } const unpairedRemovalCountByHunk = new Map(); for (const removal of removals) { if (removal.pairedWithAddition) continue; const key = hunkKey(removal.hunk); unpairedRemovalCountByHunk.set(key, (unpairedRemovalCountByHunk.get(key) ?? 0) + 1); } const linesByPath = new Map>(); for (const candidate of candidates) { const key = hunkKey(candidate.hunk); if (candidate.pairedWithRemoval || addedReleaseHeadingHunks.has(key)) continue; const unpairedRemovalCount = unpairedRemovalCountByHunk.get(key) ?? 0; if (unpairedRemovalCount > 0) { unpairedRemovalCountByHunk.set(key, unpairedRemovalCount - 1); continue; } const existing = linesByPath.get(candidate.path); if (existing) { existing.add(candidate.lineNumber); } else { linesByPath.set(candidate.path, new Set([candidate.lineNumber])); } } return linesByPath; } async function git(args: readonly string[], cwd: string): Promise { const result = await $`git -c core.fsmonitor=false -c core.untrackedCache=false -c fetch.pruneTags=false ${args}` .cwd(cwd) .quiet(); return result.text(); } async function resolveRepoRoot(repoRoot: string | undefined): Promise { if (repoRoot) return path.resolve(repoRoot); return (await git(["rev-parse", "--show-toplevel"], process.cwd())).trim(); } async function latestTag(repoRoot: string): Promise { return (await git(["describe", "--tags", "--abbrev=0"], repoRoot)).trim(); } async function allTags(repoRoot: string): Promise { return (await git(["tag", "--sort=v:refname"], repoRoot)) .split("\n") .map(tag => tag.trim()) .filter(tag => tag.length > 0); } async function gitMaybe(args: readonly string[], cwd: string): Promise { const result = await $`git -c core.fsmonitor=false -c core.untrackedCache=false -c fetch.pruneTags=false ${args}` .cwd(cwd) .quiet() .nothrow(); if (result.exitCode !== 0) return undefined; return result.text(); } async function collectHistoricalReleaseRecovery( repoRoot: string, paths: readonly string[], ): Promise> { const tags = await allTags(repoRoot); const recoveryByPath = new Map(); for (const tag of tags) { for (const changelogPath of paths) { const content = await gitMaybe(["show", `${tag}:${changelogPath}`], repoRoot); if (content === undefined) continue; const document = parseChangelog(content); let recovery = recoveryByPath.get(changelogPath); for (const section of document.sections) { if (section.title === "Unreleased" || !sectionHasContent(section)) continue; if (!recovery) { recovery = { itemKeys: new Set(), sectionsByTitle: new Map() }; recoveryByPath.set(changelogPath, recovery); } if (!recovery.sectionsByTitle.has(section.title)) { recovery.sectionsByTitle.set(section.title, cloneReleaseSection(section)); } if (recovery.sectionsByTitle.get(section.title) !== undefined) { for (const subsection of section.subsections) { for (const item of parseItems(subsection.lines)) { recovery.itemKeys.add(itemTextKey(item.lines)); } } } } } } return recoveryByPath; } async function changelogPaths(repoRoot: string): Promise { const glob = new Glob(CHANGELOG_GLOB); const paths: string[] = []; for await (const changelogPath of glob.scan(repoRoot)) { paths.push(path.isAbsolute(changelogPath) ? path.relative(repoRoot, changelogPath) : changelogPath); } paths.sort(); return paths; } async function changelogDiff(repoRoot: string, since: string, paths: readonly string[]): Promise { if (paths.length === 0) return ""; return git(["diff", "--unified=0", "--no-color", "--no-ext-diff", since, "--", ...paths], repoRoot); } export async function runChangelogFixer(options: RunChangelogFixerOptions = {}): Promise { const repoRoot = await resolveRepoRoot(options.repoRoot); const since = options.since ?? (await latestTag(repoRoot)); const paths = await changelogPaths(repoRoot); const addedItemLines = options.recover ? new Map>() : collectPromotableAddedItemLines(await changelogDiff(repoRoot, since, paths)); const historicalRecoveryByPath = options.recover ? await collectHistoricalReleaseRecovery(repoRoot, paths) : new Map(); const changedFiles: ChangedChangelogSummary[] = []; for (const changelogPath of paths) { const absolutePath = path.join(repoRoot, changelogPath); const currentContent = await Bun.file(absolutePath).text(); const historicalRecovery = historicalRecoveryByPath.get(changelogPath); const recoveredContent = historicalRecovery === undefined ? currentContent : rebuildReleasedSectionsFromHistory(currentContent, historicalRecovery.sectionsByTitle); const result = fixChangelogContent( recoveredContent, addedItemLines.get(changelogPath) ?? new Set(), historicalRecovery?.itemKeys ?? new Set(), ); if (result.content === currentContent) continue; changedFiles.push({ path: changelogPath, promotedItems: result.promotedItems, mergedDuplicateHeadings: result.mergedDuplicateHeadings, droppedReleasedDuplicates: result.droppedReleasedDuplicates, removedEmptyHeadings: result.removedEmptyHeadings, }); if (options.write !== false) { await Bun.write(absolutePath, result.content); } } return { since, changedFiles }; } function parseCliArgs(args: readonly string[]): CliOptions { const options: CliOptions = { mode: "write", recover: false, help: false }; for (let index = 0; index < args.length; index++) { const arg = args[index]; switch (arg) { case "--dry-run": options.mode = "dry-run"; break; case "--check": options.mode = "check"; break; case "--recover": options.recover = true; break; case "--since": { const value = args[index + 1]; if (!value) throw new Error("--since requires a tag or commit"); options.since = value; index++; break; } case "--repo-root": { const value = args[index + 1]; if (!value) throw new Error("--repo-root requires a path"); options.repoRoot = value; index++; break; } case "-h": case "--help": options.help = true; break; default: throw new Error(`Unknown argument: ${arg}`); } } return options; } function usage(): string { return [ "Usage: bun scripts/fix-changelogs.ts [--dry-run|--check] [--since ] [--recover]", "", "Moves changelog items added since the latest tag from released sections into [Unreleased],", "drops [Unreleased] items that already appear verbatim in a released section, removes", "blank separators between adjacent bullet items, then removes duplicate or empty", "### category headings.", "", "With --recover, the fixer also scans every tagged changelog snapshot in version order", "and treats every historically released bullet as authoritative, so stale [Unreleased]", "items copied forward by past bad releases are pruned even if the current file no longer", "contains a matching released copy.", "", "Options:", " --dry-run Print what would change without writing files.", " --check Exit 1 if any changelog would change.", " --since Compare changelog additions against this tag/commit instead of latest tag.", " --recover Rebuild against the union of historically released bullets from all tags.", " --repo-root Run against an explicit repository root.", ].join("\n"); } function printSummary(result: RunChangelogFixerResult, mode: CliOptions["mode"]): void { const suffix = mode === "write" ? "" : ` (${mode}, not written)`; if (result.changedFiles.length === 0) { console.log(`Changelogs already clean since ${result.since}.`); return; } console.log(`Fixed ${result.changedFiles.length} changelog(s) since ${result.since}${suffix}:`); for (const file of result.changedFiles) { const parts = [ `${file.promotedItems} promoted item(s)`, `${file.mergedDuplicateHeadings} merged duplicate heading(s)`, `${file.droppedReleasedDuplicates} dropped released duplicate(s)`, `${file.removedEmptyHeadings} removed empty heading(s)`, ]; console.log(` ${file.path}: ${parts.join(", ")}`); } } async function main(): Promise { try { const cliOptions = parseCliArgs(process.argv.slice(2)); if (cliOptions.help) { console.log(usage()); return; } const result = await runChangelogFixer({ repoRoot: cliOptions.repoRoot, since: cliOptions.since, write: cliOptions.mode === "write", recover: cliOptions.recover, }); printSummary(result, cliOptions.mode); if (cliOptions.mode === "check" && result.changedFiles.length > 0) { process.exit(1); } } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } } if (import.meta.main) { await main(); }