mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user