voyage/tests/lib/fix-round-4-5b2-c-prose.test.mjs
2026-11-04 11:17:19 +01:00

193 lines
11 KiB
JavaScript

// tests/lib/fix-round-4-5b2-c-prose.test.mjs
// Prose pins for fix round 4.5b2, part C (plan
// .claude/plans/trekplan-2026-11-03-review-gate-4-5b2-fixes.md, Step 7): the
// round's CHANGELOG entry. The tag in brackets is the part-C id from the
// round's plan; each tagged test fails on an assertion at the round's red base
// f1f5ab5, where the CHANGELOG holds no part C entry.
// PRC the CHANGELOG lists part C: under ### Fixed one group names each of
// the eight behaviour tags exactly once, one bullet per tag, each
// ending with the review finding it fixes; ### Doors holds the part C
// two-way line; ### Known issues holds one decision-137 bullet that
// names the guard's quoted-value hole with each of the eight commands
// measured in the plan's Context, "decision 137" and "its own round".
// No bullet of this round may hold a string an earlier prose test counts.
// The helpers below are copied, never imported from an earlier prose test.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
/** A repo file's text; the file must exist. */
function read(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel), 'utf8');
}
/** Whitespace (line breaks too) collapsed to one space. */
// Copied from tests/lib/fix-round-4-5b2-b-prose.test.mjs (flat).
const flat = (s) => s.replace(/\s+/g, ' ').trim();
// Part C stands in the same Unreleased section as the earlier parts. The
// section is found by its title: `## Unreleased — <title>` before the release,
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `.
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
/** The lines of the round's CHANGELOG section, heading excluded. */
// Copied from tests/lib/fix-round-4-5b2-b-prose.test.mjs (changelogSection).
function changelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const at = [];
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
let end = lines.length;
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
return lines.slice(at[0] + 1, end);
}
/** The lines of one `### ` subsection of the round's CHANGELOG section, heading excluded. */
function subsection(heading) {
const lines = changelogSection();
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
let end = lines.length;
for (let i = s + 1; i < lines.length; i++) if (/^### /.test(lines[i])) { end = i; break; }
return lines.slice(s + 1, end);
}
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
// Copied from tests/lib/fix-round-4-5b2-b-prose.test.mjs (changelogBullets).
function changelogBullets(heading) {
const out = [];
for (const l of subsection(heading)) {
if (/^- /.test(l)) out.push(l);
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
else if (l.trim() !== '') out.push('');
}
return out.filter((b) => b !== '').map(flat);
}
const PRC_GROUP = '**Fix round 4.5b2, part C (decisions 135 and 137)**';
// The round's behaviour tags with the review finding each fixes (first 8 hex of
// the finding id, written out here from the plan's Context table). PRC itself,
// the CHANGELOG step's own tag, is in no count.
const PRC_FINDINGS = [
['DUP', 'e57259bf'],
['RSP', 'de594275'],
['NRL', 'fd5a6e18'],
['SYM', 'd81f3c45'],
['WHD', '561d6f11'],
['AMB', 'df9f9870'],
['CLU', 'b5ae9798'],
['MMG', '73e1d174'],
];
// Strings the earlier prose tests count in this section: none may stand in a
// bullet of this round.
const COUNTED = ['decision 88', 'decision 89', 'decision 90', 'decision 91', 'decision 107', 'decision 122',
'decision 123', 'decision 124', "'90s", "bash -c $'", "<<$'\\t'", 'VOYAGE_ARGS_EOF_7F3C9E', '6.5.0',
'`review_version` 1.2', 'ref the session can move', 'unchanged caller',
'(TOK)', '(HDG)', '(CKI)', '(DLC)', '(CQW)', '(PQO)', '(CSG)', '(SLT)', '(ESC)', '(CTB)',
'(RXT)', '(CNT)', '(SFX)', '(ESR)', '(EVL)', '(SIS)', '(HDR)',
'(ANS)', '(RLS)', '(RAP)', '(V12)', '(PRX)', '(RND)', '(CAP)', '(CRY)', '(FXL)', '(POL)', '(ENG)', '(CMD)',
'(DPR)', '(CAT)', '(MEV)', '(RMP)', '(PXB)', '(FN1)'];
// The eight commands the plan's Context measured as giving no question (decision
// 137): seven from critic rounds 1-2 and the risk assessor's Phase 8 form.
const MEASURED = [
'`git tag -m "Release one" v1.0`',
'`git -C "/tmp/a b" push origin main`',
'`git -c "x.y=a b" push origin main`',
'`echo "a b"#; git push origin main`',
"`git tag -m '' v1.0`",
'`git -C ~/"My Repos"/x merge --no-ff feat`',
'`npm --workspace "$WS" publish`',
'`git -C "<path with a space>" merge --no-ff feat`',
];
const DOOR = "- two-way: fix round 4.5b2 part C (decisions 135, 137) — a revert restores part B's behaviour.";
/** Part C's group under `### Fixed`: its intro text and its bullets, each flattened. */
function fixedGroup() {
const body = subsection('### Fixed');
const at = body.filter((l) => l.replace(/\s+$/, '') === PRC_GROUP).length;
assert.equal(at, 1, `### Fixed must hold the group ${PRC_GROUP} exactly once (found ${at})`);
const start = body.findIndex((l) => l.replace(/\s+$/, '') === PRC_GROUP);
const intro = [];
const bullets = [];
for (const l of body.slice(start + 1)) {
if (/^\*\*[^*].*\*\*\s*$/.test(l)) break;
if (/^- /.test(l)) bullets.push(l);
else if (bullets.length && l.trim() !== '' && /^\s/.test(l)) bullets[bullets.length - 1] += ` ${l}`;
else if (!bullets.length && l.trim() !== '') intro.push(l);
else if (bullets.length && l.trim() !== '') break;
}
return { intro: flat(intro.join(' ')), bullets: bullets.map(flat) };
}
/** How often `needle` stands in `text`. */
const occurrences = (text, needle) => text.split(needle).length - 1;
test('[PRC] the part C group names each of the eight behaviour tags exactly once', () => {
const { intro, bullets } = fixedGroup();
assert.ok(intro.includes('`f1f5ab5`'), `the group's intro names the red base f1f5ab5: ${intro}`);
assert.ok(intro.includes('`tests/fix-round-4-5b2-c-red.sh`'), `the group's intro names tests/fix-round-4-5b2-c-red.sh: ${intro}`);
assert.equal(PRC_FINDINGS.length, 8, 'Control: eight behaviour tags');
const text = [intro, ...bullets].join('\n');
for (const [tag] of PRC_FINDINGS) {
assert.equal(occurrences(text, `(${tag})`), 1, `the group must name (${tag}) exactly once; found ${occurrences(text, `(${tag})`)}`);
assert.equal(bullets.filter((b) => b.includes(`(${tag})`)).length, 1, `exactly one bullet of the group is marked (${tag})`);
}
assert.equal(bullets.length, PRC_FINDINGS.length, `the group holds one bullet per tag: found ${bullets.length} bullets`);
assert.equal(occurrences(text, '(PRC)'), 0, 'the group does not name PRC, the CHANGELOG step\'s own tag');
for (const b of bullets) {
assert.match(b, /^- \*\*.+?\*\* \([A-Z0-9]{3}\)\./, `each bullet opens with a bold headline and its tag: ${b}`);
for (const s of COUNTED) assert.ok(!b.includes(s), `no bullet of the group may hold ${JSON.stringify(s)}: ${b}`);
}
});
test('[PRC] each part C bullet names its review finding', () => {
const { bullets } = fixedGroup();
for (const [tag, id] of PRC_FINDINGS) {
const hits = bullets.filter((b) => b.includes(`(${tag})`));
assert.equal(hits.length, 1, `exactly one bullet of the group is marked (${tag}); found ${hits.length}`);
assert.ok(hits[0].endsWith(`It names review finding \`${id}\`.`),
`the (${tag}) bullet must end "It names review finding \`${id}\`.": ${hits[0]}`);
assert.equal(bullets.filter((b) => b.includes(id)).length, 1, `review finding ${id} stands in exactly one bullet of the group`);
}
// The CLU bullet says its quoted -m half stays "no run evidence" (decision 137).
const clu = bullets.filter((b) => b.includes('(CLU)'))[0];
assert.ok(clu.includes('`-m`'), `the (CLU) bullet names the quoted \`-m\` value: ${clu}`);
assert.ok(clu.includes('no run evidence'), `the (CLU) bullet says the quoted -m half stays "no run evidence": ${clu}`);
assert.ok(clu.includes('decision 137'), `the (CLU) bullet names decision 137: ${clu}`);
// Control: no fixed finding is listed as open.
const known = changelogBullets('### Known issues');
for (const [, id] of PRC_FINDINGS) {
assert.deepEqual(known.filter((b) => b.includes(id)), [], `Control: the fixed finding ${id} must not stand under ### Known issues`);
}
});
test('[PRC] the Doors section has the part C two-way line', () => {
const doors = changelogBullets('### Doors');
const hits = doors.filter((b) => b.startsWith('- two-way: fix round 4.5b2 part C'));
assert.deepEqual(hits, [DOOR], `### Doors must hold exactly the one part C line ${DOOR}`);
for (const s of COUNTED) assert.ok(!hits[0].includes(s), `the door must not hold ${JSON.stringify(s)}, a string an earlier prose test counts`);
// Control: every door bullet of the section is a door line.
for (const b of doors) assert.match(b, /^- (one-way|two-way): \S/, `Control: every ### Doors bullet is a door line: ${b}`);
});
test('[PRC] the known issues hold one decision-137 guard bullet with each of the eight measured commands, "decision 137" and "its own round"', () => {
const known = changelogBullets('### Known issues');
const hits = known.filter((b) => b.includes('(part C, decision 137)'));
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet marked (part C, decision 137); found ${hits.length}`);
const b = hits[0];
assert.equal(MEASURED.length, 8, 'Control: eight measured commands');
for (const cmd of MEASURED) {
assert.ok(b.includes(cmd), `the decision-137 bullet must name the measured command ${cmd}: ${b}`);
}
assert.ok(b.includes('decision 137'), `the decision-137 bullet must hold "decision 137": ${b}`);
assert.ok(b.includes('its own round'), `the decision-137 bullet must hold "its own round": ${b}`);
for (const s of COUNTED) assert.ok(!b.includes(s), `the decision-137 bullet must not hold ${JSON.stringify(s)}: ${b}`);
});