diff --git a/plugins/voyage/lib/validators/profile-validator.mjs b/plugins/voyage/lib/validators/profile-validator.mjs new file mode 100644 index 0000000..48e84d6 --- /dev/null +++ b/plugins/voyage/lib/validators/profile-validator.mjs @@ -0,0 +1,184 @@ +// 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] \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); +} diff --git a/plugins/voyage/tests/fixtures/profile-invalid-enum.yaml b/plugins/voyage/tests/fixtures/profile-invalid-enum.yaml new file mode 100644 index 0000000..34ea789 --- /dev/null +++ b/plugins/voyage/tests/fixtures/profile-invalid-enum.yaml @@ -0,0 +1,21 @@ +--- +profile_version: "1.0" +name: invalid-enum +phase_models: + - phase: brief + model: sonnet + - phase: research + model: sonnet + - phase: plan + model: opus + - phase: execute + model: sonnet + - phase: review + model: opus + - phase: continue + model: sonnet +parallel_agents_min: 4 +parallel_agents_max: 6 +external_research_enabled: "yes" +brief_reviewer_iter_cap: 2 +--- diff --git a/plugins/voyage/tests/fixtures/profile-invalid-model.yaml b/plugins/voyage/tests/fixtures/profile-invalid-model.yaml new file mode 100644 index 0000000..7dfb028 --- /dev/null +++ b/plugins/voyage/tests/fixtures/profile-invalid-model.yaml @@ -0,0 +1,21 @@ +--- +profile_version: "1.0" +name: invalid-model +phase_models: + - phase: brief + model: sonnet + - phase: research + model: sonnet + - phase: plan + model: gpt-4 + - phase: execute + model: sonnet + - phase: review + model: opus + - phase: continue + model: sonnet +parallel_agents_min: 2 +parallel_agents_max: 4 +external_research_enabled: false +brief_reviewer_iter_cap: 2 +--- diff --git a/plugins/voyage/tests/validators/profile-validator.test.mjs b/plugins/voyage/tests/validators/profile-validator.test.mjs new file mode 100644 index 0000000..c0e5792 --- /dev/null +++ b/plugins/voyage/tests/validators/profile-validator.test.mjs @@ -0,0 +1,150 @@ +// tests/validators/profile-validator.test.mjs +// SC #1, #2, #3: profile-validator validates lib/profiles/{economy,balanced,premium}.yaml +// (innebygde profiler) plus rejects invalid models and invalid enum types. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { + validateProfile, + validateProfileContent, + PROFILE_REQUIRED_FIELDS, + PROFILE_REQUIRED_PHASES, +} from '../../lib/validators/profile-validator.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); + +// SC #1: alle 3 innebygde profiler grønne + +for (const profileName of ['economy', 'balanced', 'premium']) { + test(`SC #1: lib/profiles/${profileName}.yaml validates clean`, () => { + const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', `${profileName}.yaml`)); + assert.equal(r.valid, true, + `expected valid, got errors: ${JSON.stringify(r.errors, null, 2)}`); + assert.equal(r.errors.length, 0); + // Spot-check: all 6 phases present + const phases = r.parsed.frontmatter.phase_models.map(p => p.phase); + for (const required of PROFILE_REQUIRED_PHASES) { + assert.ok(phases.includes(required), `${profileName} missing phase: ${required}`); + } + }); +} + +// SC #2: PROFILE_INVALID_MODEL fired when phase_models[N].model not in allowlist + +test('SC #2: profile-invalid-model.yaml rejected with PROFILE_INVALID_MODEL at phase_models[2].model', () => { + const r = validateProfile(join(REPO_ROOT, 'tests', 'fixtures', 'profile-invalid-model.yaml')); + assert.equal(r.valid, false); + const found = r.errors.find(e => e.code === 'PROFILE_INVALID_MODEL'); + assert.ok(found, `expected PROFILE_INVALID_MODEL, got: ${JSON.stringify(r.errors)}`); + assert.equal(found.location, 'phase_models[2].model', + `expected location phase_models[2].model, got ${found.location}`); + assert.match(found.message, /gpt-4/); +}); + +// SC #3: PROFILE_INVALID_ENUM for wrong-type values + +test('SC #3: profile-invalid-enum.yaml rejected with PROFILE_INVALID_ENUM (external_research_enabled is string)', () => { + const r = validateProfile(join(REPO_ROOT, 'tests', 'fixtures', 'profile-invalid-enum.yaml')); + assert.equal(r.valid, false); + const found = r.errors.find(e => e.code === 'PROFILE_INVALID_ENUM' && /external_research_enabled/.test(e.message)); + assert.ok(found, `expected PROFILE_INVALID_ENUM for external_research_enabled, got: ${JSON.stringify(r.errors)}`); + assert.match(found.message, /boolean/); +}); + +// VOYAGE_ALLOW_HAIKU env-var allows haiku model + +test('VOYAGE_ALLOW_HAIKU=1 allows haiku in phase_models', () => { + const haikuProfile = `--- +profile_version: "1.0" +name: haiku-allowed +phase_models: + - phase: brief + model: haiku + - phase: research + model: sonnet + - phase: plan + model: opus + - phase: execute + model: sonnet + - phase: review + model: opus + - phase: continue + model: sonnet +parallel_agents_min: 2 +parallel_agents_max: 4 +external_research_enabled: false +brief_reviewer_iter_cap: 1 +--- +`; + // Default env: haiku rejected + const denied = validateProfileContent(haikuProfile, { env: { /* no VOYAGE_ALLOW_HAIKU */ } }); + assert.equal(denied.valid, false); + const haikuErr = denied.errors.find(e => e.code === 'PROFILE_INVALID_MODEL' && /haiku/.test(e.message)); + assert.ok(haikuErr, `expected haiku rejection: ${JSON.stringify(denied.errors)}`); + assert.match(haikuErr.message, /VOYAGE_ALLOW_HAIKU/); + + // With opt-in: haiku accepted + const allowed = validateProfileContent(haikuProfile, { env: { VOYAGE_ALLOW_HAIKU: '1' } }); + assert.equal(allowed.valid, true, + `expected valid with VOYAGE_ALLOW_HAIKU=1, got: ${JSON.stringify(allowed.errors)}`); +}); + +// Required fields presence + +test('PROFILE_MISSING_FIELD when name absent', () => { + const r = validateProfileContent(`--- +profile_version: "1.0" +phase_models: + - phase: brief + model: sonnet + - phase: research + model: sonnet + - phase: plan + model: opus + - phase: execute + model: sonnet + - phase: review + model: opus + - phase: continue + model: sonnet +parallel_agents_min: 2 +parallel_agents_max: 4 +external_research_enabled: false +brief_reviewer_iter_cap: 1 +--- +`); + assert.equal(r.valid, false); + const found = r.errors.find(e => e.code === 'PROFILE_MISSING_FIELD' && /name/.test(e.message)); + assert.ok(found, `expected PROFILE_MISSING_FIELD for name, got: ${JSON.stringify(r.errors)}`); +}); + +// PROFILE_NOT_FOUND for missing file + +test('PROFILE_NOT_FOUND for non-existent file', () => { + const r = validateProfile('/tmp/does-not-exist-profile-xyz.yaml'); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'PROFILE_NOT_FOUND')); +}); + +// REQUIRED_FIELDS frontmatter contract drift-pin + +test('PROFILE_REQUIRED_FIELDS export drift-pin', () => { + assert.deepEqual( + [...PROFILE_REQUIRED_FIELDS].sort(), + ['brief_reviewer_iter_cap', 'external_research_enabled', 'name', + 'parallel_agents_max', 'parallel_agents_min', 'phase_models'].sort(), + 'PROFILE_REQUIRED_FIELDS contract drift — pin contract', + ); +}); + +test('PROFILE_REQUIRED_PHASES export drift-pin', () => { + assert.deepEqual( + [...PROFILE_REQUIRED_PHASES].sort(), + ['brief', 'research', 'plan', 'execute', 'review', 'continue'].sort(), + 'PROFILE_REQUIRED_PHASES contract drift — pin contract', + ); +});