622ac4df28
Третий построчный аудит машин 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>
145 lines
8.1 KiB
JavaScript
145 lines
8.1 KiB
JavaScript
// 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');
|
||
});
|
||
});
|