Files
portal/tools/router-engine.test.mjs
T
Дмитрий 69e20099db fix(router-mentor): sharp-edges audit M1-M4 — close 8 misuse-resistance holes
Второй аудит машин 1-4 другим объективом (sharp-edges: устойчивость к
неправильному применению / мягкие умолчания / совпадение по пустоте-подстроке).
Криптоядра здоровы (подтверждено). 8 реальных дыр закрыты по TDD:

M3:
- coverage-machine F-1: покрытие считалось по двусторонней ПОДСТРОКЕ — produces
  "a" покрывал запрос "audit-rls-policy" (ложное «всё покрыто»). Новый tokensCover:
  точное равенство ИЛИ подмножество слов по границам. coveringSkill + coverageRegistry.
- router-engine F-8: confidence не проверялся на диапазон — 5/Infinity проходили как
  «уверен» (обход воздержания 5.2), -3 как принуд. abstain. validateTrace: [0,1] finite.
- round-control C: пустой roundKey="" активировал managed-режим (!= null) → все сессии
  делили один счётчик-бакет. Теперь managed требует непустую строку.
- router-learning-queue G: повторное approve уже-решённого id повторно клало запись в
  фонд (дубль). applyApprovalBatch: переводит только status==='pending'.

M2:
- plan-lock F5: шаг с пустым object был джокером (object:'' матчил действие, чей путь
  не извлёкся → object''). actionMatchesStep: пустой object шага не матчит ничего.

M4 (инертна; чистые fail-closed правки кода, корректны и при включении):
- judge-slop-counter H: битый/null вердикт в списке ронял счёт (v.missing на null).
  Теперь не крашит, считается халтурой (безопасная сторона).
- judge-engine J: consensusDecision на пустом/битом списке дрейфовал к GO. Теперь GO
  только если есть голоса И каждый чистый GO; иначе NO-GO (fail-closed для hard-risk).
- judge-orchestrator K: finalGate снимал вето пола на любой falsy floorBlocked
  (undefined от упавшей проверки = fail-open). Теперь снять может только явный false.

Регрессия tools-only 2555 passed + 2 skip (+15 TDD-тестов, 0 регрессий).

Осознанно НЕ менялось (без призраков):
- M1 receipt-sign domain default '' / разделитель пробел — backward-compat контракт
  (тест 18-19), инъективен на enum-доменах без пробелов.
- M1 action-journal атомарность записи головы + битая .jsonl строка — fail-closed
  (битьё → verifyChain ok:false → стена блокирует); чистого behavioral-теста нет.
- M3 round-control requiredSkills=[] — контракт вызывающего (пустой = не требуется).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:24:21 +03:00

199 lines
11 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';
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: {} });
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(/КОНТРАКТЫ ВЫЗЫВАЕМЫХ/);
});
});
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); });
});
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);
});
});