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:
parent
4e28dc5079
commit
b79495847d
3 changed files with 86 additions and 51 deletions
30
tests/helpers/changelog-section.mjs
Normal file
30
tests/helpers/changelog-section.mjs
Normal 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') };
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
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 ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf-8');
|
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)), []);
|
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
|
// Step 14: the CHANGELOG has the 1.9 delivery's section, with door lines the
|
||||||
// lines the release check accepts. The release check is imported as a module
|
// release check accepts. The section is found by its title, before and after the
|
||||||
// namespace, so a missing parseDoors export fails an assertion, not the load.
|
// 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 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() {
|
// The delivery section's body.
|
||||||
const lines = read('CHANGELOG.md').split('\n');
|
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
|
||||||
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 Unreleased section's body, after checking it is the first section.
|
test('CHANGELOG: the plan_version 1.9 delivery section stands exactly once', () => {
|
||||||
function unreleasedBody() {
|
assert.match(deliverySection(read('CHANGELOG.md')).heading, SECTION_HEADING);
|
||||||
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: 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');
|
assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
|
||||||
const body = unreleasedBody();
|
const body = unreleasedBody();
|
||||||
const d = releaseCheck.parseDoors(body);
|
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.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.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')),
|
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))}`);
|
`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 subsection = (body, name) => {
|
||||||
const lines = body.split('\n');
|
const lines = body.split('\n');
|
||||||
const start = lines.findIndex((l) => l.replace(/\s+$/, '') === `### ${name}`);
|
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;
|
let end = lines.length;
|
||||||
for (let i = start + 1; i < lines.length; i++) {
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
if (/^###? /.test(lines[i])) { end = i; break; }
|
if (/^###? /.test(lines[i])) { end = i; break; }
|
||||||
|
|
@ -337,7 +348,7 @@ const subsection = (body, name) => {
|
||||||
};
|
};
|
||||||
const knownIssues = (body) => subsection(body, 'Known issues');
|
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 body = unreleasedBody();
|
||||||
const known = knownIssues(body);
|
const known = knownIssues(body);
|
||||||
assert.match(known, /session split/, `Known issues name the session split: ${JSON.stringify(known)}`);
|
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)}`);
|
`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();
|
const body = unreleasedBody();
|
||||||
assert.doesNotMatch(body, /on\s+a\s+severity\s+tie/);
|
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', () => {
|
test('CHANGELOG: the section names 08deae68, edf2ec56 and operator decision 27', () => {
|
||||||
const body = unreleasedBody();
|
const body = unreleasedBody();
|
||||||
assert.ok(body.includes('08deae68'), 'the Unreleased section names 08deae68');
|
assert.ok(body.includes('08deae68'), 'the delivery section names 08deae68');
|
||||||
assert.ok(body.includes('edf2ec56'), 'the Unreleased section names edf2ec56');
|
assert.ok(body.includes('edf2ec56'), 'the delivery section names edf2ec56');
|
||||||
assert.match(body, /\boperator decision 27\b/, 'the Unreleased section names operator decision 27');
|
assert.match(body, /\boperator decision 27\b/, 'the delivery section names operator decision 27');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CHANGELOG: the b4fa1927 bullet follows operator decision 29', () => {
|
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)}`);
|
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 body = unreleasedBody();
|
||||||
const flat = oneSpace(body);
|
const flat = oneSpace(body);
|
||||||
for (const phrase of ['reruns red proof once with `plan_tests`', 'reruns the same way', 'the list red proof accepted']) {
|
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}"`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { RULE_A, RULE_B, DIFF_FIELDS } from '../../lib/review/critic-diff.mjs';
|
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 ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
|
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
|
// 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
|
// CHANGELOG's delivery section records it, Phase 9 says what a merge keeps, and
|
||||||
// keeps, and Handover 4 states the matcher's overlap rule and the distinct id.
|
// Handover 4 states the matcher's overlap rule and the distinct id. The section
|
||||||
// The release check is imported as a module namespace, so a missing parseDoors
|
// is found by its title, before and after the release commit
|
||||||
// export fails an assertion, not the load.
|
// (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 releaseCheck = await import('../../scripts/release-check.mjs');
|
||||||
|
|
||||||
// The body of the open `## Unreleased` section, up to the next `## ` heading.
|
// The delivery section's body, up to the next `## ` heading.
|
||||||
function unreleasedBody() {
|
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
function subsection(body, name) {
|
||||||
const lines = body.split('\n');
|
const lines = body.split('\n');
|
||||||
const start = lines.findIndex(l => l.replace(/\s+$/, '') === `### ${name}`);
|
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;
|
let end = lines.length;
|
||||||
for (let i = start + 1; i < lines.length; i++) {
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
if (/^###? /.test(lines[i])) { end = i; break; }
|
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');
|
assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
|
||||||
const body = unreleasedBody();
|
const body = unreleasedBody();
|
||||||
const d = releaseCheck.parseDoors(body);
|
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: ..."');
|
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)));
|
const doors48 = d.entries.filter(e => /operator\s+decision\s+48\b/.test(flat(e.text)));
|
||||||
assert.ok(doors48.length >= 3,
|
assert.ok(doors48.length >= 3,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue