diff --git a/tools/judge-gate-floor.mjs b/tools/judge-gate-floor.mjs index 3d925228..42107091 100644 --- a/tools/judge-gate-floor.mjs +++ b/tools/judge-gate-floor.mjs @@ -11,6 +11,7 @@ * - skillTakenByJournal (A2, K2): навык взят по журналу вызовов, не по тексту TodoWrite. * Тупо-механика: уболтать нельзя. Качество — думающая часть 4-D + владелец. */ +import { classifyDestructive } from './classify-destructive.mjs'; /** Обещанные навыки реально вызваны (по журналу)? Общий помощник для Гейта-1 и A2. */ function skillsCoveredByJournal(promised = [], invoked = []) { @@ -36,12 +37,31 @@ export function specToPlanCoverage({ specSections = [], planSteps = [] }) { const BARE_DONE_RE = /^(проверено|проверен[аоы]|сделано|готово|ок|done|passed|pass|ready)\.?$/i; const MIN_CRITERION_LEN = 6; -/** Гейт-2 K5: значимый шаг → конкретный проверяемый критерий; «проверено» не факт. */ +// Чисто-читающие инструменты: не мутируют состояние → шаг не значим, критерий не нужен. +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 (s && s.trivial === true) continue; // механический микрошаг — критерий не нужен - if (s && s.significant === false) continue; // явно НЕ значим + 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); diff --git a/tools/judge-gate-floor.test.mjs b/tools/judge-gate-floor.test.mjs index aeeca55b..7ffafef7 100644 --- a/tools/judge-gate-floor.test.mjs +++ b/tools/judge-gate-floor.test.mjs @@ -39,6 +39,11 @@ describe('specToPlanCoverage (Гейт-2 пол): каждый § спеки п }); }); +// 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 проходит' }] }); @@ -54,8 +59,8 @@ describe('k5CriterionCheck (Гейт-2, K5): значимый шаг → кон expect(r.ok).toBe(false); expect(r.missingCriterion).toEqual([1, 2]); }); - it('механический микрошаг (trivial) → критерий не требуется', () => { - const r = k5CriterionCheck({ planSteps: [{ n: 1, trivial: true }] }); + it('чистое чтение (op:Read) без критерия → не требуется (механически не значим)', () => { + const r = k5CriterionCheck({ planSteps: [{ n: 1, op: 'Read', object: 'tools/x.mjs' }] }); expect(r.ok).toBe(true); }); it('сомнение в значимости → шаг считается значимым (нельзя «разжаловать» чтобы проскочить)', () => { @@ -63,6 +68,41 @@ describe('k5CriterionCheck (Гейт-2, K5): значимый шаг → кон 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): критерий ↔ настоящий зелёный прогон', () => {