// tests/validators/plan-validator-parse-once.test.mjs // The plan validator parses each step's fields once (review finding 93382464): // one private `withFields(sections)` maps each section to its parsed fields, // and every check reads `s.fields` instead of calling `parseStepFields` again. // The output must not change: the expected codes below were recorded from // `validatePlanContent` at the start point of the change (1621352), each with // the step number its message names, in order. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildPlan } from '../helpers/plan-v18-builder.mjs'; import { validatePlanContent } from '../../lib/validators/plan-validator.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const SOURCE = readFileSync(join(ROOT, 'lib', 'validators', 'plan-validator.mjs'), 'utf8'); // The body of a top-level function: from its declaration to the next // top-level `function` or `export`. '' when the function is not there. function bodyOf(name) { const start = SOURCE.search(new RegExp(`^function ${name}\\(`, 'm')); if (start < 0) return ''; const rest = SOURCE.slice(start + 1); const next = rest.search(/^(?:async )?function |^export /m); return next < 0 ? SOURCE.slice(start) : SOURCE.slice(start, start + 1 + next); } // Each issue as CODE@step (the step its message names), so order and place are pinned. const tag = (x) => { const m = /\b[Ss]tep (\d+)/.exec(x.message); return m ? `${x.code}@${m[1]}` : x.code; }; const codes = (r) => ({ errors: r.errors.map(tag), warnings: r.warnings.map(tag) }); const E2E = { endToEnd: 'bash tests/e2e.sh' }; const TESTS_ONLY = { file: 'tests/only.test.mjs', test: 'tests/only.test.mjs', manifestPaths: ['tests/only.test.mjs'] }; const STEP3 = '### Step 3: Fix it\n\n- **Files:** `src/fix.mjs`, `tests/fix.test.mjs`\n- **Changes:** fix it\n' + '- **On failure:** escalate\n'; const STEP4 = '### Step 4: Check it\n\n- **Files:** `src/check.mjs`\n- **Changes:** check it\n' + '- **Verify:** `curl http://localhost/x` → expected: 200\n- **Regression:** `npm test | cat` → expected: exit 0\n' + '- **On failure:** maybe later\n'; const appended = (plan, ...steps) => plan.replace('\n## Test Strategy', `\n${steps.join('\n')}\n## Test Strategy`); test('parse once: the validator source holds one parseStepFields( call, inside function withFields', () => { const calls = SOURCE.match(/parseStepFields\(/g) ?? []; assert.equal(calls.length, 1, `parseStepFields( occurs ${calls.length} times in lib/validators/plan-validator.mjs`); const body = bodyOf('withFields'); assert.ok(body !== '', 'lib/validators/plan-validator.mjs declares function withFields'); assert.match(body, /parseStepFields\(/, 'the one parseStepFields( call stands inside function withFields'); }); for (const name of ['checkSteps', 'checkRunnable', 'checkRecovery', 'checkPathGrammar']) { test(`parse once: ${name} reads .fields in its body`, () => { const body = bodyOf(name); assert.ok(body !== '', `function ${name} is declared`); assert.match(body, /\.fields\b/, `${name} reads the parsed fields (.fields) instead of parsing again`); }); } test('parse once: a 1.7 and a 1.8 plan with a tests-only step give the codes recorded at the start point', () => { for (const version of ['1.7', '1.8']) { const plan = buildPlan({ version, steps: [TESTS_ONLY, E2E] }); assert.deepEqual(codes(validatePlanContent(plan)), { errors: [], warnings: ['PLAN_STEP_TESTS_ONLY@1'] }, version); } }); test('parse once: a 1.8 plan with an unticked path gives the codes recorded at the start point', () => { const plan = buildPlan({ version: '1.8', steps: [{ filesRaw: '`src/mod1.mjs`, src/plain.mjs - the parser' }, E2E] }); const want = { errors: [], warnings: ['PLAN_STEP_PATH_UNTICKED@1'] }; assert.deepEqual(codes(validatePlanContent(plan)), want); assert.deepEqual(codes(validatePlanContent(plan, { strict: false })), want); }); test('parse once: a 1.9 plan with refused entries gives the codes recorded at the start point', () => { const plan = buildPlan({ version: '1.9', steps: [{ filesRaw: 'the parser `src/mod1.mjs`', testFirstRaw: ' - File: `parse()` then `tests/mod1.test.mjs`' }, E2E], }); const refused = ['PLAN_STEP_PATH_NOT_FIRST@1', 'PLAN_STEP_PATH_NOT_A_PATH@1', 'PLAN_STEP_NO_TEST_FIRST@1']; assert.deepEqual(codes(validatePlanContent(plan)), { errors: refused, warnings: [] }); assert.deepEqual(codes(validatePlanContent(plan, { strict: false })), { errors: [], warnings: refused }); }); test('parse once: --steps-from gives the codes recorded at the start point', () => { assert.deepEqual(codes(validatePlanContent(appended(buildPlan({ version: '1.7' }), STEP3), { strict: true, stepsFrom: 3 })), { errors: ['MANIFEST_MISSING@3', 'PLAN_STEP_NO_TEST_FIRST@3', 'PLAN_STEP_VERIFY_NOT_RUNNABLE@3'], warnings: [], }); assert.deepEqual(codes(validatePlanContent(appended(buildPlan({ version: '1.8' }), STEP3), { strict: true, stepsFrom: 3 })), { errors: ['MANIFEST_MISSING@3', 'PLAN_STEP_VERIFY_NOT_RUNNABLE@3', 'PLAN_STEP_NO_TEST_FIRST@3'], warnings: [], }); const two = appended(buildPlan({ version: '1.7' }), STEP3, STEP4); assert.deepEqual(codes(validatePlanContent(two, { strict: true, stepsFrom: 3 })), { errors: [ 'MANIFEST_MISSING@3', 'MANIFEST_MISSING@4', 'PLAN_STEP_REGRESSION_NOT_RUNNABLE@4', 'PLAN_STEP_NO_TEST_FIRST@3', 'PLAN_STEP_NO_TEST_FIRST@4', 'PLAN_STEP_VERIFY_NOT_RUNNABLE@4', 'PLAN_STEP_VERIFY_NOT_RUNNABLE@3', ], warnings: ['PLAN_STEP_ON_FAILURE_UNKNOWN@4'], }); assert.deepEqual(codes(validatePlanContent(two, { strict: false, stepsFrom: 3 })), { errors: ['MANIFEST_MISSING@3', 'MANIFEST_MISSING@4'], warnings: [ 'PLAN_STEP_REGRESSION_NOT_RUNNABLE@4', 'PLAN_STEP_ON_FAILURE_UNKNOWN@4', 'PLAN_STEP_NO_TEST_FIRST@3', 'PLAN_STEP_NO_TEST_FIRST@4', 'PLAN_STEP_VERIFY_NOT_RUNNABLE@4', 'PLAN_STEP_VERIFY_NOT_RUNNABLE@3', ], }); });