09598dd5bd
Производство двух печатей (артефакт-решение + план-шаги), чтобы стене М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.
276 lines
16 KiB
JavaScript
276 lines
16 KiB
JavaScript
// 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([]);
|
||
});
|
||
});
|