perf: mutation-gate builds the bundle only for browser guards, compiles incrementally, shards and diffs (#332)

Four independent cuts into the 2-4 hour full run, none touching the contract
"a mutant must turn its guard red":

- guardNeedsBundle: rollup runs only for guards that open the built bundle
  (demo/ smokes, golden captures, bundle:sync) — 68 of 253 registry entries.
  Unit and backend guards never read dist/ as a build artifact (verified
  against every test that mentions dist/**: they read the git checkout or
  synthetic files), so 185 mutants skip the most expensive step entirely.
- seedTestBuild + incremental tsc: the mutant worktree starts from the main
  tree's warm test-build/ and .tsbuildinfo; tsc compares file hashes, not
  mtimes, so the fresh checkout stays warm and only the mutated delta is
  recompiled. This also speeds up the long guards that run tsc themselves.
- --changed[=range]: run only mutants whose patch files are touched by the
  diff (origin/dev..HEAD by default). An empty selection is an honest success
  with an explicit message — the full registry remains the pre-release
  contract, per the workflow comment.
- --shard=i/n: deterministic interleaved slices; the workflow runs a 4-way
  matrix, and a warm test-build step feeds every shard. Interleaving spreads
  the expensive browser mutants across shards instead of clumping them.

Measured per mutant on this machine: unit 12-13 s (was ~50-70 s), backend
6 s, browser 32 s (unchanged — the bundle is genuinely needed there). Full
run estimate drops to ~70 sequential minutes, ~20 on four shards.

Unit coverage: guard classification on real registry shapes, a floor on both
classes so the split cannot silently collapse, changed-selection semantics,
and shard completeness/disjointness with an anti-clumping bound.

Issue: #332
User-Visible: no
This commit is contained in:
Codex
2026-08-28 01:33:36 +03:00
parent 2c4b505f1a
commit 2c20f2dc35
5 changed files with 195 additions and 16 deletions
+22 -9
View File
@@ -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
+7 -2
View File
@@ -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)
+88 -3
View File
@@ -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;
+75 -1
View File
@@ -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(', ')}`);
});
+3 -1
View File
@@ -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",