test(changelog): find the 6.4.0 section by its title, before and after the release commit

Two docs tests located this delivery's CHANGELOG section by the literal
heading `## Unreleased` (one also required it to be the first section).
The release commit renames it to `## v6.4.0 — <date> — <title>`
(docs/release-runbook.md § 1.4), so all 16 content pins failed on the
release commit while the content was the same.

One shared helper, tests/helpers/changelog-section.mjs, now finds the
section by its title under either heading, wherever it stands (a later
delivery's section above it does not move the pins), with a trailing \r or
spaces tolerated, and only when it stands exactly once. Every content
assertion is unchanged; their messages no longer say "Unreleased". New
tests pin the accepted headings and seven near misses (version, date,
title, the literal dot in 1.9), and the helper on a later section, a \r,
a duplicate and a missing section.

Measured: 46/46 pass against main's CHANGELOG and against the release
commit's CHANGELOG; before the change, 16 failed on the release commit.
Three mutants of the helper (a looser date, no escaping, no trim) are
each failed by one test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-17 00:05:43 +02:00
commit b79495847d
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 86 additions and 51 deletions

View file

@ -0,0 +1,30 @@
// The CHANGELOG section of the plan_version 1.9 / converging critic loop
// delivery, found by its title. Before the release its heading is
// `## Unreleased — <title>`; the release commit makes it
// `## vX.Y.Z — <date> — <title>` (docs/release-runbook.md § 1.4). The section
// is found wherever it stands, so a later delivery's section above it does not
// move the pins, and it must stand exactly once.
import assert from 'node:assert/strict';
export const SECTION_TITLE = 'plan_version 1.9, the trial-4 findings, session-clean tests, the converging critic loop';
const escaped = SECTION_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const SECTION_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${escaped}$`);
// A heading line as written, without a trailing `\r` or trailing spaces.
const bare = line => line.replace(/\s+$/, '');
// { heading, body } of the delivery's section in CHANGELOG text, up to the
// next `## ` heading. Fails unless exactly one heading matches.
export function deliverySection(text) {
const lines = text.split('\n');
const at = [];
lines.forEach((l, i) => { if (SECTION_HEADING.test(bare(l))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md has the "${SECTION_TITLE}" section exactly once (found ${at.length})`);
const start = at[0];
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) { end = i; break; }
}
return { heading: bare(lines[start]), body: lines.slice(start + 1, end).join('\n') };
}

View file

@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { SECTION_TITLE, SECTION_HEADING, deliverySection } from '../helpers/changelog-section.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf-8');
@ -285,50 +286,60 @@ test('the template\'s own example steps validate as 1.9 with none of the new pat
assert.deepEqual([...soft.errors, ...soft.warnings].map((i) => i.code).filter((c) => NEW_CODES.includes(c)), []);
});
// Step 14: the CHANGELOG's top section is the Unreleased 1.9 delivery, with door
// lines the release check accepts. The release check is imported as a module
// namespace, so a missing parseDoors export fails an assertion, not the load.
// Step 14: the CHANGELOG has the 1.9 delivery's section, with door lines the
// release check accepts. The section is found by its title, before and after the
// release commit (tests/helpers/changelog-section.mjs). The release check is
// imported as a module namespace, so a missing parseDoors export fails an
// assertion, not the load.
const releaseCheck = await import('../../scripts/release-check.mjs');
const UNRELEASED = '## Unreleased — plan_version 1.9, the trial-4 findings, session-clean tests, the converging critic loop';
function firstChangelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const start = lines.findIndex((l) => /^## /.test(l));
assert.ok(start >= 0, 'CHANGELOG.md has no "## " section');
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) { end = i; break; }
}
return { heading: lines[start], body: lines.slice(start + 1, end).join('\n') };
}
// The delivery section's body.
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
// The Unreleased section's body, after checking it is the first section.
function unreleasedBody() {
const { heading, body } = firstChangelogSection();
assert.equal(heading, UNRELEASED);
return body;
}
test('CHANGELOG: the first "## " section is the Unreleased plan_version 1.9 section', () => {
assert.equal(firstChangelogSection().heading, UNRELEASED);
test('CHANGELOG: the plan_version 1.9 delivery section stands exactly once', () => {
assert.match(deliverySection(read('CHANGELOG.md')).heading, SECTION_HEADING);
});
test('CHANGELOG: parseDoors reads the Unreleased section\'s door lines, none bad, one naming plan_version 1.9', () => {
test('CHANGELOG: the section heading is read before and after the release commit, and nothing else', () => {
assert.match(`## Unreleased — ${SECTION_TITLE}`, SECTION_HEADING);
assert.match(`## v6.4.0 — 2026-10-17 — ${SECTION_TITLE}`, SECTION_HEADING);
for (const [heading, why] of [
[`## v6.4.0 — ${SECTION_TITLE}`, 'a release heading without its date'],
[`## Unreleased — 2026-10-17 — ${SECTION_TITLE}`, 'an Unreleased heading with a date'],
[`## v6.4 — 2026-10-17 — ${SECTION_TITLE}`, 'a two-part version'],
[`## v6.4.0 — 2026-10-7 — ${SECTION_TITLE}`, 'a one-digit day'],
[`## 6.4.0 — 2026-10-17 — ${SECTION_TITLE}`, 'a version without its v'],
[`## Unreleased — ${SECTION_TITLE} and more`, 'another title'],
[`## Unreleased — ${SECTION_TITLE.replace('1.9', '1x9')}`, 'the dot in 1.9 is literal'],
]) assert.doesNotMatch(heading, SECTION_HEADING, why);
});
test('CHANGELOG: the section is found below a later section, with a trailing \\r, and exactly once', () => {
const later = `## Unreleased — the next delivery\n\n### Doors\n\n- two-way: x — y.\n\n`;
const ours = `## v6.4.0 — 2026-10-17 — ${SECTION_TITLE}\r\n\nours\n\n## v6.3.1 — 2026-10-11 — older\n\nolder\n`;
const s = deliverySection(later + ours);
assert.equal(s.heading, `## v6.4.0 — 2026-10-17 — ${SECTION_TITLE}`);
assert.equal(s.body.trim(), 'ours');
assert.throws(() => deliverySection(ours + ours), /exactly once \(found 2\)/);
assert.throws(() => deliverySection(later), /exactly once \(found 0\)/);
});
test('CHANGELOG: parseDoors reads the delivery section\'s door lines, none bad, one naming plan_version 1.9', () => {
assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
const body = unreleasedBody();
const d = releaseCheck.parseDoors(body);
assert.equal(d.found, true, 'the Unreleased section has a "### Doors" heading');
assert.equal(d.found, true, 'the delivery section has a "### Doors" heading');
assert.ok(d.entries.length > 0, 'the Doors list has door lines');
assert.deepEqual(d.bad, [], 'every top-level door line reads "- one-way: ..." or "- two-way: ..."');
assert.ok(d.entries.some((e) => e.text.includes('plan_version 1.9')),
`one door line names plan_version 1.9: ${JSON.stringify(d.entries.map((e) => e.text))}`);
});
// A `### <name>` body of the Unreleased section, up to the next heading.
// A `### <name>` body of the delivery section, up to the next heading.
const subsection = (body, name) => {
const lines = body.split('\n');
const start = lines.findIndex((l) => l.replace(/\s+$/, '') === `### ${name}`);
assert.ok(start >= 0, `the Unreleased section has a "### ${name}" heading`);
assert.ok(start >= 0, `the delivery section has a "### ${name}" heading`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^###? /.test(lines[i])) { end = i; break; }
@ -337,7 +348,7 @@ const subsection = (body, name) => {
};
const knownIssues = (body) => subsection(body, 'Known issues');
test('CHANGELOG: the Unreleased section\'s Known issues name the session split', () => {
test('CHANGELOG: the delivery section\'s Known issues name the session split', () => {
const body = unreleasedBody();
const known = knownIssues(body);
assert.match(known, /session split/, `Known issues name the session split: ${JSON.stringify(known)}`);
@ -361,7 +372,7 @@ test('CHANGELOG: the door lines name operator decisions 20 and 23, and none says
`no door line still says "every test file on the Test first File line": ${JSON.stringify(texts)}`);
});
test('CHANGELOG: the Unreleased section no longer says "on a severity tie"', () => {
test('CHANGELOG: the delivery section no longer says "on a severity tie"', () => {
const body = unreleasedBody();
assert.doesNotMatch(body, /on\s+a\s+severity\s+tie/);
});
@ -416,9 +427,9 @@ test('CHANGELOG: door lines name operator decisions 29, 30 and 31, none bad, and
test('CHANGELOG: the section names 08deae68, edf2ec56 and operator decision 27', () => {
const body = unreleasedBody();
assert.ok(body.includes('08deae68'), 'the Unreleased section names 08deae68');
assert.ok(body.includes('edf2ec56'), 'the Unreleased section names edf2ec56');
assert.match(body, /\boperator decision 27\b/, 'the Unreleased section names operator decision 27');
assert.ok(body.includes('08deae68'), 'the delivery section names 08deae68');
assert.ok(body.includes('edf2ec56'), 'the delivery section names edf2ec56');
assert.match(body, /\boperator decision 27\b/, 'the delivery section names operator decision 27');
});
test('CHANGELOG: the b4fa1927 bullet follows operator decision 29', () => {
@ -470,11 +481,11 @@ test('CHANGELOG: the cf241526 bullet follows operator decision 30', () => {
assert.doesNotMatch(b, /as\s+decision\s+20\s+sets\s+them/, `the cf241526 bullet no longer says "as decision 20 sets them": ${JSON.stringify(b)}`);
});
test('CHANGELOG: the old rerun wording is gone from the Unreleased section', () => {
test('CHANGELOG: the old rerun wording is gone from the delivery section', () => {
const body = unreleasedBody();
const flat = oneSpace(body);
for (const phrase of ['reruns red proof once with `plan_tests`', 'reruns the same way', 'the list red proof accepted']) {
assert.ok(!flat.includes(phrase), `the Unreleased section no longer says "${phrase}"`);
assert.ok(!flat.includes(phrase), `the delivery section no longer says "${phrase}"`);
}
});

View file

@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { RULE_A, RULE_B, DIFF_FIELDS } from '../../lib/review/critic-diff.mjs';
import { deliverySection } from '../helpers/changelog-section.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
@ -68,29 +69,22 @@ test('Phase 9 no longer says the loop ends on a round with no BLOCKER', () => {
});
// The narrow round of the A/B review (operator decisions 48 and 50): the
// CHANGELOG's open Unreleased section records it, Phase 9 says what a merge
// keeps, and Handover 4 states the matcher's overlap rule and the distinct id.
// The release check is imported as a module namespace, so a missing parseDoors
// export fails an assertion, not the load.
// CHANGELOG's delivery section records it, Phase 9 says what a merge keeps, and
// Handover 4 states the matcher's overlap rule and the distinct id. The section
// is found by its title, before and after the release commit
// (tests/helpers/changelog-section.mjs). The release check is imported as a
// module namespace, so a missing parseDoors export fails an assertion, not the
// load.
const releaseCheck = await import('../../scripts/release-check.mjs');
// The body of the open `## Unreleased` section, up to the next `## ` heading.
function unreleasedBody() {
const lines = read('CHANGELOG.md').split('\n');
const start = lines.findIndex(l => /^## Unreleased\b/.test(l));
assert.ok(start >= 0, 'CHANGELOG.md has a "## Unreleased" section');
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) { end = i; break; }
}
return lines.slice(start + 1, end).join('\n');
}
// The delivery section's body, up to the next `## ` heading.
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
// A `### <name>` body of the Unreleased section, up to the next heading.
// A `### <name>` body of the delivery section, up to the next heading.
function subsection(body, name) {
const lines = body.split('\n');
const start = lines.findIndex(l => l.replace(/\s+$/, '') === `### ${name}`);
assert.ok(start >= 0, `the Unreleased section has a "### ${name}" heading`);
assert.ok(start >= 0, `the delivery section has a "### ${name}" heading`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^###? /.test(lines[i])) { end = i; break; }
@ -113,7 +107,7 @@ test('CHANGELOG: the narrow round of operator decisions 48 and 50', () => {
assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
const body = unreleasedBody();
const d = releaseCheck.parseDoors(body);
assert.equal(d.found, true, 'the Unreleased section has a "### Doors" heading');
assert.equal(d.found, true, 'the delivery section has a "### Doors" heading');
assert.deepEqual(d.bad, [], 'every top-level door line reads "- one-way: ..." or "- two-way: ..."');
const doors48 = d.entries.filter(e => /operator\s+decision\s+48\b/.test(flat(e.text)));
assert.ok(doors48.length >= 3,