voyage/tests/lib/plan-version-1-9-docs.test.mjs
Kjell Tore Guttormsen ed6699da58
refactor(simplify): one source per rule, shared test helpers, a faster isolation test [skip-docs]
/simplify over a97efb6..HEAD (the plan_version 1.9 / trial-4 delivery).

- coordinator-contract: engine names come from engine-findings ENGINES;
  one markedMajorCodeReview predicate under both lowering checks;
  isEngineFinding everywhere; the unreachable branch (c) of
  illegalBriefLowering folded into (e).
- armB workflow: RULE_KEYS = Object.keys(RULE_TIERS); the engine map is a
  literal pinned equal to ENGINES by the workflow test.
- plan-schema: usesFilesGrammar() is the one 1.9 check (validator, sweep,
  testFirstFiles, stepTestsOnlyFromFields); opensEntry built on opensChunk.
- plan-validator: one issue builder for the Files and Test first grammar.
- plan-scope-sweep: the 1.9 and the 6.2.0 reader are one if/else.
- artifact-commit: TRAILER_RE/trailerProblem no longer exported (no
  importer); the order comment says the CLI finds the repo root first.
- tests: the lock drivers in session-id-isolation (d) run side by side per
  mode (~100 s -> ~30 s); a shared artifact-repo helper; severityRank and
  extractFindingsBlock reused; childEnv(env) without a spread; one copy of
  the PLAN_VERSION_CURRENT check.

Plan readers: validator errors/warnings, plannedPaths, fieldPaths,
testFirstFiles and stepTestsOnlyFromFields compared byte for byte against
HEAD over 510 plans / 1692 steps plus 210 synthetic 1.7-1.9 cases: same.
Suite 2384 (2382/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:06:47 +02:00

183 lines
9.4 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';
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);
}
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"');
});
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');
const end = p.indexOf('\n>\n> **', h19);
const block = p.slice(h19, end < 0 ? undefined : end);
assert.ok(block.includes(ANNOTATION), `the 1.9 block says "${ANNOTATION}"`);
});
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's top section is the Unreleased 1.9 delivery, with door
// lines the release check accepts. 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 UNRELEASED = '## Unreleased — plan_version 1.9, the trial-4 findings, session-clean tests';
function firstChangelogSection() {
const lines = read('CHANGELOG.md').split('\n');
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') };
}
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', () => {
assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
const { heading, body } = firstChangelogSection();
assert.equal(heading, UNRELEASED);
const d = releaseCheck.parseDoors(body);
assert.equal(d.found, true, 'the Unreleased 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))}`);
});
test('CHANGELOG: the Unreleased section\'s Known issues name the session split', () => {
const { heading, body } = firstChangelogSection();
assert.equal(heading, UNRELEASED);
const lines = body.split('\n');
const start = lines.findIndex((l) => /^### Known issues\s*$/.test(l));
assert.ok(start >= 0, 'the Unreleased section has a "### Known issues" heading');
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^###? /.test(lines[i])) { end = i; break; }
}
const known = lines.slice(start + 1, end).join('\n');
assert.match(known, /session split/, `Known issues name the session split: ${JSON.stringify(known)}`);
});