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>
This commit is contained in:
parent
5b4a86dca9
commit
be9ad6ec07
4 changed files with 376 additions and 0 deletions
184
plugins/voyage/lib/validators/profile-validator.mjs
Normal file
184
plugins/voyage/lib/validators/profile-validator.mjs
Normal file
|
|
@ -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] <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);
|
||||
}
|
||||
21
plugins/voyage/tests/fixtures/profile-invalid-enum.yaml
vendored
Normal file
21
plugins/voyage/tests/fixtures/profile-invalid-enum.yaml
vendored
Normal file
|
|
@ -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
|
||||
---
|
||||
21
plugins/voyage/tests/fixtures/profile-invalid-model.yaml
vendored
Normal file
21
plugins/voyage/tests/fixtures/profile-invalid-model.yaml
vendored
Normal file
|
|
@ -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
|
||||
---
|
||||
150
plugins/voyage/tests/validators/profile-validator.test.mjs
Normal file
150
plugins/voyage/tests/validators/profile-validator.test.mjs
Normal file
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue