397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
16 KiB
JavaScript
278 lines
16 KiB
JavaScript
// tools/judge-gate-floor.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
existenceCheck, specToPlanCoverage, k5CriterionCheck, criteriaGreenMatched, skillTakenByJournal,
|
||
} from './judge-gate-floor.mjs';
|
||
|
||
describe('existenceCheck (Гейт-1 шаг 1, $0): вопросы отвечены + навыки реально вызваны', () => {
|
||
it('ok когда все вопросы отвечены и все обещанные навыки в журнале', () => {
|
||
const r = existenceCheck({
|
||
questions: [{ id: 'q1', answered: true }, { id: 'q2', answered: true }],
|
||
skillsPromised: ['brainstorming', 'frontend-design'],
|
||
skillsInvoked: ['brainstorming', 'frontend-design'],
|
||
});
|
||
expect(r.ok).toBe(true);
|
||
expect(r.unanswered).toEqual([]);
|
||
expect(r.missingSkills).toEqual([]);
|
||
});
|
||
it('повисший вопрос → не ok', () => {
|
||
const r = existenceCheck({ questions: [{ id: 'q1', answered: false }], skillsPromised: [], skillsInvoked: [] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.unanswered).toEqual(['q1']);
|
||
});
|
||
it('обещанный навык не вызван по журналу → не ok (фейк-покрытие)', () => {
|
||
const r = existenceCheck({ questions: [], skillsPromised: ['frontend-design'], skillsInvoked: ['brainstorming'] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingSkills).toEqual(['frontend-design']);
|
||
});
|
||
});
|
||
|
||
describe('specToPlanCoverage (Гейт-2 пол): каждый § спеки покрыт шагом', () => {
|
||
it('все секции покрыты → ok', () => {
|
||
const r = specToPlanCoverage({ specSections: ['§1', '§2'], planSteps: [{ ref: '§1' }, { ref: '§2' }, { ref: '§1' }] });
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
it('секция без шага → uncovered', () => {
|
||
const r = specToPlanCoverage({ specSections: ['§1', '§2', '§3'], planSteps: [{ ref: '§1' }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.uncovered.sort()).toEqual(['§2', '§3']);
|
||
});
|
||
});
|
||
|
||
// 5.2 (C3/F18): значимость шага — МЕХАНИЧЕСКАЯ (из класса действия в judge-gate-floor),
|
||
// НЕ из контроллер-флагов trivial/significant. Флаги больше не разжалуют значимый шаг;
|
||
// разжаловать может только реально-читающий op. Разрушительный object перевешивает op.
|
||
import { isSignificantStep } from './judge-gate-floor.mjs';
|
||
|
||
describe('k5CriterionCheck (Гейт-2, K5): значимый шаг → конкретный критерий; «проверено» не факт', () => {
|
||
it('значимый шаг с конкретным критерием → ok', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 1, criterion: 'тест RlsSmokeTest проходит' }] });
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
it('значимый шаг без критерия → missing', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 1 }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingCriterion).toEqual([1]);
|
||
});
|
||
it('критерий = голое слово «проверено» → за факт НЕ берём → missing', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 1, criterion: 'проверено' }, { n: 2, criterion: 'готово' }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingCriterion).toEqual([1, 2]);
|
||
});
|
||
it('чистое чтение (op:Read) без критерия → не требуется (механически не значим)', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 1, op: 'Read', object: 'tools/x.mjs' }] });
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
it('сомнение в значимости → шаг считается значимым (нельзя «разжаловать» чтобы проскочить)', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 5 }] }); // significant не указан → значим
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingCriterion).toEqual([5]);
|
||
});
|
||
it('C3/F18: контроллер-флаг trivial:true НЕ разжалует мутирующий шаг (Write без критерия → missing)', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 7, op: 'Write', object: 'tools/x.mjs', trivial: true }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingCriterion).toEqual([7]);
|
||
});
|
||
it('C3/F18: контроллер-флаг significant:false НЕ разжалует мутирующий шаг (Bash без критерия → missing)', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 8, op: 'Bash', object: 'git commit', significant: false }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingCriterion).toEqual([8]);
|
||
});
|
||
it('обход через op:Read при разрушительном object → всё равно значим (missing)', () => {
|
||
const r = k5CriterionCheck({ planSteps: [{ n: 9, op: 'Read', object: 'rm -rf build' }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingCriterion).toEqual([9]);
|
||
});
|
||
});
|
||
|
||
describe('isSignificantStep (5.2): механическая значимость по классу действия', () => {
|
||
it('мутирующие op значимы (Write/Edit/MultiEdit/NotebookEdit/Bash)', () => {
|
||
for (const op of ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash']) {
|
||
expect(isSignificantStep({ op, object: 'x' })).toBe(true);
|
||
}
|
||
});
|
||
it('чистое чтение не значимо (Read/Grep/Glob/LS/NotebookRead)', () => {
|
||
for (const op of ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']) {
|
||
expect(isSignificantStep({ op, object: 'x' })).toBe(false);
|
||
}
|
||
});
|
||
it('разрушительный object перевешивает readonly-op (анти-обход)', () => {
|
||
expect(isSignificantStep({ op: 'Read', object: 'git push --force' })).toBe(true);
|
||
});
|
||
it('неизвестный/пустой op → значим (сомнение → значим, fail-CLOSE)', () => {
|
||
expect(isSignificantStep({ n: 1 })).toBe(true);
|
||
expect(isSignificantStep(null)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('criteriaGreenMatched (Гейт-3, $0): критерий ↔ настоящий зелёный прогон', () => {
|
||
it('каждый критерий сопоставлен с зелёным прогоном → ok', () => {
|
||
const r = criteriaGreenMatched({ criteria: [{ id: 'c1' }, { id: 'c2' }], greenRuns: [{ criterion_id: 'c1', green: true }, { criterion_id: 'c2', green: true }] });
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
it('критерий без зелёного прогона → unproven', () => {
|
||
const r = criteriaGreenMatched({ criteria: [{ id: 'c1' }, { id: 'c2' }], greenRuns: [{ criterion_id: 'c1', green: true }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.unproven).toEqual(['c2']);
|
||
});
|
||
it('прогон есть, но КРАСНЫЙ (green:false) → критерий не доказан', () => {
|
||
const r = criteriaGreenMatched({ criteria: [{ id: 'c1' }], greenRuns: [{ criterion_id: 'c1', green: false }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.unproven).toEqual(['c1']);
|
||
});
|
||
});
|
||
|
||
describe('skillTakenByJournal (A2, K2): навык взят по журналу, не по тексту', () => {
|
||
it('требуемые навыки есть в журнале → ok', () => {
|
||
expect(skillTakenByJournal({ requiredSkills: ['Pest'], journalSkillCalls: ['Pest', 'Read'] }).ok).toBe(true);
|
||
});
|
||
it('требуемый навык отсутствует в журнале → missing', () => {
|
||
const r = skillTakenByJournal({ requiredSkills: ['Pest'], journalSkillCalls: ['Read'] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missing).toEqual(['Pest']);
|
||
});
|
||
});
|
||
|
||
import { gate1CoverageGate, criteriaFromSealedPlan } from './judge-gate-floor.mjs';
|
||
|
||
describe('gate1CoverageGate (#3 — машина охвата на Гейте-1: дыра/цикл/сирота/просьбы)', () => {
|
||
it('охват готов (ready) → ок', () => {
|
||
expect(gate1CoverageGate({ readiness: { ready: true, items: [] } }).ok).toBe(true);
|
||
});
|
||
it('есть дыра/цикл/сирота → блок + перечень провалов', () => {
|
||
const readiness = { ready: false, items: [
|
||
{ label: 'Все нужды покрыты (нет дыр)', ok: false, pointer: '§A findHoles', detail: [{ need: 'x' }] },
|
||
{ label: 'Нет циклов', ok: true },
|
||
] };
|
||
const r = gate1CoverageGate({ readiness });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.failures).toHaveLength(1);
|
||
expect(r.failures[0].label).toMatch(/нужды/);
|
||
});
|
||
it('машина охвата не отработала (нет результата) → fail-closed', () => {
|
||
expect(gate1CoverageGate({ readiness: null }).ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('criteriaFromSealedPlan (#5, F3/F9 — критерии из печати, не дозаполнены на демо)', () => {
|
||
it('все критерии из запечатанного набора → ок', () => {
|
||
expect(criteriaFromSealedPlan({ criteria: [{ id: 'c1' }, { id: 'c2' }], sealedCriterionIds: ['c1', 'c2'] }).ok).toBe(true);
|
||
});
|
||
it('критерий не из печати (дозаполнен на демо) → флаг', () => {
|
||
const r = criteriaFromSealedPlan({ criteria: [{ id: 'c1' }, { id: 'c9' }], sealedCriterionIds: ['c1'] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.unsealed).toEqual(['c9']);
|
||
});
|
||
});
|
||
|
||
// 5.3 (Δ2): свежесть отпечатка в judge-gate-floor — green засчитывается только при совпадении
|
||
// code_fingerprint зелёного прогона с ТЕКУЩИМ (изменённые файлы шага + тесты). Правка файла
|
||
// после прогона меняет текущий отпечаток → green аннулируется. Отдельный шаг лесенки (Δ6).
|
||
import { fingerprintFresh } from './judge-gate-floor.mjs';
|
||
|
||
describe('fingerprintFresh (5.3, Δ2): green свеж только при совпадении code_fingerprint', () => {
|
||
it('отпечаток зелёного совпадает с текущим → свеж (ok)', () => {
|
||
const r = fingerprintFresh({
|
||
greenRuns: [{ criterion_id: 'c1', green: true, code_fingerprint: 'aa' }],
|
||
currentFingerprints: { c1: 'aa' },
|
||
});
|
||
expect(r.ok).toBe(true);
|
||
expect(r.stale).toEqual([]);
|
||
});
|
||
it('файл изменён после прогона (отпечаток разошёлся) → stale, не ok', () => {
|
||
const r = fingerprintFresh({
|
||
greenRuns: [{ criterion_id: 'c1', green: true, code_fingerprint: 'aa' }],
|
||
currentFingerprints: { c1: 'bb' },
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.stale).toEqual(['c1']);
|
||
});
|
||
it('нет текущего отпечатка для критерия → stale (fail-CLOSE)', () => {
|
||
const r = fingerprintFresh({
|
||
greenRuns: [{ criterion_id: 'c1', green: true, code_fingerprint: 'aa' }],
|
||
currentFingerprints: {},
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.stale).toEqual(['c1']);
|
||
});
|
||
it('красный прогон (green:false) не проверяется на свежесть (его забота — criteriaGreenMatched)', () => {
|
||
const r = fingerprintFresh({
|
||
greenRuns: [{ criterion_id: 'c1', green: false, code_fingerprint: 'aa' }],
|
||
currentFingerprints: { c1: 'bb' },
|
||
});
|
||
expect(r.ok).toBe(true);
|
||
expect(r.stale).toEqual([]);
|
||
});
|
||
it('нет зелёных прогонов → ok (присутствие зелёного — отдельный шаг)', () => {
|
||
expect(fingerprintFresh({ greenRuns: [], currentFingerprints: {} }).ok).toBe(true);
|
||
});
|
||
});
|
||
|
||
// 5.4 (Δ5): подлинность green — подпись подписанта (floor-signer), НЕ совпадение id.
|
||
// judge-gate-floor реконструирует подписанную тройку {criterion_id, code_fingerprint,
|
||
// occurrence} из green-run и проверяет verifyGreen. Синергия с 5.3: подмена отпечатка для
|
||
// прохода шага свежести ломает подпись здесь. Отдельный шаг лесенки (Δ6 шаг 4), fail-CLOSE.
|
||
import { greenSignaturesValid } from './judge-gate-floor.mjs';
|
||
import { signGreen } from './floor-signer.mjs';
|
||
|
||
const SK = 'signer-test-key';
|
||
|
||
describe('greenSignaturesValid (5.4, Δ5): green засчитан только при валидной подписи подписанта', () => {
|
||
it('подпись подписанта валидна → ok', () => {
|
||
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'aa', occurrence: 1 }, SK);
|
||
const run = { ...rec, green: true };
|
||
expect(greenSignaturesValid({ greenRuns: [run], key: SK }).ok).toBe(true);
|
||
});
|
||
it('green:true без подписи → unsigned (незаверенный нельзя выдать за зелёный)', () => {
|
||
const run = { criterion_id: 'c1', green: true, code_fingerprint: 'aa', occurrence: 1 };
|
||
const r = greenSignaturesValid({ greenRuns: [run], key: SK });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.unsigned).toEqual(['c1']);
|
||
});
|
||
it('подмена отпечатка после подписи → подпись не сходится → unsigned', () => {
|
||
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'aa', occurrence: 1 }, SK);
|
||
const run = { ...rec, green: true, code_fingerprint: 'bb' }; // подменили отпечаток после подписи
|
||
expect(greenSignaturesValid({ greenRuns: [run], key: SK }).ok).toBe(false);
|
||
});
|
||
it('красный прогон (green:false) не требует подписи (его ловит criteriaGreenMatched)', () => {
|
||
const run = { criterion_id: 'c1', green: false, code_fingerprint: 'aa', occurrence: 1 };
|
||
expect(greenSignaturesValid({ greenRuns: [run], key: SK }).ok).toBe(true);
|
||
});
|
||
it('нет ключа → подпись непроверяема → unsigned (fail-CLOSE)', () => {
|
||
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'aa', occurrence: 1 }, SK);
|
||
const run = { ...rec, green: true };
|
||
expect(greenSignaturesValid({ greenRuns: [run], key: undefined }).ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
import { extractSkillCalls as extractSkillCalls4b } from './enforce-skill-journaler.mjs';
|
||
|
||
// М7 Фаза 4b §5 coverage-map: decomposition (скрытое дробление) ловит Гейт-1 existenceCheck
|
||
// через ЖУРНАЛ (обещанные навыки ← extractSkillCalls), не текст. Proof покрытия ДО retire
|
||
// no-op enforce-decomposition-detector (Ф8); live-wiring «обещанные ← журнал» в judge-gate — Ф7.
|
||
describe('existenceCheck как decomposition look-ahead через журнал (М7 Фаза 4b §4.2)', () => {
|
||
const journal = [
|
||
{ payload: { op: 'Skill', object: 'superpowers:test-driven-development' } },
|
||
{ payload: { op: 'Edit', object: 'foo.mjs' } },
|
||
];
|
||
it('promised planning skill НЕ вызван (по журналу) → existenceCheck ловит (missingSkills непуст)', () => {
|
||
const invoked = extractSkillCalls4b(journal);
|
||
const r = existenceCheck({
|
||
questions: [{ id: 'q1', answered: true }],
|
||
skillsPromised: ['superpowers:writing-plans'],
|
||
skillsInvoked: invoked,
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.missingSkills).toContain('superpowers:writing-plans');
|
||
});
|
||
it('promised skill вызван (в журнале) → existenceCheck ok', () => {
|
||
const invoked = extractSkillCalls4b(journal);
|
||
const r = existenceCheck({
|
||
questions: [{ id: 'q1', answered: true }],
|
||
skillsPromised: ['superpowers:test-driven-development'],
|
||
skillsInvoked: invoked,
|
||
});
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
});
|