Files
houseplan-card/scripts/mutation-gate.mjs
T
Matysh 328ed7afc0
Validate / provenance (push) Successful in 46s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 12s
Validate / process-gate (push) Failing after 35s
Validate / frontend (push) Successful in 6m11s
Validate / backend (push) Failing after 9m24s
Validate / golden (push) Failing after 10m10s
Validate / performance_smoke (push) Failing after 12m46s
Validate / smoke (push) Failing after 30m15s
test: a registry of known breakages that tests must catch
Five times in this project a green test meant nothing was checked. The
continuity smoke stayed green after the entire mechanism it guards was cut out.
The golden scene created to protect doorway light was empty — 1,177 warm pixels
against 107,119, all of them icons. The shadow smoke passed while no shadow was
drawn. Each time the test had been written alongside the code, went green at
once, and nobody ever asked whether it could go red.

The gate makes that question routine. Each mutant is a few lines of patch that
reproduce a known breakage, plus the name of the test that must fail on it. A
worktree is patched, the bundle rebuilt, the guard run — and a guard that stays
green fails the gate. Six mutants cover the holes documented in #85; the anchors
are exact strings from today's source, so the registry cannot silently drift —
a unit test that runs with the ordinary suite refuses a stale anchor.

The full run rebuilds the bundle per mutant, so it lives in its own workflow,
before a stable release and on a weekly schedule, not in Validate. The rules for
new tests are written at the top of docs/TESTING.md, and the sixth of them is
the cheapest: an assertion that reads back the property the code just set is
not written at all.

Issue: #85
User-Visible: no
2026-08-14 02:05:53 +03:00

262 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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=<mutant> один мутант
// 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)));
}