397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
16 KiB
JavaScript
258 lines
16 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
detectHighRisk, validateLevelSkip, LEVEL_SKIP_CATEGORIES, cheaperOf,
|
||
validateTrace, groundTrace, TRACE_SLOTS,
|
||
buildRouterPrompt, parseRouterResponse, runRouter,
|
||
} from './router-engine.mjs';
|
||
import { buildNodeGraph } from './node-graph.mjs';
|
||
import { classifyDestructive } from './classify-destructive.mjs';
|
||
|
||
const GRAPH = buildNodeGraph({ nodes: [
|
||
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' },
|
||
{ id: '#55', slug: 'discovery-interview', name: 'discovery-interview', status: 'active' },
|
||
], chains: {} });
|
||
|
||
describe('detectHighRisk — parity/consolidation на classifyDestructive (Step 1.7)', () => {
|
||
// командная-проверка detectHighRisk делегирует classifyDestructive.suspicious.
|
||
// format сохранён; новые: голый migrate / db:wipe (старый локальный DESTRUCTIVE_RE их терял).
|
||
const CASES = ['rm -rf x', 'git push --force', 'format D:', 'php artisan migrate', 'php artisan db:wipe', 'git status'];
|
||
for (const command of CASES) {
|
||
it(`detectHighRisk.high (только command) согласован с classifyDestructive.suspicious для ${command}`, () => {
|
||
const expected = classifyDestructive(command).suspicious;
|
||
expect(detectHighRisk({ op: 'Bash', command }).high).toBe(expected);
|
||
});
|
||
}
|
||
});
|
||
|
||
const CATALOG = { nodes: [
|
||
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', capabilities: 'plans', status: 'active' },
|
||
] };
|
||
|
||
const FULL_TRACE = {
|
||
candidates: ['writing-plans', 'discovery-interview'],
|
||
chosen: 'writing-plans',
|
||
why_chosen: 'multi-step feature plan',
|
||
twins_considered: 'discovery-interview — отброшен: цель не интервью',
|
||
confidence: 0.8,
|
||
};
|
||
|
||
describe('detectHighRisk (6.1 — ДЕТЕРМИНИРОВАННО, LLM-оценка только совет)', () => {
|
||
it('разрушительная операция → high', () => {
|
||
expect(detectHighRisk({ op: 'Bash', command: 'rm -rf x' }).high).toBe(true);
|
||
expect(detectHighRisk({ op: 'Bash', command: 'git push --force' }).high).toBe(true);
|
||
});
|
||
it('прод-выкат → high', () => {
|
||
expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
|
||
});
|
||
it('многошаговость над порогом → high', () => {
|
||
expect(detectHighRisk({ op: 'Skill', stepCount: 9 }).high).toBe(true);
|
||
});
|
||
it('инъецированный sensitive-флаг → high (портативно, без зашитых путей)', () => {
|
||
expect(detectHighRisk({ op: 'Edit', sensitive: true }).high).toBe(true);
|
||
});
|
||
it('обычное чтение → не high', () => {
|
||
expect(detectHighRisk({ op: 'Read' }).high).toBe(false);
|
||
});
|
||
it('reasons перечисляет причины', () => {
|
||
expect(detectHighRisk({ op: 'Bash', command: 'DROP TABLE x' }).reasons.length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
describe('validateLevelSkip (6.2 — только закрытый список категорий)', () => {
|
||
it('категория из закрытого списка → ok', () => {
|
||
expect(validateLevelSkip(LEVEL_SKIP_CATEGORIES[0]).ok).toBe(true);
|
||
});
|
||
it('свободный текст → отклонён', () => {
|
||
expect(validateLevelSkip('не хочу заморачиваться').ok).toBe(false);
|
||
});
|
||
it('пусто → отклонён', () => {
|
||
expect(validateLevelSkip('').ok).toBe(false);
|
||
expect(validateLevelSkip(null).ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('cheaperOf (6.1 — цена РАЗДЕЛИТЕЛЬ равноценных, не глушилка)', () => {
|
||
it('при равном качестве берёт дешевле', () => {
|
||
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 5 }, { skill: 'B', qualityRank: 1, costRank: 2 }]);
|
||
expect(r.skill).toBe('B');
|
||
});
|
||
it('лучшее качество побеждает несмотря на цену (цена НЕ важнее правильности)', () => {
|
||
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 9 }, { skill: 'B', qualityRank: 2, costRank: 1 }]);
|
||
expect(r.skill).toBe('A');
|
||
});
|
||
it('пусто → null', () => { expect(cheaperOf([])).toBe(null); });
|
||
});
|
||
|
||
describe('validateTrace (5.1 структурные слоты; пустой слот = красный флаг, §6.2 A)', () => {
|
||
it('полная трасса → ok', () => {
|
||
expect(validateTrace(FULL_TRACE)).toEqual({ ok: true, missingSlots: [] });
|
||
});
|
||
it('пустой слот → невалидно + перечень', () => {
|
||
const r = validateTrace({ ...FULL_TRACE, why_chosen: '' });
|
||
expect(r.ok).toBe(false); expect(r.missingSlots).toContain('why_chosen');
|
||
});
|
||
it('нет кандидатов → невалидно', () => {
|
||
expect(validateTrace({ ...FULL_TRACE, candidates: [] }).ok).toBe(false);
|
||
});
|
||
it('TRACE_SLOTS перечисляет обязательные слоты', () => {
|
||
expect(TRACE_SLOTS).toContain('chosen');
|
||
});
|
||
});
|
||
|
||
// fix: tools/router-engine.mjs (F-8, аудит M1-M4) — confidence обязан быть в [0,1]
|
||
describe('F-8: validateTrace отвергает confidence вне диапазона 0..1', () => {
|
||
it('confidence > 1 → невалидная трасса (обход воздержания закрыт)', () => {
|
||
const v = validateTrace({ ...FULL_TRACE, confidence: 5 });
|
||
expect(v.ok).toBe(false);
|
||
expect(v.missingSlots).toContain('confidence');
|
||
});
|
||
it('confidence < 0 → невалидная', () => {
|
||
expect(validateTrace({ ...FULL_TRACE, confidence: -3 }).ok).toBe(false);
|
||
});
|
||
it('Infinity / NaN → невалидная', () => {
|
||
expect(validateTrace({ ...FULL_TRACE, confidence: Infinity }).ok).toBe(false);
|
||
expect(validateTrace({ ...FULL_TRACE, confidence: NaN }).ok).toBe(false);
|
||
});
|
||
it('confidence в [0,1] по-прежнему валиден', () => {
|
||
expect(validateTrace({ ...FULL_TRACE, confidence: 0 }).ok).toBe(true);
|
||
expect(validateTrace({ ...FULL_TRACE, confidence: 1 }).ok).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('groundTrace (ОВ-Д2 C — все кандидаты в реальные узлы; выдумка → отклонение)', () => {
|
||
it('все кандидаты резолвятся → grounded', () => {
|
||
expect(groundTrace(FULL_TRACE, GRAPH)).toMatchObject({ grounded: true, invented: [] });
|
||
});
|
||
it('выдуманный кандидат → not grounded + перечень', () => {
|
||
const r = groundTrace({ ...FULL_TRACE, candidates: ['writing-plans', 'elasticsearch-magic'] }, GRAPH);
|
||
expect(r.grounded).toBe(false); expect(r.invented).toContain('elasticsearch-magic');
|
||
});
|
||
it('выдуманный chosen → not grounded', () => {
|
||
const r = groundTrace({ ...FULL_TRACE, chosen: 'made-up' }, GRAPH);
|
||
expect(r.grounded).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('buildRouterPrompt (чистая, {system,user}; граф+каталог в system для кэша 6.2)', () => {
|
||
it('детерминирована: тот же вход → тот же текст', () => {
|
||
const a = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
|
||
const b = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
|
||
expect(a.system).toBe(b.system);
|
||
expect(a.user).toBe(b.user);
|
||
});
|
||
it('system несёт вшитый брейнсторм + слоты трассы; user — задачу', () => {
|
||
const { system, user } = buildRouterPrompt({ prompt: 'add feature X', graph: GRAPH, catalog: CATALOG });
|
||
expect(system).toMatch(/вариант|brainstorm|2.?3/i);
|
||
expect(system).toMatch(/candidates/);
|
||
expect(user).toMatch(/add feature X/);
|
||
});
|
||
it('фикс-3: system несёт нюх 5.3 (подозрение к размытому) + интервьюер 4.4 (уточняющий вопрос)', () => {
|
||
const { system } = buildRouterPrompt({ prompt: 'настрой красиво', graph: GRAPH, catalog: CATALOG });
|
||
expect(system).toMatch(/нюх|5\.3|размыт/i);
|
||
expect(system).toMatch(/4\.4|уточня|переспрос/i);
|
||
});
|
||
it('фикс-2: контракты в промпте + look-ahead (нужды/решения вызываемых навыков вперёд)', () => {
|
||
const contracts = [{ skill: 'writing-plans', needs: ['spec'], 'key-decisions': ['file structure'], 'acceptance-criteria': ['each step 2-5 min'] }];
|
||
const { system } = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG, contracts });
|
||
expect(system).toMatch(/look-ahead|вперёд|вызываемых/i);
|
||
expect(system).toMatch(/spec/);
|
||
expect(system).toMatch(/file structure/);
|
||
});
|
||
it('фикс-2: без контрактов — раздел КОНТРАКТЫ не добавляется (детерминизм сохранён)', () => {
|
||
const a = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG });
|
||
const b = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG, contracts: [] });
|
||
expect(a.system).toBe(b.system);
|
||
expect(a.system).not.toMatch(/КОНТРАКТЫ ВЫЗЫВАЕМЫХ/);
|
||
});
|
||
// W1 (C2, нах.F1/C-2/CD-R6-G): catalog ≠ graph — карта районов отдельной секцией
|
||
it('W1: graph.layer0 → ОТДЕЛЬНАЯ секция КАРТА РАЙОНОВ в system (имена районов + staleness)', () => {
|
||
const graphSection = { kind: 'project-graph', districtCount: 2, layer0: [{ district: 'tools', nodeCount: 3, topNodes: [{ id: 'a' }] }, { district: 'app-backend', nodeCount: 7 }], staleness: { stale: true, commits_behind: null, uncommitted: 2 } };
|
||
const { system } = buildRouterPrompt({ prompt: 'x', graph: graphSection, catalog: CATALOG });
|
||
expect(system).toMatch(/КАРТА РАЙОНОВ/);
|
||
expect(system).toMatch(/app-backend/);
|
||
expect(system).toMatch(/stale/i);
|
||
});
|
||
it('W1: каталог берётся ТОЛЬКО из catalog.nodes — граф больше НЕ фолбэк каталога', () => {
|
||
const { system } = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: null });
|
||
expect(system).not.toMatch(/#19/); // узлы GRAPH не рендерятся как каталог
|
||
});
|
||
it('W1: заголовка «(100%)» больше нет — каталог подписан как skill-каталог', () => {
|
||
const { system } = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG });
|
||
expect(system).not.toMatch(/\(100%\)/);
|
||
expect(system).toMatch(/КАТАЛОГ УЗЛОВ \(skill-каталог\)/);
|
||
});
|
||
it('F-C2-3: catalog не передан/пуст → явный маркер пустого каталога + воздержание (не молча)', () => {
|
||
const a = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: null });
|
||
expect(a.system).toMatch(/КАТАЛОГ ПУСТ/);
|
||
expect(a.system).toMatch(/воздержание/i);
|
||
const b = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: { nodes: [] } });
|
||
expect(b.system).toMatch(/КАТАЛОГ ПУСТ/);
|
||
});
|
||
});
|
||
|
||
describe('parseRouterResponse', () => {
|
||
it('парсит JSON-трассу', () => {
|
||
const t = parseRouterResponse('```json\n{"chosen":"writing-plans","candidates":["writing-plans"],"why_chosen":"x","twins_considered":"y","confidence":0.9}\n```');
|
||
expect(t.chosen).toBe('writing-plans');
|
||
});
|
||
it('мусор → null', () => { expect(parseRouterResponse('не json')).toBe(null); });
|
||
// FR-4 (финревью 2026-06-11): жадный {…} ловил от первой { до ПОСЛЕДНЕЙ } —
|
||
// два JSON-объекта в тексте роняли парс в null; берём первый сбалансированный.
|
||
it('FR-4: два JSON-объекта в тексте → парсится ПЕРВЫЙ, не null', () => {
|
||
const t = parseRouterResponse('вот трасса {"chosen":"a","confidence":0.7} а вот мусор {"другой":"объект"}');
|
||
expect(t).not.toBe(null);
|
||
expect(t.chosen).toBe('a');
|
||
});
|
||
it('FR-4: вложенные скобки в первом объекте не ломают балансный срез', () => {
|
||
const t = parseRouterResponse('x {"a":{"b":1},"c":2} y {"d":3}');
|
||
expect(t).toEqual({ a: { b: 1 }, c: 2 });
|
||
});
|
||
});
|
||
|
||
// FR-2 router-engine-часть (финревью 2026-06-11): пустой layer0 — НЕ пустой заголовок
|
||
// секции (маркер отсутствия — забота mentor-seam F-C6; здесь секция просто не рендерится).
|
||
describe('FR-2: пустой layer0 не рендерит пустую секцию районов', () => {
|
||
it('graph.layer0=[] → ни заголовка КАРТА РАЙОНОВ, ни staleness-строки в system', () => {
|
||
const graphSection = { kind: 'project-graph', districtCount: 0, layer0: [], staleness: { stale: true, commits_behind: null, uncommitted: 2 } };
|
||
const { system } = buildRouterPrompt({ prompt: 'x', graph: graphSection, catalog: CATALOG });
|
||
expect(system).not.toMatch(/КАРТА РАЙОНОВ/);
|
||
expect(system).not.toMatch(/staleness:/);
|
||
});
|
||
});
|
||
|
||
describe('runRouter (оркестратор; llmCall мокается)', () => {
|
||
const okTrace = { candidates: ['writing-plans'], chosen: 'writing-plans', why_chosen: 'feature', twins_considered: 'none', confidence: 0.85 };
|
||
it('валидная заземлённая трасса с уверенностью → ok-результат', async () => {
|
||
const r = await runRouter({ prompt: 'add feature', graph: GRAPH, llmCall: async () => okTrace });
|
||
expect(r.ok).toBe(true); expect(r.trace.chosen).toBe('writing-plans'); expect(r.abstain).toBe(false);
|
||
});
|
||
it('пустой слот трассы → невалидно (мех. флаг, не результат)', async () => {
|
||
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, why_chosen: '' }) });
|
||
expect(r.ok).toBe(false); expect(r.reason).toMatch(/слот|trace|missing/i);
|
||
});
|
||
it('выдуманный кандидат → отклонение (заземление)', async () => {
|
||
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, candidates: ['made-up'], chosen: 'made-up' }) });
|
||
expect(r.ok).toBe(false); expect(r.reason).toMatch(/выдум|invent|заземл/i);
|
||
});
|
||
it('низкая уверенность → воздержание (5.2), не угадывание', async () => {
|
||
const r = await runRouter({ prompt: 'x', graph: GRAPH, confidenceThreshold: 0.7, llmCall: async () => ({ ...okTrace, confidence: 0.3 }) });
|
||
expect(r.abstain).toBe(true);
|
||
});
|
||
it('llmCall вернул мусор → ok=false (не падает)', async () => {
|
||
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => null });
|
||
expect(r.ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('F1 — выбор ОБЯЗАН быть среди перечисленных кандидатов (§6.3 метод-брейнсторм)', () => {
|
||
it('chosen — реальный узел графа, но НЕ среди candidates → отклонение (обход брейнсторма)', async () => {
|
||
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ candidates: ['writing-plans'], chosen: 'discovery-interview', why_chosen: 'w', twins_considered: 't', confidence: 0.9 }) });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/кандидат|candidates|выбор/i);
|
||
});
|
||
it('chosen среди candidates → проходит (контроль не ломает легитимный выбор)', async () => {
|
||
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ candidates: ['writing-plans', 'discovery-interview'], chosen: 'discovery-interview', why_chosen: 'w', twins_considered: 't', confidence: 0.9 }) });
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
});
|