From e229cf070664ebdf13bf4e22a572d761273e5ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 9 Jun 2026 12:24:30 +0300 Subject: [PATCH] feat(criterion-gate): per-criterion green producer (Level B task4) --- tools/produce-criterion-greens.mjs | 73 +++++++++++++++++++++++++ tools/produce-criterion-greens.test.mjs | 37 +++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tools/produce-criterion-greens.mjs create mode 100644 tools/produce-criterion-greens.test.mjs diff --git a/tools/produce-criterion-greens.mjs b/tools/produce-criterion-greens.mjs new file mode 100644 index 00000000..20ade416 --- /dev/null +++ b/tools/produce-criterion-greens.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * produce-criterion-greens (Level B) — производитель по-критерийного подписанного GREEN. + * Для каждого листа запечатанного плана, меняющего исходник: прогнать тест критерия (baseline), + * сломать код мутацией и переспросить тест (P18), посчитать отпечаток (изменённый+тест-файл), + * produceGreen и дописать в greenRuns-store. Производитель сам гоняет тест+мутацию (SE-LB-7), + * никаких флагов от контролёра. I/O-main — под живой активацией владельцем; чистые ядра — тестируемы. + */ +import { produceGreen, codeFingerprint } from './criterion-green.mjs'; + +const READONLY_OPS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']); +const WRITE_OPS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']); + +/** Файлы критерия из шага плана. Только файловые мутирующие шаги; иначе null. */ +export function mapStepToFiles(step) { + if (!step || typeof step !== 'object') return null; + const op = String(step.op || ''); + if (!WRITE_OPS.has(op) || READONLY_OPS.has(op)) return null; + const obj = String(step.object || ''); + if (!obj || !obj.endsWith('.mjs')) return null; + const sourceFile = obj; + const testFile = obj.endsWith('.test.mjs') ? obj : obj.replace(/\.mjs$/, '.test.mjs'); + return { sourceFile, testFile }; +} + +/** Чистая обёртка produceGreen: настоящий green ⇔ testPassed && mutationKilled && ключ. */ +export function buildCriterionGreen({ criterion_id, occurrence, code_fingerprint, testPassed, mutationKilled, signerKey, coverage_of_changed = null }) { + return produceGreen({ criterion_id, occurrence, code_fingerprint, coverage_of_changed, testPassed, mutationKilled, signerKey }); +} + +// ── I/O-main (под активацией владельцем; не вызывается в TDD-юнитах) ── +async function main() { + const { loadFrozenPlan, verifyFrozenPlan, treeLeaves } = await import('./plan-lock.mjs'); + const { resolveReceiptKey } = await import('./receipt-key-config.mjs'); + const { runMutationForFile, classifyMutationResult } = await import('./mutate-runner.mjs'); + const { runVitestJson } = await import('./run-test-json.mjs'); + const { generateMutants } = await import('./mutate-operators.mjs'); + const { readFileSync, existsSync, writeFileSync, mkdirSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { homedir } = await import('node:os'); + + const sess = process.env.CLAUDE_SESSION_ID || 'unknown'; + const gitCwd = process.cwd(); + const key = resolveReceiptKey(); + const runtimeDir = join(homedir(), '.claude', 'runtime'); + const plan = loadFrozenPlan({ sessionId: sess, runtimeDir }); + if (!plan || !verifyFrozenPlan(plan, key)) { process.stdout.write('[produce-criterion-greens] нет валидного запечатанного плана\n'); process.exit(0); } + + const greens = []; + let occ = 0; + for (const leaf of treeLeaves(plan.steps || [])) { + const files = mapStepToFiles(leaf); + if (!files) continue; + const { sourceFile, testFile } = files; + if (!existsSync(join(gitCwd, sourceFile)) || !existsSync(join(gitCwd, testFile))) { + greens.push({ criterion_id: leaf.criterion_id, green: false, reason: 'no-test' }); continue; + } + const runTest = (tf) => runVitestJson(tf, gitCwd); + const mut = runMutationForFile({ filePath: join(gitCwd, sourceFile), testFile, generate: generateMutants, runTest }); + const { mutationKilled } = classifyMutationResult({ baselineGreen: mut.baselineGreen, mutantOutcomes: mut.mutantOutcomes }); + const fp = codeFingerprint({ [sourceFile]: readFileSync(join(gitCwd, sourceFile), 'utf-8'), [testFile]: readFileSync(join(gitCwd, testFile), 'utf-8') }); + occ += 1; + greens.push(buildCriterionGreen({ criterion_id: leaf.criterion_id, occurrence: occ, code_fingerprint: fp, testPassed: mut.baselineGreen, mutationKilled, signerKey: key })); + } + try { mkdirSync(runtimeDir, { recursive: true }); } catch { /* ignore */ } + writeFileSync(join(runtimeDir, `criterion-greens-${sess}.json`), JSON.stringify(greens)); + process.stdout.write(`[produce-criterion-greens] критериев: ${greens.length}, зелёных: ${greens.filter((g) => g.green).length}\n`); + process.exit(0); +} + +import { fileURLToPath } from 'node:url'; +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/produce-criterion-greens.test.mjs b/tools/produce-criterion-greens.test.mjs new file mode 100644 index 00000000..b3ffff36 --- /dev/null +++ b/tools/produce-criterion-greens.test.mjs @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { mapStepToFiles, buildCriterionGreen } from './produce-criterion-greens.mjs'; +import { verifyGreen } from './floor-signer.mjs'; + +const KEY = 'signer-key'; + +describe('mapStepToFiles', () => { + it('Write tools/x.mjs → {sourceFile, testFile по конвенции}', () => { + expect(mapStepToFiles({ op: 'Write', object: 'tools/x.mjs' })) + .toEqual({ sourceFile: 'tools/x.mjs', testFile: 'tools/x.test.mjs' }); + }); + it('сам тест-файл как object → source=test, testFile=он же', () => { + expect(mapStepToFiles({ op: 'Edit', object: 'tools/x.test.mjs' })) + .toEqual({ sourceFile: 'tools/x.test.mjs', testFile: 'tools/x.test.mjs' }); + }); + it('не-файловый / Bash шаг → null (нечего мутировать)', () => { + expect(mapStepToFiles({ op: 'Bash', object: 'git status' })).toBe(null); + expect(mapStepToFiles({ op: 'Read', object: 'tools/x.mjs' })).toBe(null); + }); +}); + +describe('buildCriterionGreen', () => { + const base = { criterion_id: 'c1', occurrence: 3, code_fingerprint: 'a'.repeat(64), signerKey: KEY }; + it('testPassed && mutationKilled → подписанный green, occurrence сохранён', () => { + const r = buildCriterionGreen({ ...base, testPassed: true, mutationKilled: true }); + expect(r.green).toBe(true); + expect(r.criterion_id).toBe('c1'); + expect(r.occurrence).toBe(3); + expect(verifyGreen({ criterion_id: r.criterion_id, code_fingerprint: r.code_fingerprint, occurrence: r.occurrence, sig: r.sig }, KEY)).toBe(true); + }); + it('мутант выжил → green:false reason mutation-survived (P18 fail-CLOSE)', () => { + expect(buildCriterionGreen({ ...base, testPassed: true, mutationKilled: false }).green).toBe(false); + }); + it('тест не прошёл → green:false reason test-not-passed', () => { + expect(buildCriterionGreen({ ...base, testPassed: false, mutationKilled: true }).reason).toBe('test-not-passed'); + }); +});