Files
portal/tools/judge-engine.test.mjs
T
Дмитрий 622ac4df28 fix(router-mentor): third audit (correctness lens) — close 3 M4 fail-open/crash holes
Третий построчный аудит машин 1-4 свежим объективом (корректность логики /
реальные баги — НЕ понимание, НЕ грабли; это были два прошлых прохода).
4 читающих под-агента code-analyzer. M1/M2/M3 — багов ядра нет (подтверждено).
M4 (судья, инертен; код должен быть верен и при включении): 3 реальные дыры по TDD.

M4:
- judge-engine.mjs runJudge: (raw.objections||[]).filter((o)=>o.verdict) падал на
  objections=[null] (o.verdict на null) и на не-массиве (.filter is not a function).
  || гасит только falsy. Краш ломал вердикт; в инертной обёртке выброс уходил в
  catch→block:false = fail-open. Fix: Array.isArray(...)?...:[] + (o && o.verdict).
- judge-verdict-slots.mjs: String(raw).trim().length скрывал не-строки — слот {}
  давал '[object Object]' (длина 15) и проходил как содержательный (мусорный
  объект/массив штамповал форму вердикта). Fix: слот обязан быть строкой
  (typeof raw !== 'string' → trivial). Мягкий fail-open формы закрыт.
- judge-orchestrator.mjs runGateLadder: step.run() без try/catch пробрасывал
  исключение упавшего шага пола вместо «пол не пройден» → решение неопределённо
  (в обёртке catch→block:false = fail-open). Fix: бросок шага = passed:false
  (fail-closed → блок), последующие не запускаются. Чистый модуль теперь сам
  гарантирует безопасную сторону, не полагаясь на обёртку.

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

Осознанно НЕ менялось (без призраков):
- M1 verifyChain без 3-го арг = нарушение контракта вызова, не валидный вход.
- M2 node-в-цепочке = то же разрешение, что одиночный node (контракт, тест L53);
  readonly-git-в-цепочке блок = осознанный default-deny (fail-safe).
- M3 defer уже защищён G-фиксом (if e.status!=='pending' return e — ДО defer);
  N3 stale-комментарий (код строже докстринга).
- M4-C DESTRUCTIVE_RE иллюстративен (divergence всё равно судится; разрушительный
  bash режется полом M2/M5 до судьи); M4-D slop-counter↔logVerdict — live-wiring.

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

