Отказ подготовки называет виновника

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

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

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

Issue: #568
User-Visible: no
This commit is contained in:
Codex
2026-09-14 08:55:17 +03:00
committed by Codex
parent 60f93a10ed
commit 264fba0902
7 changed files with 254 additions and 13 deletions
+49
View File
@@ -0,0 +1,49 @@
// Атрибуция отказа подготовки свидетеля (#568).
//
// Модуль отдельный не ради красоты: чтение реестра базы живёт в
// `mutation-selection.mjs`, запуск мутанта — в `mutation-execution.mjs`, а
// границу между ними держит тест #558 — запуск не имеет права зависеть от
// отбора. Композиция двух механик — это третье место, и вот оно.
import { runMutant } from './mutation-execution.mjs';
import { setupFailureOwner } from './mutation-guard-outcome.mjs';
import { baseRegistry } from './mutation-selection.mjs';
/**
* Кому принадлежит отказ подготовки (#568).
*
* Сравнивается ПОДОБНОЕ С ПОДОБНЫМ: определение мутанта из базы диапазона на
* дереве базы. Первая версия брала определение из головы — и собственная
* сломанная правка реестра выглядела «предсуществующей»: такое определение не
* компилируется и на базе тоже. Мутанта, которого в базе нет, оправдывать нечем
* по построению. `null` — сказать нечего: базы нет, реестр базы не прочитан или
* прогон сорвался; тогда отказ остаётся отказом этой задачи.
*/
export async function attributeSetupFailure(mutant, outcome, rangeBase, {
registryOf = baseRegistry,
run = runMutant,
log = console.log,
} = {}) {
if (!rangeBase) return null;
let registry = null;
try {
registry = await registryOf(rangeBase);
} catch (error) {
log(` атрибуция не удалась: ${error.message}`);
return null;
}
if (!registry) {
log(` атрибуция: реестр базы ${rangeBase} не прочитан`);
return null;
}
const baseDefinition = registry.find((item) => item.id === mutant.id) || null;
if (!baseDefinition) {
log(' атрибуция: в базе такого свидетеля нет — отказ внесён этим диффом');
return 'introduced';
}
log(` атрибуция: определение базы на дереве базы ${rangeBase}…`);
try {
return setupFailureOwner(outcome, run(baseDefinition, { ref: rangeBase }));
} catch (error) {
log(` атрибуция не удалась: ${error.message}`);
return null;
}
}
+11 -6
View File
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle,
MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle, setupFailureOwner,
} from './mutation-guard-outcome.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -36,11 +36,16 @@ function sh(cmd, cwd, extraEnv = {}) {
});
}
export function makeWorktree() {
/**
* Рабочее дерево для одного мутанта. `ref` нужен атрибуции отказа подготовки
* (#568): тот же мутант прогоняется на дереве базы диапазона, и по исходу
* видно, чей это отказ — диффа или предсуществующий.
*/
export function makeWorktree(ref = 'HEAD') {
const dir = mkdtempSync(join(tmpdir(), 'hp-mutant-'));
const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, 'HEAD'],
const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, ref],
{ encoding: 'utf8' });
if (added.status !== 0) throw new Error(`git worktree add: ${added.stderr}`);
if (added.status !== 0) throw new Error(`git worktree add ${ref}: ${added.stderr}`);
// node_modules не копируется — символическая ссылка на настоящий. Установка
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction');
@@ -163,10 +168,10 @@ function printMutantOutcome(mutant, outcome) {
if (outcome.detail) console.log(` ${outcome.detail}`);
}
export function runMutant(mutant) {
export function runMutant(mutant, { ref = 'HEAD' } = {}) {
let dir;
try {
dir = makeWorktree();
dir = makeWorktree(ref);
const outcome = runMutationLifecycle({
apply: () => applyPatches(dir, mutant.patches),
prepare: () => {
+36 -2
View File
@@ -22,6 +22,7 @@ import {
import {
LEDGER_SCHEMA, readLedger, recordCaught, splitByLedger, witnessFingerprint,
} from './mutation-evidence.mjs';
import { attributeSetupFailure } from './mutation-attribution.mjs';
import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -64,6 +65,7 @@ export async function main(argv) {
}
const changedArg = argv.find((a) => a === '--changed' || a.startsWith('--changed='));
let rangeBase = null;
const ledgerArg = argv.find((a) => a.startsWith('--ledger='))?.slice(9);
if (ledgerArg && !changedArg) {
// Полный прогон журнал не читает — он его пишет по расписанию целиком;
@@ -92,6 +94,9 @@ export async function main(argv) {
console.log(`package.json в диффе: ${relevance.relevant ? 'задевает гварды' : 'гварды не задевает'} — ${relevance.reason}`);
if (!relevance.relevant) files = files.filter((f) => f !== 'package.json');
}
// База диапазона нужна не только отбору: по ней атрибутируется отказ
// подготовки (#568).
rangeBase = range.includes('..') ? range.split('..')[0] : range;
// #492 §6.4: правка реестра отбирает добавленные и изменённые определения
// явно — новый свидетель не обязан трогать чужие patch/guard-файлы.
let base = null;
@@ -217,18 +222,47 @@ export async function main(argv) {
if (!runCleanGuards(toRun)) return 2;
let caught = 0;
let unverifiable = false;
// #568: свидетель, который не готовится к прогону, ломает гейт той задачи,
// чей дифф его выбрал, — а причина может лежать в чужом коммите. Тогда автор
// либо чинит чужое, либо стоит; я сам потерял на этом два круга. Поэтому
// отказ подготовки атрибутируется: тот же мутант прогоняется на дереве базы
// диапазона. Ложных срабатываний тут быть не может — сравниваются два
// прогона одного и того же мутанта, а не код с ожиданием.
const preExisting = [];
for (const entry of plan) {
const outcome = runMutant(entry.mutant);
if (!isProofOutcome(outcome)) {
if (outcome.kind === MUTATION_OUTCOME.SETUP) {
const verdict = await attributeSetupFailure(entry.mutant, outcome, rangeBase);
if (verdict === 'pre-existing') {
// Не красим гейт этой задачи (решение владельца 14.09): поломка не из
// этого диффа. Но и не теряем её — она названа здесь и обязана
// покраснеть в ночном полном прогоне, у которого есть адресат (#472).
console.log(` ПРЕДСУЩЕСТВУЮЩИЙ: не готовится и на базе — отказ не из этого диффа`);
preExisting.push(entry.mutant.id);
continue;
}
if (verdict === 'introduced') {
console.log(' отказ внесён этим диффом: на базе тот же мутант готовится');
}
}
if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;
continue;
}
caught++;
if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, outcome.proof);
}
console.log(`\nпоймано ${caught} из ${toRun.length}`);
console.log(`\nпоймано ${caught} из ${toRun.length - preExisting.length}`);
if (preExisting.length) {
// Строку читает человек и (при желании) CI. Формат менять синхронно с теми,
// кто её разбирает.
console.log(`предсуществующих отказов подготовки: ${preExisting.length}`);
console.log(`pre-existing-setup-failures=${preExisting.join(',')}`);
console.log('Эти свидетели мертвы до текущего диффа: гейт задачи они не красят, '
+ 'а ночной полный прогон обязан их назвать (#472, #568).');
}
if (unverifiable) return 2;
return caught === toRun.length ? 0 : 1;
return caught === toRun.length - preExisting.length ? 0 : 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+21
View File
@@ -24,6 +24,27 @@ export const MUTATION_PROOF = Object.freeze({
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. */
+38 -4
View File
@@ -72,6 +72,40 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'attribution-judges-the-head-definition-on-the-base',
guard: 'node --test --test-name-pattern="#568" test/mutation-guard-outcome.test.mjs',
because: '#568: атрибуция обязана сравнивать подобное с подобным — определение БАЗЫ на '
+ 'дереве базы; с определением из головы сломанная своей же правкой запись реестра '
+ 'выглядит предсуществующей, и гейт оправдывает автора вместо того, чтобы назвать его',
patches: [{
file: 'scripts/mutation-attribution.mjs',
find: ' return setupFailureOwner(outcome, run(baseDefinition, { ref: rangeBase }));',
replace: ' return setupFailureOwner(outcome, run(mutant, { ref: rangeBase }));',
}],
},
{
id: 'attribution-lets-a-new-witness-off',
guard: 'node --test --test-name-pattern="#568" test/mutation-guard-outcome.test.mjs',
because: '#568: свидетеля, которого в базе нет, оправдывать нечем — иначе новый мутант, '
+ 'не готовящийся к прогону с рождения, проезжает гейт молча',
patches: [{
file: 'scripts/mutation-attribution.mjs',
find: " return 'introduced';\n }\n log(` атрибуция: определение базы",
replace: " return 'pre-existing';\n }\n log(` атрибуция: определение базы",
}],
},
{
id: 'attribution-still-reddens-a-foreign-failure',
guard: 'node --test --test-name-pattern="#568" test/mutation-guard-outcome.test.mjs',
because: '#568: решение владельца — предсуществующий отказ гейт задачи не красит; если '
+ 'итог считать по полному списку, «не красим» остаётся словами',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: ' return caught === toRun.length - preExisting.length ? 0 : 1;',
replace: ' return caught === toRun.length ? 0 : 1;',
}],
},
{
id: 'invariants-blame-every-stale-position',
guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs',
@@ -3839,10 +3873,10 @@ const MUTANT_DEFINITIONS = [
+ 'skip it on every later push and hide the exact rot the gate exists for (#481)',
patches: [{
file: 'scripts/mutation-gate.mjs',
find: ' if (!isProofOutcome(outcome)) {\n'
+ ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;',
replace: ' if (!isProofOutcome(outcome)) {\n'
+ " if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, 'assertion');\n"
// Якорь переехал: между `isProofOutcome` и этой строкой встала атрибуция
// отказа подготовки (#568), поэтому патч цепляется за сам сторож.
find: ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;',
replace: " if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, 'assertion');\n"
+ ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;',
}, {
// Unit-наблюдаемая половина того же контракта: пустой отпечаток в