169 lines
9.7 KiB
JavaScript
169 lines
9.7 KiB
JavaScript
// tests/lib/fix-round-4-5b-part-2-prose.test.mjs
|
|
// Prose pins for fix round 4-5b, part 2 (plan
|
|
// .claude/projects/2026-10-27-fix-round-4-5b-part-2, decision 88): the fixes
|
|
// that are text a session reads. The tags in brackets are the part-2 ids from
|
|
// the round's brief; each tagged test fails on an assertion at 52c382c, the
|
|
// round's start point. The keep-behaviour checks sit inside the tagged test as
|
|
// "Control:" assertions.
|
|
// DLC /trekexecute's Sub-step G says what `{hash}` is: the full commit id
|
|
// from `git rev-parse HEAD` after the step's last commit, never the short
|
|
// id `git commit` prints (done-line --check refuses a short id with
|
|
// exit 2). The progress line stores that full id, and Handover 5's
|
|
// `commit` row says so.
|
|
// KI the CHANGELOG lists part 2's fixes under ### Fixed, its door under
|
|
// ### Doors, and the review's other findings (7 MAJOR, 3 MINOR, by their
|
|
// 8-hex ids) and part 2's accepted side effects under ### Known issues.
|
|
// No static import of a new export: the file must load at 52c382c.
|
|
// 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)), '..', '..');
|
|
|
|
const readCache = new Map();
|
|
|
|
/** A repo file's text; the file must exist. Read from disk once per run. */
|
|
function read(rel) {
|
|
if (!readCache.has(rel)) {
|
|
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
|
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
|
|
}
|
|
return readCache.get(rel);
|
|
}
|
|
|
|
/** Whitespace (line breaks too) collapsed to one space. */
|
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
|
|
/** The text from `heading` up to the next heading of the same or a higher level; the heading must exist. */
|
|
function section(text, heading) {
|
|
const at = text.indexOf(heading);
|
|
assert.ok(at >= 0, `missing heading "${heading}"`);
|
|
const level = heading.match(/^#+/)[0].length;
|
|
const rest = text.slice(at + heading.length);
|
|
const next = rest.search(new RegExp(`\\n#{1,${level}} `));
|
|
return heading + (next >= 0 ? rest.slice(0, next) : rest);
|
|
}
|
|
|
|
const EXEC = 'commands/trekexecute.md';
|
|
const CONTRACTS = 'docs/HANDOVER-CONTRACTS.md';
|
|
|
|
// --- DLC ---------------------------------------------------------------------------
|
|
|
|
test('[DLC] Sub-step G records the full commit id from git rev-parse HEAD after the checkpoint', () => {
|
|
const g = section(read(EXEC), '#### Sub-step G — Checkpoint, done line, unlock');
|
|
const gFlat = flat(g);
|
|
|
|
// Red: the checkpoint says how {hash} is read, before the done line runs.
|
|
const checkpointAt = g.indexOf('**Checkpoint.**');
|
|
const doneLineAt = g.indexOf('**The done line**');
|
|
assert.ok(checkpointAt >= 0 && doneLineAt > checkpointAt, 'Sub-step G must hold **Checkpoint.** before **The done line**');
|
|
const revParseAt = g.indexOf('`git rev-parse HEAD`');
|
|
assert.ok(revParseAt > checkpointAt && revParseAt < doneLineAt,
|
|
'Sub-step G must name `git rev-parse HEAD` in the checkpoint item, after **Checkpoint.** and before the done-line check');
|
|
assert.ok(gFlat.includes('full commit id'), 'Sub-step G must say {hash} is the full commit id');
|
|
assert.ok(gFlat.includes('never the short id'), 'Sub-step G must say {hash} is never the short id `git commit` prints');
|
|
assert.ok(gFlat.includes('Read it again after any later commit of the step'),
|
|
'Sub-step G must say the id is read again after a later commit of the step');
|
|
|
|
const commitLines = g.split('\n').filter((l) => l.includes('`steps.{N}.commit = {hash}`'));
|
|
assert.ok(commitLines.length > 0, 'Sub-step G must hold the `steps.{N}.commit = {hash}` progress line');
|
|
for (const l of commitLines) {
|
|
assert.ok(l.includes('(the full commit id)'), `the progress line must say it stores the full commit id: ${l}`);
|
|
}
|
|
|
|
const handover5 = section(read(CONTRACTS), '## Handover 5 ');
|
|
const rows = handover5.split('\n').filter((l) => l.startsWith('| `commit` |'));
|
|
assert.equal(rows.length, 1, `Handover 5 must hold exactly one \`commit\` row; found ${rows.length}`);
|
|
assert.ok(rows[0].includes('full commit id'), `the Handover 5 \`commit\` row must say "full commit id": ${rows[0]}`);
|
|
|
|
// Control: the done-line check keeps its literal, Phase 7.5 keeps reading the
|
|
// stored id, and Sub-step G asks for no abbreviated id anywhere.
|
|
assert.ok(g.includes('done-line.mjs --check --step {N} --report {report_path} --commit {hash}'),
|
|
'Control: Sub-step G keeps the done-line --check literal with --commit {hash}');
|
|
const p75 = section(read(EXEC), '## Phase 7.5 — Manifest audit (independent)');
|
|
assert.ok(p75.includes('--commit {steps.N.commit}'), 'Control: Phase 7.5 keeps --commit {steps.N.commit}');
|
|
assert.doesNotMatch(g, /--short|%h|--abbrev/, 'Control: Sub-step G must not ask for an abbreviated commit id');
|
|
});
|
|
|
|
// --- KI: the round's CHANGELOG entry -----------------------------------------------
|
|
// Part 2 stands in the same Unreleased section as part 1. The section is found
|
|
// by its title, by the rule tests/lib/fix-round-4-5b-prose.test.mjs uses
|
|
// (copied, not imported): `## 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. */
|
|
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. */
|
|
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);
|
|
}
|
|
|
|
// The review's findings part 2 leaves open (7 MAJOR, then 3 MINOR), and the
|
|
// ones it fixes; the ids are the review's, written out here.
|
|
const OPEN_IDS = ['c76a0bb8', 'b0fd0a04', 'f7e8278b', '4ec591f4', '2c904235', '450dbabb', 'e3824c6d', '5114fa84', 'e585ce27', '8959d2cd'];
|
|
const FIXED_IDS = ['c9b5bce4', '021996c6', '20375547', 'b30f057c', '49080c36'];
|
|
|
|
test('[KI] the CHANGELOG lists part 2\'s fixes and the review\'s other findings under Known issues', () => {
|
|
const known = changelogBullets('### Known issues');
|
|
|
|
// Red: each open finding has exactly one Known-issues bullet, and that bullet names decision 88.
|
|
for (const id of OPEN_IDS) {
|
|
const hits = known.filter((b) => b.includes(id));
|
|
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming ${id}; found ${hits.length}`);
|
|
}
|
|
for (const b of known.filter((k) => OPEN_IDS.some((id) => k.includes(id)))) {
|
|
assert.ok(b.includes('decision 88'), `a Known-issues bullet naming an open finding does not say decision 88: ${b}`);
|
|
}
|
|
const followed = known.filter((b) => b.includes('VOYAGE_ARGS_EOF_7F3C9E') && b.includes('stays blocked'));
|
|
assert.equal(followed.length, 1, `### Known issues must hold one bullet saying a command that mentions VOYAGE_ARGS_EOF_7F3C9E with a line after it stays blocked; found ${followed.length}`);
|
|
const bashC = known.filter((b) => b.includes("bash -c $'"));
|
|
assert.equal(bashC.length, 1, `### Known issues must hold one bullet on the one-physical-line bash -c $'...' case; found ${bashC.length}`);
|
|
const tok = known.filter((b) => b.includes("'90s"));
|
|
assert.equal(tok.length, 1, `### Known issues must hold one bullet on the tokenizer's word-initial quote ('90s); found ${tok.length}`);
|
|
|
|
// Red elsewhere: one ### Fixed bullet per part-2 id, and a door for the round.
|
|
const fixed = changelogBullets('### Fixed');
|
|
for (const id of ['(TOK)', '(HDG)', '(CKI)', '(DLC)']) {
|
|
const hits = fixed.filter((b) => b.includes(id));
|
|
assert.equal(hits.length, 1, `### Fixed must hold exactly one bullet marked ${id}; found ${hits.length}`);
|
|
}
|
|
const doors = changelogBullets('### Doors').filter((b) => b.includes('decision 88'));
|
|
assert.equal(doors.length, 1, `### Doors must hold one bullet naming decision 88; found ${doors.length}`);
|
|
|
|
// Control: the three earlier Known issues stay, no fixed finding is listed as
|
|
// open, and the R6 Fixed bullet keeps the one backticked rule name.
|
|
for (const start of ["- **A plan's `profile:` field has no writer.**", '- **Splices and dropped', '- **Two 4.5b findings moved to fix round 4.5c.**']) {
|
|
assert.equal(known.filter((b) => b.startsWith(start)).length, 1, `Control: ### Known issues must keep the bullet opening ${start}`);
|
|
}
|
|
for (const id of FIXED_IDS) {
|
|
assert.deepEqual(known.filter((b) => b.includes(id)), [], `Control: the fixed finding ${id} must not stand under ### Known issues`);
|
|
}
|
|
assert.equal(fixed.filter((b) => b.includes('`args-heredoc-closed-early`')).length, 1,
|
|
'Control: exactly one ### Fixed bullet holds `args-heredoc-closed-early`');
|
|
});
|