// lib/validators/profile-validator.mjs // Validate model-profile YAML files (lib/profiles/*.yaml + custom voyage-profiles/*.yaml). // // Profile schema (v4.1, profile_version 1.0): // name : string // phase_models : list-of-dicts [{phase: string, model: string}, ...] // 6 entries required (brief, research, plan, execute, review, continue) // parallel_agents_min : number (≥ 1) // parallel_agents_max : number (≥ parallel_agents_min) // external_research_enabled : boolean // brief_reviewer_iter_cap : number (≥ 1) // // Issue codes: // PROFILE_MISSING_FIELD — required top-level frontmatter field absent // PROFILE_INVALID_MODEL — phase_models[N].model not in allowlist // PROFILE_INVALID_ENUM — wrong-type value (e.g. external_research_enabled is string) // PROFILE_READ_ERROR — file unreadable or parse-error // PROFILE_NOT_FOUND — file does not exist // // Allowed model values: ['sonnet', 'opus']. Haiku is allowed only when // VOYAGE_ALLOW_HAIKU=1 (per global CLAUDE.md modellvalg-prinsipp: Haiku skal // ikke brukes som default; eksplisitt opt-in for spesielle bruksmønstre). import { readFileSync, existsSync } from 'node:fs'; import { parseDocument } from '../util/frontmatter.mjs'; import { issue, ok, fail } from '../util/result.mjs'; export const PROFILE_REQUIRED_FIELDS = Object.freeze([ 'name', 'phase_models', 'parallel_agents_min', 'parallel_agents_max', 'external_research_enabled', 'brief_reviewer_iter_cap', ]); export const PROFILE_REQUIRED_PHASES = Object.freeze([ 'brief', 'research', 'plan', 'execute', 'review', 'continue', ]); export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus']); function getAllowedModels(env = process.env) { if (env.VOYAGE_ALLOW_HAIKU === '1') { return [...BASE_ALLOWED_MODELS, 'haiku']; } return BASE_ALLOWED_MODELS; } export function validateProfileContent(text, opts = {}) { const env = opts.env || process.env; const allowedModels = getAllowedModels(env); const doc = parseDocument(text); if (!doc.valid) { return fail(issue('PROFILE_READ_ERROR', `Frontmatter parse error: ${doc.errors[0]?.message || 'unknown'}`)); } const fm = doc.parsed.frontmatter || {}; const errors = []; const warnings = []; // Required field presence for (const k of PROFILE_REQUIRED_FIELDS) { if (!(k in fm)) { errors.push(issue('PROFILE_MISSING_FIELD', `Required profile field missing: ${k}`)); } } // Type checks for scalar fields if ('name' in fm && typeof fm.name !== 'string') { errors.push(issue('PROFILE_INVALID_ENUM', `name must be string (got ${typeof fm.name})`)); } if ('parallel_agents_min' in fm && typeof fm.parallel_agents_min !== 'number') { errors.push(issue('PROFILE_INVALID_ENUM', `parallel_agents_min must be number (got ${typeof fm.parallel_agents_min})`)); } if ('parallel_agents_max' in fm && typeof fm.parallel_agents_max !== 'number') { errors.push(issue('PROFILE_INVALID_ENUM', `parallel_agents_max must be number (got ${typeof fm.parallel_agents_max})`)); } if (typeof fm.parallel_agents_min === 'number' && typeof fm.parallel_agents_max === 'number') { if (fm.parallel_agents_max < fm.parallel_agents_min) { errors.push(issue('PROFILE_INVALID_ENUM', `parallel_agents_max (${fm.parallel_agents_max}) < parallel_agents_min (${fm.parallel_agents_min})`)); } } if ('external_research_enabled' in fm && typeof fm.external_research_enabled !== 'boolean') { errors.push(issue('PROFILE_INVALID_ENUM', `external_research_enabled must be boolean (got ${typeof fm.external_research_enabled})`)); } if ('brief_reviewer_iter_cap' in fm && typeof fm.brief_reviewer_iter_cap !== 'number') { errors.push(issue('PROFILE_INVALID_ENUM', `brief_reviewer_iter_cap must be number (got ${typeof fm.brief_reviewer_iter_cap})`)); } // phase_models validation if ('phase_models' in fm) { if (!Array.isArray(fm.phase_models)) { errors.push(issue('PROFILE_INVALID_ENUM', `phase_models must be a list-of-dicts (got ${typeof fm.phase_models})`)); } else { const seenPhases = new Set(); for (let i = 0; i < fm.phase_models.length; i++) { const entry = fm.phase_models[i]; if (!entry || typeof entry !== 'object') { errors.push(issue('PROFILE_INVALID_ENUM', `phase_models[${i}] must be a dict {phase, model} (got ${typeof entry})`, undefined, `phase_models[${i}]`)); continue; } if (typeof entry.phase !== 'string') { errors.push(issue('PROFILE_INVALID_ENUM', `phase_models[${i}].phase must be string`, undefined, `phase_models[${i}].phase`)); } else { seenPhases.add(entry.phase); if (!PROFILE_REQUIRED_PHASES.includes(entry.phase)) { errors.push(issue('PROFILE_INVALID_ENUM', `phase_models[${i}].phase "${entry.phase}" not in [${PROFILE_REQUIRED_PHASES.join(', ')}]`, undefined, `phase_models[${i}].phase`)); } } if (typeof entry.model !== 'string') { errors.push(issue('PROFILE_INVALID_MODEL', `phase_models[${i}].model must be string`, undefined, `phase_models[${i}].model`)); } else if (!allowedModels.includes(entry.model)) { errors.push(issue('PROFILE_INVALID_MODEL', `phase_models[${i}].model "${entry.model}" not in [${allowedModels.join(', ')}]` + (entry.model === 'haiku' ? ' (set VOYAGE_ALLOW_HAIKU=1 to allow)' : ''), undefined, `phase_models[${i}].model`)); } } // All 6 required phases must be present for (const required of PROFILE_REQUIRED_PHASES) { if (!seenPhases.has(required)) { errors.push(issue('PROFILE_MISSING_FIELD', `phase_models missing required phase entry: ${required}`)); } } } } return { valid: errors.length === 0, errors, warnings, parsed: { frontmatter: fm } }; } export function validateProfile(filePath, opts = {}) { if (!existsSync(filePath)) { return fail(issue('PROFILE_NOT_FOUND', `Profile file not found: ${filePath}`)); } let text; try { text = readFileSync(filePath, 'utf-8'); } catch (e) { return fail(issue('PROFILE_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); } const r = validateProfileContent(text, opts); return { ...r, parsed: { ...(r.parsed || {}), filePath } }; } if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); const filePath = args.find(a => !a.startsWith('--')); if (!filePath) { process.stderr.write('Usage: profile-validator.mjs [--json] \n'); process.exit(2); } const r = validateProfile(filePath); if (args.includes('--json')) { process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n'); } else { process.stdout.write(`profile-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`); for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}${e.location ? ' at ' + e.location : ''}\n`); for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`); } process.exit(r.valid ? 0 : 1); }