feat(criterion-gate): mutation runner in-place + restore (Level B task3)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user