// lib/validators/plan-validator.mjs // Wraps plan-schema (heading shape) + manifest-yaml (per-step Manifest blocks). // This is the JS equivalent of Phase 5.5 grep checks in planning-orchestrator. // // Schema is forward-compatible: unknown top-level frontmatter keys are // tolerated silently. Strict-key checks are intentionally avoided so new // optional fields (jf. the source_findings precedent) can be added without // a plan_version bump. // // plan_version 1.8 rules (apply only to plans declaring >= 1.8; 1.7 plans // validate exactly as before): // - every step has `Test first:` naming a File and what it Verifies; // - the plan has a "run the thing" step: an `End-to-end:` field with a // backticked command (without it a result is "not verified end to end"); // - the optional step fields `Interface in:`, `Interface out:`, // `Review focus:` are never empty when present; // - `ceremony:` is small | bounded | architectural (absent = architectural); // an architectural plan carries `## Design` (see checkDesign); // - every check the run will execute can run (checkRunnable): each // `## Verification` criterion, each step's `Verify:` (its first backticked // span is the step's test command) and each `End-to-end:` command pass the // criteria runner's allowlist, and each `End-to-end:` command also stands // under `## Verification`, where Phase 7 runs it. // Strict mode reports these as errors; --soft downgrades them to warnings. // // Every plan version (the fields are new; a plan without them is unchanged): // - K10: the optional step field `Regression:` holds ONE command the // criteria runner's allowlist runs (first backticked span, no pipe, no // `&&`); the executor runs it green before the step's checkpoint. One that // can never run is an error (a warning under --soft). // - K11: `On failure:` opens with revert | retry | skip | escalate, the words // /trekexecute reads; any other opening is a warning. // - A step whose `Files:` are all tests (plan-schema `stepTestsOnly`) is the // warning PLAN_STEP_TESTS_ONLY: its tests pass at its start point, so it can // never be proven red (voyage-lekerepo trial 3, 2026-10-09). import { readFileSync, existsSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony, parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks, versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES, ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnly, } from '../parsers/plan-schema.mjs'; import { validateAllManifests } from '../parsers/manifest-yaml.mjs'; import { parseDocument } from '../util/frontmatter.mjs'; import { issue, fail } from '../util/result.mjs'; import { parsePlanVerification, firstCommand, runnableCheck } from '../verification/criteria-runner.mjs'; export const OPTIONAL_STEP_FIELDS = Object.freeze(['Interface in', 'Interface out', 'Review focus']); export const DESIGN_SUBSECTIONS = Object.freeze([ 'Architecture', 'Data Flow', 'Interfaces', 'Data Models', 'Error Handling', 'Module Map', 'Architecture Rules', 'Requirements Check', ]); const BACKTICKED = /`[^`\n]+`/; function checkSteps(sections) { const out = []; let endToEnd = 0; for (const s of sections) { const f = parseStepFields(s.body); const tf = f.get('Test first'); const names = tf && /\bFile:\**[ \t]*`[^`\n]+`/i.test(tf.text) && /\bVerifies:\**[ \t]*\S/i.test(tf.text); if (!names) { out.push(issue( 'PLAN_STEP_NO_TEST_FIRST', `Step ${s.n}: ${tf ? '`Test first:` does not name a File (backticked path) and what it Verifies' : 'no `Test first:` field'}`, 'plan_version 1.8 requires `Test first:` on every step, with `File:` and `Verifies:` sub-bullets.', )); } for (const name of OPTIONAL_STEP_FIELDS) { if (f.has(name) && f.get(name).text === '') { out.push(issue('PLAN_STEP_FIELD_EMPTY', `Step ${s.n}: \`${name}:\` is present but empty`, 'Fill the field or remove it; it is optional.')); } } if (f.has('End-to-end') && BACKTICKED.test(f.get('End-to-end').text)) endToEnd += 1; } if (sections.length > 0 && endToEnd === 0) { out.push(issue( 'PLAN_NO_END_TO_END', 'No "run the thing" step: no step carries `End-to-end:` with a backticked command', 'Add a step that runs the product the way a user does (`End-to-end: `` → expected: …`). ' + 'Without it the result is "not verified end to end", never green.', )); } return out; } const WAY_IN = 'A check the allowlist has no name for (a pipe, `jq`, `grep`, the product\'s own binary) is ' + 'wrapped in a script under tests/ and declared as `bash tests/