import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { validatePlanContent } from '../../lib/validators/plan-validator.mjs'; import { ON_FAILURE_ACTIONS } from '../../lib/parsers/plan-schema.mjs'; import { buildPlan } from '../helpers/plan-v18-builder.mjs'; const VALID_PLAN = `--- plan_version: "1.7" --- # Plan ## Implementation Plan ### Step 1: Add foo - Files: a.ts - Manifest: \`\`\`yaml manifest: expected_paths: - a.ts min_file_count: 1 commit_message_pattern: "^feat:" bash_syntax_check: [] forbidden_paths: [] must_contain: [] \`\`\` ### Step 2: Add bar - Files: b.ts - Manifest: \`\`\`yaml manifest: expected_paths: - b.ts min_file_count: 1 commit_message_pattern: "^feat:" bash_syntax_check: [] forbidden_paths: [] must_contain: [] \`\`\` `; const FORBIDDEN_PLAN = `--- plan_version: "1.7" --- ## Fase 1: Drift form content `; const STEP_WITHOUT_MANIFEST = `### Step 1: oops no manifest ### Step 2: ok - Manifest: \`\`\`yaml manifest: expected_paths: [foo] min_file_count: 1 commit_message_pattern: "^x:" bash_syntax_check: [] forbidden_paths: [] must_contain: [] \`\`\` `; test('validatePlan — strict accepts canonical v1.7 plan', () => { const r = validatePlanContent(VALID_PLAN, { strict: true }); assert.equal(r.valid, true, JSON.stringify(r.errors)); assert.equal(r.parsed.steps.length, 2); assert.equal(r.parsed.planVersion, '1.7'); }); test('validatePlan — forbidden Fase form blocks in strict mode', () => { const r = validatePlanContent(FORBIDDEN_PLAN, { strict: true }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'PLAN_FORBIDDEN_HEADING')); }); test('validatePlan — manifest count mismatch caught', () => { const r = validatePlanContent(STEP_WITHOUT_MANIFEST, { strict: true }); assert.equal(r.valid, false); assert.ok(r.errors.find(e => /Step 1/.test(e.message) && /MANIFEST_MISSING/.test(e.code))); }); test('validatePlan — version warning when missing', () => { const noVersion = VALID_PLAN.replace(/plan_version: "1\.7"\n/, ''); const r = validatePlanContent(noVersion, { strict: true }); assert.ok(r.warnings.find(w => w.code === 'PLAN_NO_VERSION')); }); test('validatePlan — older version triggers warning', () => { const old = VALID_PLAN.replace('plan_version: "1.7"', 'plan_version: "1.5"'); const r = validatePlanContent(old, { strict: true }); assert.ok(r.warnings.find(w => w.code === 'PLAN_VERSION_MISMATCH')); }); test('validatePlan — prose plan_version line parses, no PLAN_NO_VERSION (defect S26)', () => { // A plan shaped like the real template: plan_version lives only in the // prose "Generated by" blockquote, not frontmatter. Must still parse. const prosePlan = VALID_PLAN .replace(/---\nplan_version: "1\.7"\n---\n\n/, '') .replace( '# Plan', '# Plan\n\n> Generated by trekplan v4.1.0 on 2026-05-10 — `plan_version: 1.7`', ); const r = validatePlanContent(prosePlan, { strict: true }); assert.equal(r.parsed.planVersion, '1.7'); assert.equal(r.warnings.find(w => w.code === 'PLAN_NO_VERSION'), undefined); }); const codes = (list) => list.map((e) => e.code); // ---- PLAN_STEP_TESTS_ONLY: a step whose Files are all tests can never be proven red ---- // Step 2 changes only tests for behaviour step 1 delivered. const TESTS_ONLY_PLAN = buildPlan({ steps: [{}, { file: 'tests/e2e.sh', test: 'tests/e2e-guard.test.mjs', endToEnd: 'bash tests/e2e.sh' }], }); test('a step whose Files are all tests gives PLAN_STEP_TESTS_ONLY, a warning on that step', () => { const r = validatePlanContent(TESTS_ONLY_PLAN); const w = r.warnings.filter((x) => x.code === 'PLAN_STEP_TESTS_ONLY'); assert.equal(w.length, 1, codes(r.warnings).join(',')); assert.match(w[0].message, /Step 2/); assert.ok(!codes(r.errors).includes('PLAN_STEP_TESTS_ONLY'), 'a warning, never an error'); assert.equal(r.valid, true, codes(r.errors).join(',')); }); test('a step with code and its test gives no PLAN_STEP_TESTS_ONLY', () => { const r = validatePlanContent(buildPlan()); assert.ok(!codes(r.warnings).includes('PLAN_STEP_TESTS_ONLY'), codes(r.warnings).join(',')); }); // ---- K10 `Regression:` and K11 `On failure:` ----------------------------------- // Step 1's Verify line in the builder's plan; the new field goes right under it. const STEP1_VERIFY = '- **Verify:** `node --test tests/mod1.test.mjs` → expected: pass'; const withRegression = (line, plan = buildPlan()) => plan.replace(STEP1_VERIFY, `${STEP1_VERIFY}\n${line}`); const withOnFailure = (text, plan = buildPlan()) => plan.replace(/^- \*\*On failure:\*\* .*$/m, `- **On failure:** ${text}`); test('K10: `Regression: npm test | tee x` is refused — one command, unpiped', () => { const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x` → expected: exit 0')); assert.equal(r.valid, false); const e = r.errors.find((x) => x.code === 'PLAN_STEP_REGRESSION_NOT_RUNNABLE'); assert.ok(e, codes(r.errors).join(',')); assert.match(e.message, /Step 1/); assert.match(e.message, /shell operator/); }); test('K10: `Regression: npm test` is accepted', () => { const r = validatePlanContent(withRegression('- **Regression:** `npm test` → expected: exit 0')); assert.deepEqual(codes(r.errors), []); assert.ok(!codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE')); }); test('K10: without the field the plan validates exactly as before', () => { const r = validatePlanContent(buildPlan()); assert.equal(r.valid, true, codes(r.errors).join(',')); assert.deepEqual(codes(r.warnings), []); }); test('K10: `npm test && bash tests/e2e.sh`, a bare word, or no backticks is refused', () => { for (const line of [ '- **Regression:** `npm test && bash tests/e2e.sh`', '- **Regression:** `./run-all-tests`', '- **Regression:** npm test', '- **Regression:**', ]) { const r = validatePlanContent(withRegression(line)); assert.ok(codes(r.errors).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'), `${line}: ${codes(r.errors).join(',')}`); } }); test('K10: --soft reports an unrunnable Regression as a warning, not an error', () => { const r = validatePlanContent(withRegression('- **Regression:** `npm test | tee x`'), { strict: false }); assert.equal(r.valid, true, codes(r.errors).join(',')); assert.ok(codes(r.warnings).includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE')); }); test('K11: `On failure: try again` warns, and the plan stays valid', () => { const r = validatePlanContent(withOnFailure('try again')); assert.equal(r.valid, true, codes(r.errors).join(',')); const w = r.warnings.find((x) => x.code === 'PLAN_STEP_ON_FAILURE_UNKNOWN'); assert.ok(w, codes(r.warnings).join(',')); assert.match(w.message, /Step 1/); for (const word of ON_FAILURE_ACTIONS) assert.match(w.hint, new RegExp(word)); }); test('K11: `On failure: retry once, then escalate` gives no warning', () => { const r = validatePlanContent(withOnFailure('retry once, then escalate')); assert.ok(!codes(r.warnings).includes('PLAN_STEP_ON_FAILURE_UNKNOWN'), codes(r.warnings).join(',')); });