chore: updated changelogs + remove summarization marker

This commit is contained in:
can1357
2026-07-22 23:59:32 +02:00
parent 242bcbef8c
commit 7d404686c2
12 changed files with 84 additions and 224 deletions
+1 -54
View File
@@ -3,15 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $ } from "bun";
import {
collectPromotableAddedItemLines,
fixChangelogContent,
parseChangelog,
parseItems,
recordSummarizedItemFingerprints,
renderChangelog,
runChangelogFixer,
} from "./fix-changelogs";
import { collectPromotableAddedItemLines, fixChangelogContent, runChangelogFixer } from "./fix-changelogs";
describe("collectPromotableAddedItemLines", () => {
it("keeps new changelog item additions while ignoring moves and edits", () => {
@@ -163,51 +155,6 @@ describe("fixChangelogContent", () => {
);
});
it("drops stale source bullets after summarization changes their text", () => {
const sourceItem =
"- Fixed isolated `task` subagents mutating the parent checkout and stacking parallel task branches ([#6003](https://github.com/can1357/oh-my-pi/issues/6003)).";
const document = parseChangelog(
["# Changelog", "", "## [Unreleased]", "", "### Fixed", "", sourceItem, ""].join("\n"),
);
const unreleased = document.sections.find(section => section.title === "Unreleased");
if (!unreleased) throw new Error("fixture is missing Unreleased");
const sourceItems = unreleased.subsections.flatMap(sub => parseItems(sub.lines));
const summarizedItem =
"- Fixed isolated `task` subagents mutating the parent checkout by detaching the git directory.";
unreleased.subsections = [{ title: "Fixed", lines: [{ text: summarizedItem, lineNumber: 0 }] }];
recordSummarizedItemFingerprints(document, sourceItems, unreleased);
const staleMerge = renderChangelog(document).replace(summarizedItem, `${summarizedItem}\n${sourceItem}`);
const result = fixChangelogContent(staleMerge, new Set());
expect(result.droppedReleasedDuplicates).toBe(1);
expect(result.content).toContain(summarizedItem);
expect(result.content).not.toContain(sourceItem);
});
it("keeps bullets the rewrite preserved verbatim instead of dropping them as consumed", () => {
const keptItem =
"- Fixed crash when opening the empty settings panel ([#1234](https://github.com/can1357/oh-my-pi/issues/1234)).";
const document = parseChangelog(
["# Changelog", "", "## [Unreleased]", "", "### Fixed", "", keptItem, ""].join("\n"),
);
const unreleased = document.sections.find(section => section.title === "Unreleased");
if (!unreleased) throw new Error("fixture is missing Unreleased");
const sourceItems = unreleased.subsections.flatMap(sub => parseItems(sub.lines));
// The model returned this bullet unchanged; applyRewrite writes it back.
unreleased.subsections = [{ title: "Fixed", lines: [{ text: keptItem, lineNumber: 0 }] }];
recordSummarizedItemFingerprints(document, sourceItems, unreleased);
const afterRewrite = renderChangelog(document);
expect(afterRewrite).not.toContain("changelog-source-item");
const result = fixChangelogContent(afterRewrite, new Set());
expect(result.droppedReleasedDuplicates).toBe(0);
expect(result.content).toContain(keptItem);
});
it("can recover Unreleased by dropping bullets known to be historically released", () => {
const content = [
"# Changelog",
+10 -67
View File
@@ -7,7 +7,6 @@ const CHANGELOG_GLOB = "packages/*/CHANGELOG.md";
const ORDERED_SECTION_TITLES = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const;
const CHANGELOG_BASELINE_REF = "refs/clog";
const CHANGELOG_BASELINE_NAME = "clog";
const SUMMARIZED_ITEM_MARKER = /^<!-- changelog-source-item sha256:([0-9a-f]{64}) -->$/;
export interface NumberedLine {
text: string;
@@ -278,58 +277,6 @@ function itemTextKey(itemLines: readonly string[]): string {
return trimBlankLines(itemLines).join("\n");
}
function itemFingerprint(itemLines: readonly string[]): string {
const text = itemTextKey(itemLines);
if (!text) return "";
return new Bun.CryptoHasher("sha256").update(text).digest("hex");
}
function collectRecordedItemFingerprints(lines: readonly NumberedLine[], keys: Set<string>): void {
for (const line of lines) {
const fingerprint = line.text.match(SUMMARIZED_ITEM_MARKER)?.[1];
if (fingerprint) keys.add(fingerprint);
}
}
function addItemKeys(keys: Set<string>, itemLines: readonly string[]): void {
const text = itemTextKey(itemLines);
if (text) keys.add(text);
const fingerprint = itemFingerprint(itemLines);
if (fingerprint) keys.add(fingerprint);
}
/**
* Records fingerprints for source items consumed by summarization. Called
* after the rewrite is applied: items whose exact text survives in the
* rewritten section are skipped — recording those would make the next fixer
* run treat the still-valid Unreleased bullet as already consumed and drop it.
*/
export function recordSummarizedItemFingerprints(
document: ChangelogDocument,
sourceItems: readonly ParsedItem[],
rewrittenSection: ReleaseSection,
): void {
const surviving = new Set<string>();
for (const subsection of rewrittenSection.subsections) {
for (const item of parseItems(subsection.lines)) {
const text = itemTextKey(item.lines);
if (text) surviving.add(text);
}
}
const recorded = new Set<string>();
collectRecordedItemFingerprints(document.prefixLines, recorded);
for (const item of sourceItems) {
if (surviving.has(itemTextKey(item.lines))) continue;
const fingerprint = itemFingerprint(item.lines);
if (!fingerprint || recorded.has(fingerprint)) continue;
document.prefixLines.push({
text: `<!-- changelog-source-item sha256:${fingerprint} -->`,
lineNumber: 0,
});
recorded.add(fingerprint);
}
}
function subsectionHasItem(subsection: Subsection, itemLines: readonly string[]): boolean {
const wanted = itemTextKey(itemLines);
if (!wanted) return true;
@@ -341,12 +288,12 @@ function subsectionHasItem(subsection: Subsection, itemLines: readonly string[])
function collectReleasedItemKeys(document: ChangelogDocument): Set<string> {
const keys = new Set<string>();
collectRecordedItemFingerprints(document.prefixLines, keys);
for (const section of document.sections) {
if (section.title === "Unreleased") continue;
for (const subsection of section.subsections) {
for (const item of parseItems(subsection.lines)) {
addItemKeys(keys, item.lines);
const key = itemTextKey(item.lines);
if (key) keys.add(key);
}
}
}
@@ -354,10 +301,11 @@ function collectReleasedItemKeys(document: ChangelogDocument): Set<string> {
}
/**
* Drops items from [Unreleased] that already appeared in a released section or
* were consumed by changelog summarization. Summarization records source
* fingerprints before replacing the text, so stale branches cannot reintroduce
* the original wording. Runs before promotion and only mutates Unreleased.
* 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,
@@ -371,11 +319,7 @@ function dropUnreleasedDuplicatesOfReleased(
let dropped = 0;
for (const subsection of unreleased.subsections) {
const duplicates = parseItems(subsection.lines).filter(item => {
const text = itemTextKey(item.lines);
const fingerprint = itemFingerprint(item.lines);
return releasedKeys.has(text) || releasedKeys.has(fingerprint);
});
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));
@@ -860,12 +804,11 @@ async function collectHistoricalReleaseRecovery(
if (recovery.sectionsByTitle.get(section.title) !== undefined) {
for (const subsection of section.subsections) {
for (const item of parseItems(subsection.lines)) {
addItemKeys(recovery.itemKeys, item.lines);
recovery.itemKeys.add(itemTextKey(item.lines));
}
}
}
}
if (recovery) collectRecordedItemFingerprints(document.prefixLines, recovery.itemKeys);
}
}
@@ -986,7 +929,7 @@ function usage(): string {
"Usage: bun scripts/fix-changelogs.ts [--dry-run|--check] [--since <tag>] [--recover] [--pin]",
"",
"Moves changelog items added since the baseline from released sections into [Unreleased],",
"drops [Unreleased] items already released or consumed by changelog summarization, removes",
"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.",
"",
-3
View File
@@ -51,7 +51,6 @@ import {
parseChangelog,
parseItems,
type ReleaseSection,
recordSummarizedItemFingerprints,
renderChangelog,
resolveRepoRoot,
} from "./fix-changelogs";
@@ -335,10 +334,8 @@ async function run(options: RunOptions): Promise<RunResult> {
.replace(/^## \[Unreleased\]\n?/, "")
.trim();
const sourceItems = section.subsections.flatMap(sub => parseItems(sub.lines));
const rewritten = await requestRewrite(model, changelogPath, unreleasedBody);
applyRewrite(section, rewritten);
recordSummarizedItemFingerprints(document, sourceItems, section);
const next = renderChangelog(document);
if (next === content) continue;