ktg-plugin-marketplace/plugins/voyage/lib/validators/profile-validator.mjs

184 lines
7.3 KiB
JavaScript

// 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] <profile.yaml>\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);
}