Files
houseplan-card/scripts/pre-push-gate.mjs
T
Claude 1866c0921e ci: ревьюер не перегоняет зелёные гейты, локальный набор перед пушем
Ревьюер гонял tsc, юниты и сборку заново в каждом раунде, хотя Validate на том
же SHA уже зелёный. Промпт прямо это требовал. Теперь шаг `validated` спрашивает
у Validate состояние ровно этого SHA, и доказательство такое же строгое, как у
reuse-маркеров (#208): не «недавно было зелено», а completed success на этом
коммите. После ребейза SHA другой, прогона для него нет — ревьюер честно гоняет
сам, и промпт это говорит.

Что Validate не покрывает, в примечании названо отдельно: смоки по диффу,
golden при правке рендера, инварианты на конкретной конфигурации. Иначе
экономия превратилась бы в «CI зелёный, значит всё проверено».

scripts/pre-push-gate.mjs — локальный набор: tsc, юниты, смоки по диффу
(smoke-select), мутанты по диффу (mutation-gate --changed). Замер на реальном
диапазоне 953f675~1..953f675: 46 секунд на всё вместе с двумя смоками.

Три свойства, без которых набор бесполезен: не останавливается на первом
упавшем; громко перечисляет, чего не проверял; не претендует на полноту. Бандл
не собирает — раскладывает закоммиченный dist, а свежесть проверяет сам продукт
через assertFreshDemoBundle внутри смока.

В хуке выключен по умолчанию: 20-45 секунд на каждый пуш, включая пуш одной
строки документации, — цена осознанная, включается HP_PREPUSH_GATE=1.

Дельта-промпт для spec-ревью (пункт 2) уже существует: блок «объём разбора по
дельте» из #214 покрывает оба этапа и прямо называет «дифф файла ТЗ или тела
issue для spec». Ничего не добавлял.

Issue: #343
User-Visible: no
2026-08-28 02:15:03 +03:00

166 lines
9.1 KiB
JavaScript
Raw 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
/**
* Локальный набор гейтов перед пушем (#343).
*
* node scripts/pre-push-gate.mjs
* node scripts/pre-push-gate.mjs --base origin/dev --head HEAD
* node scripts/pre-push-gate.mjs --no-smokes --no-mutants
* node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1
*
* Зачем. Красный CI — дорогой способ узнать о проблеме: пять минут ожидания, а
* при код-ревью ещё и лишний раунд. Прецедент назван в задаче: r2-H1 в #329
* стоил целого раунда и ловился локальным `npm test`. Здесь то же самое
* прогоняется одной командой и до пуша.
*
* Три правила, без которых такой набор бесполезен.
*
* 1. **Он не останавливается на первом упавшем.** Иначе автор узнаёт о втором
* нарушении следующим кругом — ровно то, от чего гейт и защищает.
* 2. **Он громко перечисляет, чего НЕ проверял.** Молчаливый пропуск дважды
* стоил проекту дня (#171, #207), а «Verified» без названной команды и её
* результата доказательством не является.
* 3. **Он не претендует на полноту.** Golden, полная матрица смоков,
* HA-харнесс и весь мутационный реестр — предрелизный гейт; здесь только то,
* что укладывается в минуты и ловит 90% возвратов.
*
* Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а
* свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока
* сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка.
*/
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(`--${name}`);
const value = (name, fallback) => {
const found = argv.find((item) => item.startsWith(`--${name}=`));
if (found) return found.slice(name.length + 3);
const index = argv.indexOf(`--${name}`);
return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('--')
? argv[index + 1] : fallback;
};
const base = value('base', 'origin/dev');
const head = value('head', 'HEAD');
const maxSmokes = Number(value('max-smokes', 6));
const maxMutants = Number(value('max-mutants', 2));
const run = (label, command, args, options = {}) => {
const started = Date.now();
process.stdout.write(`\n── ${label}\n ${command} ${args.join(' ')}\n`);
const result = spawnSync(command, args, { cwd: ROOT, stdio: 'inherit', ...options });
const seconds = ((Date.now() - started) / 1000).toFixed(0);
const ok = result.status === 0;
process.stdout.write(` ${ok ? 'ok' : 'ПРОВАЛ'} · ${seconds} с\n`);
return { label, ok, seconds: Number(seconds), command: `${command} ${args.join(' ')}` };
};
const capture = (command, args) => spawnSync(command, args, { cwd: ROOT, encoding: 'utf8' });
const steps = [];
const skipped = [];
// ---- что тронуто ----------------------------------------------------------
const diff = capture('git', ['diff', '--name-only', `${base}..${head}`]);
if (diff.status !== 0) {
console.error(`git diff ${base}..${head} не удался:\n${diff.stderr}`);
process.exit(2);
}
const changed = diff.stdout.split('\n').filter(Boolean);
console.log(`Диапазон ${base}..${head}: файлов ${changed.length}`);
for (const file of changed.slice(0, 20)) console.log(` ${file}`);
if (changed.length > 20) console.log(` и ещё ${changed.length - 20}`);
if (!changed.length) {
console.log('\nДиффа нет — проверять нечего.');
process.exit(0);
}
// ---- типы и юниты: всегда -------------------------------------------------
steps.push(run('Типы', 'npx', ['tsc', '--noEmit']));
steps.push(run('Юнит-тесты', 'npm', ['test']));
// ---- смоки по диффу -------------------------------------------------------
if (flag('no-smokes')) {
skipped.push('смоки — запрошено --no-smokes');
} else {
const select = capture('node', ['scripts/smoke-select.mjs', '--base', base, '--head', head, '--json']);
let picked = [];
let parsed = null;
try {
parsed = JSON.parse(select.stdout || '{}');
// Прямое совпадение и зарегистрированная связь — разные виды ответа, но для
// прогона равноправны. Третий вид, `unproven`, смоков не даёт и обязан
// прозвучать отдельно: это не «проверять нечего» (#241).
picked = [...new Set([
...(parsed.direct || []).map((item) => item.smoke),
...(parsed.registered || []).map((item) => item.smoke),
])].filter((name) => typeof name === 'string');
} catch {
skipped.push('смоки — не удалось разобрать вывод smoke-select (запустите его вручную)');
}
if (parsed?.noExecutableDiff) {
console.log('\n── Смоки\n исполняемого кода дифф не трогает — смоки не требуются');
} else if (parsed?.unproven) {
skipped.push('смоки — дифф исполняемый, но связь ни с одним смоком не доказана.'
+ ' Это НЕ «проверять нечего»: молчание здесь стоило #234 бета-блокирующего'
+ ' регресса. Либо назовите смок в scripts/smoke-links.mjs, либо напишите новый');
}
if (picked.length) {
// Стенд читает свою копию бандла; без раскладки смок врёт согласованно (#236).
if (existsSync(resolve(ROOT, 'dist/houseplan-card.js'))) {
steps.push(run('Раскладка бандла', 'node', ['scripts/bundle-sync.mjs']));
} else {
skipped.push('смоки — нет dist/houseplan-card.js, нужен `npm run build`');
picked = [];
}
}
if (picked.length > maxSmokes) {
skipped.push(`смоки помимо первых ${maxSmokes}: ${picked.slice(maxSmokes).join(', ')}`
+ ' — снимите ограничение --max-smokes, если время есть');
picked = picked.slice(0, maxSmokes);
}
for (const name of picked) {
steps.push(run(`Смок ${name}`, 'node', [`demo/${name}`]));
}
}
// ---- мутанты по диффу -----------------------------------------------------
if (flag('no-mutants')) {
skipped.push('мутанты — запрошено --no-mutants');
} else {
const list = capture('node', ['scripts/mutation-gate.mjs', '--changed=' + `${base}..${head}`, '--check']);
const touched = Number(/мутантов затронуто (\d+)/.exec(list.stdout || '')?.[1] ?? -1);
if (touched === 0) {
console.log('\n── Мутанты\n дифф не задевает ни одного patch.file');
} else if (touched < 0) {
skipped.push('мутанты — не удалось определить выборку по диффу');
} else if (touched > maxMutants) {
skipped.push(`мутанты (${touched}) — больше лимита ${maxMutants};`
+ ` каждый пересобирает бандл. Прогон: node scripts/mutation-gate.mjs --changed=${base}..${head}`);
} else {
steps.push(run('Мутанты по диффу', 'node',
['scripts/mutation-gate.mjs', `--changed=${base}..${head}`]));
}
}
// ---- вердикт --------------------------------------------------------------
const failed = steps.filter((step) => !step.ok);
const total = steps.reduce((sum, step) => sum + step.seconds, 0);
console.log(`\n${'═'.repeat(60)}`);
for (const step of steps) console.log(`${step.ok ? 'ok ' : 'ПРОВАЛ'} ${step.label} (${step.seconds} с)`);
console.log(`Суммарно: ${Math.floor(total / 60)} мин ${total % 60} с`);
if (skipped.length) {
console.log('\nЧего этот набор НЕ проверял:');
for (const item of skipped) console.log(` · ${item}`);
}
console.log(' · golden, полная матрица смоков, HA-харнесс, весь мутационный реестр —'
+ ' предрелизный гейт, не этот набор');
if (failed.length) {
console.log(`\nПровалов: ${failed.length}. Пуш до починки — это лишний раунд ревью.`);
for (const step of failed) console.log(` ${step.command}`);
}
process.exit(failed.length ? 1 : 0);