voyage/tests/validators/plan-validator-parse-once.test.mjs
Kjell Tore Guttormsen 045191f51d
refactor(plan): the plan validator parses each step once
Review finding 93382464.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:47:33 +02:00

112 lines
6 KiB
JavaScript

// 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',
],
});
});