// 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); } // 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])}`); }); for (const [label, slice] of [['the /trekplan 1.9 block', trekplan19Block], ['the template 1.9 paragraph', template19Paragraph]]) { 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'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))}`); }); // The Unreleased section's `### Known issues` body, up to the next heading. const knownIssues = (body) => { 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; } } return lines.slice(start + 1, end).join('\n'); }; test('CHANGELOG: the Unreleased section\'s Known issues name the session split', () => { const { heading, body } = firstChangelogSection(); assert.equal(heading, UNRELEASED); 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 remediation's Step 13 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 { heading, body } = firstChangelogSection(); assert.equal(heading, UNRELEASED); 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 Unreleased section no longer says "on a severity tie"', () => { const { heading, body } = firstChangelogSection(); assert.equal(heading, UNRELEASED); 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 { heading, body } = firstChangelogSection(); assert.equal(heading, UNRELEASED); 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 no longer names acd0e3d and names the remediation\'s Step 13 commit', () => { const { heading, body } = firstChangelogSection(); assert.equal(heading, UNRELEASED); 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.match(intro, /the\s+remediation's\s+Step\s+13\s+commit/, `the intro names "the remediation's Step 13 commit": ${JSON.stringify(intro)}`); });