feat(criterion-gate): mutation runner in-place + restore (Level B task3)

This commit is contained in:
Дмитрий
2026-06-09 12:22:40 +03:00
parent 9414699ad9
commit caa41e6cac
2 changed files with 101 additions and 0 deletions
+45
View File
@@ -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 };
}
+56
View File
@@ -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 });
});
});