Files
houseplan-card/scripts/mutation-guard-outcome.mjs
T
CodexandCodex 264fba0902 Отказ подготовки называет виновника
Свидетель, который не готовится к прогону, краснил гейт той задачи, чей дифф его
выбрал, даже когда причина лежала в чужом коммите: на #566 это стоило двух
кругов. Теперь при исходе `setup-failure` в дифф-режиме прогоняется ОПРЕДЕЛЕНИЕ
БАЗЫ на дереве базы, и раннер говорит прямо — отказ предсуществующий или внесён
этим диффом. Сравнение подобного с подобным здесь принципиально: с определением
из головы собственная сломанная правка реестра выглядела бы предсуществующей.

Предсуществующий гейт задачи не красит (решение владельца 14.09) и не теряется:
он назван машиночитаемой строкой и обязан покраснеть в ночном полном прогоне, у
которого есть адресат (#472). Мутанта, которого в базе нет, оправдывать нечем по
построению.

Композиция чтения реестра базы и запуска мутанта живёт отдельным модулем:
границу «запуск не зависит от отбора» держит тест #558, и CLI обязан остаться
тонким.

Issue: #568
User-Visible: no
2026-09-14 08:55:17 +03:00

177 lines
7.5 KiB
JavaScript

/**
* Honest outcome taxonomy for one mutation witness (#550).
*
* A shell command such as `tsc && fix-test-build && node --test` has two
* different meanings: the prefix prepares the oracle, while only the last
* command can prove the behavioural assertion. Treating every non-zero exit
* as "caught" turns a missing dependency or a mutant that no longer compiles
* into false test evidence. This module keeps that distinction pure and
* fixture-testable; the worktree/build orchestration lives in
* mutation-execution.mjs.
*/
export const MUTATION_OUTCOME = Object.freeze({
INVALID: 'invalid-mutation',
SETUP: 'setup-failure',
ASSERTION_KILLED: 'assertion-killed',
COMPILE_KILLED: 'compile-killed',
SURVIVED: 'survived',
INTERRUPTED: 'infrastructure-interruption',
});
export const MUTATION_PROOF = Object.freeze({
ASSERTION: 'assertion',
COMPILE: 'compile',
});
/**
* Кому принадлежит отказ подготовки (#568).
*
* Свидетель, который не готовится к прогону, краснит гейт той задачи, чей дифф
* его выбрал, — но причина может лежать в чужом коммите. Единственное честное
* доказательство здесь — прогон ТОГО ЖЕ мутанта на дереве базы диапазона:
* ложных срабатываний быть не может, потому что сравниваются два прогона одного
* мутанта, а не код с ожиданием.
*
* `null` означает «сказать нечего»: базы нет либо прогон на ней сорвался. Тогда
* поведение остаётся прежним — отказ считается отказом этой задачи, потому что
* недоказанная невиновность не оправдание.
*/
export function setupFailureOwner(headOutcome, baseOutcome) {
if (headOutcome?.kind !== MUTATION_OUTCOME.SETUP) return null;
if (!baseOutcome) return null;
if (baseOutcome.kind === MUTATION_OUTCOME.SETUP) return 'pre-existing';
if (baseOutcome.kind === MUTATION_OUTCOME.INTERRUPTED) return null;
return 'introduced';
}
const outputOf = (result = {}) => `${result.stdout || ''}\n${result.stderr || ''}`.trim();
/** Split `&&` only when it is a shell operator, not text inside quotes. */
export function splitAndChain(command) {
const parts = [];
let start = 0;
let quote = '';
let escaped = false;
const text = String(command || '');
for (let index = 0; index < text.length - 1; index++) {
const char = text[index];
if (escaped) { escaped = false; continue; }
if (char === '\\' && quote !== "'") { escaped = true; continue; }
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === "'" || char === '"') { quote = char; continue; }
if (char === '&' && text[index + 1] === '&') {
const part = text.slice(start, index).trim();
if (part) parts.push(part);
start = index + 2;
index++;
}
}
const tail = text.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
export function guardPhases(guard, proof = MUTATION_PROOF.ASSERTION) {
if (!Object.values(MUTATION_PROOF).includes(proof)) {
throw new Error(`unknown mutation oracle: ${proof}`);
}
const commands = splitAndChain(guard);
if (!commands.length) throw new Error('empty mutation guard');
// A compile-time witness is deliberately explicit. Its command is the
// oracle itself; silently treating a prefix compile as evidence is forbidden.
if (proof === MUTATION_PROOF.COMPILE) {
if (commands.length !== 1) throw new Error('compile witness guard must be one command');
return { setup: [], oracle: commands[0], proof };
}
return { setup: commands.slice(0, -1), oracle: commands.at(-1), proof };
}
function interruption(result = {}) {
if (result.error || result.signal || result.status == null) {
const code = result.error?.code || result.signal || 'no-exit-status';
return { kind: MUTATION_OUTCOME.INTERRUPTED, detail: String(code), command: '' };
}
return null;
}
// These mean the declared oracle could not load/collect/start. Ordinary
// exceptions from product code are intentionally absent: a test that executes
// the mutated path and crashes has still exposed the regression.
const ORACLE_SETUP_FAILURE = new RegExp([
'command not found', 'is not recognized as an internal or external command',
'ENOENT', 'ERR_MODULE_NOT_FOUND', 'Cannot find (?:module|package)',
'No module named', 'ImportError while (?:loading conftest|importing test module)',
'ERROR collecting', 'collected 0 items', 'no tests ran',
].join('|'), 'i');
// Test frameworks include user/fixture text in failure diagnostics. A fixture
// may literally mention ERR_MODULE_NOT_FOUND, so a proven assertion failure
// must win over a setup-looking substring quoted inside that assertion.
const ASSERTION_EVIDENCE = /(?:ERR_ASSERTION|AssertionError|^FAILED\s+\S+|^# Subtest:|^✖\s+)/im;
export function classifyCommandResult(result, { phase = 'oracle', proof = MUTATION_PROOF.ASSERTION } = {}) {
const stopped = interruption(result);
if (stopped) return { ...stopped, command: result?.command || '' };
if (Number(result.status) === 0) {
return { kind: MUTATION_OUTCOME.SURVIVED, detail: '', command: result?.command || '' };
}
const detail = outputOf(result).slice(-2000);
if (phase === 'setup') {
return { kind: MUTATION_OUTCOME.SETUP, detail, command: result?.command || '' };
}
if (proof !== MUTATION_PROOF.COMPILE && !ASSERTION_EVIDENCE.test(detail)
&& ORACLE_SETUP_FAILURE.test(detail)) {
return { kind: MUTATION_OUTCOME.SETUP, detail, command: result?.command || '' };
}
return {
kind: proof === MUTATION_PROOF.COMPILE
? MUTATION_OUTCOME.COMPILE_KILLED : MUTATION_OUTCOME.ASSERTION_KILLED,
proof,
detail,
command: result?.command || '',
};
}
/** Execute setup commands in order, then the one declared oracle. */
export function runGuardPhases(guard, {
proof = MUTATION_PROOF.ASSERTION,
execute,
} = {}) {
if (typeof execute !== 'function') throw new Error('mutation guard executor is required');
let phases;
try {
phases = guardPhases(guard, proof);
} catch (error) {
return { kind: MUTATION_OUTCOME.INVALID, detail: error.message, command: String(guard || '') };
}
for (const command of phases.setup) {
const result = execute(command, 'setup') || {};
const outcome = classifyCommandResult({ ...result, command }, { phase: 'setup', proof });
if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) return outcome;
}
const result = execute(phases.oracle, 'oracle') || {};
return classifyCommandResult({ ...result, command: phases.oracle }, { phase: 'oracle', proof });
}
/** Keep patch/preparation exceptions distinct from a test verdict. */
export function runMutationLifecycle({ apply, prepare, guard, proof, execute }) {
try { apply(); } catch (error) {
return { kind: MUTATION_OUTCOME.INVALID, detail: error.message, command: '' };
}
try { prepare(); } catch (error) {
return { kind: MUTATION_OUTCOME.SETUP, detail: error.message, command: '' };
}
return runGuardPhases(guard, { proof, execute });
}
export function isProofOutcome(outcome) {
return (outcome?.kind === MUTATION_OUTCOME.ASSERTION_KILLED
&& outcome.proof === MUTATION_PROOF.ASSERTION)
|| (outcome?.kind === MUTATION_OUTCOME.COMPILE_KILLED
&& outcome.proof === MUTATION_PROOF.COMPILE);
}