ktg-plugin-marketplace/plugins/voyage/lib/validators/profile-validator.mjs
Kjell Tore Guttormsen be9ad6ec07 feat(voyage): add lib/validators/profile-validator.mjs — SC #1, #2, #3
Step 5 av v4.1-execute (Wave 2, Session 2).

Profile-validator etter brief-validator-mønsteret eksakt: validateProfileContent
(pure), validateProfile (file-reader), CLI shim med --json flag. Eksporter
PROFILE_REQUIRED_FIELDS (frozen), PROFILE_REQUIRED_PHASES (frozen).

Validerer:
- Required frontmatter fields (name, phase_models, parallel_agents_min/max,
  external_research_enabled, brief_reviewer_iter_cap)
- phase_models = list-of-dicts med phase + model
- 6 required phases (brief, research, plan, execute, review, continue)
- parallel_agents_max ≥ parallel_agents_min
- Allowed model values: ['sonnet', 'opus']; haiku tillatt KUN ved
  VOYAGE_ALLOW_HAIKU=1 (per global CLAUDE.md modellvalg-prinsipp)

Issue codes: PROFILE_MISSING_FIELD, PROFILE_INVALID_MODEL, PROFILE_INVALID_ENUM,
PROFILE_READ_ERROR, PROFILE_NOT_FOUND.

Field-path-reporting i error-location: phase_models[N].model for SC #2.

Tester (10 nye, baseline 377 → 387):
- SC #1 x3 (innebygde profiler grønne)
- SC #2 (PROFILE_INVALID_MODEL med location phase_models[2].model)
- SC #3 (PROFILE_INVALID_ENUM for external_research_enabled: "yes" string)
- VOYAGE_ALLOW_HAIKU env-var deny/allow
- PROFILE_MISSING_FIELD når name fraværende
- PROFILE_NOT_FOUND for ikke-eksisterende fil
- 2 export drift-pins

Fixturer: profile-invalid-model.yaml (gpt-4 i phase_models[2]),
profile-invalid-enum.yaml (external_research_enabled som string "yes").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 09:26:23 +02:00

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