145 lines
8.1 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.
// tools/judge-engine.test.mjs
import { describe, it, expect } from 'vitest';
import {
requiredLensesFor, buildJudgePrompt, classifyByReversibility, runJudge, consensusDecision,
PREMORTEM_CLASSES, VOTE_LAYOUTS,
} from './judge-engine.mjs';
describe('requiredLensesFor (§7 раскладка голосов по функциям)', () => {
it('Гейт-1 несёт 4 ядра, включая премортем (линза K7)', () => {
const l = requiredLensesFor('gate1');
expect(l).toContain('completeness');
expect(l).toContain('premortem'); // K7
expect(l).toContain('goal_advocate');
expect(l).toContain('correctness');
});
it('Гейт-2 несёт 5 голосов, включая проверяемость (K5)', () => {
expect(requiredLensesFor('gate2')).toEqual(VOTE_LAYOUTS.gate2);
expect(requiredLensesFor('gate2')).toContain('verifiability');
});
it('риск добавляет Атакующего и Деньги', () => {
const l = requiredLensesFor('gate2', { risk: true });
expect(l).toContain('attacker');
expect(l).toContain('money');
});
});
describe('buildJudgePrompt (чистая, детерминированная; слепа к переписке)', () => {
const args = { functionName: 'gate1', requiredLenses: ['completeness', 'premortem'], product: { spec: 'X' }, goal: 'лендинг', cards: ['frontend-design'] };
it('тот же вход → тот же промпт (детерминизм)', () => {
expect(buildJudgePrompt(args)).toEqual(buildJudgePrompt({ ...args }));
});
it('system содержит требуемые линзы и классы премортема и якорь цели', () => {
const { system, user } = buildJudgePrompt(args);
expect(system).toContain('completeness');
expect(system).toContain('premortem');
for (const c of PREMORTEM_CLASSES) expect(system).toContain(c);
expect(user).toContain('лендинг');
});
});
describe('classifyByReversibility (G1: фатальное=тяжёлое И необратимое → блок)', () => {
it('тяжёлое И необратимое → блок', () => {
expect(classifyByReversibility({ severity: 'fatal', reversible: false })).toBe('block');
expect(classifyByReversibility({ severity: 'heavy', reversible: false })).toBe('block');
});
it('обратимое → совет (даже тяжёлое)', () => {
expect(classifyByReversibility({ severity: 'heavy', reversible: true })).toBe('advice');
});
it('косметика → совет', () => {
expect(classifyByReversibility({ severity: 'cosmetic', reversible: false })).toBe('advice');
});
it('тяжёлое БЕЗ явной пометки обратимости → блок (сомнение→блок, #8)', () => {
expect(classifyByReversibility({ severity: 'fatal' })).toBe('block');
expect(classifyByReversibility({ severity: 'heavy', reversible: undefined })).toBe('block');
});
});
describe('runJudge (механические проверки вокруг мокнутой модели)', () => {
const lenses = ['completeness', 'premortem'];
const fullSlots = { completeness: 'все нужды §1 закрыты подробно', premortem: 'риск отступа не закрыт детально' };
const base = { functionName: 'gate1', requiredLenses: lenses, promptArgs: { product: {}, goal: 'g', cards: [] } };
it('полные слоты + GO + нет блокирующих → GO', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
expect(r.accepted).toBe(true);
});
it('пустой требуемый слот → вердикт невалиден → НЕ GO', () => {
const llmCall = () => ({ decision: 'GO', slots: { completeness: 'ок и подробно' }, objections: [] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('NO-GO');
expect(r.accepted).toBe(false);
});
it('якорное фатально-необратимое возражение → блок', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [
{ verdict: 'NO', text: 'дыра', severity: 'fatal', reversible: false, anchor: { kind: 'spec_section', ref: '§2' } },
] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('NO-GO');
expect(r.blocking).toHaveLength(1);
});
it('безъякорное «НЕТ» → демотится в совет, ворота не клинит → GO', () => {
const llmCall = () => ({ decision: 'NO-GO', slots: fullSlots, objections: [
{ verdict: 'NO', text: 'вообще не нравится', severity: 'fatal', reversible: false },
] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
expect(r.advice.length).toBeGreaterThan(0);
});
it('обратимое якорное возражение → совет, не блок', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [
{ verdict: 'NO', text: 'некрасиво', severity: 'heavy', reversible: true, anchor: { kind: 'observation', ref: 'L10' } },
] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
expect(r.advice).toHaveLength(1);
});
it('требуемый под-прогон отсутствует → вердикт не принят (фейк прилежности)', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [] });
const r = runJudge({ ...base, llmCall, subRunsRequired: [{ lens: 'premortem' }], subRuns: [] });
expect(r.decision).toBe('NO-GO');
expect(r.accepted).toBe(false);
});
// fix: tools/judge-engine.mjs (аудит M1-M4, свежий объектив) — битый objections от модели
// не роняет runJudge (раньше (raw.objections||[]).filter гасил только falsy, не null-элемент / не-массив).
it('objections с null-элементом → не падает, нет блокирующих → GO', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [null] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
expect(r.accepted).toBe(true);
});
it('objections не массив (модель вернула строку) → не падает → GO', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: 'возражений нет' });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
});
it('валидное якорное «НЕТ» рядом с null-элементом → блок (null отброшен, реальное возражение учтено)', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [
null,
{ verdict: 'NO', text: 'дыра', severity: 'fatal', reversible: false, anchor: { kind: 'spec_section', ref: '§2' } },
] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('NO-GO');
expect(r.blocking).toHaveLength(1);
});
});
describe('consensusDecision (J3: один НЕТ → блок)', () => {
it('любой NO-GO среди судей → общий NO-GO', () => {
expect(consensusDecision([{ decision: 'GO' }, { decision: 'NO-GO' }, { decision: 'GO' }])).toBe('NO-GO');
});
it('все GO → GO', () => {
expect(consensusDecision([{ decision: 'GO' }, { decision: 'GO' }])).toBe('GO');
});
// fix: tools/judge-engine.mjs (J, аудит M1-M4) — нет голосов / битый голос = НЕ согласие (fail-closed)
it('пустой список судей → NO-GO (нет голосов ≠ согласие)', () => {
expect(consensusDecision([])).toBe('NO-GO');
});
it('битый/неполный голос среди судей → NO-GO (не дрейфует к GO)', () => {
expect(consensusDecision([{ decision: 'GO' }, { decision: undefined }])).toBe('NO-GO');
expect(consensusDecision([{ decision: 'GO' }, null])).toBe('NO-GO');
});
});