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
This commit is contained in:
@@ -66,6 +66,22 @@ ledger. Compile-time проверка допустима как самостоя
|
|||||||
browser/backend guard нельзя. Все недоказанные исходы завершают гейт кодом 2 и
|
browser/backend guard нельзя. Все недоказанные исходы завершают гейт кодом 2 и
|
||||||
отдельно называются в ночном отчёте.
|
отдельно называются в ночном отчёте.
|
||||||
|
|
||||||
|
С #568 `setup-failure` в дифф-режиме **атрибутируется**: раннер прогоняет
|
||||||
|
ОПРЕДЕЛЕНИЕ БАЗЫ диапазона на дереве базы и говорит, чей это отказ.
|
||||||
|
Предсуществующий (не готовится и на базе) гейт задачи не красит — автор не чинит
|
||||||
|
чужое, — но называется строкой `pre-existing-setup-failures=<id,…>` и обязан
|
||||||
|
покраснеть в ночном полном прогоне, у которого есть адресат (#472). Отказ,
|
||||||
|
внесённый диффом, краснит как раньше; свидетеля, которого в базе нет, оправдывать
|
||||||
|
нечем по построению. Сравнение именно подобного с подобным: с определением из
|
||||||
|
головы своя же сломанная правка реестра выглядела бы предсуществующей.
|
||||||
|
|
||||||
|
Приём, который делает мутанта мёртвым молча, один и тот же: **статически**
|
||||||
|
мёртвая ветка в `.ts` — `if (false)`, `false &&`, безусловный `return` в начале
|
||||||
|
функции. TypeScript перестаёт помнить сужения, сделанные выше (`profile` снова
|
||||||
|
`| null`), компиляция падает до заявленного теста. Писать ложь, ложную в
|
||||||
|
рантайме, но не статически: `if (eps < 0)`, `String(mode) === 'mutant-never-…'`,
|
||||||
|
`if (walls.length >= 0) return walls.slice();`. В `.mjs` этого ограничения нет.
|
||||||
|
|
||||||
В CI `changed_mutants` бежит не на каждом пуше, а по запросу (#510):
|
В CI `changed_mutants` бежит не на каждом пуше, а по запросу (#510):
|
||||||
`workflow_dispatch validate.yml -f mutants=true` (его делают ревью-конвейер на
|
`workflow_dispatch validate.yml -f mutants=true` (его делают ревью-конвейер на
|
||||||
материале ревью и слияние на кандидате), `full=true` (ночь, кнопка), PR и кандидат
|
материале ревью и слияние на кандидате), `full=true` (ночь, кнопка), PR и кандидат
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle,
|
MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle, setupFailureOwner,
|
||||||
} from './mutation-guard-outcome.mjs';
|
} from './mutation-guard-outcome.mjs';
|
||||||
|
|
||||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
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 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' });
|
{ 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 не копируется — символическая ссылка на настоящий. Установка
|
// node_modules не копируется — символическая ссылка на настоящий. Установка
|
||||||
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
|
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
|
||||||
symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction');
|
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}`);
|
if (outcome.detail) console.log(` ${outcome.detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runMutant(mutant) {
|
export function runMutant(mutant, { ref = 'HEAD' } = {}) {
|
||||||
let dir;
|
let dir;
|
||||||
try {
|
try {
|
||||||
dir = makeWorktree();
|
dir = makeWorktree(ref);
|
||||||
const outcome = runMutationLifecycle({
|
const outcome = runMutationLifecycle({
|
||||||
apply: () => applyPatches(dir, mutant.patches),
|
apply: () => applyPatches(dir, mutant.patches),
|
||||||
prepare: () => {
|
prepare: () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LEDGER_SCHEMA, readLedger, recordCaught, splitByLedger, witnessFingerprint,
|
LEDGER_SCHEMA, readLedger, recordCaught, splitByLedger, witnessFingerprint,
|
||||||
} from './mutation-evidence.mjs';
|
} from './mutation-evidence.mjs';
|
||||||
|
import { attributeSetupFailure } from './mutation-attribution.mjs';
|
||||||
import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs';
|
import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs';
|
||||||
|
|
||||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
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='));
|
const changedArg = argv.find((a) => a === '--changed' || a.startsWith('--changed='));
|
||||||
|
let rangeBase = null;
|
||||||
const ledgerArg = argv.find((a) => a.startsWith('--ledger='))?.slice(9);
|
const ledgerArg = argv.find((a) => a.startsWith('--ledger='))?.slice(9);
|
||||||
if (ledgerArg && !changedArg) {
|
if (ledgerArg && !changedArg) {
|
||||||
// Полный прогон журнал не читает — он его пишет по расписанию целиком;
|
// Полный прогон журнал не читает — он его пишет по расписанию целиком;
|
||||||
@@ -92,6 +94,9 @@ export async function main(argv) {
|
|||||||
console.log(`package.json в диффе: ${relevance.relevant ? 'задевает гварды' : 'гварды не задевает'} — ${relevance.reason}`);
|
console.log(`package.json в диффе: ${relevance.relevant ? 'задевает гварды' : 'гварды не задевает'} — ${relevance.reason}`);
|
||||||
if (!relevance.relevant) files = files.filter((f) => f !== 'package.json');
|
if (!relevance.relevant) files = files.filter((f) => f !== 'package.json');
|
||||||
}
|
}
|
||||||
|
// База диапазона нужна не только отбору: по ней атрибутируется отказ
|
||||||
|
// подготовки (#568).
|
||||||
|
rangeBase = range.includes('..') ? range.split('..')[0] : range;
|
||||||
// #492 §6.4: правка реестра отбирает добавленные и изменённые определения
|
// #492 §6.4: правка реестра отбирает добавленные и изменённые определения
|
||||||
// явно — новый свидетель не обязан трогать чужие patch/guard-файлы.
|
// явно — новый свидетель не обязан трогать чужие patch/guard-файлы.
|
||||||
let base = null;
|
let base = null;
|
||||||
@@ -217,18 +222,47 @@ export async function main(argv) {
|
|||||||
if (!runCleanGuards(toRun)) return 2;
|
if (!runCleanGuards(toRun)) return 2;
|
||||||
let caught = 0;
|
let caught = 0;
|
||||||
let unverifiable = false;
|
let unverifiable = false;
|
||||||
|
// #568: свидетель, который не готовится к прогону, ломает гейт той задачи,
|
||||||
|
// чей дифф его выбрал, — а причина может лежать в чужом коммите. Тогда автор
|
||||||
|
// либо чинит чужое, либо стоит; я сам потерял на этом два круга. Поэтому
|
||||||
|
// отказ подготовки атрибутируется: тот же мутант прогоняется на дереве базы
|
||||||
|
// диапазона. Ложных срабатываний тут быть не может — сравниваются два
|
||||||
|
// прогона одного и того же мутанта, а не код с ожиданием.
|
||||||
|
const preExisting = [];
|
||||||
for (const entry of plan) {
|
for (const entry of plan) {
|
||||||
const outcome = runMutant(entry.mutant);
|
const outcome = runMutant(entry.mutant);
|
||||||
if (!isProofOutcome(outcome)) {
|
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;
|
if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
caught++;
|
caught++;
|
||||||
if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, outcome.proof);
|
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;
|
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]) {
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
|
|||||||
@@ -24,6 +24,27 @@ export const MUTATION_PROOF = Object.freeze({
|
|||||||
COMPILE: 'compile',
|
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();
|
const outputOf = (result = {}) => `${result.stdout || ''}\n${result.stderr || ''}`.trim();
|
||||||
|
|
||||||
/** Split `&&` only when it is a shell operator, not text inside quotes. */
|
/** Split `&&` only when it is a shell operator, not text inside quotes. */
|
||||||
|
|||||||
@@ -72,6 +72,40 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
|
|||||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||||
const MUTANT_DEFINITIONS = [
|
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',
|
id: 'invariants-blame-every-stale-position',
|
||||||
guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs',
|
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)',
|
+ 'skip it on every later push and hide the exact rot the gate exists for (#481)',
|
||||||
patches: [{
|
patches: [{
|
||||||
file: 'scripts/mutation-gate.mjs',
|
file: 'scripts/mutation-gate.mjs',
|
||||||
find: ' if (!isProofOutcome(outcome)) {\n'
|
// Якорь переехал: между `isProofOutcome` и этой строкой встала атрибуция
|
||||||
+ ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;',
|
// отказа подготовки (#568), поэтому патч цепляется за сам сторож.
|
||||||
replace: ' if (!isProofOutcome(outcome)) {\n'
|
find: ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;',
|
||||||
+ " if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, 'assertion');\n"
|
replace: " if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, 'assertion');\n"
|
||||||
+ ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;',
|
+ ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;',
|
||||||
}, {
|
}, {
|
||||||
// Unit-наблюдаемая половина того же контракта: пустой отпечаток в
|
// Unit-наблюдаемая половина того же контракта: пустой отпечаток в
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MUTATION_OUTCOME, guardPhases, isProofOutcome, runGuardPhases,
|
MUTATION_OUTCOME, guardPhases, isProofOutcome, runGuardPhases, setupFailureOwner,
|
||||||
runMutationLifecycle, splitAndChain,
|
runMutationLifecycle, splitAndChain,
|
||||||
} from '../scripts/mutation-guard-outcome.mjs';
|
} from '../scripts/mutation-guard-outcome.mjs';
|
||||||
|
import { attributeSetupFailure } from '../scripts/mutation-attribution.mjs';
|
||||||
|
|
||||||
const result = (status, output = '') => ({ status, stdout: output, stderr: '' });
|
const result = (status, output = '') => ({ status, stdout: output, stderr: '' });
|
||||||
|
|
||||||
@@ -140,3 +142,83 @@ test('#550: compile-time proof is explicit and cannot hide in an assertion chain
|
|||||||
assert.equal(isProofOutcome(compile), true);
|
assert.equal(isProofOutcome(compile), true);
|
||||||
assert.throws(() => guardPhases('prepare && tsc', 'compile'), /one command/);
|
assert.throws(() => guardPhases('prepare && tsc', 'compile'), /one command/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #568: отказ подготовки краснил гейт той задачи, чей дифф выбрал свидетеля,
|
||||||
|
// даже когда причина лежала в чужом коммите — на этом я потерял два круга по
|
||||||
|
// #566. Атрибуция доказывает принадлежность прогоном ТОГО ЖЕ мутанта на базе.
|
||||||
|
const setup = { kind: MUTATION_OUTCOME.SETUP, command: 'tsc', detail: 'TS18047' };
|
||||||
|
|
||||||
|
test('#568: не готовится и на базе — отказ предсуществующий', () => {
|
||||||
|
assert.equal(setupFailureOwner(setup, { kind: MUTATION_OUTCOME.SETUP }), 'pre-existing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#568: на базе тот же мутант готовится — отказ внесён диффом', () => {
|
||||||
|
for (const kind of [MUTATION_OUTCOME.ASSERTION_KILLED, MUTATION_OUTCOME.COMPILE_KILLED,
|
||||||
|
MUTATION_OUTCOME.SURVIVED, MUTATION_OUTCOME.INVALID]) {
|
||||||
|
assert.equal(setupFailureOwner(setup, { kind }), 'introduced', kind);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#568: недоказанная невиновность оправданием не считается', () => {
|
||||||
|
// Базы нет, прогон на ней сорвался или голова упала не на подготовке —
|
||||||
|
// молчим, и отказ остаётся отказом этой задачи. Иначе прерывание
|
||||||
|
// инфраструктуры превращалось бы в индульгенцию.
|
||||||
|
assert.equal(setupFailureOwner(setup, null), null);
|
||||||
|
assert.equal(setupFailureOwner(setup, { kind: MUTATION_OUTCOME.INTERRUPTED }), null);
|
||||||
|
assert.equal(setupFailureOwner({ kind: MUTATION_OUTCOME.SURVIVED }, { kind: MUTATION_OUTCOME.SETUP }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#568: атрибуция судит определение базы, а не головы', async () => {
|
||||||
|
// Проверка исполнением, а не regexp по исходнику: подменяются реестр базы и
|
||||||
|
// запуск, и видно, ЧЬЁ определение пошло на прогон. Первая версия брала
|
||||||
|
// определение из головы — и своя же сломанная правка реестра выглядела
|
||||||
|
// предсуществующей; поймал это демонстрацией на себе.
|
||||||
|
const head = { id: 'w', guard: 'g', patches: [{ file: 'f', find: 'a', replace: 'HEAD' }] };
|
||||||
|
const base = { id: 'w', guard: 'g', patches: [{ file: 'f', find: 'a', replace: 'BASE' }] };
|
||||||
|
const ran = [];
|
||||||
|
const verdict = await attributeSetupFailure(head, setup, 'base-sha', {
|
||||||
|
registryOf: async () => [base],
|
||||||
|
run: (mutant) => { ran.push(mutant.patches[0].replace); return { kind: MUTATION_OUTCOME.ASSERTION_KILLED }; },
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
assert.deepEqual(ran, ['BASE'], 'на базе прогоняется определение базы');
|
||||||
|
assert.equal(verdict, 'introduced');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#568: не готовится и на базе — предсуществующий; нового свидетеля оправдывать нечем', async () => {
|
||||||
|
const mutant = { id: 'w', guard: 'g', patches: [] };
|
||||||
|
assert.equal(await attributeSetupFailure(mutant, setup, 'base-sha', {
|
||||||
|
registryOf: async () => [mutant],
|
||||||
|
run: () => ({ kind: MUTATION_OUTCOME.SETUP }),
|
||||||
|
log: () => {},
|
||||||
|
}), 'pre-existing');
|
||||||
|
// Мутанта, которого в базе нет, оправдывать нечем по построению.
|
||||||
|
assert.equal(await attributeSetupFailure(mutant, setup, 'base-sha', {
|
||||||
|
registryOf: async () => [],
|
||||||
|
run: () => { throw new Error('прогон не должен случиться'); },
|
||||||
|
log: () => {},
|
||||||
|
}), 'introduced');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#568: сказать нечего — отказ остаётся отказом этой задачи', async () => {
|
||||||
|
const mutant = { id: 'w', guard: 'g', patches: [] };
|
||||||
|
const quiet = { log: () => {}, run: () => ({ kind: MUTATION_OUTCOME.SETUP }) };
|
||||||
|
assert.equal(await attributeSetupFailure(mutant, setup, '', { ...quiet, registryOf: async () => [mutant] }), null,
|
||||||
|
'базы нет');
|
||||||
|
assert.equal(await attributeSetupFailure(mutant, setup, 'base-sha', { ...quiet, registryOf: async () => null }), null,
|
||||||
|
'реестр базы не прочитан');
|
||||||
|
assert.equal(await attributeSetupFailure(mutant, setup, 'base-sha', {
|
||||||
|
...quiet, registryOf: async () => { throw new Error('git сломался'); },
|
||||||
|
}), null, 'чтение реестра бросило');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#568: раннер не красит гейт предсуществующим отказом', () => {
|
||||||
|
const source = readFileSync(new URL('../scripts/mutation-gate.mjs', import.meta.url), 'utf8');
|
||||||
|
assert.match(source, /await attributeSetupFailure\(entry\.mutant, outcome, rangeBase\)/,
|
||||||
|
'раннер зовёт атрибуцию на исходе подготовки');
|
||||||
|
assert.match(source, /preExisting\.push\(entry\.mutant\.id\);/);
|
||||||
|
// Итог считается без предсуществующих, иначе «не красим» осталось бы словами.
|
||||||
|
assert.match(source, /return caught === toRun\.length - preExisting\.length \? 0 : 1;/);
|
||||||
|
assert.match(source, /pre-existing-setup-failures=/,
|
||||||
|
'список назван машиночитаемой строкой: его читает человек и CI');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user