diff --git a/.githooks/pre-push b/.githooks/pre-push index 5744c771..b02aa0bf 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -31,6 +31,16 @@ fi status=0 +# Локальный набор гейтов (#343). Выключен по умолчанию намеренно: 20-45 секунд на +# каждый пуш, включая пуши одной строки документации, — цена, которую стоит +# платить осознанно. Документация: docs/TESTING.md. +if [ "${HP_PREPUSH_GATE:-}" = "1" ] && [ -f "$repo_root/scripts/pre-push-gate.mjs" ]; then + echo "pre-push-gate: HP_PREPUSH_GATE=1, прогоняю локальный набор" >&2 + if ! node "$repo_root/scripts/pre-push-gate.mjs" >&2; then + status=1 + fi +fi + while read -r local_ref local_sha remote_ref remote_sha; do # Deleting a remote branch pushes nothing to examine. if [ "$local_sha" = "$zero" ]; then diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index ec41b543..818423bc 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -327,6 +327,41 @@ jobs: --add-label S6-in-progress --remove-label S7-code-review echo "S7-code-review -> S6-in-progress (ревью не запускалось)" + # Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя + # Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет + # ревью тратится на повторение CI вместо чтения кода. + # + # Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не + # «недавно было зелено», а «completed success ровно на этом SHA». После + # ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам. + - name: Зелёные гейты на этом SHA + id: validated + if: steps.rebase.outputs.conflict != 'true' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + run: | + sha=$(git rev-parse HEAD) + short=$(git rev-parse --short HEAD) + row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \ + --commit "$sha" --limit 5 \ + --json status,conclusion,url \ + --jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty') + { + echo 'note<s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))') + echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url" + echo "" + echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода." + echo "" + echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи." + else + echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат." + fi + echo 'EOF_NOTE' + } >> "$GITHUB_OUTPUT" + if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi + # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог # измениться именно в ней, и установка по копии из dev дала бы не то дерево. - name: Установить зависимости @@ -453,8 +488,10 @@ jobs: правке — не тщательность, а потеря времени: полные наборы это предрелизный гейт (PROCESS.md §8), а не гейт ревью. - Всегда, они дешёвые, и в повторном раунде тоже: код изменился, - а стоят они минуты: + ${{ steps.validated.outputs.note }} + + Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые, + и в повторном раунде тоже: код изменился, а стоят они минуты: `npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает `src/**`: отпечаток скриншотов документации считается по всему diff --git a/docs/TESTING.md b/docs/TESTING.md index e046c507..20a8e0ed 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -711,6 +711,62 @@ missing destructive confirmation or an editor exception that breaks View. filters, and hovering never replaces or flashes the Glow pool/gradient DOM. Editors do neither [auto: smoke_ux_fixes + smoke_glow; manual visual] +## Локальный набор перед пушем (#343) + +Красный CI — дорогой способ узнать о проблеме: пять минут ожидания, а при +код-ревью ещё и лишний раунд. Прецедент назван в задаче: находка r2-H1 в #329 +стоила целого раунда и ловилась локальным `npm test`. + +```bash +node scripts/pre-push-gate.mjs # origin/dev..HEAD +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 +``` + +Что прогоняется: `npx tsc --noEmit`, `npm test`, смоки, выбранные +`scripts/smoke-select.mjs` по диффу, и мутанты, выбранные +`scripts/mutation-gate.mjs --changed` по тем же файлам. Замер на реальном +диапазоне (`953f675~1..953f675`, правка `src/houseplan-card.ts`): типы 5 с, +юниты 17–19 с, два смока 22 с — **46 секунд** на всё. + +Три свойства, без которых такой набор бесполезен: + +- **не останавливается на первом упавшем** — иначе автор узнаёт о втором + нарушении следующим кругом, то есть ровно то, от чего набор защищает; +- **громко перечисляет, чего не проверял** — молчаливый пропуск дважды стоил + проекту дня (#171, #207), а «Verified» без названной команды и её результата + доказательством не является; +- **не претендует на полноту.** Golden, полная матрица смоков, HA-харнесс и весь + мутационный реестр — предрелизный гейт, а не этот набор. + +Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а +свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока +сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка. + +Лимиты по умолчанию — шесть смоков и два мутанта. Мутант дорог: каждый +пересобирает бандл, а правка `src/houseplan-card.ts` задевает их 62. Превышение +лимита не проглатывается — набор печатает точную команду для полного прогона. + +Три вида ответа `smoke-select` различаются и здесь: дифф без исполняемого кода — +«смоки не требуются»; прямое совпадение или зарегистрированная связь — +прогоняется; **связь не доказана** — отдельная громкая строка, потому что это не +«проверять нечего»: молчание стоило #234 бета-блокирующего регресса. + +### Как включить в хук + +Набор намеренно не включён в `.githooks/pre-push` по умолчанию: 20–45 секунд на +каждый пуш, включая пуши одной строки документации, — цена, которую стоит платить +осознанно. Включается переменной окружения: + +```bash +export HP_PREPUSH_GATE=1 # в профиль оболочки +git push # хук прогонит набор перед процессным гейтом +``` + +Обойти, как и процессный гейт, можно через `git push --no-verify` — и тогда то же +самое найдёт Validate, уже после того как код окажется в `dev`. + ## Environments matrix Run View/kiosk core flows in every applicable touch environment. Run editor core diff --git a/scripts/pre-push-gate.mjs b/scripts/pre-push-gate.mjs new file mode 100644 index 00000000..bdbaf6ab --- /dev/null +++ b/scripts/pre-push-gate.mjs @@ -0,0 +1,165 @@ +#!/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);