diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index d9af24e8..34af413c 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -5,11 +5,17 @@ name: Мутационный гейт # оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит # защитой, и это хуже его отсутствия. # -# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в -# Validate и не идёт на каждый push. Его место — перед стабильным релизом -# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился -# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — -# идёт с обычными юнитами: test/mutation-gate.test.mjs. +# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push. +# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по +# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина — +# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами: +# test/mutation-gate.test.mjs. +# +# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle), +# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный +# tsc), а реестр режется на четыре чересполосных шарда — полный прогон +# укладывается в десятки минут вместо часов. Локальный дифф-режим: +# node scripts/mutation-gate.mjs --changed origin/dev..HEAD on: workflow_dispatch: @@ -31,10 +37,14 @@ concurrency: jobs: mutants: - name: "Мутанты: каждый обязан красить тесты" + name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)" runs-on: ubuntu-latest - # Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально: - # гейт предрелизный. Час — потолок против зависшего Chromium. + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + # Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды. + # Час — потолок против зависшего Chromium. timeout-minutes: 60 steps: - uses: actions/checkout@v7 @@ -70,5 +80,8 @@ jobs: - name: Реестр применим к текущему коду run: node scripts/mutation-gate.mjs --check + - name: Тёплый test-build для инкрементальной компиляции мутантов + run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs + - name: Каждый тест ловит свою поломку - run: node scripts/mutation-gate.mjs + run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 diff --git a/docs/TESTING.md b/docs/TESTING.md index 1c34f8f2..e046c507 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,8 +29,13 @@ но не при её неработоспособности. Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы; -полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по -понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`. +полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда, +`--shard=i/4`), перед стабильным релизом и по понедельникам. Дешёвая половина +идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи — +`node scripts/mutation-gate.mjs --changed origin/dev..HEAD`: гоняются только +мутанты, чьи patch-файлы задеты диффом (#332). Бандл собирается только +мутантам с браузерным гвардом; компиляция тестов в worktree стартует с +тёплого `test-build/` основного дерева. ## Stable wall-segment identity (#282) diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 6e17ddbc..87293cdd 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3411,6 +3411,36 @@ export function guardNeedsTestBuild(guard) { return /(^|[\s&|;])node --test\b/.test(guard) && !guard.includes('tsconfig.test.json'); } +/** + * Нужен ли гварду СОБРАННЫЙ бандл (#332). + * + * Бандл читают только браузерные проверки: смоки и golden-сцены грузят + * `demo/srv/assets/houseplan-card.js`, и мутант обязан попасть в него, иначе + * guard проверяет чистый код. Юнит- и бэкенд-гварды бандл не открывают ни в + * каком виде (проверено по реестру и по исходникам тестов: dist/** читается + * только как git-чекаут, который в worktree и так есть). Rollup-сборка — + * самая дорогая часть прогона (255 мутантов × ~15-20 с), поэтому она + * выполняется только там, где её результат кто-то откроет. + */ +export function guardNeedsBundle(guard) { + return guard.includes('demo/') || guard.includes('bundle:sync'); +} + +/** + * Тёплый старт компиляции мутанта (#332): скопировать `test-build/` вместе с + * `.tsbuildinfo` из основного дерева. Мутант меняет один-два файла, и + * инкрементальный tsc пересобирает только их дельту вместо всего проекта; + * `.tsbuildinfo` сверяет файлы по хэшу содержимого, поэтому свежие mtime + * worktree его не сбивают, а мутированный файл гарантированно пересобирается. + * Отсутствие каталога в основном дереве — не ошибка: холодная сборка просто + * идёт прежним полным путём. + */ +function seedTestBuild(dir) { + const warm = join(repoRoot, 'test-build'); + if (!existsSync(warm)) return; + cpSync(warm, join(dir, 'test-build'), { recursive: true }); +} + /** * Собрать `test-build/` из мутированного src в каталоге мутанта. * @@ -3420,6 +3450,7 @@ export function guardNeedsTestBuild(guard) { * если его нет, падаем громко, а не отдаём тесту пустоту. */ function buildTestBuild(dir) { + seedTestBuild(dir); sh('npx tsc -p tsconfig.test.json', dir); const fixed = sh('node scripts/fix-test-build.mjs', dir); if (!existsSync(join(dir, 'test-build'))) { @@ -3441,7 +3472,7 @@ function runMutant(mutant) { const dir = makeWorktree(); try { applyPatches(dir, mutant.patches); - buildBundle(dir); + if (guardNeedsBundle(mutant.guard)) buildBundle(dir); if (guardNeedsTestBuild(mutant.guard)) buildTestBuild(dir); const guard = sh(mutant.guard, dir); if (guard.status === 0) { @@ -3463,7 +3494,7 @@ function runCleanGuards(mutants) { const guards = [...new Set(mutants.map((m) => m.guard))]; const dir = makeWorktree(); try { - buildBundle(dir); + if (guards.some(guardNeedsBundle)) buildBundle(dir); // Один worktree на все чистые гварды — значит и компиляция одна. if (guards.some(guardNeedsTestBuild)) buildTestBuild(dir); for (const guard of guards) { @@ -3481,14 +3512,68 @@ function runCleanGuards(mutants) { } } +/** + * Мутанты, чьи патч-файлы задеты диффом (#332). Дифф-режим — для локальной + * проверки и ревью-циклов; полный набор остаётся предрелизным контрактом, + * поэтому пустая выборка — честный успех с явным сообщением, а не ошибка. + */ +export function selectChangedMutants(mutants, changedFiles) { + const changed = new Set(changedFiles); + return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file))); +} + +/** + * Детерминированный шард `index/total` (#332): реестр сортируется по id и + * режется чересполосно, чтобы дорогие смок-мутанты (соседи по алфавиту) + * не скапливались в одном шарде. Объединение шардов равно реестру, + * пересечений нет — закреплено юнитом. + */ +export function shardMutants(mutants, index, total) { + const ordered = [...mutants].sort((a, b) => a.id.localeCompare(b.id)); + return ordered.filter((_, position) => position % total === index - 1); +} + function main(argv) { const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5); - const selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS; + let selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS; if (idArg && !selected.length) { console.error(`мутант «${idArg}» не объявлен; --list покажет реестр`); return 2; } + const changedArg = argv.find((a) => a === '--changed' || a.startsWith('--changed=')); + if (changedArg) { + const range = changedArg.includes('=') ? changedArg.split('=')[1] : 'origin/dev..HEAD'; + const diff = spawnSync('git', ['-C', repoRoot, 'diff', '--name-only', range], + { encoding: 'utf8' }); + if (diff.status !== 0) { + console.error(`git diff ${range} не удался:\n${diff.stderr}`); + return 2; + } + const files = diff.stdout.split('\n').filter(Boolean); + const before = selected.length; + selected = selectChangedMutants(selected, files); + console.log(`дифф-режим ${range}: файлов в диффе ${files.length}, ` + + `мутантов затронуто ${selected.length} из ${before}`); + if (!selected.length) { + console.log('дифф не задевает ни одного patch.file — гонять нечего; ' + + 'полный реестр остаётся предрелизным контрактом'); + return 0; + } + } + + const shardArg = argv.find((a) => a.startsWith('--shard='))?.slice(8); + if (shardArg) { + const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(shardArg); + if (!match || Number(match[1]) > Number(match[2])) { + console.error(`--shard ожидает i/n с 1 <= i <= n, получено «${shardArg}»`); + return 2; + } + const before = selected.length; + selected = shardMutants(selected, Number(match[1]), Number(match[2])); + console.log(`шард ${shardArg}: ${selected.length} из ${before} мутантов`); + } + if (argv.includes('--list')) { for (const m of MUTANTS) console.log(`${m.id}\n guard: ${m.guard}\n ${m.because}\n`); return 0; diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 82b41318..bb3cf3be 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -5,7 +5,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { MUTANTS, applyPatches, guardNeedsTestBuild } from '../scripts/mutation-gate.mjs'; +import { + MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, + selectChangedMutants, shardMutants, +} from '../scripts/mutation-gate.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -106,3 +109,74 @@ test('no mutant guard is left unable to resolve test-build (#235)', () => { ); } }); + +// --- #332: бандл собирается только тем, кто его читает --- + +test('#332: guardNeedsBundle отличает браузерные гварды от остальных', () => { + // Браузерные: смоки, golden и явный bundle:sync. + assert.equal(guardNeedsBundle('node demo/smoke_junction_limits.mjs'), true); + assert.equal(guardNeedsBundle( + 'node demo/golden/run.mjs --mode=capture --scenario=openings-filled-tunnel-dark', + ), true); + assert.equal(guardNeedsBundle('npm run bundle:sync && node demo/smoke_v8_draft_write.mjs'), true); + // Юнит, бэкенд и хелперы бандл не открывают. + assert.equal(guardNeedsBundle( + 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="x" test/junction-limits.test.mjs', + ), false); + assert.equal(guardNeedsBundle( + 'node scripts/backend-test-guard.mjs some_pattern tests_backend/test_trails.py', + ), false); + assert.equal(guardNeedsBundle('node scripts/trail-resume-test-guard.mjs'), false); +}); + +test('#332: каждый гвард реестра классифицируется, смешанных нет', () => { + // Гвард, который гоняет и юниты, и смок, обязан получить бандл: критерий + // demo/ это покрывает. Здесь фиксируется, что доля браузерных гвардов не + // «схлопнулась» молча — экономия #332 живёт только пока классификация умна. + const bundle = MUTANTS.filter((m) => guardNeedsBundle(m.guard)).length; + const rest = MUTANTS.length - bundle; + assert.ok(bundle >= 40, `браузерных гвардов подозрительно мало: ${bundle}`); + assert.ok(rest >= 150, `небраузерных гвардов подозрительно мало: ${rest}`); +}); + +// --- #332: дифф-режим и шарды --- + +test('#332: selectChangedMutants берёт мутанта при любом задетом patch.file', () => { + const mutants = [ + { id: 'a', patches: [{ file: 'src/one.ts' }] }, + { id: 'b', patches: [{ file: 'src/two.ts' }, { file: 'src/three.ts' }] }, + { id: 'c', patches: [{ file: 'custom_components/houseplan/x.py' }] }, + ]; + assert.deepEqual( + selectChangedMutants(mutants, ['src/three.ts', 'docs/README.md']).map((m) => m.id), + ['b'], + ); + assert.deepEqual(selectChangedMutants(mutants, ['docs/README.md']), []); + assert.deepEqual( + selectChangedMutants(mutants, ['src/one.ts', 'custom_components/houseplan/x.py']) + .map((m) => m.id), + ['a', 'c'], + ); +}); + +test('#332: шарды покрывают реестр целиком и не пересекаются', () => { + const TOTAL = 4; + const seen = new Map(); + for (let index = 1; index <= TOTAL; index++) { + for (const mutant of shardMutants(MUTANTS, index, TOTAL)) { + assert.equal(seen.has(mutant.id), false, `мутант ${mutant.id} попал в два шарда`); + seen.set(mutant.id, index); + } + } + assert.equal(seen.size, MUTANTS.length, 'объединение шардов не равно реестру'); + // Чересполосная нарезка: дорогие смок-мутанты размазаны, а не в одном шарде. + const perShard = new Map(); + for (const [id, shard] of seen) { + const isBundle = guardNeedsBundle(MUTANTS.find((m) => m.id === id).guard); + if (isBundle) perShard.set(shard, (perShard.get(shard) || 0) + 1); + } + const counts = [...perShard.values()]; + assert.ok(Math.max(...counts) - Math.min(...counts) <= MUTANTS.length / TOTAL / 2, + `браузерные мутанты скучковались: ${counts.join(', ')}`); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index e326d9cc..91eecf20 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -5,7 +5,9 @@ "outDir": "test-build", "noEmit": false, "declaration": false, - "resolveJsonModule": true + "resolveJsonModule": true, + "incremental": true, + "tsBuildInfoFile": "test-build/.tsbuildinfo" }, "include": [ "src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",