Files
portal/tools/judge-orchestrator.test.mjs
T
Дмитрий 5ad4688f77 feat(m5): критерий-гейт = лесенка из 4 шагов (Пакет 5, 5.5, Δ6)
Δ6: НЕ плодим criterionFullyProven — критерий-гейт = РОВНО 4 проверки поверх
существующей runGateLadder. criterionGateSteps(input) → [criteria-from-sealed-plan,
criteria-green-matched, fingerprint-fresh, green-signatures-valid] (И-семантика:
id ∈ печать → green-присутствие → свежесть отпечатка Δ2 → подпись подписанта Δ5).
runCriterionGate гоняет их через runGateLadder (короткое замыкание на первом провале).
Структурный тест «ровно эти 4 шага в порядке» — лекарство «забыл шаг». End-to-end:
валидный сценарий (signGreen) проходит; по одному провалу на каждый шаг.

+7 тестов (2 структура + 5 И-семантика). Полная tools-only регрессия 2707 + 2 skip
(0 регрессий; import judge-gate-floor, цикла нет).

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

234 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.
// tools/judge-orchestrator.test.mjs
import { describe, it, expect } from 'vitest';
import {
gateFires, runGateLadder, finalGate, appealStep, judgeHealth, gate3Terminal, logVerdict,
} from './judge-orchestrator.mjs';
describe('gateFires (A0: ворота срабатывают СТРУКТУРНО от состояния, не по желанию)', () => {
it('готово и не запечатано → ворота срабатывают', () => {
expect(gateFires({ ready: true, alreadySealed: false })).toBe(true);
});
it('не готово → не срабатывают', () => {
expect(gateFires({ ready: false, alreadySealed: false })).toBe(false);
});
it('уже запечатано → не пере-срабатывают', () => {
expect(gateFires({ ready: true, alreadySealed: true })).toBe(false);
});
});
describe('runGateLadder (лесенка: провал любого шага → стоп раньше)', () => {
it('все шаги ок → passed', () => {
const r = runGateLadder([
{ name: 'существование', run: () => ({ ok: true }) },
{ name: 'верность', run: () => ({ ok: true }) },
{ name: 'судья', run: () => ({ ok: true, decision: 'GO' }) },
]);
expect(r.passed).toBe(true);
});
it('первый провал → стоп раньше, последующие НЕ запускаются', () => {
let ran3 = false;
const r = runGateLadder([
{ name: 'существование', run: () => ({ ok: false, detail: 'вопрос повис' }) },
{ name: 'верность', run: () => ({ ok: true }) },
{ name: 'судья', run: () => { ran3 = true; return { ok: true }; } },
]);
expect(r.passed).toBe(false);
expect(r.stoppedAt).toBe('существование');
expect(ran3).toBe(false);
});
// fix: tools/judge-orchestrator.mjs (аудит M1-M4, свежий объектив) — упавший шаг пола.
// Раньше step.run() без try/catch пробрасывал исключение вместо «пол не пройден». Теперь
// бросок шага пола = лесенка НЕ пройдена (fail-closed → блок), последующие не запускаются.
it('шаг пола кинул исключение → лесенка не пройдена (fail-closed), следующий не запускается', () => {
let ran2 = false;
const r = runGateLadder([
{ name: 'существование', run: () => { throw new Error('I/O пол упал'); } },
{ name: 'верность', run: () => { ran2 = true; return { ok: true }; } },
]);
expect(r.passed).toBe(false);
expect(r.stoppedAt).toBe('существование');
expect(ran2).toBe(false);
});
});
describe('finalGate (J9: «да» судьи НЕ отпирает пол)', () => {
it('пол блокирует (необратимое) → блок даже при GO судьи', () => {
expect(finalGate({ judgeDecision: 'GO', floorBlocked: true })).toBe('block');
});
it('пол чист + судья GO → allow', () => {
expect(finalGate({ judgeDecision: 'GO', floorBlocked: false })).toBe('allow');
});
it('пол чист + судья NO-GO → block', () => {
expect(finalGate({ judgeDecision: 'NO-GO', floorBlocked: false })).toBe('block');
});
// fix: tools/judge-orchestrator.mjs (K, аудит M1-M4) — снять вето пола может только явный false
it('floorBlocked undefined/null (проверка пола кинула/не дала ответ) → block при GO судьи', () => {
expect(finalGate({ judgeDecision: 'GO', floorBlocked: undefined })).toBe('block');
expect(finalGate({ judgeDecision: 'GO', floorBlocked: null })).toBe('block');
});
});
describe('appealStep (F8: 3 круга на одно несогласие → владелец)', () => {
it('круги 1..3 не эскалируют', () => {
expect(appealStep({ round: 0 })).toEqual({ round: 1, toOwner: false });
expect(appealStep({ round: 1 })).toEqual({ round: 2, toOwner: false });
expect(appealStep({ round: 2 })).toEqual({ round: 3, toOwner: false });
});
it('4-й круг → к владельцу', () => {
expect(appealStep({ round: 3 })).toEqual({ round: 4, toOwner: true });
});
});
describe('judgeHealth (J5: упал/деградировал → громкий крик + деградация до пола)', () => {
it('доступен и не деградировал → на посту', () => {
expect(judgeHealth({ available: true, degraded: false })).toEqual({ onPost: true, cry: false, mode: 'judge' });
});
it('недоступен → пост пустой, крик, режим пола', () => {
expect(judgeHealth({ available: false, degraded: false })).toEqual({ onPost: false, cry: true, mode: 'floor-only' });
});
it('деградировал → крик + режим пола', () => {
expect(judgeHealth({ available: true, degraded: true }).mode).toBe('floor-only');
});
});
describe('gate3Terminal (F10: Гейт-3 ничего не печатает вниз)', () => {
it('GO → принято, печати-вниз НЕТ', () => {
expect(gate3Terminal({ decision: 'GO' })).toEqual({ accepted: true, sealDown: null });
});
it('NO-GO → не принято', () => {
expect(gate3Terminal({ decision: 'NO-GO' })).toEqual({ accepted: false, sealDown: null });
});
});
describe('logVerdict (J8: каждый вердикт append-only в журнал)', () => {
it('пишет запись вердикта через инъектированный журнал', () => {
const written = [];
const entry = logVerdict({ verdict: { decision: 'GO', functionName: 'gate1' }, nowMs: 5, journal: (e) => written.push(e) });
expect(written).toHaveLength(1);
expect(entry.kind).toBe('verdict');
expect(entry.decision).toBe('GO');
});
});
import { a2CaseSelect, runGateFunction } from './judge-orchestrator.mjs';
import { classifyDestructive } from './classify-destructive.mjs';
describe('a2CaseSelect (#4 — A2 различает расхождение vs разрушительную команду)', () => {
it('разрушительные команды → destructive', () => {
expect(a2CaseSelect({ op: 'Bash', object: 'rm -rf build' })).toBe('destructive');
expect(a2CaseSelect({ op: 'Bash', object: 'psql -c "DROP TABLE deals"' })).toBe('destructive');
expect(a2CaseSelect({ op: 'Bash', object: 'git push --force origin main' })).toBe('destructive');
expect(a2CaseSelect({ op: 'Bash', object: 'php artisan migrate' })).toBe('destructive');
});
it('обычное действие → divergence', () => {
expect(a2CaseSelect({ op: 'Edit', object: 'tools/foo.mjs' })).toBe('divergence');
expect(a2CaseSelect({ op: 'Bash', object: 'git status' })).toBe('divergence');
});
});
describe('a2CaseSelect — parity/consolidation на classifyDestructive (Step 1.6)', () => {
// a2CaseSelect делегирует classifyDestructive.suspicious. former-destructive стабильны;
// new: format/db:wipe старый локальный DESTRUCTIVE_RE терял → теперь destructive.
const CASES = [
'rm -rf build',
'psql -c "DROP TABLE deals"',
'git push --force origin main',
'php artisan migrate',
'php artisan db:wipe',
'format D:',
'git status',
'tools/foo.mjs',
];
for (const cmd of CASES) {
it(`a2CaseSelect согласован с classifyDestructive.suspicious для ${cmd}`, () => {
const expected = classifyDestructive(cmd).suspicious ? 'destructive' : 'divergence';
expect(a2CaseSelect({ op: 'Bash', object: cmd })).toBe(expected);
});
}
});
describe('runGateFunction (#4 — упаковка: пол ($0) → судья)', () => {
it('весь пол ок + судья GO → GO на стадии judge', () => {
const r = runGateFunction({
floorSteps: [{ name: 'существование', run: () => ({ ok: true }) }],
runEngine: () => ({ decision: 'GO' }),
});
expect(r.decision).toBe('GO');
expect(r.stage).toBe('judge');
});
it('провал пола → NO-GO на стадии floor, судья не зовётся', () => {
let engineRan = false;
const r = runGateFunction({
floorSteps: [{ name: 'существование', run: () => ({ ok: false, detail: 'вопрос повис' }) }],
runEngine: () => { engineRan = true; return { decision: 'GO' }; },
});
expect(r.decision).toBe('NO-GO');
expect(r.stage).toBe('floor');
expect(r.stoppedAt).toBe('существование');
expect(engineRan).toBe(false);
});
});
// 5.5 (Δ6): критерий-гейт = лесенка из РОВНО 4 шагов поверх существующей runGateLadder (НЕ
// плодим criterionFullyProven). Структурный тест «ровно эти 4 шага» — лекарство «забыл шаг».
// И-семантика и короткое замыкание на первом провале наследуются от runGateLadder.
import { criterionGateSteps, runCriterionGate } from './judge-orchestrator.mjs';
import { signGreen } from './floor-signer.mjs';
const GK = 'gate-signer-key';
function validScenario() {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, GK);
return {
criteria: [{ id: 'c1' }],
greenRuns: [{ ...rec, green: true }],
sealedCriterionIds: ['c1'],
currentFingerprints: { c1: 'fp1' },
signerKey: GK,
};
}
describe('criterionGateSteps (5.5, Δ6): структура лесенки — РОВНО 4 шага', () => {
it('ровно 4 шага в каноническом порядке (забыть шаг невозможно)', () => {
const steps = criterionGateSteps(validScenario());
expect(steps.map((s) => s.name)).toEqual([
'criteria-from-sealed-plan',
'criteria-green-matched',
'fingerprint-fresh',
'green-signatures-valid',
]);
});
it('каждый шаг — {name, run()} (run возвращает {ok:boolean})', () => {
const steps = criterionGateSteps(validScenario());
for (const s of steps) {
expect(typeof s.run).toBe('function');
expect(typeof s.run().ok).toBe('boolean');
}
});
});
describe('runCriterionGate (5.5, Δ6): И-семантика 4 шагов через runGateLadder', () => {
it('все 4 шага ок → passed', () => {
expect(runCriterionGate(validScenario()).passed).toBe(true);
});
it('критерий не из печати → стоп на шаге 1', () => {
const r = runCriterionGate({ ...validScenario(), sealedCriterionIds: [] });
expect(r.passed).toBe(false);
expect(r.stoppedAt).toBe('criteria-from-sealed-plan');
});
it('нет зелёного прогона → стоп на шаге 2', () => {
const r = runCriterionGate({ ...validScenario(), greenRuns: [] });
expect(r.passed).toBe(false);
expect(r.stoppedAt).toBe('criteria-green-matched');
});
it('отпечаток разошёлся (правка после прогона) → стоп на шаге 3', () => {
const r = runCriterionGate({ ...validScenario(), currentFingerprints: { c1: 'fp-OTHER' } });
expect(r.passed).toBe(false);
expect(r.stoppedAt).toBe('fingerprint-fresh');
});
it('подпись подписанта не сходится (чужой ключ) → стоп на шаге 4', () => {
const r = runCriterionGate({ ...validScenario(), signerKey: 'wrong-key' });
expect(r.passed).toBe(false);
expect(r.stoppedAt).toBe('green-signatures-valid');
});
});