mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Свидетель, который не готовится к прогону, краснил гейт той задачи, чей дифф его выбрал, даже когда причина лежала в чужом коммите: на #566 это стоило двух кругов. Теперь при исходе `setup-failure` в дифф-режиме прогоняется ОПРЕДЕЛЕНИЕ БАЗЫ на дереве базы, и раннер говорит прямо — отказ предсуществующий или внесён этим диффом. Сравнение подобного с подобным здесь принципиально: с определением из головы собственная сломанная правка реестра выглядела бы предсуществующей. Предсуществующий гейт задачи не красит (решение владельца 14.09) и не теряется: он назван машиночитаемой строкой и обязан покраснеть в ночном полном прогоне, у которого есть адресат (#472). Мутанта, которого в базе нет, оправдывать нечем по построению. Композиция чтения реестра базы и запуска мутанта живёт отдельным модулем: границу «запуск не зависит от отбора» держит тест #558, и CLI обязан остаться тонким. Issue: #568 User-Visible: no
177 lines
7.5 KiB
JavaScript
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);
|
|
}
|