feat(voyage): add lib/profiles/resolver.mjs — locked interface SC #5-#9
Step 6 av v4.1-execute (Wave 2, Session 2).
Implementer locked interface contract fra brief Preferences:
- loadProfile(name, opts) → ProfileObject
Leser lib/profiles/<name>.yaml (built-in) eller custom fra
<cwd>/voyage-profiles/ > ~/.claude/voyage-profiles/. Throws Error med
cause: PROFILE_NOT_FOUND. Returnerer parsed object med phase_models
flattened til {brief: 'sonnet', research: 'opus', ...} (object form
for downstream JSON-stats).
- resolveProfile(argv, env) → {profile, profile_source}
Ordre: --profile flag > VOYAGE_PROFILE env > 'premium' default.
- resolveTrekcontinueProfile(planPath, argv, opts) → {profile, profile_source}
--profile flag wins ('flag'); ellers leser plan.md frontmatter
('inheritance'); v4.0-stil plan uten profile-felt → 'default' premium
(backward-compat). Flag overstyrer arv → console.error advisory.
- validateProfileFile(path) → Result
Tynn re-eksport av validateProfile fra profile-validator.mjs.
- findProfilePath(name, opts) → {path, attempted}
Lookup-helper. attempted-array brukes i error-melding for HIGH-risk-
mitigering (ENOENT-diagnose).
Tester (13 nye, baseline 387 → 400):
- SC #5 x4 (loadProfile economy/balanced/premium + PROFILE_NOT_FOUND)
- SC #6 (flag > env > default ordre)
- SC #7 (performance: 1000-iter < 50ms gjennomsnitt; faktisk ~0.055ms)
- SC #8 x2 (cwd > home precedence + error-msg attempted-paths)
- SC #9 x2 (inheritance + flag-override-advisory)
- Backward-compat x2 (v4.0 plan + non-existent plan)
- validateProfileFile re-export sanity
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
be9ad6ec07
commit
f419121682
4 changed files with 474 additions and 0 deletions
26
plugins/voyage/tests/fixtures/plan-with-profile.md
vendored
Normal file
26
plugins/voyage/tests/fixtures/plan-with-profile.md
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
plan_version: "1.7"
|
||||
profile: balanced
|
||||
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
|
||||
---
|
||||
|
||||
# Test plan (with profile)
|
||||
|
||||
This fixture has explicit profile + phase_models in frontmatter.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Stub
|
||||
- Files: src/stub.mjs
|
||||
15
plugins/voyage/tests/fixtures/plan-without-profile.md
vendored
Normal file
15
plugins/voyage/tests/fixtures/plan-without-profile.md
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
plan_version: "1.6"
|
||||
---
|
||||
|
||||
# Test plan (v4.0-style, no profile field)
|
||||
|
||||
This fixture is a v4.0-style plan WITHOUT the v4.1 profile/phase_models fields.
|
||||
Used by tests/lib/profile-application.test.mjs to verify backward-compat
|
||||
edge-case: resolveTrekcontinueProfile returns {profile: 'premium', profile_source: 'default'}
|
||||
without throwing when the plan has no profile concept.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Stub
|
||||
- Files: src/stub.mjs
|
||||
230
plugins/voyage/tests/lib/profile-application.test.mjs
Normal file
230
plugins/voyage/tests/lib/profile-application.test.mjs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
// tests/lib/profile-application.test.mjs
|
||||
// SC #5-#9 + backward-compat edge-case for lib/profiles/resolver.mjs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
loadProfile,
|
||||
resolveProfile,
|
||||
resolveTrekcontinueProfile,
|
||||
validateProfileFile,
|
||||
findProfilePath,
|
||||
} from '../../lib/profiles/resolver.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, '..', '..');
|
||||
|
||||
// SC #5: loadProfile returns matrix-match for all 6 phase_models
|
||||
|
||||
test('SC #5: loadProfile("economy") returns flattened phase_models with all 6 phases', () => {
|
||||
const p = loadProfile('economy');
|
||||
assert.equal(p.name, 'economy');
|
||||
assert.equal(p.phase_models.brief, 'sonnet');
|
||||
assert.equal(p.phase_models.research, 'sonnet');
|
||||
assert.equal(p.phase_models.plan, 'sonnet');
|
||||
assert.equal(p.phase_models.execute, 'sonnet');
|
||||
assert.equal(p.phase_models.review, 'sonnet');
|
||||
assert.equal(p.phase_models.continue, 'sonnet');
|
||||
assert.equal(p.parallel_agents_min, 2);
|
||||
assert.equal(p.parallel_agents_max, 3);
|
||||
assert.equal(p.external_research_enabled, false);
|
||||
assert.equal(p.brief_reviewer_iter_cap, 1);
|
||||
});
|
||||
|
||||
test('SC #5: loadProfile("balanced") returns mixed phase_models', () => {
|
||||
const p = loadProfile('balanced');
|
||||
assert.equal(p.phase_models.plan, 'opus');
|
||||
assert.equal(p.phase_models.review, 'opus');
|
||||
assert.equal(p.phase_models.brief, 'sonnet');
|
||||
assert.equal(p.phase_models.execute, 'sonnet');
|
||||
});
|
||||
|
||||
test('SC #5: loadProfile("premium") returns all-opus', () => {
|
||||
const p = loadProfile('premium');
|
||||
for (const phase of ['brief', 'research', 'plan', 'execute', 'review', 'continue']) {
|
||||
assert.equal(p.phase_models[phase], 'opus', `premium ${phase} should be opus`);
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #5: loadProfile throws PROFILE_NOT_FOUND for unknown profile', () => {
|
||||
try {
|
||||
loadProfile('does-not-exist-xyz');
|
||||
assert.fail('expected throw');
|
||||
} catch (e) {
|
||||
assert.equal(e.cause, 'PROFILE_NOT_FOUND');
|
||||
assert.match(e.message, /not found/);
|
||||
assert.ok(Array.isArray(e.attempted), 'should expose attempted paths');
|
||||
}
|
||||
});
|
||||
|
||||
// SC #6: env-var fallback flag > env > default
|
||||
|
||||
test('SC #6: resolveProfile flag > env > default', () => {
|
||||
// flag wins
|
||||
const r1 = resolveProfile({ flags: { '--profile': 'balanced' } }, { VOYAGE_PROFILE: 'economy' });
|
||||
assert.equal(r1.profile, 'balanced');
|
||||
assert.equal(r1.profile_source, 'flag');
|
||||
|
||||
// env wins when no flag
|
||||
const r2 = resolveProfile({ flags: {} }, { VOYAGE_PROFILE: 'economy' });
|
||||
assert.equal(r2.profile, 'economy');
|
||||
assert.equal(r2.profile_source, 'env');
|
||||
|
||||
// default when neither
|
||||
const r3 = resolveProfile({ flags: {} }, {});
|
||||
assert.equal(r3.profile, 'premium');
|
||||
assert.equal(r3.profile_source, 'default');
|
||||
});
|
||||
|
||||
// SC #7: performance — loadProfile 1000 iter < 50ms average (allowing some headroom)
|
||||
|
||||
test('SC #7: loadProfile 1000-iter performance < 50ms average', () => {
|
||||
const iterations = 1000;
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
loadProfile('economy');
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
const avgMs = elapsed / iterations;
|
||||
assert.ok(avgMs < 50, `loadProfile too slow: ${avgMs.toFixed(3)}ms average over ${iterations} iter`);
|
||||
});
|
||||
|
||||
// SC #8: custom.yaml from repo-root trumps ~/.claude/
|
||||
|
||||
test('SC #8: custom profile from <cwd>/voyage-profiles/<name>.yaml takes precedence over ~/.claude/', () => {
|
||||
const tmpRepo = mkdtempSync(join(tmpdir(), 'voyage-resolver-repo-'));
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'voyage-resolver-home-'));
|
||||
try {
|
||||
// Place custom profile in repo and home — repo should win
|
||||
mkdirSync(join(tmpRepo, 'voyage-profiles'), { recursive: true });
|
||||
mkdirSync(join(tmpHome, '.claude', 'voyage-profiles'), { recursive: true });
|
||||
|
||||
writeFileSync(join(tmpRepo, 'voyage-profiles', 'mycustom.yaml'),
|
||||
`---
|
||||
profile_version: "1.0"
|
||||
name: mycustom-repo
|
||||
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: 1
|
||||
parallel_agents_max: 2
|
||||
external_research_enabled: false
|
||||
brief_reviewer_iter_cap: 1
|
||||
---
|
||||
`);
|
||||
writeFileSync(join(tmpHome, '.claude', 'voyage-profiles', 'mycustom.yaml'),
|
||||
`---
|
||||
profile_version: "1.0"
|
||||
name: mycustom-home
|
||||
phase_models:
|
||||
- phase: brief
|
||||
model: opus
|
||||
- phase: research
|
||||
model: opus
|
||||
- phase: plan
|
||||
model: opus
|
||||
- phase: execute
|
||||
model: opus
|
||||
- phase: review
|
||||
model: opus
|
||||
- phase: continue
|
||||
model: opus
|
||||
parallel_agents_min: 1
|
||||
parallel_agents_max: 2
|
||||
external_research_enabled: true
|
||||
brief_reviewer_iter_cap: 3
|
||||
---
|
||||
`);
|
||||
|
||||
const found = findProfilePath('mycustom', { cwd: tmpRepo, home: tmpHome });
|
||||
assert.ok(found.path, `expected to find mycustom; attempted: ${found.attempted.join(', ')}`);
|
||||
assert.ok(found.path.startsWith(tmpRepo),
|
||||
`expected repo-rot win (path under ${tmpRepo}), got: ${found.path}`);
|
||||
|
||||
const p = loadProfile('mycustom', { cwd: tmpRepo, home: tmpHome });
|
||||
assert.equal(p.name, 'mycustom-repo', 'repo profile should win');
|
||||
assert.equal(p.phase_models.brief, 'sonnet');
|
||||
} finally {
|
||||
rmSync(tmpRepo, { recursive: true, force: true });
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #8: missing profile error message includes both attempted paths', () => {
|
||||
const tmpRepo = mkdtempSync(join(tmpdir(), 'voyage-resolver-empty-'));
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'voyage-resolver-emptyhome-'));
|
||||
try {
|
||||
try {
|
||||
loadProfile('not-a-real-profile', { cwd: tmpRepo, home: tmpHome });
|
||||
assert.fail('expected throw');
|
||||
} catch (e) {
|
||||
assert.equal(e.cause, 'PROFILE_NOT_FOUND');
|
||||
// Both attempted paths should be in the error message for diagnostic clarity
|
||||
const msg = e.message;
|
||||
assert.match(msg, /voyage-profiles\/not-a-real-profile\.yaml/);
|
||||
assert.match(msg, /\.claude\/voyage-profiles\/not-a-real-profile\.yaml/);
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmpRepo, { recursive: true, force: true });
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// SC #9: resolveTrekcontinueProfile inheritance from plan-frontmatter
|
||||
|
||||
test('SC #9: resolveTrekcontinueProfile inherits from plan-frontmatter (profile: balanced)', () => {
|
||||
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-with-profile.md');
|
||||
const r = resolveTrekcontinueProfile(planPath, { flags: {} });
|
||||
assert.equal(r.profile, 'balanced');
|
||||
assert.equal(r.profile_source, 'inheritance');
|
||||
});
|
||||
|
||||
test('SC #9: resolveTrekcontinueProfile flag overrides plan-frontmatter (advisory)', () => {
|
||||
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-with-profile.md');
|
||||
const advisories = [];
|
||||
const fakeConsole = { error: (m) => advisories.push(m) };
|
||||
const r = resolveTrekcontinueProfile(planPath,
|
||||
{ flags: { '--profile': 'economy' } },
|
||||
{ console: fakeConsole });
|
||||
assert.equal(r.profile, 'economy');
|
||||
assert.equal(r.profile_source, 'flag');
|
||||
assert.equal(advisories.length, 1, 'expected one advisory message');
|
||||
assert.match(advisories[0], /balanced.*economy/);
|
||||
assert.match(advisories[0], /\[voyage\]/);
|
||||
});
|
||||
|
||||
// Backward-compat edge-case: v4.0-style plan WITHOUT profile field
|
||||
|
||||
test('Backward-compat: resolveTrekcontinueProfile on v4.0 plan without profile field returns default premium', () => {
|
||||
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-without-profile.md');
|
||||
const r = resolveTrekcontinueProfile(planPath, { flags: {} });
|
||||
assert.equal(r.profile, 'premium');
|
||||
assert.equal(r.profile_source, 'default');
|
||||
});
|
||||
|
||||
test('Backward-compat: resolveTrekcontinueProfile with non-existent plan path returns default premium', () => {
|
||||
const r = resolveTrekcontinueProfile('/tmp/does-not-exist-plan-xyz.md', { flags: {} });
|
||||
assert.equal(r.profile, 'premium');
|
||||
assert.equal(r.profile_source, 'default');
|
||||
});
|
||||
|
||||
// validateProfileFile re-export sanity
|
||||
|
||||
test('validateProfileFile re-exports validateProfile (locked-interface compat)', () => {
|
||||
const r = validateProfileFile(join(REPO_ROOT, 'lib', 'profiles', 'economy.yaml'));
|
||||
assert.equal(r.valid, true);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue