docs(changelog): review gate 4.5b2 part B, its door and known issues

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 21:42:38 +01:00
commit 03c9f527a3
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 212 additions and 2 deletions

View file

@ -36,13 +36,20 @@
// its own before the merge call, and goes on when it is not written;
// docs/HANDOVER-CONTRACTS.md documents the file as internal: its path
// under the git common dir, never tracked, not part of Handover 5.
// PRB the CHANGELOG lists part B: one two-way Doors bullet names the
// `review_version` 1.2 bump, the Added group names each of the 15
// behaviour tags exactly once, and Known issues name the unchanged-caller
// case of the pre-existing mark and the answer path for this delivery's
// own review; and the new review modules import no scripts/ or hooks/
// code (checked on the real files, a planted import and a clean copy).
// 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 { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
@ -443,3 +450,182 @@ test('[MEV] docs/HANDOVER-CONTRACTS.md documents the evidence file as internal',
// Control: Handover 5 does not name the file.
assert.doesNotMatch(section(text, '## Handover 5'), /merge-evidence/, 'Control: Handover 5 is unchanged');
});
// --- PRB: the CHANGELOG entry and the lib import rule (Step 12) ---------------------
// Part B 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-5b-part-4-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 bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
// Copied from tests/lib/fix-round-4-5b-part-4-prose.test.mjs (changelogBullets).
function changelogBullets(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}"`);
const out = [];
for (const l of lines.slice(s + 1)) {
if (/^### /.test(l)) break;
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 PRB_GROUP = '**Review gate, 4.5b2 part B (decisions 93-105, 128)**';
// The round's behaviour tags, every tag of this round but PRB itself.
const PRB_TAGS = ['ANS', 'RLS', 'RAP', 'V12', 'PRX', 'RND', 'CAP', 'CRY', 'FXL', 'POL', 'ENG', 'CMD', 'DPR', 'CAT', 'MEV'];
// Strings the earlier prose tests count in this section (plan Gotchas): 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 $'",
'(TOK)', '(CSG)', '(SLT)', '(ESC)', '(CTB)', '(RXT)', '(CNT)', '(SFX)', '(ESR)', '(EVL)', '(SIS)', '(HDR)'];
/** Part B's group under `### Added`: its intro text and its bullets, each flattened. */
function addedGroup() {
const lines = changelogSection();
const added = lines.findIndex((l) => l.replace(/\s+$/, '') === '### Added');
assert.ok(added !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "### Added"`);
let end = lines.length;
for (let i = added + 1; i < lines.length; i++) if (/^### /.test(lines[i])) { end = i; break; }
const body = lines.slice(added + 1, end);
const at = body.filter((l) => l.replace(/\s+$/, '') === PRB_GROUP).length;
assert.equal(at, 1, `### Added must hold the group ${PRB_GROUP} exactly once (found ${at})`);
const start = body.findIndex((l) => l.replace(/\s+$/, '') === PRB_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 (l.trim() !== '') bullets.push('');
}
return { intro: flat(intro.join(' ')), bullets: bullets.filter((b) => b !== '').map(flat) };
}
/** How often `needle` stands in `text`. */
const occurrences = (text, needle) => text.split(needle).length - 1;
test('[PRB] one Doors bullet names the review_version 1.2 bump and is two-way', () => {
const doors = changelogBullets('### Doors');
const hits = doors.filter((b) => b.includes('`review_version` 1.2'));
assert.equal(hits.length, 1, `### Doors must hold exactly one bullet naming \`review_version\` 1.2; found ${hits.length}`);
assert.ok(hits[0].startsWith('- two-way: '), `the review_version 1.2 door is two-way: ${hits[0]}`);
assert.ok(hits[0].includes('decisions 93-105, 128'), `the door names decisions 93-105, 128: ${hits[0]}`);
assert.match(hits[0], /a revert restores the 1\.1 gate/, `the door says a revert restores the 1.1 gate: ${hits[0]}`);
assert.match(hits[0], /unknown keys tolerated/, `the door says 1.2 files still read under the old validator: ${hits[0]}`);
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('[PRB] the Added group names each of the 15 behaviour tags exactly once', () => {
const { intro, bullets } = addedGroup();
assert.ok(intro.includes('`96fba75`'), `the group's intro names the red base 96fba75: ${intro}`);
assert.ok(intro.includes('`tests/fix-round-4-5b2-b-red.sh`'), `the group's intro names tests/fix-round-4-5b2-b-red.sh: ${intro}`);
assert.equal(PRB_TAGS.length, 15, 'Control: 15 behaviour tags');
const text = [intro, ...bullets].join('\n');
for (const tag of PRB_TAGS) {
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, PRB_TAGS.length, `the group holds one bullet per tag: found ${bullets.length} bullets`);
assert.equal(occurrences(text, '(PRB)'), 0, 'the group does not name PRB, 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('[PRB] Known issues name the unchanged-caller case and the answer path for this delivery\'s own review', () => {
const known = changelogBullets('### Known issues');
const caller = known.filter((b) => b.includes('unchanged caller') && b.includes('pre-existing'));
assert.equal(caller.length, 1, `### Known issues must hold exactly one bullet naming the unchanged caller of the pre-existing mark; found ${caller.length}`);
assert.match(caller[0], /\bMAJOR\b/, `the unchanged-caller bullet says which finding it marks: ${caller[0]}`);
const own = known.filter((b) => b.includes('6.5.0') && b.includes('review-answers.mjs'));
assert.equal(own.length, 1, `### Known issues must hold exactly one bullet naming the 6.5.0 plugin and review-answers.mjs; found ${own.length}`);
assert.match(own[0], /release check/, `the 6.5.0 bullet says the answers come before the release check: ${own[0]}`);
assert.match(own[0], /docs\/release-runbook\.md/, `the 6.5.0 bullet names the runbook path: ${own[0]}`);
for (const b of [...caller, ...own]) {
for (const s of COUNTED) assert.ok(!b.includes(s), `a Known-issues bullet of this round must not hold ${JSON.stringify(s)}: ${b}`);
}
});
// The new lib files of part B; lib never imports scripts/ or hooks/ code.
const NEW_LIB = [
'lib/review/review-answers.mjs', 'lib/review/pre-existing.mjs', 'lib/review/review-rounds.mjs',
'lib/review/review-policy.mjs', 'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs',
'lib/review/review-finalize.mjs', 'lib/review/merge-evidence.mjs', 'lib/util/doors.mjs',
];
const FORBIDDEN_DIRS = ['scripts', 'hooks'];
const PLANTED = "import { checkReview } from '../../scripts/release-check.mjs';\n";
/** The specifiers of a module's import lines: `import … from`, bare `import '…'`, `export … from`, `import('…')`. */
function importSpecifiers(src) {
const out = [];
for (const m of src.matchAll(/^[ \t]*import\b(?!\s*\()[^;]*?(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
for (const m of src.matchAll(/^[ \t]*export\s*(?:\*|\{)[^;]*?\bfrom\s*(['"])([^'"\n]+)\1/gm)) out.push(m[2]);
for (const m of src.matchAll(/\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g)) out.push(m[2]);
return out;
}
/** `file: specifier` for each import of `files` (paths under `root`) that resolves into scripts/ or hooks/ of `root`. */
function forbiddenImports(root, files) {
const out = [];
for (const rel of files) {
const abs = join(root, rel);
for (const spec of importSpecifiers(readFileSync(abs, 'utf8'))) {
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
const target = relative(root, resolve(dirname(abs), spec)).split(sep).join('/');
if (FORBIDDEN_DIRS.some((d) => target === d || target.startsWith(`${d}/`))) out.push(`${rel}: ${spec}`);
}
}
return out;
}
test('[PRB] the new review modules import no scripts/ or hooks/ code', () => {
assert.deepEqual([...NEW_LIB].sort(), [
'lib/review/engine-compare.mjs', 'lib/review/instruction-scope.mjs', 'lib/review/merge-evidence.mjs',
'lib/review/pre-existing.mjs', 'lib/review/review-answers.mjs', 'lib/review/review-finalize.mjs',
'lib/review/review-policy.mjs', 'lib/review/review-rounds.mjs', 'lib/util/doors.mjs',
], 'the scanner reads exactly these nine files');
for (const rel of NEW_LIB) assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
// The real files: nothing.
assert.deepEqual(forbiddenImports(ROOT, NEW_LIB), [], 'a new review module imports scripts/ or hooks/ code');
const tmp = mkdtempSync(join(tmpdir(), 'prb-imports-'));
try {
for (const rel of NEW_LIB) {
mkdirSync(dirname(join(tmp, rel)), { recursive: true });
writeFileSync(join(tmp, rel), read(rel));
}
// Control: the copy reads like the real files.
assert.deepEqual(forbiddenImports(tmp, NEW_LIB), [], 'Control: the clean copy reports nothing');
// A planted import of a scripts/ module is reported.
writeFileSync(join(tmp, NEW_LIB[0]), PLANTED + read(NEW_LIB[0]));
assert.deepEqual(forbiddenImports(tmp, NEW_LIB), [`${NEW_LIB[0]}: ../../scripts/release-check.mjs`],
'the scanner reports the planted import of ../../scripts/release-check.mjs');
// The clean copy again: nothing.
writeFileSync(join(tmp, NEW_LIB[0]), read(NEW_LIB[0]));
assert.deepEqual(forbiddenImports(tmp, NEW_LIB), [], 'the clean copy again reports nothing');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});