mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* Ship-queue train tooling: one CHANGELOG rule, base keys stay, stacked-child carry (GSTA-129) Four train-24 (GSTA-96) defects in one change. verify --train applies the same CHANGELOG rule before and after the merge: main's CHANGELOG plus the PR's own lines (its authored diff plus what mergeChangelogs writes for it). A head the pre-merge verify accepted no longer freezes the queue after its squash merge, and a freeze record lists a slot GitHub reports MERGED as merged. restamp's stamp_paths stage leaves a *_KEY_VERSION line the base introduced after the fork point alone, base merge committed or staged, and says so. mergeChangelogs with a base carries a stacked child's edits to its released parent entry as "Correction to the <version> entry below" bullets under the child's own Gate integrity section instead of returning null; the slot one-liner prints each carried correction. #3453's two 1.91.276.0 corrections are restored that way here. The digest-freshness defect is already closed by #3451 (regeneratedAtBase); docs/ship-policy.md now says how an unstamped generator change ships. Docs: ship-policy § Run a merge train, captain.md § Merge trains and the slot-call template carry the stamp-files and stacked-child rules. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Review fixes: carried union from slot 2, GitHub-merged slot-1 freeze, no-own-entry refusal, fail-closed base diff (GSTA-129) Pre-landing review pass 1 (six specialists and the Red Team, all INFORMATIONAL): - mergeStep unions the carried child from slot 2 on, so a stacked child behind another slot never repeats its released parent's text under a second [Unreleased] (Red Team, lib/ship-queue-train.ts). - stopped() freezes the queue when any slot is MERGED on GitHub, so a slot-1 squash that fails the post-merge check no longer answers "nothing merged" (Red Team, lib/ship-queue-train-verify.ts). - ownEntryAboveMain refuses a CHANGELOG equal to main's: no entry of the PR's own (state-machines, lib/changelog-check.ts). - branchAddedLines refuses when the base diff cannot run instead of reading it as "nothing added" (Red Team, lib/restamp.ts). - slot-call.md and docs/captain.md name the stamp files as the ones the slot call names, STAMP_FILES plus a *_KEY_VERSION file the PR introduced, matching docs/ship-policy.md (maintainability). Also: the coverage tests Step 7 added (post-merge parent check, carry shapes), and two dependency-map rows (test/helpers/free-fixtures-data.ts for the merge-train-3453 fixtures, scripts/test-pr-profile.ts for captain/templates/**) so the paid-gate selector and the FREE_FIXTURES inventory accept the new files. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Review pass 2: an unreadable GitHub state at a failed slot refuses, never "nothing merged" (GSTA-129) stopped() read prState's 'unreadable' as "not merged": with nothing in t.merged, a slot-1 squash already on main while gh pr view failed answered "nothing of the train merged, so the queue is not frozen". It now refuses with QUEUE_PROOF_MISSING ("whether #<n> merged on GitHub could not be read, so whether the queue must freeze is unknown") and asks for the verify to be rerun; no freeze, no backstop. Raised independently by five pass-2 reviewers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Docs audit: troubleshooting codes and ship-policy sections match the shipped train rules (GSTA-129) The /document-release audit (edit mode) corrected two docs the PR had not reached: docs/troubleshooting.md's QUEUE_TRAIN_TREE_MISMATCH, QUEUE_PROOF_MISSING, QUEUE_TRAIN_FROZEN and POLICY_SOURCE_UNAVAILABLE entries, and docs/ship-policy.md's train build conflict rule (the carried stacked child is the exception), the verify freeze paragraph (slot-1 GitHub-MERGED freeze, unreadable-state refusal) and the restamp stamp_paths stage (base-added lines stay; an unrunnable base diff refuses). Five mid-sentence colons in the PR's own paragraphs are rewritten for the prose lint, and the CHANGELOG Docs bullet names docs/troubleshooting.md. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * HARNESS_VERSION: scripts/test-pr-profile.ts row recorded non-behavioral at version 6 (GSTA-129) The remote free tier's only red was the HARNESS_VERSION guard: scripts/test-pr-profile.ts is a pinned shared-harness file and gained the captain/templates/** FREE_ONLY_PR_FILES row. Recorded with bump-harness-version.ts --non-behavioral: no paid case reads the crew templates, so no selection, input or verdict changes and no pass-rate series resets. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: type the saved fixture PR row for the test-code ratchet (GSTA-129) CI's typecheck:test ratchet refused one new TS2698 (spread of a loosely typed fixture row) in the slot-1 freeze test; the row is read as any, as the neighbouring state writes already do. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-queue verify: build the freeze record from the one GitHub read that decided to freeze freezeQueue took a second prState read per unverified slot and filed anything but an exact MERGED under unmerged, so a slot that read MERGED at stopped() and was unreadable a moment later was recorded as unmerged (the record half of train 24's #3453 misfile). stopped() now reads each unverified slot once and passes those states in; a MERGED slot stays merged, and a slot unreadable beside a merged one is kept out of unmerged and named in the reason. The fixture's gh shim gains a per-PR vanish count so a test can change GitHub's answer between two reads. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-queue verify: say what happens to a slot unreadable beside a merged one The freeze reason, docs and CHANGELOG said the slot was kept out of unmerged 'until read', but nothing reads it again: the NEXT after the repair is built from unmerged alone. Say so (the operator seats it in a train by hand once gh answers) and pin the record shape with a test. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.282.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
379 lines
21 KiB
TypeScript
379 lines
21 KiB
TypeScript
/**
|
|
* changelog-check — CHANGELOG re-heading and checks for gstack-restamp (C2)
|
|
* and the tree receipt (C3). A restamp moves the PR's section (`[Unreleased]`
|
|
* or the version currently stamped on the branch) to the top under the new
|
|
* version, above its predecessor's entry. Duplicate version headings beyond
|
|
* those the base branch already carries, and a top entry that is not VERSION,
|
|
* are errors. The tree receipt uses
|
|
* `normalizeChangelog` to accept a heading move as a stamp, never a change.
|
|
*/
|
|
import type { ResultCodeName } from './result-codes';
|
|
|
|
export interface ChangelogSection {
|
|
/** The heading line as written. */
|
|
heading: string;
|
|
version: string | null;
|
|
unreleased: boolean;
|
|
/** Line offsets into the file (heading inclusive, end exclusive). */
|
|
start: number;
|
|
end: number;
|
|
lines: string[];
|
|
}
|
|
export interface ParsedChangelog { header: string[]; sections: ChangelogSection[] }
|
|
export interface ChangelogError { code: ResultCodeName; path: string; message: string }
|
|
|
|
const HEADING_RE = /^##\s+(\[)?(?:v)?(\d+(?:\.\d+){1,3}|Unreleased|unreleased)\]?(?:\s*[-–—]\s*(\S.*))?\s*$/;
|
|
|
|
export function headingVersion(line: string): { version: string | null; unreleased: boolean } | null {
|
|
const m = HEADING_RE.exec(line);
|
|
if (!m) return null;
|
|
const token = m[2]!;
|
|
if (token.toLowerCase() === 'unreleased') return { version: null, unreleased: true };
|
|
return { version: token, unreleased: false };
|
|
}
|
|
|
|
export function parseChangelog(text: string): ParsedChangelog {
|
|
const lines = text.split('\n');
|
|
const starts: number[] = [];
|
|
lines.forEach((l, i) => { if (headingVersion(l)) starts.push(i); });
|
|
const header = lines.slice(0, starts[0] ?? lines.length);
|
|
const sections = starts.map((start, k) => {
|
|
const end = starts[k + 1] ?? lines.length;
|
|
const hv = headingVersion(lines[start]!)!;
|
|
return { heading: lines[start]!, version: hv.version, unreleased: hv.unreleased, start, end, lines: lines.slice(start, end) };
|
|
});
|
|
return { header, sections };
|
|
}
|
|
|
|
/**
|
|
* Duplicate headings, and (when `expectedTop` is given) the top entry equals
|
|
* VERSION. `base` holds readings of the base branch's CHANGELOG (the base tip,
|
|
* and the commit the PR's own lines are counted from): a heading it already carries N
|
|
* times may appear N times here (history the PR inherited and may not
|
|
* rewrite); only an occurrence beyond that count is a duplicate the PR
|
|
* introduced. The count is per heading, not per section body: an inherited
|
|
* copy the PR edits or replaces is caught by the train's released-history
|
|
* checks (`ownEntryAboveMain`, `changelogDamage`), not here.
|
|
*/
|
|
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md', base: string[] = []): ChangelogError[] {
|
|
const errors: ChangelogError[] = [];
|
|
const parsed = parseChangelog(text);
|
|
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
|
const inherited = new Map<string, number>();
|
|
for (const reading of base) {
|
|
const counts = new Map<string, number>();
|
|
for (const s of parseChangelog(reading).sections) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
|
|
for (const [k, n] of counts) inherited.set(k, Math.max(inherited.get(k) ?? 0, n));
|
|
}
|
|
const seen = new Map<string, number[]>();
|
|
for (const s of parsed.sections) {
|
|
const k = key(s);
|
|
const starts = seen.get(k) ?? [];
|
|
const allowed = Math.max(1, inherited.get(k) ?? 0);
|
|
if (starts.length >= allowed) {
|
|
const where = allowed > 1 ? `headed ${starts.length + 1} times (the base carries it ${allowed} times; first at line ${starts[0]! + 1})` : `already headed at line ${starts[0]! + 1}`;
|
|
errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${k} ${where}` });
|
|
}
|
|
seen.set(k, [...starts, s.start]);
|
|
}
|
|
if (expectedTop) {
|
|
const top = parsed.sections[0];
|
|
if (!top) errors.push({ code: 'CHANGELOG_TOP_MISMATCH', path: file, message: `no entry; expected ${expectedTop} at the top` });
|
|
else if (top.unreleased || top.version !== expectedTop) errors.push({ code: 'CHANGELOG_TOP_MISMATCH', path: `${file}:${top.start + 1}`, message: `top entry is ${top.unreleased ? '[Unreleased]' : top.version}, VERSION is ${expectedTop}` });
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function renderHeading(like: string, version: string, date: string): string {
|
|
const bracket = /^##\s+\[/.test(like);
|
|
const vPrefix = /^##\s+\[?v\d/.test(like);
|
|
const dash = /\s+[–—]\s+/.exec(like)?.[0]?.trim() ?? '-';
|
|
const token = `${vPrefix ? 'v' : ''}${version}`;
|
|
return `## ${bracket ? `[${token}]` : token} - ${date}`.replace(' - ', ` ${dash} `);
|
|
}
|
|
|
|
export interface ReheadResult { text: string; moved: boolean; from: string; to: string; error?: ChangelogError }
|
|
|
|
/**
|
|
* Move the PR's section to the top under `to`. `from` is the version the
|
|
* branch currently carries (its own earlier stamp) or null; `[Unreleased]`
|
|
* is taken first. Idempotent: a top section already headed `to` is a no-op.
|
|
*/
|
|
export function reheadChangelog(text: string, opts: { from: string | null; to: string; date: string; file?: string }): ReheadResult {
|
|
const file = opts.file ?? 'CHANGELOG.md';
|
|
const parsed = parseChangelog(text);
|
|
const already = parsed.sections.find(s => s.version === opts.to);
|
|
if (already && parsed.sections[0] === already) return { text, moved: false, from: opts.to, to: opts.to };
|
|
const unreleased = parsed.sections.find(s => s.unreleased);
|
|
const own = opts.from ? parsed.sections.find(s => s.version === opts.from) : undefined;
|
|
const section = unreleased ?? own ?? already;
|
|
if (!section) {
|
|
return { text, moved: false, from: opts.from ?? 'Unreleased', to: opts.to, error: { code: 'CHANGELOG_SECTION_MISSING', path: file, message: `no [Unreleased]${opts.from ? ` or ${opts.from}` : ''} section` } };
|
|
}
|
|
const dup = parsed.sections.filter(s => s !== section && (s.unreleased ? section.unreleased : s.version === section.version));
|
|
if (dup.length) {
|
|
return { text, moved: false, from: section.unreleased ? 'Unreleased' : section.version!, to: opts.to, error: { code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${dup[0]!.start + 1}`, message: `${section.unreleased ? 'Unreleased' : section.version} is headed twice` } };
|
|
}
|
|
const body = section.lines.slice(1);
|
|
const newHeading = renderHeading(section.heading, opts.to, opts.date);
|
|
const rest = parsed.sections.filter(s => s !== section).flatMap(s => s.lines);
|
|
const header = [...parsed.header];
|
|
while (header.length && header[header.length - 1] === '') header.pop();
|
|
const out = [...header, ...(header.length ? [''] : []), newHeading, ...body];
|
|
const trimmed = [...out];
|
|
while (trimmed.length && trimmed[trimmed.length - 1] === '') trimmed.pop();
|
|
const joined = [...trimmed, '', ...rest].join('\n');
|
|
return { text: joined.endsWith('\n') ? joined : joined + '\n', moved: true, from: section.unreleased ? 'Unreleased' : section.version!, to: opts.to };
|
|
}
|
|
|
|
/**
|
|
* Receipt normalization: version headings are replaced by a placeholder and
|
|
* sections are compared as an order-independent multiset of lines, so a
|
|
* heading move or re-heading is a stamp and any other edited byte is a change.
|
|
*/
|
|
export function normalizeChangelog(text: string): string {
|
|
const parsed = parseChangelog(text);
|
|
const sections = parsed.sections.map(s => ['## <version>', ...s.lines.slice(1)].join('\n').replace(/\n+$/, '')).sort();
|
|
return [parsed.header.join('\n').replace(/\n+$/, ''), ...sections].join('\n\u0000');
|
|
}
|
|
|
|
/**
|
|
* A merged-in head's CHANGELOG, however its conflict was resolved: why it is
|
|
* not main's CHANGELOG with the PR's own entry above it, or null. Main's
|
|
* header and every main section must follow unchanged and in order, at least
|
|
* one section must sit above them (a CHANGELOG equal to main's has lost the
|
|
* entry the gate carried), and those sections may hold only lines the PR itself added (`authored`,
|
|
* the + lines of its pin's CHANGELOG diff), so a hand resolve can drop
|
|
* damage but never add text the train gate did not carry.
|
|
*/
|
|
export function ownEntryAboveMain(head: string, main: string, authored: ReadonlySet<string>): string | null {
|
|
const h = parseChangelog(head);
|
|
const m = parseChangelog(main);
|
|
const trim = (lines: string[]) => { const out = [...lines]; while (out.length && !out[out.length - 1]!.trim()) out.pop(); return out.join('\n'); };
|
|
if (trim(h.header) !== trim(m.header)) return 'the header differs from main';
|
|
const own = h.sections.length - m.sections.length;
|
|
if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`;
|
|
const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines));
|
|
if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`;
|
|
if (own === 0) return "it is main's CHANGELOG unchanged, with no entry of the PR's own above it";
|
|
const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased'));
|
|
const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased'));
|
|
if (clash) return `${clash.heading.trim()} is headed twice`;
|
|
const added = h.sections.slice(0, own).flatMap(s => s.lines.slice(1)).find(l => l.trim() && !authored.has(l));
|
|
return added === undefined ? null : `the PR's entry holds a line its pin never added: ${JSON.stringify(added.slice(0, 80))}`;
|
|
}
|
|
|
|
const MIN_BLOCK_CHARS = 12;
|
|
|
|
/** Paragraph blocks of a section's entry text (heading lines dropped), long enough to identify one entry. */
|
|
function entryBlocks(s: ChangelogSection): string[] {
|
|
const blocks: string[] = [];
|
|
let run: string[] = [];
|
|
const flush = () => { const text = run.join('\n').trim(); if (text.length >= MIN_BLOCK_CHARS) blocks.push(text); run = []; };
|
|
for (const line of s.lines.slice(1)) {
|
|
if (!line.trim() || line.startsWith('#')) flush();
|
|
else run.push(line);
|
|
}
|
|
flush();
|
|
return blocks;
|
|
}
|
|
|
|
/**
|
|
* Why one side of a CHANGELOG merge damaged released history, or null. A
|
|
* side damages it when it lacks a released heading the merge base carries
|
|
* (dropped, or renamed), or when a section only that side carries holds a
|
|
* paragraph of a released entry the other side carries (a released heading
|
|
* replaced, so its body reads as part of the section above it; #3345).
|
|
*/
|
|
export function changelogDamage(ours: string, theirs: string, base?: string): string | null {
|
|
const sides = [{ name: 'ours', parsed: parseChangelog(ours) }, { name: 'theirs', parsed: parseChangelog(theirs) }];
|
|
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
|
if (base !== undefined) {
|
|
const released = parseChangelog(base).sections.filter(s => !s.unreleased).map(key);
|
|
for (const side of sides) {
|
|
const has = new Set(side.parsed.sections.map(key));
|
|
const missing = released.find(v => !has.has(v));
|
|
if (missing) return `${side.name} drops released heading ${missing} that the merge base carries`;
|
|
}
|
|
}
|
|
for (const [i, side] of sides.entries()) {
|
|
const other = sides[1 - i]!.parsed.sections;
|
|
const otherKeys = new Set(other.map(key));
|
|
const releasedText = new Map<string, string>();
|
|
for (const s of other.filter(s => !s.unreleased)) for (const b of entryBlocks(s)) releasedText.set(b, s.version!);
|
|
for (const s of side.parsed.sections.filter(s => !otherKeys.has(key(s)))) {
|
|
const hit = entryBlocks(s).find(b => releasedText.has(b));
|
|
if (hit) return `${side.name}'s ${key(s)} section holds released ${releasedText.get(hit)} text (${JSON.stringify(hit.slice(0, 60))}); a released heading was dropped or renamed`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const SIMILAR = 0.5;
|
|
const GATE_INTEGRITY_RE = /^###\s+Gate integrity\s*$/;
|
|
|
|
/** The characters two lines share at their start and end (together never more than the shorter line). */
|
|
function commonEnds(a: string, b: string): { p: number; s: number } {
|
|
let p = 0;
|
|
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
|
let s = 0;
|
|
while (s < Math.min(a.length, b.length) - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
|
|
return { p, s };
|
|
}
|
|
|
|
/** Whether one line is an edit of the other: half or more of the longer one is shared at its ends, or the shorter is wholly kept (an extension). */
|
|
function isEdit(a: string, b: string): boolean {
|
|
const strip = (l: string) => l.trim().replace(/^[-*]\s+/, '');
|
|
const [x, y] = [strip(a), strip(b)];
|
|
const { p, s } = commonEnds(x, y);
|
|
const min = Math.min(x.length, y.length);
|
|
return p + s >= Math.max(x.length, y.length) * SIMILAR || (min >= 3 && p + s === min);
|
|
}
|
|
|
|
/** The fragments an edit replaced, widened to word boundaries and to at least one word on each side. */
|
|
function fragments(removed: string, added: string): { old: string; new: string } {
|
|
const { p, s } = commonEnds(removed, added);
|
|
let from = removed.slice(0, p).search(/\S*$/);
|
|
let keep = s - (/^\S*/.exec(removed.slice(removed.length - s))?.[0].length ?? 0);
|
|
const cut = () => ({ old: removed.slice(from, removed.length - keep).trim(), new: added.slice(from, added.length - keep).trim() });
|
|
let f = cut();
|
|
while ((!f.old || !f.new) && (from > 0 || keep > 0)) {
|
|
from = Math.max(0, removed.slice(0, from).search(/\S+\s*$/));
|
|
keep -= /^\s*\S*/.exec(removed.slice(removed.length - keep))?.[0].length ?? keep;
|
|
f = cut();
|
|
}
|
|
return f.old && f.new ? f : { old: removed.trim(), new: added.trim() };
|
|
}
|
|
|
|
/**
|
|
* A stacked child whose parent squash-merged and was released since the fork
|
|
* (GSTA-96, train 24: #3453 after #3434 became 1.91.276.0). `ours`'
|
|
* `[Unreleased]` still holds the parent's entry as the child forked it, maybe
|
|
* edited, while `theirs` carries that entry under a released heading the
|
|
* merge base lacks. The parent's lines leave the child's section; each line
|
|
* the child changed inside them becomes a correction bullet under the child's
|
|
* own `### Gate integrity` (the convention #3453's entry used for its parent),
|
|
* so no wording the gate carried is lost and the released entry below stays
|
|
* byte for byte main's. Null when the shape is something else: no single
|
|
* `[Unreleased]` on our side, no released entry of theirs the base lacks whose
|
|
* text our section holds, or nothing of the child's own left.
|
|
*/
|
|
export function carryReleasedParent(ours: string, theirs: string, base: string): { text: string; notes: string[] } | null {
|
|
const a = parseChangelog(ours);
|
|
const unreleased = a.sections.filter(s => s.unreleased);
|
|
if (unreleased.length !== 1) return null;
|
|
const baseKeys = new Set(parseChangelog(base).sections.map(s => s.version ?? 'Unreleased'));
|
|
const content = (l: string) => Boolean(l.trim()) && !l.startsWith('#');
|
|
let lines = unreleased[0]!.lines.slice(1);
|
|
const notes: string[] = [];
|
|
const bullets: string[] = [];
|
|
let carried = false;
|
|
for (const parent of parseChangelog(theirs).sections.filter(s => !s.unreleased && !baseKeys.has(s.version!))) {
|
|
const pl = parent.lines.slice(1);
|
|
const ownBlocks = entryBlocks({ ...unreleased[0]!, lines: ['', ...lines] });
|
|
if (!entryBlocks(parent).some(b => ownBlocks.includes(b))) continue;
|
|
carried = true;
|
|
// LCS over content lines only: blanks and sub-headings recur in both entries and would anchor wrongly.
|
|
const lcs = Array.from({ length: pl.length + 1 }, () => new Uint32Array(lines.length + 1));
|
|
for (let i = pl.length - 1; i >= 0; i--) for (let j = lines.length - 1; j >= 0; j--) {
|
|
lcs[i]![j] = pl[i] === lines[j] && content(pl[i]!) ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
|
}
|
|
const parentAt = new Set<number>();
|
|
const hunks: Array<{ removed: string[]; added: number[] }> = [{ removed: [], added: [] }];
|
|
for (let i = 0, j = 0; i < pl.length || j < lines.length;) {
|
|
if (i < pl.length && j < lines.length && pl[i] === lines[j] && content(pl[i]!)) { parentAt.add(j); i++; j++; hunks.push({ removed: [], added: [] }); }
|
|
else if (i < pl.length && (j === lines.length || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) { if (content(pl[i]!)) hunks.at(-1)!.removed.push(pl[i]!); i++; }
|
|
else { if (content(lines[j]!)) hunks.at(-1)!.added.push(j); j++; }
|
|
}
|
|
const v = parent.version!;
|
|
const strip = (l: string) => l.trim().replace(/^[-*]\s+/, '');
|
|
hunks.forEach((h, k) => {
|
|
for (const removed of h.removed) {
|
|
const best = h.added.find(j => isEdit(removed, lines[j]!));
|
|
if (best !== undefined) {
|
|
h.added.splice(h.added.indexOf(best), 1);
|
|
parentAt.add(best);
|
|
const f = fragments(removed, lines[best]!);
|
|
bullets.push(`- Correction to the ${v} entry below: "${f.old}" should read "${f.new}".`);
|
|
notes.push(`${v}: "${f.old}" -> "${f.new}"`);
|
|
} else {
|
|
bullets.push(`- Correction to the ${v} entry below: "${strip(removed)}" no longer applies.`);
|
|
notes.push(`${v}: dropped "${strip(removed).slice(0, 60)}"`);
|
|
}
|
|
}
|
|
// Lines added between two of the parent's own lines, or after its last beside an edit there, belong to its entry; the rest are the child's.
|
|
if (k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) {
|
|
parentAt.add(j);
|
|
bullets.push(`- Addition to the ${v} entry below: ${strip(lines[j]!)}`);
|
|
notes.push(`${v}: added "${strip(lines[j]!).slice(0, 60)}"`);
|
|
}
|
|
});
|
|
lines = lines.filter((_, j) => !parentAt.has(j));
|
|
}
|
|
if (!carried) return null;
|
|
// Sub-headings left without content, then runs of blank lines, then the edges.
|
|
const kept: string[] = [];
|
|
lines.forEach((l, j) => {
|
|
if (l.startsWith('#')) { const next = lines.slice(j + 1).find(x => x.trim()); if (!next || next.startsWith('#')) return; }
|
|
if (!l.trim() && (!kept.length || !kept.at(-1)!.trim())) return;
|
|
kept.push(l);
|
|
});
|
|
while (kept.length && !kept.at(-1)!.trim()) kept.pop();
|
|
if (!kept.some(content)) return null;
|
|
if (bullets.length) {
|
|
const at = kept.findIndex(l => GATE_INTEGRITY_RE.test(l));
|
|
if (at < 0) kept.push('', '### Gate integrity', ...bullets);
|
|
else {
|
|
let end = kept.findIndex((l, j) => j > at && l.startsWith('#'));
|
|
if (end < 0) end = kept.length;
|
|
while (end > at + 1 && !kept[end - 1]!.trim()) end--;
|
|
kept.splice(end, 0, ...bullets);
|
|
}
|
|
}
|
|
const section = { ...unreleased[0]!, lines: [unreleased[0]!.heading, '', ...kept, ''] };
|
|
const out = [...a.header, ...a.sections.flatMap(s => (s === unreleased[0] ? section.lines : s.lines))].join('\n');
|
|
return { text: out.endsWith('\n') ? out : out + '\n', notes };
|
|
}
|
|
|
|
/**
|
|
* Gate-ahead merge of two CHANGELOGs that conflict textually but not
|
|
* semantically: the predecessor's header and sections, with the branch's own
|
|
* sections (those the predecessor lacks, by heading) placed first. When the
|
|
* branch's top section carries the same version heading as a predecessor
|
|
* section with a different body (both took the same tentative stamp), the
|
|
* branch's entry is kept under `[Unreleased]` so the restamp re-heads it.
|
|
* Both sides carrying `[Unreleased]` is a real conflict and returns null, and
|
|
* so is damage to released history (`changelogDamage`): null means a hand
|
|
* resolve, never a merge that carries the damage through. With `base`, a
|
|
* stacked child whose parent was released since the fork is first rewritten
|
|
* by `carryReleasedParent`; `mergeChangelogsWithNotes` returns what it
|
|
* carried.
|
|
*/
|
|
export function mergeChangelogs(ours: string, theirs: string, base?: string): string | null {
|
|
return mergeChangelogsWithNotes(ours, theirs, base).text;
|
|
}
|
|
|
|
export function mergeChangelogsWithNotes(ours: string, theirs: string, base?: string): { text: string | null; notes: string[] } {
|
|
const carry = base !== undefined ? carryReleasedParent(ours, theirs, base) : null;
|
|
if (carry) ours = carry.text;
|
|
const notes = carry?.notes ?? [];
|
|
if (changelogDamage(ours, theirs, base)) return { text: null, notes };
|
|
const a = parseChangelog(ours);
|
|
const b = parseChangelog(theirs);
|
|
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
|
const entryText = (s: ChangelogSection) => s.lines.slice(1).join('\n').replace(/\n+$/, '');
|
|
const top = a.sections[0];
|
|
if (top && !top.unreleased && b.sections.some(s => key(s) === key(top) && entryText(s) !== entryText(top))) {
|
|
const heading = /^##\s+\[/.test(top.heading) ? '## [Unreleased]' : '## Unreleased';
|
|
a.sections[0] = { ...top, unreleased: true, version: null, heading, lines: [heading, ...top.lines.slice(1)] };
|
|
}
|
|
const theirKeys = new Set(b.sections.map(key));
|
|
const ownOnly = a.sections.filter(s => !theirKeys.has(key(s)));
|
|
if (a.sections.filter(s => s.unreleased).length > 1) return { text: null, notes };
|
|
if (a.sections.some(s => s.unreleased) && b.sections.some(s => s.unreleased)) return { text: null, notes };
|
|
const header = [...b.header];
|
|
while (header.length && header[header.length - 1] === '') header.pop();
|
|
const body = [...ownOnly, ...b.sections].flatMap(s => { const lines = [...s.lines]; while (lines.length && lines[lines.length - 1] === '') lines.pop(); return [...lines, '']; });
|
|
return { text: [...header, ...(header.length ? [''] : []), ...body].join('\n').replace(/\n+$/, '\n'), notes };
|
|
}
|