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>
184 lines
7.3 KiB
JavaScript
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);
|
|
}
|