Files
houseplan-card/scripts/pre-push-gate.mjs
T
2026-09-08 00:31:25 +03:00

189 lines
10 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';
import { rebaseAdvice } from './branch-state.mjs';
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 = [];
// ---- приведена ли ветка к dev ---------------------------------------------
// Конвейер ребейзит сам (#257), но после ребейза разбор становится полным, а не
// по дельте (§7.2), и конфликт всплывает в комментарии через сорок минут вместо
// машины автора. Поэтому предупреждение, а не гейт: гейтом остаётся конвейер.
if (!flag('no-rebase-check')) {
const fetched = capture('git', ['fetch', '-q', 'origin', base.replace(/^origin\//, '')]);
if (fetched.status !== 0) {
skipped.push(`проверка отставания от ${base} — git fetch не удался`);
} else {
const ancestor = capture('git', ['merge-base', '--is-ancestor', base, head]);
if (ancestor.status === 0) {
console.log(`\n── Ветка\n содержит весь ${base}, ребейз не нужен`);
} else {
const counted = capture('git', ['rev-list', '--count', `${head}..${base}`]);
const advice = rebaseAdvice({ behind: Number(counted.stdout.trim()), base });
if (advice) skipped.push(advice);
}
}
}
// ---- что тронуто ----------------------------------------------------------
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).
const missingEntries = ['houseplan-card.js', 'houseplan-panel.js']
.filter((name) => !existsSync(resolve(ROOT, 'dist', name)));
if (!missingEntries.length) {
steps.push(run('Раскладка бандла', 'node', ['scripts/bundle-sync.mjs']));
} else {
skipped.push(`смоки — нет dist/${missingEntries.join(', dist/')}, нужен \`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);