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); }); });