Files
portal/tools/enforce-judge-gate.test.mjs
T
Дмитрий 09598dd5bd feat(seal): sealed-plan production pipeline (M7 Фаза 8 code-precondition)
Производство двух печатей (артефакт-решение + план-шаги), чтобы стене М2 было
что матчить — код-предусловие флипа. Inline TDD, спека/план одобрены владельцем.

- C1 artifact-from-spec.mjs: спека markdown -> {sections, source_sha} по якорям {#id} (P2-2).
- C2 plan-steps-parse.mjs: план -> [{op,object,ref}], fail-CLOSE, reject op:Task (VA-4),
  канон object = repo-relative POSIX (SE-5; pathNormalize только на матче в стене, не на парсе).
- C3/C4 plan-lock.mjs: judge_mode в ПОДПИСАННОЙ базе freezePlan (VA-2) + атомарный persist
  temp->rename для обоих save (SE-4/VA-3, артефакт ДО плана).
- C6 seal-orchestration.mjs: sealableArtifact/sealablePlan + judgedHashOf (SD-1) +
  sealArtifact/sealPlan на РЕАЛЬНОМ GO (SE-3 wired===true), штамп artifact_id из текущего
  артефакта (SD-3), judge_mode впрыснут в печать ПОСЛЕ хеш-сверки sealOnApproval (фикс TOCTOU).
- C5 enforce-judge-gate.mjs: SPEC_PATH_RE + sealOnWiredGo (печать на wired GO, инъекция в main,
  юнит-тесты hermetic) + judged_hash в вердикте runJudgeGate. extractGate2Product не тронут
  (Гейт-2 = планы; Гейт-1 spec-judging — отдельный заход перед флипом).
- Интеграция seal-to-wall: печать -> decideMode стены М2 (allow / non-match block / closed-door).

Тесты: full tools-only регрессия 3427 passed | 2 skipped, 0 регрессий (+29 новых кейсов).
Печать в рантайме НЕ производится до флипа (стена/судья не зарегистрированы) — сборка
готовит код-предусловие. Спека docs/superpowers/specs/2026-06-09-sealed-plan-production-design.md.
2026-06-09 17:50:25 +03:00

276 lines
16 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/enforce-judge-gate.test.mjs
import { describe, it, expect } from 'vitest';
import {
decide, runJudgeGate, parseJudgeResponse, extractGate2Product, callJudgeModel,
buildVerdictEntry, logVerdictLine, warnJudgeUnavailable, runJudgeTurn,
sealOnWiredGo, SPEC_PATH_RE,
} from './enforce-judge-gate.mjs';
const okText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee' }, objections: [] });
const noText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee' }, objections: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§1' }, severity: 'heavy', reversible: false }] });
const planEv = () => ({ tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '# П\n## Цель\nцель плана тут\nшаг' } });
describe('enforce-judge-gate decide (М7 Фаза 7 §8) — mode-aware + finalGate', () => {
it('inert → allow ($0)', () => {
const r = decide({ mode: 'inert' });
expect(r.block).toBe(false);
expect(r.reason).toMatch(/inert|\$0/i);
});
it('shadow → allow (D28 тихий, логирует не блокирует)', () => {
const r = decide({ mode: 'shadow', verdict: { decision: 'NO-GO' } });
expect(r.block).toBe(false);
expect(r.reason).toMatch(/shadow|D28/i);
});
it('live-block + судья GO + пол чист → allow', () => {
expect(decide({ mode: 'live-block', verdict: { decision: 'GO' }, floorBlocked: false }).block).toBe(false);
});
it('live-block + судья NO-GO → block', () => {
const r = decide({ mode: 'live-block', verdict: { decision: 'NO-GO' }, floorBlocked: false });
expect(r.block).toBe(true);
});
it('live-block + пол заблокировал (floorBlocked=true) → block даже при судье GO (пол перевешивает)', () => {
const r = decide({ mode: 'live-block', verdict: { decision: 'GO' }, floorBlocked: true });
expect(r.block).toBe(true);
});
it('live-block + битый/пустой вердикт → block (сомнение → NO-GO, fail-closed)', () => {
expect(decide({ mode: 'live-block', verdict: null, floorBlocked: false }).block).toBe(true);
expect(decide({ mode: 'live-block', verdict: {}, floorBlocked: false }).block).toBe(true);
});
});
describe('runJudgeGate (async) — рубильник + детект + префетч (Δ-C degraded-allow)', () => {
const bashEv = { tool_name: 'Bash', tool_input: { command: 'ls' } };
it('не активен (нет флага/ключа) → wired:false, транспорт не зовётся', async () => {
let calls = 0;
const deps = { judgeActiveImpl: () => false, transport: async () => { calls++; return okText; } };
const r = await runJudgeGate(planEv(), deps);
expect(r.decision).toBe('GO');
expect(r.wired).toBe(false);
expect(calls).toBe(0);
});
it('активен, но не план (Bash) → wired:false, транспорт не зовётся ($0)', async () => {
let calls = 0;
const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; } };
const r = await runJudgeGate(bashEv, deps);
expect(r.wired).toBe(false);
expect(calls).toBe(0);
});
it('активен, нет ROUTER_LLM_KEY → degraded allow (wired:false, unavailable:true, GO), транспорт не зовётся', async () => {
let calls = 0;
const deps = { judgeActiveImpl: () => true, apiKey: '', transport: async () => { calls++; return okText; } };
const r = await runJudgeGate(planEv(), deps);
expect(calls).toBe(0);
expect(r.wired).toBe(false);
expect(r.unavailable).toBe(true);
expect(r.decision).toBe('GO');
});
it('активен + план + чистый вердикт → GO, wired:true, functionName=gate2', async () => {
const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText };
const r = await runJudgeGate(planEv(), deps);
expect(r.wired).toBe(true);
expect(r.decision).toBe('GO');
expect(r.verdict.functionName).toBe('gate2');
});
it('активен + план + якорное тяжёлое NO → NO-GO', async () => {
const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText };
const r = await runJudgeGate(planEv(), deps);
expect(r.decision).toBe('NO-GO');
});
});
describe('parseJudgeResponse — fail-closed', () => {
it('валидный JSON → {slots, objections}', () => {
const txt = JSON.stringify({ slots: { plan_soundness: 'план полный и связный' }, objections: [] });
const r = parseJudgeResponse(txt);
expect(r.slots.plan_soundness).toBe('план полный и связный');
expect(Array.isArray(r.objections)).toBe(true);
});
it('JSON в ```json-заборе → распарсен', () => {
const r = parseJudgeResponse('```json\n{"slots":{"x":"yyyyyyyy"},"objections":[]}\n```');
expect(r.slots.x).toBe('yyyyyyyy');
});
it('битый текст → {} (движок отвергнет по слотам)', () => {
expect(parseJudgeResponse('не json вовсе')).toEqual({});
});
it('массив/строка/пусто → {}', () => {
expect(parseJudgeResponse('[1,2]')).toEqual({});
expect(parseJudgeResponse('"строка"')).toEqual({});
expect(parseJudgeResponse('')).toEqual({});
});
it('objections не массив → нормализуется в []', () => {
const r = parseJudgeResponse(JSON.stringify({ slots: {}, objections: 'нет' }));
expect(r.objections).toEqual([]);
});
});
describe('extractGate2Product — детект плана (Write-only, Δ-A) + извлечение', () => {
const planPath = 'docs/superpowers/plans/2026-06-09-foo.md';
it('Write плана → shouldJudge, product=content, functionName=gate2, goal, cards=[]', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: planPath, content: '# План\n## Цель\nсделать X\nшаги...' } };
const r = extractGate2Product(ev);
expect(r.shouldJudge).toBe(true);
expect(r.functionName).toBe('gate2');
expect(r.product).toContain('сделать X');
expect(r.goal).toContain('сделать X');
expect(r.cards).toEqual([]);
});
it('Write плана с обратными слэшами → распознаётся', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: 'docs\\superpowers\\plans\\x.md', content: 'c' } };
expect(extractGate2Product(ev).shouldJudge).toBe(true);
});
it('Edit плана → shouldJudge:false (Δ-A: фрагмент, не весь план)', () => {
const ev = { tool_name: 'Edit', tool_input: { file_path: planPath, new_string: 'новый шаг плана' } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('MultiEdit плана → shouldJudge:false (Δ-A)', () => {
const ev = { tool_name: 'MultiEdit', tool_input: { file_path: planPath, edits: [{ old_string: 'a', new_string: 'b' }] } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('не-план путь (Write) → shouldJudge:false', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs', content: 'c' } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('спека (specs/) → shouldJudge:false (это Гейт-1, не Гейт-2)', () => {
const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x.md', content: 'c' } };
expect(extractGate2Product(ev).shouldJudge).toBe(false);
});
it('Bash → shouldJudge:false', () => {
expect(extractGate2Product({ tool_name: 'Bash', tool_input: { command: 'ls' } }).shouldJudge).toBe(false);
});
it('пустое/битое событие → shouldJudge:false', () => {
expect(extractGate2Product(null).shouldJudge).toBe(false);
expect(extractGate2Product({}).shouldJudge).toBe(false);
});
});
describe('callJudgeModel — транспорт + парс + спенд-гейт (Δ-B unavailable)', () => {
const promptArgs = { product: 'p', goal: 'g', cards: [] };
const lenses = ['plan_soundness'];
it('нет apiKey → {unavailable:true}, транспорт НЕ зовётся', async () => {
let calls = 0;
const transport = async () => { calls++; return '{}'; };
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: '', transport });
expect(calls).toBe(0);
expect(r).toEqual({ unavailable: true });
});
it('есть apiKey → транспорт зовётся, ответ распарсен', async () => {
let calls = 0;
const transport = async (pm, opts) => {
calls++;
expect(opts.apiKey).toBe('K');
expect(pm.system).toMatch(/судья/i);
return JSON.stringify({ slots: { plan_soundness: 'ок-ок-ок' }, objections: [] });
};
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
expect(calls).toBe(1);
expect(r.slots.plan_soundness).toBe('ок-ок-ок');
});
it('транспорт бросил → {unavailable:true} (не NO-GO, fail-closed против over-block)', async () => {
const transport = async () => { throw new Error('net'); };
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
expect(r).toEqual({ unavailable: true });
});
it('транспорт вернул мусор → {} (движок отвергнет → NO-GO)', async () => {
const transport = async () => 'не json';
const r = await callJudgeModel({ functionName: 'gate2', requiredLenses: lenses, promptArgs, apiKey: 'K', transport });
expect(r).toEqual({});
});
});
describe('shadow-лог вердиктов + warn (Δ-D)', () => {
it('buildVerdictEntry: вердикт+событие → entry {kind, functionName, decision, at}', () => {
const e = buildVerdictEntry({ decision: 'GO', verdict: { functionName: 'gate2', decision: 'GO' } }, 123);
expect(e.kind).toBe('verdict');
expect(e.functionName).toBe('gate2');
expect(e.decision).toBe('GO');
expect(e.at).toBe(123);
});
it('logVerdictLine: JSONL-строка дописывается в файл (инъекция fs)', () => {
const writes = [];
const fsImpl = { appendFileSync: (p, s) => writes.push([p, s]), mkdirSync: () => {} };
logVerdictLine({ kind: 'verdict', decision: 'GO' }, { fsImpl, dir: '/r' });
expect(writes).toHaveLength(1);
expect(writes[0][0]).toMatch(/judge-verdicts\.jsonl$/);
expect(writes[0][1]).toMatch(/"decision":"GO"/);
expect(writes[0][1].endsWith('\n')).toBe(true);
});
it('logVerdictLine: ошибка fs проглатывается (best-effort, не бросает)', () => {
const fsImpl = { appendFileSync: () => { throw new Error('io'); }, mkdirSync: () => {} };
expect(() => logVerdictLine({ decision: 'GO' }, { fsImpl, dir: '/r' })).not.toThrow();
});
it('warnJudgeUnavailable: дописывает judge_unavailable строку (инъекция fs)', () => {
const writes = [];
const fsImpl = { appendFileSync: (p, s) => writes.push([p, s]), mkdirSync: () => {} };
warnJudgeUnavailable({ tool_name: 'Write' }, { fsImpl, dir: '/r' });
expect(writes).toHaveLength(1);
expect(writes[0][1]).toMatch(/judge_unavailable/);
expect(writes[0][1].endsWith('\n')).toBe(true);
});
});
describe('runJudgeTurn — режим-aware (Δ-D inert/shadow/live-block, без I/O)', () => {
it('inert → allow, прогона нет, лога нет', async () => {
const logged = [];
const r = await runJudgeTurn(planEv(), { mode: 'inert', judgeActiveImpl: () => false, apiKey: 'K', transport: async () => okText, logImpl: (e) => logged.push(e) });
expect(r.block).toBe(false);
expect(logged).toHaveLength(0);
});
it('shadow + план → прогон + лог вердикта, allow (D28 не блокирует)', async () => {
const logged = [];
const r = await runJudgeTurn(planEv(), { mode: 'shadow', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: (e) => logged.push(e) });
expect(r.block).toBe(false);
expect(logged).toHaveLength(1);
expect(logged[0].decision).toBe('NO-GO');
});
it('live-block + GO → allow + лог', async () => {
const logged = [];
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText, logImpl: (e) => logged.push(e) });
expect(r.block).toBe(false);
expect(logged).toHaveLength(1);
});
it('live-block + NO-GO → block', async () => {
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: () => {} });
expect(r.block).toBe(true);
});
it('live-block + не-план → allow (нечего судить, $0)', async () => {
const r = await runJudgeTurn({ tool_name: 'Bash', tool_input: { command: 'ls' } }, { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText, logImpl: () => {} });
expect(r.block).toBe(false);
});
it('live-block + unavailable (нет ключа) → allow + warnImpl вызван, не verdict-лог (Δ-D)', async () => {
const logged = [];
let warned = 0;
const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: '', transport: async () => okText, logImpl: (e) => logged.push(e), warnImpl: () => { warned++; } });
expect(r.block).toBe(false);
expect(logged).toHaveLength(0);
expect(warned).toBe(1);
});
});
describe('sealed-plan production Task 5 — seal on wired GO (SPEC_PATH_RE + sealOnWiredGo)', () => {
it('SPEC_PATH_RE matches docs/superpowers/specs/*.md, not plans', () => {
expect(SPEC_PATH_RE.test('docs/superpowers/specs/2026-06-09-x-design.md')).toBe(true);
expect(SPEC_PATH_RE.test('docs/superpowers/plans/x.md')).toBe(false);
});
it('sealOnWiredGo: spec-Write + real GO → sealArtifact invoked with judgeMode', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: '## R {#r}\nt' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: true, decision: 'GO' }, judgeMode: 'shadow',
deps: { sealArtifact: (a) => { calls.push(['artifact', a.judgeMode]); return { sealed: true }; }, persistArtifact: () => {} } });
expect(calls[0]).toEqual(['artifact', 'shadow']);
});
it('sealOnWiredGo: plan-Write + real GO → sealPlan invoked with current artifact', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '```steps-json\n[{"op":"Edit","object":"a","ref":"r"}]\n```' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: true, decision: 'GO' }, judgeMode: 'live-block',
deps: { loadCurrentArtifact: () => ({ artifact_id: 'AID' }), sealPlan: (a) => { calls.push(['plan', a.currentArtifact.artifact_id, a.judgeMode]); return { sealed: true }; }, persistPlan: () => {} } });
expect(calls[0]).toEqual(['plan', 'AID', 'live-block']);
});
it('sealOnWiredGo: degraded GO (wired:false) → no seal call', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: 'x' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: false, decision: 'GO' }, judgeMode: 'shadow',
deps: { sealArtifact: () => { calls.push('a'); return {}; } } });
expect(calls).toEqual([]);
});
});