diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml new file mode 100644 index 00000000..26491f73 --- /dev/null +++ b/.github/workflows/mutation-gate.yml @@ -0,0 +1,61 @@ +name: Mutation gate + +# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код +# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест, +# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит +# защитой, и это хуже его отсутствия. +# +# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в +# Validate и не идёт на каждый push. Его место — перед стабильным релизом +# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился +# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — +# идёт с обычными юнитами: test/mutation-gate.test.mjs. + +on: + workflow_dispatch: + schedule: + # Понедельник, 05:20 UTC — до начала рабочего дня владельца. + - cron: '20 5 * * 1' + +permissions: + contents: read + +concurrency: + group: mutation-gate + cancel-in-progress: true + +jobs: + mutants: + runs-on: ubuntu-latest + # Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это + # нормально: гейт предрелизный. Час — потолок против зависшего Chromium. + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Установить Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Реестр применим к текущему коду + run: node scripts/mutation-gate.mjs --check + + - name: Каждый тест ловит свою поломку + run: node scripts/mutation-gate.mjs diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs new file mode 100644 index 00000000..47a3b5f3 --- /dev/null +++ b/scripts/mutation-gate.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env node +// Реестр известных поломок (mutation gate), issue #85. +// +// node scripts/mutation-gate.mjs --list мутанты и кого они проверяют +// node scripts/mutation-gate.mjs --check патчи применимы к текущему коду +// node scripts/mutation-gate.mjs полный прогон: все мутанты +// node scripts/mutation-gate.mjs --id= один мутант +// node scripts/mutation-gate.mjs --build-only применить и собрать, тест не гонять +// +// Код выхода: 0 — каждый тест поймал свою поломку, 1 — хотя бы один не поймал, +// 2 — не смог проверить (патч не лёг, сборка упала). +// +// Зачем. Зелёный тест в этом проекте несколько раз означал «ничего не +// проверено», и выяснялось это после того, как баг доезжал до владельца: +// смок непрерывности не заметил удаления механизма, который защищает; golden, +// заведённый под #71, был пуст — 1 177 тёплых пикселей против 107 119, и все +// 1 177 были иконками. Общее у всех случаев: тест ни разу не проверяли на +// способность падать. Этот гейт делает такую проверку регулярной. +// +// Каждый мутант — маленький патч продуктового исходника, воспроизводящий +// известную поломку, и имя теста, который ОБЯЗАН на ней покраснеть. Прогон: +// worktree → патч → сборка бандла → бандл в demo/srv/assets → тест. Тест, +// оставшийся зелёным, — это провал гейта, а не успех теста. +// +// Прогон дорогой (пересборка бандла на мутанта), поэтому его место — перед +// стабильным релизом (.github/workflows/mutation-gate.yml), не на каждой бете. +// Дешёвая часть — «патчи применимы, guard-файлы существуют» — живёт в +// test/mutation-gate.test.mjs и идёт с обычными юнитами: реестр, отставший от +// кода, хуже отсутствующего, потому что выглядит защитой. + +import { spawnSync } from 'node:child_process'; +import { + cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// --- реестр --------------------------------------------------------------- +// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда +// попало», проверяет не то, что объявлен проверять. Это контролирует --check. +export const MUTANTS = [ + { + id: 'continuity-long-resume-noop', + guard: 'node demo/smoke_visual_continuity.mjs', + because: 'смок обязан доказывать, что long-resume СРАБОТАЛ (токен ушёл вперёд), ' + + 'а не что ничего не спрятали: в beta.7 он не заметил удаления всего механизма', + patches: [{ + file: 'src/visual-continuity.ts', + find: "if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {", + replace: "if ((signal.kind === 'visible' || signal.kind === 'pageshow')) {", + }], + }, + { + id: 'opening-cut-degenerate', + guard: 'node demo/smoke_glow.mjs', + because: 'проём, выродившийся в точку, остаётся кладкой — свет перестаёт ' + + 'проходить через дверь; смок обязан это увидеть по освещённому полу за проёмом', + patches: [{ + file: 'src/houseplan-card.ts', + find: 'cuts.push([o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy]);', + replace: 'cuts.push([o.rx, o.ry, o.rx, o.ry]);', + }], + }, + { + id: 'column-shadow-removed', + guard: 'node demo/smoke_glow.mjs', + because: 'физические тела выпали из окклюдеров — колонна перестаёт отбрасывать ' + + 'тень; исторически смок теней был зелёным, пока тени физически не рисовались', + patches: [{ + file: 'src/houseplan-card.ts', + find: 'for (const body of physical) occluders.push(...polygonSegments(body));', + replace: 'void physical;', + }], + }, + { + id: 'feather-20px', + guard: 'node demo/smoke_glow.mjs', + because: 'растушёвка 20 px вместо 2 размывает границу света на полкомнаты; ' + + 'смок читает data-feather-px и обязан отвергнуть значение больше 3', + patches: [{ + file: 'src/houseplan-card.ts', + find: 'const GLOW_EDGE_FEATHER_PX = 2;', + replace: 'const GLOW_EDGE_FEATHER_PX = 20;', + }], + }, + { + id: 'barrier-cache-never-invalidated', + guard: 'node demo/smoke_openwall.mjs', + because: 'кэш барьеров, который не инвалидируется по содержимому, — это свет ' + + 'сквозь стену, которая уже существует; смок переключает виртуальную границу ' + + 'и обязан увидеть смену освещённости соседней комнаты', + patches: [{ + file: 'src/houseplan-card.ts', + find: 'if (this._lightBarrierCache?.key === cacheKey) return this._lightBarrierCache.value;', + replace: 'if (this._lightBarrierCache) return this._lightBarrierCache.value;', + }], + }, + { + id: 'golden-lamp-out-of-reach', + guard: 'node demo/golden/run.mjs --mode=verify --scenario=lighting-opaque-glow-two-doorways-dark', + because: 'сцена заведена как защита дверного света (#71) и однажды уже была ' + + 'пустой: лампа стояла так, что пятно не доходило до стены. Уведённая лампа ' + + 'обязана ронять семантический ассерт сцены, а не только пиксельный дифф', + // Мутируется фикстура сцены, не продуктовый код: пустота сцены — свойство + // фикстуры. Пересборка бандла всё равно нужна, путь тот же. + patches: [{ + file: 'demo/golden/matrix.mjs', + find: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },", + replace: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.06, y: 0.90 } },", + }], + }, +]; + +// --- механика --------------------------------------------------------------- +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + +export function applyPatches(root, patches) { + for (const patch of patches) { + const path = join(root, patch.file); + const source = readFileSync(path, 'utf8'); + const hits = source.split(patch.find).length - 1; + if (hits !== 1) { + throw new Error(`${patch.file}: якорь найден ${hits} раз(а), нужен ровно 1 — реестр отстал от кода`); + } + writeFileSync(path, source.replace(patch.find, patch.replace)); + } +} + +function sh(cmd, cwd, extraEnv = {}) { + return spawnSync(cmd, { + cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, ...extraEnv }, + }); +} + +function makeWorktree() { + const dir = mkdtempSync(join(tmpdir(), 'hp-mutant-')); + const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, 'HEAD'], + { encoding: 'utf8' }); + if (added.status !== 0) throw new Error(`git worktree add: ${added.stderr}`); + // node_modules не копируется — символическая ссылка на настоящий. Установка + // зависимостей на каждого мутанта превратила бы вечерний гейт в суточный. + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction'); + return dir; +} + +function dropWorktree(dir) { + spawnSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', dir], { encoding: 'utf8' }); + rmSync(dir, { recursive: true, force: true }); +} + +function buildBundle(dir) { + // Только rollup, без tsc --noEmit: мутант имеет право быть нестрогим по + // типам — он воспроизводит поломку, а не образцовый код. + const built = sh('npx rollup -c', dir); + if (built.status !== 0) { + throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`); + } + cpSync(join(dir, 'dist', 'houseplan-card.js'), join(dir, 'demo', 'srv', 'assets', 'houseplan-card.js')); +} + +function runMutant(mutant) { + const dir = makeWorktree(); + try { + applyPatches(dir, mutant.patches); + buildBundle(dir); + const guard = sh(mutant.guard, dir); + if (guard.status === 0) { + console.log(`FAIL ${mutant.id}: тест остался зелёным на сломанном коде`); + console.log(` guard: ${mutant.guard}`); + console.log(` ${mutant.because}`); + return false; + } + console.log(`ok ${mutant.id}: тест покраснел, как обязан`); + return true; + } finally { + dropWorktree(dir); + } +} + +// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта, +// «ловит» поломку тривиально и не доказывает ничего. +function runCleanGuards(mutants) { + const guards = [...new Set(mutants.map((m) => m.guard))]; + const dir = makeWorktree(); + try { + buildBundle(dir); + for (const guard of guards) { + const result = sh(guard, dir); + if (result.status !== 0) { + console.log(`FAIL чистый прогон: ${guard} красный без мутанта`); + console.log((result.stderr || result.stdout).slice(-1500)); + return false; + } + console.log(`ok чистый прогон: ${guard}`); + } + return true; + } finally { + dropWorktree(dir); + } +} + +function main(argv) { + const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5); + const selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS; + if (idArg && !selected.length) { + console.error(`мутант «${idArg}» не объявлен; --list покажет реестр`); + return 2; + } + + if (argv.includes('--list')) { + for (const m of MUTANTS) console.log(`${m.id}\n guard: ${m.guard}\n ${m.because}\n`); + return 0; + } + + if (argv.includes('--check')) { + let stale = 0; + for (const m of selected) { + try { + for (const patch of m.patches) { + const source = readFileSync(join(repoRoot, patch.file), 'utf8'); + const hits = source.split(patch.find).length - 1; + if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`); + } + console.log(`ok ${m.id}`); + } catch (error) { + console.log(`FAIL ${m.id}: ${error.message}`); + stale++; + } + } + return stale ? 2 : 0; + } + + if (argv.includes('--build-only')) { + for (const m of selected) { + const dir = makeWorktree(); + try { + applyPatches(dir, m.patches); + buildBundle(dir); + console.log(`ok ${m.id}: патч лёг, бандл собрался`); + } catch (error) { + console.log(`FAIL ${m.id}: ${error.message}`); + return 2; + } finally { + dropWorktree(dir); + } + } + return 0; + } + + if (!runCleanGuards(selected)) return 2; + let caught = 0; + for (const m of selected) if (runMutant(m)) caught++; + console.log(`\nпоймано ${caught} из ${selected.length}`); + return caught === selected.length ? 0 : 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exit(main(process.argv.slice(2))); +} diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs new file mode 100644 index 00000000..504ee2c1 --- /dev/null +++ b/test/mutation-gate.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { MUTANTS, applyPatches } from '../scripts/mutation-gate.mjs'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + +// Дешёвая половина гейта, идёт с обычными юнитами на каждом прогоне. Полный +// прогон с пересборкой бандла на мутанта — предрелизный, он в +// .github/workflows/mutation-gate.yml. +// +// Реестр, отставший от кода, хуже отсутствующего: он выглядит защитой. Поэтому +// дрейф якорей ловится здесь, а не при редком полном прогоне. + +test('every mutant patch anchors exactly once in the current source', () => { + for (const mutant of MUTANTS) { + for (const patch of mutant.patches) { + const path = join(repoRoot, patch.file); + assert.ok(existsSync(path), `${mutant.id}: файла ${patch.file} больше нет`); + const source = readFileSync(path, 'utf8'); + const hits = source.split(patch.find).length - 1; + assert.equal(hits, 1, + `${mutant.id}: якорь в ${patch.file} найден ${hits} раз(а) — реестр отстал от кода`); + assert.notEqual(patch.find, patch.replace, `${mutant.id}: патч ничего не меняет`); + } + } +}); + +test('every guard command points at a file that exists', () => { + for (const mutant of MUTANTS) { + const script = mutant.guard.split(' ').find((part) => part.endsWith('.mjs')); + assert.ok(script, `${mutant.id}: guard не называет исполняемый файл`); + assert.ok(existsSync(join(repoRoot, script)), + `${mutant.id}: guard-файла ${script} не существует`); + } +}); + +test('every mutant explains itself', () => { + const ids = new Set(); + for (const mutant of MUTANTS) { + assert.ok(mutant.because && mutant.because.length > 40, + `${mutant.id}: без объяснения мутант превратится в карго-культ`); + assert.ok(!ids.has(mutant.id), `дубль id: ${mutant.id}`); + ids.add(mutant.id); + } + assert.ok(MUTANTS.length >= 6, 'стартовый набор — шесть мутантов по дырам из #85'); +}); + +test('applyPatches rewrites the anchor and refuses a stale one', () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-mg-')); + try { + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'src', 'a.ts'), 'const KEEP = 1;\nconst FEATHER = 2;\n'); + + applyPatches(dir, [{ file: 'src/a.ts', find: 'const FEATHER = 2;', replace: 'const FEATHER = 20;' }]); + assert.match(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), /FEATHER = 20/); + + // Якоря нет — отказ, а не тихий пропуск: патч «в никуда» выглядит защитой. + assert.throws( + () => applyPatches(dir, [{ file: 'src/a.ts', find: 'no such anchor', replace: 'x' }]), + /0 раз/, + ); + + // Якорь двоится — тоже отказ: патч лёг бы «куда попало». + writeFileSync(join(dir, 'src', 'a.ts'), 'twice\ntwice\n'); + assert.throws( + () => applyPatches(dir, [{ file: 'src/a.ts', find: 'twice', replace: 'x' }]), + /2 раз/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});