Files
brain/tools/judge-gate-floor.mjs
T

149 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* judge-gate-floor (§3.1/§3.2/§3.5, K2/K5, F7) — механический пол гейтов ($0, не ИИ).
* Бесплатные проверки «было/не было», на которых стоит думающая часть судьи:
* - existenceCheck (Гейт-1 шаг 1): все вопросы отвечены + обещанные навыки реально
* вызваны по журналу (неподделываемый канал), не по тексту.
* - specToPlanCoverage (Гейт-2): каждый § запечатанной спеки покрыт ≥1 шагом плана.
* - k5CriterionCheck (Гейт-2, жемчужина K5): значимый шаг объявляет КОНКРЕТНЫЙ критерий;
* голое «проверено/готово» за факт НЕ берём; значимость осторожна (сомнение → значим).
* - criteriaGreenMatched (Гейт-3): каждый критерий сопоставлен с настоящим ЗЕЛЁНЫМ прогоном.
* - skillTakenByJournal (A2, K2): навык взят по журналу вызовов, не по тексту TodoWrite.
* Тупо-механика: уболтать нельзя. Качество — думающая часть 4-D + владелец.
*/
import { classifyDestructive } from './classify-destructive.mjs';
import { verifyGreen } from './floor-signer.mjs';
/** Обещанные навыки реально вызваны (по журналу)? Общий помощник для Гейта-1 и A2. */
function skillsCoveredByJournal(promised = [], invoked = []) {
const seen = new Set(invoked);
return (promised || []).filter((s) => !seen.has(s));
}
/** Гейт-1 шаг 1: вопросы отвечены + обещанные навыки в журнале. */
export function existenceCheck({ questions = [], skillsPromised = [], skillsInvoked = [] }) {
const unanswered = (questions || []).filter((q) => !q.answered).map((q) => q.id);
const missingSkills = skillsCoveredByJournal(skillsPromised, skillsInvoked);
return { ok: unanswered.length === 0 && missingSkills.length === 0, unanswered, missingSkills };
}
/** Гейт-2 пол: каждый § спеки покрыт хотя бы одним шагом плана (по step.ref). */
export function specToPlanCoverage({ specSections = [], planSteps = [] }) {
const covered = new Set((planSteps || []).map((s) => s && s.ref).filter(Boolean));
const uncovered = (specSections || []).filter((sec) => !covered.has(sec));
return { ok: uncovered.length === 0, uncovered };
}
// Голое done-слово (не критерий). Кириллические варианты — подстрокой/якорем строки.
const BARE_DONE_RE = /^(проверено|проверен[аоы]|сделано|готово|ок|done|passed|pass|ready)\.?$/i;
const MIN_CRITERION_LEN = 6;
// Чисто-читающие инструменты: не мутируют состояние → шаг не значим, критерий не нужен.
const READONLY_OPS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']);
/**
* 5.2 (C3/F18): МЕХАНИЧЕСКАЯ значимость шага — НЕ из контроллер-флагов. Значим, ЕСЛИ:
* - object разрушителен (classifyDestructive.suspicious) — перевешивает любой op (анти-обход
* «op:Read, object: rm -rf»); ИЛИ
* - op НЕ из чисто-читающего набора (мутирующий / неизвестный / пустой op → сомнение → значим).
* Контроллер-флаги trivial/significant ИГНОРИРУЮТСЯ: их подделка больше не разжалует значимый шаг.
*/
export function isSignificantStep(step) {
if (!step || typeof step !== 'object') return true; // сомнение → значим (fail-CLOSE)
const object = String(step.object ?? step.command ?? '');
if (classifyDestructive(object).suspicious) return true; // разрушительное → всегда значимо
const op = String(step.op || '');
if (READONLY_OPS.has(op)) return false; // чистое чтение — критерий не нужен
return true; // мутирующий / неизвестный op → значим
}
/** Гейт-2 K5: значимый шаг → конкретный проверяемый критерий; «проверено» не факт.
* Значимость — механическая (isSignificantStep), флаги контроллера не учитываются (C3/F18). */
export function k5CriterionCheck({ planSteps = [] }) {
const missingCriterion = [];
for (const s of planSteps || []) {
if (!isSignificantStep(s)) continue; // механически не значим (чистое чтение)
const c = s && typeof s.criterion === 'string' ? s.criterion.trim() : '';
const concrete = c.length >= MIN_CRITERION_LEN && !BARE_DONE_RE.test(c);
if (!concrete) missingCriterion.push(s.n);
}
return { ok: missingCriterion.length === 0, missingCriterion };
}
/** Гейт-3 пол: каждый объявленный критерий сопоставлен с настоящим ЗЕЛЁНЫМ прогоном.
* Это «green-присутствие» (Δ6 шаг 2): свежесть отпечатка и подпись — отдельные шаги. */
export function criteriaGreenMatched({ criteria = [], greenRuns = [] }) {
const greenIds = new Set((greenRuns || []).filter((r) => r && r.green === true).map((r) => r.criterion_id));
const unproven = (criteria || []).filter((c) => !greenIds.has(c.id)).map((c) => c.id);
return { ok: unproven.length === 0, unproven };
}
/**
* 5.3 (Δ2, Δ6 шаг 3): свежесть отпечатка. Зелёный прогон засчитывается только если его
* code_fingerprint совпадает с ТЕКУЩИМ (изменённые файлы шага + тест-файлы; считается живым
* гейтом, инъектируется как currentFingerprints[criterion_id]). Правка файла после прогона
* меняет текущий отпечаток → расхождение → green аннулирован (stale). Чистая функция.
* Конструктивно fail-CLOSE: нет текущего отпечатка (undefined) ≠ записанному → stale.
* Красные прогоны не проверяются (их «не-зелёность» ловит criteriaGreenMatched).
*/
export function fingerprintFresh({ greenRuns = [], currentFingerprints = {} }) {
const stale = (greenRuns || [])
.filter((r) => r && r.green === true)
.filter((r) => r.code_fingerprint !== currentFingerprints[r.criterion_id])
.map((r) => r.criterion_id);
return { ok: stale.length === 0, stale };
}
/**
* 5.4 (Δ5, Δ6 шаг 4): подлинность green — подпись ПОДПИСАНТА, не совпадение id. Подпись
* подписанта (floor-signer) покрывает аутентифицирующую тройку {criterion_id, code_fingerprint,
* occurrence}; реконструируем её из green-run и проверяем verifyGreen. id = целостность (Δ5),
* подлинность = подпись. Синергия с 5.3: подмена отпечатка для прохода свежести ломает подпись
* здесь (отпечаток в подписанной тройке). Чистая, fail-CLOSE (нет ключа/sig → unsigned).
* Красные прогоны не требуют подписи (их «не-зелёность» ловит criteriaGreenMatched).
*/
export function greenSignaturesValid({ greenRuns = [], key }) {
const unsigned = (greenRuns || [])
.filter((r) => r && r.green === true)
.filter((r) => !verifyGreen(
{ criterion_id: r.criterion_id, code_fingerprint: r.code_fingerprint, occurrence: r.occurrence, sig: r.sig },
key,
))
.map((r) => r.criterion_id);
return { ok: unsigned.length === 0, unsigned };
}
/** A2 K2: требуемые навыки взяты по фактическому журналу вызовов (не по тексту). */
export function skillTakenByJournal({ requiredSkills = [], journalSkillCalls = [] }) {
const missing = skillsCoveredByJournal(requiredSkills, journalSkillCalls);
return { ok: missing.length === 0, missing };
}
/**
* #3 (Гейт-1, §3.1 шаг 3): механический пол полноты — потребляет результат машины
* охвата Машины 3 (`readinessChecklist` → {ready, items}) и гейтит на дыры/циклы/
* сироты/непокрытые просьбы. Результат инъектируется (без жёсткой связки с файлом
* coverage-machine — оркестрация/живой режим вызывает readinessChecklist и подаёт сюда).
* Нет результата → fail-closed (сужу вслепую по полноте).
*/
export function gate1CoverageGate({ readiness }) {
if (!readiness || typeof readiness !== 'object') {
return { ok: false, failures: [{ label: 'машина охвата не отработала — нет результата (fail-closed)', pointer: null }] };
}
const failures = (readiness.items || [])
.filter((i) => i && i.ok !== true)
.map((i) => ({ label: i.label, pointer: i.pointer, detail: i.detail }));
return { ok: readiness.ready === true && failures.length === 0, failures };
}
/**
* #5 (F3/F9): объявленные критерии приёмки обязаны происходить из ЗАПЕЧАТАННОГО набора
* (Гейт-1/Гейт-2), а не дозаполняться контроллером на демо-времени. Критерий, чьего id
* нет в sealedCriterionIds, → unsealed (дозаполнен мимо печати) → флаг.
*/
export function criteriaFromSealedPlan({ criteria = [], sealedCriterionIds = [] }) {
const sealed = new Set(sealedCriterionIds);
const unsealed = (criteria || []).filter((c) => !sealed.has(c.id)).map((c) => c.id);
return { ok: unsealed.length === 0, unsealed };
}