397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
14 KiB
JavaScript
289 lines
14 KiB
JavaScript
// 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');
|
||
});
|
||
});
|
||
|
||
import {
|
||
planGateSteps,
|
||
runPlanGate,
|
||
runCriterionGate as runCriterionGate4c,
|
||
} from './judge-orchestrator.mjs';
|
||
import { signGreen as signGreen4c } from './floor-signer.mjs';
|
||
|
||
describe('Гейт-2 planGateSteps/runPlanGate (М7 Фаза 4c §4.2): план-требование sealed, не text-mention', () => {
|
||
it('planGateSteps возвращает РОВНО 2 именованных шага в порядке coverage → criterion', () => {
|
||
const steps = planGateSteps({ specSections: ['§1'], planSteps: [] });
|
||
expect(steps.map((s) => s.name)).toEqual(['spec-to-plan-coverage', 'k5-criterion-check']);
|
||
});
|
||
it('§ спеки НЕ покрыт шагом плана → NO-GO на spec-to-plan-coverage', () => {
|
||
const r = runPlanGate({ specSections: ['§1', '§2'], planSteps: [{ ref: '§1', op: 'Read', criterion: 'смотрю файл целиком' }] });
|
||
expect(r.passed).toBe(false);
|
||
expect(r.stoppedAt).toBe('spec-to-plan-coverage');
|
||
});
|
||
it('значимый шаг без конкретного критерия → NO-GO на k5-criterion-check', () => {
|
||
const r = runPlanGate({ specSections: ['§1'], planSteps: [{ n: 1, ref: '§1', op: 'Edit', criterion: 'готово' }] });
|
||
expect(r.passed).toBe(false);
|
||
expect(r.stoppedAt).toBe('k5-criterion-check');
|
||
});
|
||
it('спека покрыта + значимый шаг несёт конкретный критерий → passed', () => {
|
||
const r = runPlanGate({ specSections: ['§1'], planSteps: [{ n: 1, ref: '§1', op: 'Edit', criterion: 'тест enforce-x возвращает block=true' }] });
|
||
expect(r.passed).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('Гейт-3 runCriterionGate как verify-before-push absorption (М7 Фаза 4c §4.2)', () => {
|
||
const SK4c = 'phase4c-signer-key';
|
||
it('подписанный зелёный по критерию из печати → passed (расписка М5, не само-sentinel)', () => {
|
||
const rec = signGreen4c({ criterion_id: 'c1', code_fingerprint: 'aa', occurrence: 1 }, SK4c);
|
||
const green = { ...rec, green: true };
|
||
const r = runCriterionGate4c({
|
||
criteria: [{ id: 'c1' }],
|
||
greenRuns: [green],
|
||
sealedCriterionIds: ['c1'],
|
||
currentFingerprints: { c1: 'aa' },
|
||
signerKey: SK4c,
|
||
});
|
||
expect(r.passed).toBe(true);
|
||
});
|
||
it('зелёный без подписи (само-написанный sentinel) → NO-GO', () => {
|
||
const green = { criterion_id: 'c1', green: true, code_fingerprint: 'aa', occurrence: 1 };
|
||
const r = runCriterionGate4c({
|
||
criteria: [{ id: 'c1' }],
|
||
greenRuns: [green],
|
||
sealedCriterionIds: ['c1'],
|
||
currentFingerprints: { c1: 'aa' },
|
||
signerKey: SK4c,
|
||
});
|
||
expect(r.passed).toBe(false);
|
||
});
|
||
});
|