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>
525 lines
31 KiB
JavaScript
525 lines
31 KiB
JavaScript
// tests/lib/plan-version-1-9-docs.test.mjs
|
|
// plan_version 1.9 is the current target, and the contract docs follow it:
|
|
// Handover 4, the plan template and CLAUDE.md name 1.9 and its rule, "the file
|
|
// first on every line"; the template, /trekplan and the planning orchestrator
|
|
// say every path stands before any annotation; and the rules introduced at 1.8,
|
|
// which still bind 1.8 and 1.9 plans, say "1.8 or later" in the sentence that
|
|
// carries each of them (never "1.9", which would release a 1.8 plan from them).
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
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');
|
|
|
|
const RULE = 'file first on every line';
|
|
const ANNOTATION = 'every path stands before any annotation';
|
|
const NEW_CODES = ['PLAN_STEP_PATH_NOT_FIRST', 'PLAN_STEP_PATH_NOT_A_PATH', 'PLAN_STEP_PATH_UNTICKED'];
|
|
|
|
// Sentence boundaries: a full stop or question mark before whitespace, a blank
|
|
// line, or the start of a list item. "1.8" and ".mjs" are no boundary.
|
|
const BOUNDARY = /[.?!](?=\s)|\n[ \t>]*\n|\n[ \t>]*[-*] /g;
|
|
|
|
/**
|
|
* The sentence of `text` that holds the first match of `anchor`: from the
|
|
* boundary before the match to the first boundary after it.
|
|
*/
|
|
function sentence(text, anchor, label) {
|
|
const m = anchor.exec(text);
|
|
assert.ok(m, `${label}: anchor ${anchor} not found`);
|
|
let start = 0;
|
|
let end = text.length;
|
|
for (const b of text.matchAll(BOUNDARY)) {
|
|
const bEnd = b.index + b[0].length;
|
|
if (bEnd <= m.index) start = bEnd;
|
|
else if (b.index >= m.index + m[0].length) { end = b.index; break; }
|
|
}
|
|
return text.slice(start, end);
|
|
}
|
|
|
|
// A line that opens a list item: its indent, then `-`, `*`, `+`, `1.` or `1)`.
|
|
const LIST_ITEM_LINE = /^([ \t>]*)(?:[-*+]|\d+[.)]) /;
|
|
|
|
/**
|
|
* The list item of `text` that holds the first match of `anchor`: from the
|
|
* start of the line that opens it (`- `) to the next line that opens a list
|
|
* item at the same indent (or a smaller one, which closes its list too), a
|
|
* blank line, or the end of the text. A wrapped line of the item belongs to it.
|
|
*/
|
|
function bullet(text, anchor, label) {
|
|
const m = anchor.exec(text);
|
|
assert.ok(m, `${label}: anchor ${anchor} not found`);
|
|
const lines = text.split('\n');
|
|
let at = 0;
|
|
let i = 0;
|
|
while (i < lines.length - 1 && at + lines[i].length < m.index) { at += lines[i].length + 1; i++; }
|
|
let first = i;
|
|
while (first >= 0 && !LIST_ITEM_LINE.test(lines[first])) {
|
|
assert.ok(lines[first].trim() !== '', `${label}: the match of ${anchor} stands in no list item`);
|
|
first--;
|
|
}
|
|
assert.ok(first >= 0, `${label}: the match of ${anchor} stands in no list item`);
|
|
const indent = LIST_ITEM_LINE.exec(lines[first])[1].length;
|
|
let last = first + 1;
|
|
for (; last < lines.length; last++) {
|
|
if (lines[last].trim() === '' || /^[ \t>]*$/.test(lines[last])) break;
|
|
const item = LIST_ITEM_LINE.exec(lines[last]);
|
|
if (item && item[1].length <= indent) break;
|
|
}
|
|
return lines.slice(first, last).join('\n');
|
|
}
|
|
|
|
function holds18OrLater(text, anchor, label) {
|
|
const s = sentence(text, anchor, label);
|
|
assert.match(s, /1\.8 or later/, `${label} must say "1.8 or later" in the sentence that carries the rule: ${JSON.stringify(s)}`);
|
|
assert.doesNotMatch(s, /1\.9/, `${label} is a 1.8 rule that still binds 1.8 plans; it must not say 1.9: ${JSON.stringify(s)}`);
|
|
}
|
|
|
|
const handover4 = () => {
|
|
const c = read('docs/HANDOVER-CONTRACTS.md');
|
|
const start = c.indexOf('## Handover 4');
|
|
assert.ok(start >= 0, '## Handover 4 missing');
|
|
return c.slice(start, c.indexOf('## Handover 5', start));
|
|
};
|
|
|
|
const claudeRow = () => {
|
|
const m = /^\| `\/trekplan` \|.*$/m.exec(read('CLAUDE.md'));
|
|
assert.ok(m, 'CLAUDE.md has no /trekplan row');
|
|
return m[0];
|
|
};
|
|
|
|
test('Handover 4 names plan_version 1.9 and the "file first on every line" rule', () => {
|
|
const h4 = handover4();
|
|
assert.match(h4, /plan_version 1\.9/);
|
|
assert.ok(h4.includes(RULE), `Handover 4 must name the rule "${RULE}"`);
|
|
assert.match(h4, /`"1\.9"` \(current\), `"1\.8"` \(valid\), `"1\.7"` \(valid\)/);
|
|
});
|
|
|
|
test('the plan template declares plan_version 1.9 and states the rule', () => {
|
|
const t = read('templates/plan-template.md');
|
|
assert.ok(t.includes('`plan_version: 1.9` · `ceremony:'), 'the template header line names plan_version 1.9');
|
|
assert.ok(t.includes(RULE), `the template must state the rule "${RULE}"`);
|
|
});
|
|
|
|
test('CLAUDE.md /trekplan row names Plan 1.9 and the rule, and labels the Test first / End-to-end rules "From 1.8"', () => {
|
|
const row = claudeRow();
|
|
assert.match(row, /Plan 1\.9: the file first on every line/);
|
|
assert.match(row, /From 1\.8: `Test first:` per step \+ one `End-to-end:` step/);
|
|
assert.doesNotMatch(row, /Plan 1\.8:/);
|
|
});
|
|
|
|
test('the template, /trekplan and the planning orchestrator say every path stands before any annotation', () => {
|
|
for (const rel of ['templates/plan-template.md', 'commands/trekplan.md', 'agents/planning-orchestrator.md']) {
|
|
assert.ok(read(rel).includes(ANNOTATION), `${rel} must say "${ANNOTATION}"`);
|
|
}
|
|
});
|
|
|
|
test('the plan template: the Test first and End-to-end rules say "1.8 or later" in their own sentence', () => {
|
|
const t = read('templates/plan-template.md');
|
|
holds18OrLater(t, /Every step has\s+`Test first:`/, 'template Test first sentence');
|
|
holds18OrLater(t, /without an\s+`End-to-end:`\s+step is invalid/, 'template End-to-end sentence');
|
|
});
|
|
|
|
test('plan-critic: the Test first check says "1.8 or later" in its own sentence', () => {
|
|
holds18OrLater(read('agents/plan-critic.md'), /does every step's\s+`Test first:`\s+name a test/, 'plan-critic Test first check');
|
|
});
|
|
|
|
test('design template: the opening line and the --strict line say "1.8 or later"', () => {
|
|
const d = read('templates/design-template.md');
|
|
holds18OrLater(d, /The design part of a plan/, 'design template opening line');
|
|
holds18OrLater(d, /`plan-validator --strict` checks/, 'design template --strict line');
|
|
});
|
|
|
|
test('HANDOVER-CONTRACTS: the design-part line and invariant 5 say "1.8 or later"; invariant 7 reads the Test first File line', () => {
|
|
// The design-part line stands in Handover 3 (it tells the external slot from
|
|
// the plan's own design); invariants 5 and 7 stand in Handover 4.
|
|
holds18OrLater(read('docs/HANDOVER-CONTRACTS.md'), /A plan's own design/, 'HANDOVER-CONTRACTS design-part line');
|
|
const h4 = handover4();
|
|
holds18OrLater(h4, /\(1\.7 plans are not held to these\)/, 'Handover 4 invariant 5');
|
|
const bullet = sentence(h4, /\*\*A step of only tests\*\*/, 'Handover 4 invariant 7 tests-only bullet');
|
|
assert.ok(bullet.includes('a file on its `Test first:` `File:` line'),
|
|
`invariant 7 must say "a file on its \`Test first:\` \`File:\` line": ${JSON.stringify(bullet)}`);
|
|
assert.ok(!bullet.includes('its `Test first:` file,'), 'invariant 7 no longer says "its `Test first:` file"');
|
|
});
|
|
|
|
const trekplan19Block = () => {
|
|
const p = read('commands/trekplan.md');
|
|
const h19 = p.indexOf('**plan_version 1.9 additions**');
|
|
assert.ok(h19 >= 0, 'commands/trekplan.md has the "**plan_version 1.9 additions**" block');
|
|
const end = p.indexOf('\n>\n> **', h19);
|
|
return p.slice(h19, end < 0 ? undefined : end);
|
|
};
|
|
|
|
test('/trekplan: the 1.8 additions heading says "1.8 or later", and a separate 1.9 additions block follows it', () => {
|
|
const p = read('commands/trekplan.md');
|
|
const h18 = p.indexOf('**plan_version 1.8 or later additions**');
|
|
assert.ok(h18 >= 0, 'the heading reads "**plan_version 1.8 or later additions**"');
|
|
assert.ok(!p.includes('**plan_version 1.8 additions**'), 'the old 1.8-only heading is gone');
|
|
const h19 = p.indexOf('**plan_version 1.9 additions**');
|
|
assert.ok(h19 > h18, 'a separate "**plan_version 1.9 additions**" block stands after the 1.8 block');
|
|
assert.ok(trekplan19Block().includes(ANNOTATION), `the 1.9 block says "${ANNOTATION}"`);
|
|
});
|
|
|
|
// Step 12: the docs follow the 1.9 fixes. Operator decision 20 (12.10): the
|
|
// `Test first:` files are the backticked paths right after `File:`, and the
|
|
// red proof checks `--tests` against them (`tests-mismatch`). The 1.9 `File:`
|
|
// line takes the numbered markers, a wrapped line joins it, a list item ends
|
|
// it. Operator decision 22 (13.10): a backticked placeholder or a mixed-case
|
|
// bare name is no path (`./Name` names such a file); the bare word none,
|
|
// unticked, stays PLAN_STEP_PATH_NOT_FIRST.
|
|
const BOTH_NONES = ['the bare word none', 'a backticked placeholder'];
|
|
|
|
function holdsBothNones(text, label) {
|
|
for (const phrase of BOTH_NONES) {
|
|
assert.ok(text.includes(phrase), `${label} must say "${phrase}": ${JSON.stringify(text)}`);
|
|
}
|
|
}
|
|
|
|
const invariant7 = () => {
|
|
const h4 = handover4();
|
|
const m7 = /^7\. \*\*Every plan_version/m.exec(h4);
|
|
assert.ok(m7, 'Handover 4 has invariant 7');
|
|
const m8 = /^8\. \*\*/m.exec(h4.slice(m7.index));
|
|
return h4.slice(m7.index, m8 ? m7.index + m8.index : undefined);
|
|
};
|
|
|
|
const template19Paragraph = () => {
|
|
const t = read('templates/plan-template.md');
|
|
const start = t.indexOf('From plan_version 1.9 the rule is the file first on every line');
|
|
assert.ok(start >= 0, 'the template has its 1.9 paragraph');
|
|
const end = t.indexOf('\n### Step 1:', start);
|
|
return t.slice(start, end < 0 ? undefined : end);
|
|
};
|
|
|
|
test('Handover 4 invariant 7 names operator decision 20 and the red proof\'s tests-mismatch', () => {
|
|
const inv7 = invariant7();
|
|
assert.match(inv7, /operator decision 20\b/, `invariant 7 names operator decision 20: ${JSON.stringify(inv7)}`);
|
|
assert.ok(inv7.includes('`tests-mismatch`'), `invariant 7 names \`tests-mismatch\`: ${JSON.stringify(inv7)}`);
|
|
});
|
|
|
|
test('Handover 4 invariant 8, the Test first bullet: numbered markers, the wrapped line, a list item ends the entry', () => {
|
|
const b = bullet(handover4(), /A line counts only when/, 'invariant 8 Test first bullet');
|
|
assert.ok(b.includes('`1.`') && b.includes('`1)`'), `the Test first bullet names the \`1.\` and \`1)\` markers: ${JSON.stringify(b)}`);
|
|
assert.match(b, /wrapped/, `the Test first bullet names the wrapped line: ${JSON.stringify(b)}`);
|
|
assert.match(b, /any list item\b[^;]*\bends\b/, `the Test first bullet says any list item ends the entry: ${JSON.stringify(b)}`);
|
|
});
|
|
|
|
test('Handover 4 invariant 8, the refusal bullet: operator decision 22 with PLAN_STEP_PATH_NOT_A_PATH, and both nones', () => {
|
|
const b = bullet(handover4(), /an entry that does not open with a backticked span/, 'invariant 8 refusal bullet');
|
|
const s = sentence(b, /operator decision 22\b/, 'invariant 8 refusal bullet, decision 22');
|
|
assert.ok(s.includes('`PLAN_STEP_PATH_NOT_A_PATH`'), `operator decision 22 stands with \`PLAN_STEP_PATH_NOT_A_PATH\`: ${JSON.stringify(s)}`);
|
|
holdsBothNones(b, 'the invariant 8 refusal bullet');
|
|
});
|
|
|
|
test('Handover 4 code table: the PLAN_STEP_PATH_NOT_A_PATH row names placeholders and mixed-case bare names', () => {
|
|
const m = /^\| `PLAN_STEP_PATH_NOT_A_PATH` \|.*$/m.exec(handover4());
|
|
assert.ok(m, 'Handover 4 has a PLAN_STEP_PATH_NOT_A_PATH row');
|
|
assert.match(m[0], /placeholder/, `the row names placeholders: ${JSON.stringify(m[0])}`);
|
|
assert.match(m[0], /mixed-case/, `the row names mixed-case bare names: ${JSON.stringify(m[0])}`);
|
|
});
|
|
|
|
// Second remediation, Step 8: the docs follow operator decisions 29 to 31
|
|
// (14.10). Decision 29: in 1.9 a wrapped line keeps its paths only when the
|
|
// entry so far ends with a comma; otherwise it is description, and a `File:`
|
|
// line with nothing after it takes its first wrapped line. Decision 31: below
|
|
// 1.9 the run of test files goes on past a note in brackets and the words
|
|
// and, plus and +. Decision 30: red proof reads the list from the plan, the
|
|
// documented call passes no `--tests`, and an unreadable plan is
|
|
// `plan-unreadable`.
|
|
|
|
// A blockquote's text without its `> ` markers, so a phrase wrapped across two
|
|
// quoted lines reads as one.
|
|
const unquote = (text) => text.replace(/\n>[ \t]?/g, '\n');
|
|
|
|
test('Handover 4 invariant 8, the Files bullet: operator decision 29, "ends with a comma", "takes its first wrapped line"', () => {
|
|
const b = bullet(handover4(), /the entries of a step's `Files:` field/, 'invariant 8 Files bullet');
|
|
assert.match(b, /\boperator decision 29\b/, `the Files bullet names operator decision 29: ${JSON.stringify(b)}`);
|
|
assert.match(b, /ends\s+with\s+a\s+comma/, `the Files bullet says a wrapped line keeps its paths when the entry ends with a comma: ${JSON.stringify(b)}`);
|
|
assert.match(b, /takes\s+its\s+first\s+wrapped\s+line/, `the Files bullet says an empty entry takes its first wrapped line: ${JSON.stringify(b)}`);
|
|
});
|
|
|
|
test('Handover 4 invariant 7 names operator decisions 30 and 31, plan-unreadable and plus', () => {
|
|
const inv7 = invariant7();
|
|
assert.match(inv7, /\boperator decision 30\b/, `invariant 7 names operator decision 30: ${JSON.stringify(inv7)}`);
|
|
assert.match(inv7, /\boperator decision 31\b/, `invariant 7 names operator decision 31: ${JSON.stringify(inv7)}`);
|
|
assert.ok(inv7.includes('plan-unreadable'), `invariant 7 names \`plan-unreadable\`: ${JSON.stringify(inv7)}`);
|
|
assert.match(inv7, /`plus`/, `invariant 7 names the word \`plus\`: ${JSON.stringify(inv7)}`);
|
|
});
|
|
|
|
test('Handover 4 invariant 7: "This holds at every plan_version" comes before "Below plan_version 1.9"', () => {
|
|
const inv7 = invariant7();
|
|
const every = inv7.indexOf('This holds at every plan_version');
|
|
const below = inv7.indexOf('Below plan_version 1.9');
|
|
assert.ok(every >= 0, `invariant 7 says "This holds at every plan_version": ${JSON.stringify(inv7)}`);
|
|
assert.ok(below >= 0, `invariant 7 says "Below plan_version 1.9": ${JSON.stringify(inv7)}`);
|
|
assert.ok(every < below, 'the every-plan_version sentence stands before the below-1.9 sentence');
|
|
});
|
|
|
|
for (const [label, slice] of [['the /trekplan 1.9 block', trekplan19Block], ['the template 1.9 paragraph', template19Paragraph]]) {
|
|
test(`${label} follows operator decision 29: a wrapped line is description unless the entry ends with a comma`, () => {
|
|
const text = unquote(slice());
|
|
assert.match(text, /entry\s+so\s+far\s+does\s+not\s+end\s+with\s+a\s+comma[^.]*description/,
|
|
`${label} says a wrapped line is description once the entry so far does not end with a comma: ${JSON.stringify(text)}`);
|
|
assert.match(text, /takes\s+its\s+first\s+wrapped\s+line/,
|
|
`${label} says a \`File:\` line with nothing after it takes its first wrapped line: ${JSON.stringify(text)}`);
|
|
});
|
|
|
|
test(`${label} names the placeholders, \`./Name\` and both nones`, () => {
|
|
const text = slice();
|
|
assert.ok(text.includes('`TBD`') && text.includes('`N/A`'), `${label} names the placeholders (\`TBD\`, \`N/A\`): ${JSON.stringify(text)}`);
|
|
assert.ok(text.includes('`./Name`'), `${label} names \`./Name\`: ${JSON.stringify(text)}`);
|
|
holdsBothNones(text, label);
|
|
});
|
|
}
|
|
|
|
test('the template\'s own example steps validate as 1.9 with none of the new path codes', () => {
|
|
const r = validatePlanContent(read('templates/plan-template.md'));
|
|
assert.equal(r.parsed.planVersion, '1.9');
|
|
const found = [...r.errors, ...r.warnings].map((i) => i.code).filter((c) => NEW_CODES.includes(c));
|
|
assert.deepEqual(found, [], JSON.stringify([...r.errors, ...r.warnings].filter((i) => NEW_CODES.includes(i.code)), null, 2));
|
|
const soft = validatePlanContent(read('templates/plan-template.md'), { strict: false });
|
|
assert.deepEqual([...soft.errors, ...soft.warnings].map((i) => i.code).filter((c) => NEW_CODES.includes(c)), []);
|
|
});
|
|
|
|
// 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');
|
|
|
|
// The delivery section's body.
|
|
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
|
|
|
|
test('CHANGELOG: the plan_version 1.9 delivery section stands exactly once', () => {
|
|
assert.match(deliverySection(read('CHANGELOG.md')).heading, SECTION_HEADING);
|
|
});
|
|
|
|
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 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 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 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; }
|
|
}
|
|
return lines.slice(start + 1, end).join('\n');
|
|
};
|
|
const knownIssues = (body) => subsection(body, 'Known issues');
|
|
|
|
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)}`);
|
|
});
|
|
|
|
// Step 14 of the remediation: the CHANGELOG records the review fixes. The door
|
|
// line for the Test first files names operator decision 20 (the backticked paths
|
|
// right after File:), the workflow put-back names operator decision 23, the
|
|
// engine-provenance entry states the tier rank instead of "on a severity tie",
|
|
// Known issues name the default path's verdict gate (operator decision 25), and
|
|
// the intro's measurement names the second remediation's Step 8 commit. Word
|
|
// boundaries keep "operator decision 2026-10-12" from counting as decision 20.
|
|
test('CHANGELOG: the door lines name operator decisions 20 and 23, and none says "every test file on the Test first File line"', () => {
|
|
const body = unreleasedBody();
|
|
const texts = releaseCheck.parseDoors(body).entries.map((e) => e.text);
|
|
assert.ok(texts.some((t) => /\boperator decision 20\b/.test(t)),
|
|
`a door line names operator decision 20: ${JSON.stringify(texts)}`);
|
|
assert.ok(texts.some((t) => /\boperator decision 23\b/.test(t)),
|
|
`a door line names operator decision 23: ${JSON.stringify(texts)}`);
|
|
assert.ok(!texts.some((t) => /every test file on the Test first File line/.test(t)),
|
|
`no door line still says "every test file on the Test first File line": ${JSON.stringify(texts)}`);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
|
|
test('CHANGELOG: Known issues name the verdict gate, the engine payloads and operator decision 25', () => {
|
|
const body = unreleasedBody();
|
|
const known = knownIssues(body);
|
|
assert.match(known, /verdict\s+gate/, `Known issues name the verdict gate: ${JSON.stringify(known)}`);
|
|
assert.match(known, /engine\s+payloads/, `Known issues name the engine payloads: ${JSON.stringify(known)}`);
|
|
assert.match(known, /\boperator\s+decision\s+25\b/, `Known issues name operator decision 25: ${JSON.stringify(known)}`);
|
|
});
|
|
|
|
test('CHANGELOG: the intro names the third remediation\'s Step 2 commit with its measured suite, and no longer acd0e3d, 5b94952 or the Step 13 commit', () => {
|
|
const body = unreleasedBody();
|
|
const firstSub = body.search(/^### /m);
|
|
const intro = firstSub < 0 ? body : body.slice(0, firstSub);
|
|
assert.ok(!intro.includes('acd0e3d'), `the intro no longer names acd0e3d: ${JSON.stringify(intro)}`);
|
|
assert.ok(!intro.includes('5b94952'), `the intro no longer names 5b94952: ${JSON.stringify(intro)}`);
|
|
assert.doesNotMatch(intro, /the\s+remediation's\s+Step\s+13\s+commit/,
|
|
`the intro no longer names "the remediation's Step 13 commit": ${JSON.stringify(intro)}`);
|
|
assert.match(intro, /the\s+third\s+remediation's\s+Step\s+2\s+commit/,
|
|
`the intro names "the third remediation's Step 2 commit": ${JSON.stringify(intro)}`);
|
|
assert.doesNotMatch(intro, /the\s+second\s+remediation's\s+Step\s+8\s+commit/,
|
|
`the intro no longer names "the second remediation's Step 8 commit": ${JSON.stringify(intro)}`);
|
|
// Measured by `npm test` at 829da48, the third remediation's Step 2 commit.
|
|
assert.match(intro, /Suite\s+2517\s+\(2515\s+pass,\s+0\s+fail,\s+2\s+skipped\)/,
|
|
`the intro names the measured suite "Suite 2517 (2515 pass, 0 fail, 2 skipped)": ${JSON.stringify(intro)}`);
|
|
assert.ok(intro.includes('(`829da48`)'), `the intro names the backticked short sha 829da48: ${JSON.stringify(intro)}`);
|
|
});
|
|
|
|
// Second remediation, Step 9: the CHANGELOG records round 2. Door lines name
|
|
// operator decisions 29, 30 and 31; the Fixed bullets that name `b4fa1927`,
|
|
// `6c31212c` and `cf241526` follow decisions 29, 31 and 30; three new Fixed
|
|
// bullets name `08deae68`, `edf2ec56` and `7961a700` (operator decision 27).
|
|
// Each bullet is read on its own with bullet(): the CHANGELOG's bullets have no
|
|
// blank line between them. Phrases are looked for with runs of whitespace
|
|
// read as one space, so a phrase wrapped over two lines still counts.
|
|
const oneSpace = (text) => text.replace(/\s+/g, ' ');
|
|
|
|
test('CHANGELOG: door lines name operator decisions 29, 30 and 31, none bad, and the decision-29 line says "takes its first wrapped line"', () => {
|
|
const body = unreleasedBody();
|
|
const d = releaseCheck.parseDoors(body);
|
|
assert.deepEqual(d.bad, [], 'every top-level door line reads "- one-way: ..." or "- two-way: ..."');
|
|
const texts = d.entries.map((e) => e.text);
|
|
for (const re of [/\boperator decision 29\b/, /\boperator decision 30\b/, /\boperator decision 31\b/]) {
|
|
assert.ok(texts.some((t) => re.test(t)), `a door line matches ${re}: ${JSON.stringify(texts)}`);
|
|
}
|
|
const d29 = texts.filter((t) => /\boperator decision 29\b/.test(t));
|
|
assert.ok(d29.some((t) => /takes\s+its\s+first\s+wrapped\s+line/.test(t)),
|
|
`the door line naming operator decision 29 says "takes its first wrapped line": ${JSON.stringify(d29)}`);
|
|
});
|
|
|
|
test('CHANGELOG: the section names 08deae68, edf2ec56 and operator decision 27', () => {
|
|
const body = unreleasedBody();
|
|
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', () => {
|
|
const body = unreleasedBody();
|
|
const b = bullet(body, /b4fa1927/, 'Fixed bullet b4fa1927');
|
|
assert.match(b, /\boperator decision 29\b/, `the b4fa1927 bullet names operator decision 29: ${JSON.stringify(b)}`);
|
|
assert.ok(b.includes('4b6460e5'), `the b4fa1927 bullet names 4b6460e5: ${JSON.stringify(b)}`);
|
|
assert.match(b, /only\s+after\s+a\s+comma/, `the b4fa1927 bullet says "only after a comma": ${JSON.stringify(b)}`);
|
|
assert.match(b, /takes\s+its\s+first\s+wrapped\s+line/, `the b4fa1927 bullet says "takes its first wrapped line": ${JSON.stringify(b)}`);
|
|
const flat = oneSpace(b);
|
|
assert.ok(!flat.includes('a wrapped line joins it'), `the b4fa1927 bullet no longer says "a wrapped line joins it": ${JSON.stringify(b)}`);
|
|
assert.ok(!flat.includes('keeps the paths on its wrapped lines'),
|
|
`the b4fa1927 bullet no longer says "keeps the paths on its wrapped lines": ${JSON.stringify(b)}`);
|
|
});
|
|
|
|
test('CHANGELOG: the 6c31212c bullet names operator decision 31 below 1.9 and keeps decision 20 at every plan_version', () => {
|
|
const body = unreleasedBody();
|
|
const b = bullet(body, /6c31212c/, 'Fixed bullet 6c31212c');
|
|
assert.match(b, /\boperator decision 31\b/, `the 6c31212c bullet names operator decision 31: ${JSON.stringify(b)}`);
|
|
assert.match(b, /\bplus\b/, `the 6c31212c bullet names plus: ${JSON.stringify(b)}`);
|
|
assert.match(b, /separated\s+only\s+by\s+commas\s+or\s+spaces,\s+and\s+below\s+plan_version\s+1\.9/,
|
|
`the 6c31212c bullet qualifies "separated only by commas or spaces" below 1.9: ${JSON.stringify(b)}`);
|
|
assert.match(b, /at\s+every\s+plan_version\s+\(operator\s+decision\s+20\b/,
|
|
`the 6c31212c bullet keeps "at every plan_version (operator decision 20": ${JSON.stringify(b)}`);
|
|
});
|
|
|
|
test('CHANGELOG: the edf2ec56, 08deae68 and 7961a700 bullets say what was fixed', () => {
|
|
const body = unreleasedBody();
|
|
const e = bullet(body, /edf2ec56/, 'Fixed bullet edf2ec56');
|
|
assert.match(e, /allow_blocked_by/, `the edf2ec56 bullet names allow_blocked_by: ${JSON.stringify(e)}`);
|
|
assert.match(e, /catalogue\s+tier\s+or\s+higher/, `the edf2ec56 bullet says "catalogue tier or higher": ${JSON.stringify(e)}`);
|
|
const o = bullet(body, /08deae68/, 'Fixed bullet 08deae68');
|
|
assert.match(o, /own\s+severity/, `the 08deae68 bullet says "own severity": ${JSON.stringify(o)}`);
|
|
assert.match(o, /pass1_raised_from/, `the 08deae68 bullet names pass1_raised_from: ${JSON.stringify(o)}`);
|
|
const k = bullet(body, /7961a700/, 'Fixed bullet 7961a700');
|
|
assert.match(k, /\boperator decision 27\b/, `the 7961a700 bullet names operator decision 27: ${JSON.stringify(k)}`);
|
|
});
|
|
|
|
test('CHANGELOG: the cf241526 bullet follows operator decision 30', () => {
|
|
const body = unreleasedBody();
|
|
const b = bullet(body, /cf241526/, 'Fixed bullet cf241526');
|
|
assert.match(b, /\boperator decision 30\b/, `the cf241526 bullet names operator decision 30: ${JSON.stringify(b)}`);
|
|
assert.match(b, /pass\s+no\s+`--tests`/, `the cf241526 bullet says the callers pass no \`--tests\`: ${JSON.stringify(b)}`);
|
|
assert.match(b, /decision\s+20\s+and,\s+below\s+1\.9,\s+decision\s+31/,
|
|
`the cf241526 bullet says "decision 20 and, below 1.9, decision 31": ${JSON.stringify(b)}`);
|
|
assert.match(b, /printed\s+list/, `the cf241526 bullet names the printed list: ${JSON.stringify(b)}`);
|
|
assert.ok(b.includes('plan-unreadable'), `the cf241526 bullet names plan-unreadable: ${JSON.stringify(b)}`);
|
|
assert.ok(b.includes('tests-mismatch'), `the cf241526 bullet names tests-mismatch: ${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 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 delivery section no longer says "${phrase}"`);
|
|
}
|
|
});
|
|
|
|
// Third remediation, Step 3: the CHANGELOG records round 3. A door line names
|
|
// operator decision 41 (below 1.8 the helper's own files go to red proof); the
|
|
// Fixed bullets name `703d87e1` and `7b3b59ed`; Known issues name each finding
|
|
// operator decision 42 defers, and the accepted deviation of operator decision
|
|
// 38. The Known-issues bullets are read from that slice only (knownIssues
|
|
// above), so the round-1 Fixed bullet that names `4b6460e5` never counts.
|
|
|
|
const fixedSlice = (body) => subsection(body, 'Fixed');
|
|
|
|
test('CHANGELOG: round 3 — the decision-41 door line, two Fixed bullets and the findings deferred by decision 42', () => {
|
|
const body = unreleasedBody();
|
|
const d = releaseCheck.parseDoors(body);
|
|
assert.deepEqual(d.bad, [], 'every top-level door line reads "- one-way: ..." or "- two-way: ..."');
|
|
const texts = d.entries.map((e) => e.text);
|
|
assert.ok(texts.some((t) => /\boperator decision 41\b/.test(t)),
|
|
`a door line names operator decision 41: ${JSON.stringify(texts)}`);
|
|
|
|
const fixed = fixedSlice(body);
|
|
const f703 = bullet(fixed, /703d87e1/, 'Fixed bullet 703d87e1');
|
|
assert.ok(f703.includes('tests/helpers/timing.mjs'), `the 703d87e1 bullet names tests/helpers/timing.mjs: ${JSON.stringify(f703)}`);
|
|
const f7b3 = bullet(fixed, /7b3b59ed/, 'Fixed bullet 7b3b59ed');
|
|
assert.match(f7b3, /\boperator\s+decision\s+41\b/, `the 7b3b59ed bullet names operator decision 41: ${JSON.stringify(f7b3)}`);
|
|
|
|
const known = knownIssues(body);
|
|
for (const id of ['2d624b54', 'c06d5385', '6ff82e75', '4b6460e5', '4e1607cb', '4f22b101']) {
|
|
const b = bullet(known, new RegExp(id), `Known-issues bullet ${id}`);
|
|
assert.match(b, /\boperator\s+decision\s+42\b/, `the ${id} Known-issues bullet names operator decision 42: ${JSON.stringify(b)}`);
|
|
}
|
|
const dev = bullet(known, /4dce7c74/, 'Known-issues bullet 4dce7c74');
|
|
assert.match(dev, /\boperator\s+decision\s+38\b/, `the 4dce7c74 Known-issues bullet names operator decision 38: ${JSON.stringify(dev)}`);
|
|
const c06 = bullet(known, /c06d5385/, 'Known-issues bullet c06d5385');
|
|
assert.ok(c06.includes('e031a480'), `the c06d5385 bullet names e031a480: ${JSON.stringify(c06)}`);
|
|
assert.ok(c06.includes('original_severity'), `the c06d5385 bullet names original_severity: ${JSON.stringify(c06)}`);
|
|
});
|