diff --git a/docs/TESTING.md b/docs/TESTING.md index 60d9a1b0..b5cb8d99 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -66,6 +66,22 @@ ledger. Compile-time проверка допустима как самостоя browser/backend guard нельзя. Все недоказанные исходы завершают гейт кодом 2 и отдельно называются в ночном отчёте. +С #568 `setup-failure` в дифф-режиме **атрибутируется**: раннер прогоняет +ОПРЕДЕЛЕНИЕ БАЗЫ диапазона на дереве базы и говорит, чей это отказ. +Предсуществующий (не готовится и на базе) гейт задачи не красит — автор не чинит +чужое, — но называется строкой `pre-existing-setup-failures=` и обязан +покраснеть в ночном полном прогоне, у которого есть адресат (#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): `workflow_dispatch validate.yml -f mutants=true` (его делают ревью-конвейер на материале ревью и слияние на кандидате), `full=true` (ночь, кнопка), PR и кандидат diff --git a/scripts/mutation-attribution.mjs b/scripts/mutation-attribution.mjs new file mode 100644 index 00000000..dc3abd93 --- /dev/null +++ b/scripts/mutation-attribution.mjs @@ -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; + } +} diff --git a/scripts/mutation-execution.mjs b/scripts/mutation-execution.mjs index 08661d21..3c1ecdf1 100644 --- a/scripts/mutation-execution.mjs +++ b/scripts/mutation-execution.mjs @@ -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: () => { diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index d6159d56..79a57595 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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]) { diff --git a/scripts/mutation-guard-outcome.mjs b/scripts/mutation-guard-outcome.mjs index 603e7dc2..1785b1fc 100644 --- a/scripts/mutation-guard-outcome.mjs +++ b/scripts/mutation-guard-outcome.mjs @@ -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. */ diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index a1837f91..32e8bed1 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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-наблюдаемая половина того же контракта: пустой отпечаток в diff --git a/test/mutation-guard-outcome.test.mjs b/test/mutation-guard-outcome.test.mjs index 6e1e5cc5..3202af52 100644 --- a/test/mutation-guard-outcome.test.mjs +++ b/test/mutation-guard-outcome.test.mjs @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { readFileSync } from 'node:fs'; import { - MUTATION_OUTCOME, guardPhases, isProofOutcome, runGuardPhases, + MUTATION_OUTCOME, guardPhases, isProofOutcome, runGuardPhases, setupFailureOwner, runMutationLifecycle, splitAndChain, } from '../scripts/mutation-guard-outcome.mjs'; +import { attributeSetupFailure } from '../scripts/mutation-attribution.mjs'; 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.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'); +});