mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Code review r1 (M1): scripts/pre-push-gate.mjs — in its comment and in the text every developer sees before a push — still named the full mutation registry a pre-release gate; the same phrase lived in the header of test/mutation-gate.test.mjs. Both now point at the nightly schedule (#513); golden/smokes/HA harness are named as the heavy Validate set on the candidate. Issue: #513 User-Visible: no
190 lines
11 KiB
JavaScript
190 lines
11 KiB
JavaScript
#!/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-харнесс — heavy-набор Validate на кандидате; весь мутационный реестр —
|
||
* ночное расписание (#513); здесь только то, что укладывается в минуты и
|
||
* ловит 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-харнесс — heavy-набор Validate на кандидате;'
|
||
+ ' весь мутационный реестр — ночное расписание (#513), не этот набор');
|
||
if (failed.length) {
|
||
console.log(`\nПровалов: ${failed.length}. Пуш до починки — это лишний раунд ревью.`);
|
||
for (const step of failed) console.log(` ${step.command}`);
|
||
}
|
||
process.exit(failed.length ? 1 : 0);
|