import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; import { findSteps, findForbiddenHeadings, sliceSteps, validatePlanHeadings, extractPlanVersion, stepTestsOnly, onFailureAction, ON_FAILURE_ACTIONS, } from '../../lib/parsers/plan-schema.mjs'; const GOOD_PLAN = `--- plan_version: "1.7" --- ## Implementation Plan ### Step 1: First step - Files: a.ts ### Step 2: Second step - Files: b.ts ### Step 3: Third step - Files: c.ts `; const FORBIDDEN_FASE = `## Implementation Plan ## Fase 1: Forberedelse content here ## Fase 2: Implementering more content `; const FORBIDDEN_PHASE = `### Phase 1: Setup content `; const FORBIDDEN_STAGE = `### Stage 1: Initial work content `; const FORBIDDEN_STEG = `### Steg 1: Norsk drift content `; test('findSteps — locates all canonical step headings', () => { const steps = findSteps(GOOD_PLAN); assert.equal(steps.length, 3); assert.equal(steps[0].n, 1); assert.equal(steps[0].title, 'First step'); assert.equal(steps[2].n, 3); assert.equal(steps[2].title, 'Third step'); }); test('findSteps — empty for plan without steps', () => { assert.deepEqual(findSteps('## Implementation Plan\n\nno steps yet'), []); }); test('findForbiddenHeadings — Fase (Norwegian)', () => { const f = findForbiddenHeadings(FORBIDDEN_FASE); assert.equal(f.length, 2); assert.match(f[0].raw, /Fase 1/); }); test('findForbiddenHeadings — Phase (English)', () => { const f = findForbiddenHeadings(FORBIDDEN_PHASE); assert.equal(f.length, 1); }); test('findForbiddenHeadings — Stage', () => { assert.equal(findForbiddenHeadings(FORBIDDEN_STAGE).length, 1); }); test('findForbiddenHeadings — Steg (Norwegian variant)', () => { assert.equal(findForbiddenHeadings(FORBIDDEN_STEG).length, 1); }); test('findForbiddenHeadings — clean plan has zero', () => { assert.equal(findForbiddenHeadings(GOOD_PLAN).length, 0); }); test('sliceSteps — body bounded by next step', () => { const sections = sliceSteps(GOOD_PLAN); assert.equal(sections.length, 3); assert.match(sections[0].body, /First step/); assert.match(sections[0].body, /Files: a\.ts/); assert.ok(!sections[0].body.includes('Second step')); }); test('validatePlanHeadings — strict accepts good plan', () => { const r = validatePlanHeadings(GOOD_PLAN, { strict: true }); assert.equal(r.valid, true); assert.equal(r.parsed.steps.length, 3); }); test('validatePlanHeadings — strict rejects forbidden Fase form', () => { const r = validatePlanHeadings(FORBIDDEN_FASE, { strict: true }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PLAN_FORBIDDEN_HEADING')); }); test('validatePlanHeadings — soft mode demotes forbidden to warning', () => { const r = validatePlanHeadings(`### Step 1: ok\n\n### Phase 2: drift\n`, { strict: false }); assert.equal(r.errors.find(e => e.code === 'PLAN_FORBIDDEN_HEADING'), undefined); assert.ok(r.warnings.find(w => w.code === 'PLAN_FORBIDDEN_HEADING')); }); test('validatePlanHeadings — non-contiguous numbering is an error', () => { const broken = '### Step 1: ok\ncontent\n\n### Step 3: skip\ncontent\n'; const r = validatePlanHeadings(broken, { strict: true }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PLAN_STEP_NUMBERING')); }); test('validatePlanHeadings — empty plan errors with PLAN_NO_STEPS', () => { const r = validatePlanHeadings('## Implementation Plan\n\nno steps\n'); assert.ok(r.errors.find(e => e.code === 'PLAN_NO_STEPS')); }); test('extractPlanVersion — from frontmatter', () => { assert.equal(extractPlanVersion('plan_version: "1.7"\nfoo: bar\n'), '1.7'); assert.equal(extractPlanVersion('plan_version: 1.8\n'), '1.8'); }); test('extractPlanVersion — null when absent', () => { assert.equal(extractPlanVersion('foo: bar\n'), null); }); test('extractPlanVersion — from prose "Generated by" metadata line (defect S26)', () => { // The plan template emits plan_version only in the prose blockquote line // (`> Generated by ... — `plan_version: 1.7``), not as frontmatter. // extractPlanVersion must honor its documented "frontmatter or doc body" // contract and parse the backtick-wrapped prose form. const prose = '> Generated by trekplan v4.1.0 on 2026-05-10 — `plan_version: 1.7`\n'; assert.equal(extractPlanVersion(prose), '1.7'); }); test('extractPlanVersion — recognizes the canonical plan template (regression pin S26)', () => { // The real generated artifact: no test exercised the parser against the // actual template, which is why defect S26 went unnoticed. const tpl = readFileSync(new URL('../../templates/plan-template.md', import.meta.url), 'utf-8'); assert.equal(extractPlanVersion(tpl), '1.9'); }); // ---- stepTestsOnly: a step whose Files are all tests can never be proven red ---- test('stepTestsOnly: Files all tests (by path or as the Test first file) or not; no Files = not tests-only', () => { const body = (files, tf = 'tests/x.test.mjs') => [ '### Step 1: x', '', ...(files === null ? [] : [`- **Files:** ${files}`]), '- **Test first:**', ` - File: \`${tf}\` (new)`, ' - Verifies: x', ].join('\n'); assert.equal(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).testsOnly, true); assert.equal(stepTestsOnly(body('`src/a.test.ts` (new file), `lib/b.spec.js`, `pkg/c_test.go`')).testsOnly, true); assert.equal(stepTestsOnly(body('`__tests__/a.js`, `test/b.js`')).testsOnly, true); // The Test first file counts as a test wherever it lives. assert.equal(stepTestsOnly(body('`scripts/check.sh`', 'scripts/check.sh')).testsOnly, true); assert.equal(stepTestsOnly(body('`src/cli.mjs`, `tests/cli.test.mjs`')).testsOnly, false); assert.equal(stepTestsOnly(body('`src/cli.mjs`')).testsOnly, false); assert.equal(stepTestsOnly(body(null)).testsOnly, false); assert.deepEqual(stepTestsOnly(body('`tests/e2e.sh`, `tests/e2e-guard.sh`')).files, ['tests/e2e.sh', 'tests/e2e-guard.sh']); }); test('stepTestsOnly: a plain path beside a backticked one is still read - a step that also changes code is not tests-only', () => { const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n'); const mixed = stepTestsOnly(body('src/cli.mjs (modify), `tests/x.test.mjs` (new)')); assert.deepEqual(mixed.files, ['src/cli.mjs', 'tests/x.test.mjs']); assert.equal(mixed.testsOnly, false); const list = stepTestsOnly(['### Step 1: x', '', '- **Files:**', ' - `tests/x.test.mjs` (new)', ' - src/cli.mjs'].join('\n')); assert.equal(list.testsOnly, false, JSON.stringify(list.files)); }); test('stepTestsOnly: a document under spec/ or specs/ is not a test for this reading', () => { const body = (files) => ['### Step 1: x', '', `- **Files:** ${files}`].join('\n'); assert.equal(stepTestsOnly(body('`docs/specs/api.md`, `specs/001-auth/plan.md`')).testsOnly, false); assert.equal(stepTestsOnly(body('`spec/cli_spec.rb`')).testsOnly, true); }); // ---- onFailureAction (K11) --------------------------------------------------- test('K11: the four words, in any case and with markdown around them, are read; a longer word is not', () => { assert.deepEqual([...ON_FAILURE_ACTIONS], ['revert', 'retry', 'skip', 'escalate']); assert.equal(onFailureAction('revert — `git checkout -- x`'), 'revert'); assert.equal(onFailureAction('Escalate: stop the run'), 'escalate'); assert.equal(onFailureAction('**skip** this step'), 'skip'); assert.equal(onFailureAction('`retry` once'), 'retry'); assert.equal(onFailureAction('retrying is fine'), null); assert.equal(onFailureAction('try again'), null); assert.equal(onFailureAction(''), null); });