Files
portal/tools/router-engine.test.mjs
T
Дмитрий 22b84fbb2e feat(m5): classifyDestructive двухуровневый + rewire a2CaseSelect/detectHighRisk (§4, N1)
Пакет 1 Машины 5 (роутер-наставник, пол). Единый источник разрушительности
classify-destructive.mjs: floor (точный необратимый набор, hard-block) + suspicious
(грубый набор для голосов судьи), инвариант floor => suspicious.

- N1: голый migrate/migrate:rollback/migrate --force => suspicious, НЕ floor (деплой не ломается).
- rewire a2CaseSelect (M4) и detectHighRisk (M3) на classifyDestructive.suspicious;
  оба локальных DESTRUCTIVE_RE удалены (Δ9-б — единственный источник).
- Δ9(б) seed CI-инвариант m5-floor-invariants.test.mjs (positive-control, не вакуумный).
- sharp-edges (Step 1.9): floor force-push выровнен с каноном shell-content — закрыт
  обход кавычками git push "--force" (длинные флаги без обязательного \s; -f/+ с \s).
- parity к двум прежним regex сохранён (format/db:wipe/force-push-литерал).

Регрессия tools-only: 2608 passed + 2 skip (+48). Residuals (chaining/reset-quote)
переданы Пакету 2 (tokenizeBash посегментно).

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

212 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/КОНТРАКТЫ ВЫЗЫВАЕМЫХ/);
});
});
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);
});
});