276 lines
9.5 KiB
JavaScript
276 lines
9.5 KiB
JavaScript
// tests/validators/profile-validator.test.mjs
|
|
// SC #1, #2, #3: profile-validator validates lib/profiles/{economy,balanced,premium,fable}.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,
|
|
BASE_ALLOWED_MODELS,
|
|
} from '../../lib/validators/profile-validator.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = join(__dirname, '..', '..');
|
|
|
|
// SC #1: alle 4 innebygde profiler grønne
|
|
|
|
for (const profileName of ['economy', 'balanced', 'premium', 'fable']) {
|
|
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)}`);
|
|
});
|
|
|
|
// Fable tier (v5.9): fable accepted under DEFAULT env (no opt-in flag),
|
|
// unknown models still rejected — the allowlist gate must demonstrably fire.
|
|
|
|
test('fable accepted in phase_models under default env (no env flag)', () => {
|
|
const fableProfile = `---
|
|
profile_version: "1.0"
|
|
name: fable-inline
|
|
phase_models:
|
|
- phase: brief
|
|
model: fable
|
|
- phase: research
|
|
model: fable
|
|
- phase: plan
|
|
model: fable
|
|
- phase: execute
|
|
model: fable
|
|
- phase: review
|
|
model: fable
|
|
- phase: continue
|
|
model: fable
|
|
parallel_agents_min: 6
|
|
parallel_agents_max: 8
|
|
external_research_enabled: true
|
|
brief_reviewer_iter_cap: 3
|
|
---
|
|
`;
|
|
const r = validateProfileContent(fableProfile, { env: { /* default: no flags */ } });
|
|
assert.equal(r.valid, true,
|
|
`expected fable accepted under default env, got: ${JSON.stringify(r.errors)}`);
|
|
assert.equal(r.errors.length, 0);
|
|
});
|
|
|
|
test('unknown model gpt5 rejected with PROFILE_INVALID_MODEL under default env', () => {
|
|
const gpt5Profile = `---
|
|
profile_version: "1.0"
|
|
name: gpt5-inline
|
|
phase_models:
|
|
- phase: brief
|
|
model: gpt5
|
|
- 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
|
|
---
|
|
`;
|
|
const r = validateProfileContent(gpt5Profile, { env: { /* default: no flags */ } });
|
|
assert.equal(r.valid, false);
|
|
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_MODEL' && /gpt5/.test(e.message));
|
|
assert.ok(found, `expected PROFILE_INVALID_MODEL for gpt5, got: ${JSON.stringify(r.errors)}`);
|
|
});
|
|
|
|
// BASE_ALLOWED_MODELS allowlist drift-pin (mirrors the PROFILE_REQUIRED_FIELDS pin)
|
|
|
|
test('BASE_ALLOWED_MODELS export drift-pin', () => {
|
|
assert.deepEqual(
|
|
[...BASE_ALLOWED_MODELS],
|
|
['sonnet', 'opus', 'fable'],
|
|
'BASE_ALLOWED_MODELS contract drift — pin contract',
|
|
);
|
|
});
|
|
|
|
// 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',
|
|
);
|
|
});
|
|
|
|
// S34 (V30) — economy carries an explicit `experimental` marker.
|
|
// The cross-tier Jaccard floor (0.55) rests on parked-synthetic fixtures;
|
|
// empirical Step-17 calibration is deferred to v4.2 ($60-120 LLM budget,
|
|
// unauthorized). Until a real calibration lands, `economy` is self-declared
|
|
// experimental in the profile DATA — not only in prose — so the status
|
|
// survives a docs rewrite. See tests/synthetic/profile-jaccard-calibration.md.
|
|
|
|
test('S34: economy.yaml declares experimental: true (uncalibrated Jaccard floor)', () => {
|
|
const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', 'economy.yaml'));
|
|
assert.equal(r.valid, true, `economy.yaml should validate: ${JSON.stringify(r.errors)}`);
|
|
assert.equal(r.parsed.frontmatter.experimental, true,
|
|
'economy.yaml must carry experimental: true until the Jaccard floor is empirically calibrated (S34/V30)');
|
|
});
|
|
|
|
test('S34: premium + balanced are NOT flagged experimental', () => {
|
|
for (const name of ['premium', 'balanced']) {
|
|
const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', `${name}.yaml`));
|
|
assert.equal(r.valid, true, `${name}.yaml should validate: ${JSON.stringify(r.errors)}`);
|
|
assert.notEqual(r.parsed.frontmatter.experimental, true,
|
|
`${name}.yaml must not be flagged experimental`);
|
|
}
|
|
});
|
|
|
|
test('S34: validator type-checks experimental as boolean (PROFILE_INVALID_ENUM)', () => {
|
|
const bad = `---
|
|
profile_version: "1.0"
|
|
name: bad-experimental
|
|
phase_models:
|
|
- phase: brief
|
|
model: sonnet
|
|
- phase: research
|
|
model: sonnet
|
|
- phase: plan
|
|
model: sonnet
|
|
- phase: execute
|
|
model: sonnet
|
|
- phase: review
|
|
model: sonnet
|
|
- phase: continue
|
|
model: sonnet
|
|
parallel_agents_min: 2
|
|
parallel_agents_max: 3
|
|
external_research_enabled: false
|
|
brief_reviewer_iter_cap: 1
|
|
experimental: "yes"
|
|
---
|
|
`;
|
|
const r = validateProfileContent(bad);
|
|
assert.equal(r.valid, false, 'experimental: "yes" (string) must be rejected');
|
|
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_ENUM' && /experimental/.test(e.message));
|
|
assert.ok(found, `expected PROFILE_INVALID_ENUM for experimental, got: ${JSON.stringify(r.errors)}`);
|
|
assert.match(found.message, /boolean/);
|
|
});
|