diff --git a/tools/mutate-runner.mjs b/tools/mutate-runner.mjs new file mode 100644 index 00000000..e7197309 --- /dev/null +++ b/tools/mutate-runner.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * mutate-runner (Level B, P18) — живая мутация одного файла под один тест. + * baseline-GREEN до мутации (SE-LB-2: краснота атрибутируема мутации, не пред-существующему провалу); + * мутация IN-PLACE с железобетонным восстановлением (SE-LB-3): тест импортирует РЕАЛЬНЫЙ путь, поэтому + * мутанта надо положить туда же; оригинал держим в памяти и пишем обратно в finally + сверяем восстановление. + * Утечка мутанта при жёстком kill ловится потребителем по несовпадению отпечатка (defense-in-depth). + * classifyMutationResult — чистая: убит ⇔ ≥1 валидный (не loadError) мутант покраснел. + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +export function classifyMutationResult({ baselineGreen, mutantOutcomes = [] }) { + if (baselineGreen !== true) return { mutationKilled: false, reason: 'baseline-not-green' }; + const valid = (mutantOutcomes || []).filter((o) => o && o.loadError !== true); + if (valid.length === 0) return { mutationKilled: false, reason: 'no-valid-mutants' }; + const killed = valid.some((o) => o.allGreen === false); + return killed ? { mutationKilled: true, reason: 'ok' } : { mutationKilled: false, reason: 'mutation-survived' }; +} + +/** + * Прогнать мутацию для filePath под testFile. generate/runTest инъектируются (юнит-тесты — фейки; + * live main подаёт generateMutants и (tf)=>runVitestJson(tf, gitCwd)). testFile передаётся в runTest. + */ +export function runMutationForFile({ filePath, testFile, generate, runTest }) { + const original = readFileSync(filePath, 'utf-8'); + const baseline = runTest(testFile); + if (!(baseline.allGreen === true && baseline.numPassed > 0)) { + return { baselineGreen: false, testCount: baseline.numPassed || 0, mutantOutcomes: [] }; + } + const mutants = generate(original) || []; + const mutantOutcomes = []; + try { + for (const m of mutants) { + writeFileSync(filePath, m.mutated); + const r = runTest(testFile); + mutantOutcomes.push({ label: m.label, allGreen: r.allGreen, loadError: r.loadError }); + } + } finally { + writeFileSync(filePath, original); // восстановление гарантировано + } + if (readFileSync(filePath, 'utf-8') !== original) { + throw new Error('mutate-runner: восстановление файла не удалось (fail-CLOSE)'); + } + return { baselineGreen: true, testCount: baseline.numPassed, mutantOutcomes }; +} diff --git a/tools/mutate-runner.test.mjs b/tools/mutate-runner.test.mjs new file mode 100644 index 00000000..89f686a6 --- /dev/null +++ b/tools/mutate-runner.test.mjs @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { classifyMutationResult, runMutationForFile } from './mutate-runner.mjs'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('classifyMutationResult (чистая классификация P18)', () => { + it('baseline не зелёный → killed:false reason baseline-not-green', () => { + expect(classifyMutationResult({ baselineGreen: false, mutantOutcomes: [] })) + .toEqual({ mutationKilled: false, reason: 'baseline-not-green' }); + }); + it('нет валидных мутантов (все loadError) → no-valid-mutants', () => { + expect(classifyMutationResult({ baselineGreen: true, mutantOutcomes: [{ allGreen: false, loadError: true }] })) + .toEqual({ mutationKilled: false, reason: 'no-valid-mutants' }); + }); + it('хоть один валидный мутант покраснел (allGreen:false, не loadError) → killed', () => { + expect(classifyMutationResult({ baselineGreen: true, mutantOutcomes: [ + { allGreen: true, loadError: false }, { allGreen: false, loadError: false } ] })) + .toEqual({ mutationKilled: true, reason: 'ok' }); + }); + it('все валидные мутанты выжили (тест ничего не проверяет) → mutation-survived', () => { + expect(classifyMutationResult({ baselineGreen: true, mutantOutcomes: [{ allGreen: true, loadError: false }] })) + .toEqual({ mutationKilled: false, reason: 'mutation-survived' }); + }); +}); + +describe('runMutationForFile (in-place + восстановление SE-LB-3)', () => { + it('восстанавливает оригинал файла после прогона (даже если мутант валит тест)', () => { + const dir = mkdtempSync(join(tmpdir(), 'mut-')); + const file = join(dir, 'x.mjs'); + const original = 'export const f = (a, b) => a === b;\n'; + writeFileSync(file, original); + // Фейковый runTest: baseline зелёный; на мутированном содержимом (=== заменён) — красный. + const runTest = () => { + const cur = readFileSync(file, 'utf-8'); + const baseline = cur === original; + return { allGreen: baseline, numPassed: baseline ? 1 : 0, numFailed: baseline ? 0 : 1, loadError: false }; + }; + const res = runMutationForFile({ filePath: file, generate: (s) => [{ label: 'm', mutated: s.replace('===', '!==') }], runTest }); + expect(res.baselineGreen).toBe(true); + expect(res.mutantOutcomes.some((o) => o.allGreen === false)).toBe(true); + expect(readFileSync(file, 'utf-8')).toBe(original); // восстановлен + rmSync(dir, { recursive: true, force: true }); + }); + it('baseline красный → мутацию не запускает, baselineGreen:false, файл цел', () => { + const dir = mkdtempSync(join(tmpdir(), 'mut-')); + const file = join(dir, 'y.mjs'); + writeFileSync(file, 'broken'); + const runTest = () => ({ allGreen: false, numPassed: 0, numFailed: 1, loadError: false }); + const res = runMutationForFile({ filePath: file, generate: () => [{ label: 'm', mutated: 'mutant' }], runTest }); + expect(res.baselineGreen).toBe(false); + expect(res.mutantOutcomes).toEqual([]); + expect(readFileSync(file, 'utf-8')).toBe('broken'); + rmSync(dir, { recursive: true, force: true }); + }); +});