// 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. import { readFileSync, existsSync } from 'node:fs'; import { sliceSteps, validatePlanHeadings, extractPlanVersion } 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'; export function validatePlanContent(text, opts = {}) { const strict = opts.strict !== false; const headRes = validatePlanHeadings(text, { strict }); const errors = [...headRes.errors]; const warnings = [...headRes.warnings]; const steps = headRes.parsed?.steps || []; const sections = sliceSteps(text); const manRes = validateAllManifests(sections); errors.push(...manRes.errors); warnings.push(...manRes.warnings); if (steps.length > 0 && manRes.parsed.length !== steps.length) { errors.push(issue( 'PLAN_MANIFEST_COUNT_MISMATCH', `Step count (${steps.length}) does not equal manifest count (${manRes.parsed.length})`, )); } const planVersion = extractPlanVersion(text); if (planVersion === null) { warnings.push(issue('PLAN_NO_VERSION', 'No plan_version detected; current target is 1.7')); } else if (planVersion !== '1.7') { warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is 1.7`)); } // v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only. // If plan frontmatter declares `profile: ` and any step manifest // declares `profile_used: `, emit a warning (NOT an error) so // operators see drift but parsing remains forward-compat. if (strict) { const planFm = parseDocument(text).parsed?.frontmatter; const planProfile = planFm && typeof planFm.profile === 'string' ? planFm.profile : null; if (planProfile) { for (const m of manRes.parsed) { const stepProfile = m.manifest && m.manifest.profile_used; if (typeof stepProfile === 'string' && stepProfile !== planProfile) { warnings.push(issue( 'MANIFEST_PROFILE_DRIFT', `step ${m.n}: profile_used = ${stepProfile}, plan profile = ${planProfile}`, 'A step manifest declares a different profile than the plan frontmatter; ' + 'verify whether this is intentional (manual override) or accidental drift.', )); } } } } return { valid: errors.length === 0, errors, warnings, parsed: { steps, manifests: manRes.parsed, planVersion }, }; } export function validatePlan(filePath, opts = {}) { if (!existsSync(filePath)) return fail(issue('PLAN_NOT_FOUND', `File not found: ${filePath}`)); let text; try { text = readFileSync(filePath, 'utf-8'); } catch (e) { return fail(issue('PLAN_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); } const r = validatePlanContent(text, opts); return { ...r, parsed: { ...r.parsed, filePath } }; } if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); const strict = !args.includes('--soft'); const filePath = args.find(a => !a.startsWith('--')); if (!filePath) { process.stderr.write('Usage: plan-validator.mjs [--strict|--soft] \n'); process.exit(2); } const r = validatePlan(filePath, { strict }); if (args.includes('--json')) { process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings, steps: r.parsed?.steps?.length ?? 0, planVersion: r.parsed?.planVersion ?? null, }, null, 2) + '\n'); } else { process.stdout.write(`plan-validator: ${r.valid ? 'READY' : 'FAIL'} ${filePath} (${r.parsed?.steps?.length ?? 0} steps)\n`); for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`); for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`); } process.exit(r.valid ? 0 : 1); }