Files
houseplan-card/scripts/pre-push-gate.mjs
T
Claudeandclaude[bot] 88c4e4e9ae infra(process): bundle and review index change only on the way to dev (#657)
Решения владельца: 1б — индекс ревью не пересобирается в ветке задачи,
только коммитами, идущими в dev; 2б — бандл меняет только кандидат
беты/релиза, стенд dev берёт его из артефакта Validate.

- scripts/bundle-policy.mjs: коммит, трогающий dist/** или
  custom_components/houseplan/frontend/**, обязан нести Release:
  (хук commit-msg и история в CI через validate-commit-provenance;
  коммиты с датой автора до 2026-09-27 не судятся); --verify судит
  целостность свежей сборки всегда, побайтовую сверку с закоммиченной
  копией — только на коммите, меняющем бандл, или кандидате; --clean.
- release-prerelease: публикация отказывает, если отпечаток исходников
  в закоммиченном манифесте не равен отпечатку дерева (хотфикс поверх
  кандидата без пересборки).
- bundle-sync: по умолчанию только demo/srv/assets; --release
  (npm run bundle:release) — ещё и custom_components.
- rebase-on-dev: конфликт в бандле берёт копию dev, без пересборки
  и amend.
- validate.yml: job dev_build публикует card-bundle головы dev в
  сиротскую ветку dev-build (scripts/dev-build.mjs); стенд накладывает
  её demo/stand/update-dev-bundle.sh.
- _process.yml: индекс ревью больше не пересобирается при приведении
  к dev и при публикации документа в ветку задачи.
- golden-wsl-artifact/golden-container: сборка перед съёмкой не
  считается правкой источника, после — bundle:clean.
- test/bundle-tree-committed: судит закоммиченный снимок, не диск.
- 11 мутантов в реестре; PROCESS/AGENTS/DEVELOPMENT/AUTHOR/REVIEWER.

Issue: #657
User-Visible: no
2026-09-26 07:28:23 +00:00

354 lines
20 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) и режим хука (#633).
*
* node scripts/pre-push-gate.mjs --hook # из .githooks/pre-push, строки git на stdin
* 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% возвратов.
*
* Режим хука (#633). `.githooks/pre-push` передаёт сюда строки, которые git
* подаёт хуку, и для каждой ветки `issue/*` решает, гнать ли `npm run gate:small`
* (`scripts/gate-small.mjs`). По умолчанию — гнать: ошибка, которую набор ловит за
* минуты, иначе всплывает в Validate и конвейере через полчаса. Не гонится:
* · `HP_PREPUSH_GATE=0` — явное выключение (`=1` — наоборот, для любой ветки и
* любого диффа);
* · ветка не `issue/*` (dev, main, теги, удаление) — там свой контур;
* · дифф ветки от merge-base с origin/dev — только класс C/D (документы ревью,
* changelog, бандл): набору нечего в нём проверить.
* Набор проверяет рабочее дерево, поэтому пушиться должен HEAD. Ветка с
* исполняемым диффом, которая не HEAD, отклоняется — иначе зелёный вердикт был
* бы вынесен чужому дереву; обход назван в сообщении.
*
* Бандл не собирается: `bundle-sync.mjs` раскладывает `dist` рабочего дерева, а
* свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока
* сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка.
* С #657 закоммиченный `dist` законно отстаёт до кандидата беты: перед push
* ветки, трогающей исходники, соберите `npm run build` (в коммит сборка не
* идёт — `npm run bundle:clean`, если она попала в индекс).
*/
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { rebaseAdvice } from './branch-state.mjs';
import { classify } from './process-gate.mjs';
import { isMainModule, portableCommand } from './spawn-portable.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const ZERO_SHA = /^0{40}$/;
// ---- режим хука (#633) ----------------------------------------------------
/** Строки, которые git подаёт pre-push: `<local ref> <local sha> <remote ref> <remote sha>`. */
export function parsePushLines(text) {
return String(text ?? '').split('\n').map((line) => line.trim()).filter(Boolean)
.map((line) => {
const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/);
return { localRef, localSha, remoteRef, remoteSha };
})
.filter((ref) => ref.localSha && ref.remoteRef);
}
/** `off` — явно выключен, `force` — для любой ветки и диффа, `default` — ветки issue/*. */
export function gateMode(env = process.env) {
const raw = String(env.HP_PREPUSH_GATE ?? '').trim().toLowerCase();
if (['0', 'off', 'false', 'no'].includes(raw)) return 'off';
if (['1', 'on', 'true', 'yes'].includes(raw)) return 'force';
return 'default';
}
/** Ветки, которые набор вообще рассматривает: не удаление, не тег, `issue/*` (или любая при force). */
export function candidateRefs(refs, mode) {
return refs.filter((ref) => !ZERO_SHA.test(ref.localSha)
&& ref.remoteRef.startsWith('refs/heads/')
&& (mode === 'force' || ref.remoteRef.startsWith('refs/heads/issue/')));
}
/** Дифф только из документации и сгенерированного (C/D) — набору нечего проверять. */
export function isDocsOrGeneratedOnly(files) {
return files.length > 0 && files.every((file) => ['C', 'D'].includes(classify(file)));
}
/**
* Решение хука. Чистая функция: git передаётся через `mergeBase` и `changedFiles`.
*
* @returns {{ action: 'skip' | 'run' | 'reject', reason: string, base?: string, ref?: string }}
*/
export function planHook({ mode, refs, headSha, mergeBase, changedFiles }) {
if (mode === 'off') return { action: 'skip', reason: 'HP_PREPUSH_GATE=0 — gate:small выключен явно' };
const candidates = candidateRefs(refs, mode);
if (!candidates.length) {
return { action: 'skip', reason: 'в пуше нет веток issue/* — gate:small не требуется (HP_PREPUSH_GATE=1 включит для любой)' };
}
const needing = [];
const notes = [];
for (const ref of candidates) {
const base = mergeBase(ref.localSha);
const files = base ? changedFiles(base, ref.localSha) : null;
if (mode !== 'force' && files && !files.length) {
notes.push(`${ref.remoteRef}: диффа от origin/dev нет`);
continue;
}
if (mode !== 'force' && files && isDocsOrGeneratedOnly(files)) {
notes.push(`${ref.remoteRef}: дифф только класс C/D (${files.length} файл.)`);
continue;
}
needing.push({ ...ref, base: base || 'origin/dev' });
}
if (!needing.length) return { action: 'skip', reason: `${notes.join('; ')} — gate:small не требуется` };
const foreign = needing.filter((ref) => ref.localSha !== headSha);
if (foreign.length) {
return {
action: 'reject',
reason: `пушится не HEAD: ${foreign.map((ref) => `${ref.localRef} (${ref.localSha.slice(0, 12)})`).join(', ')}.`
+ ' gate:small проверяет рабочее дерево, а оно не совпадает с пушимым коммитом.'
+ ' Переключитесь на ветку и повторите push, либо осознанно: HP_PREPUSH_GATE=0 git push …',
};
}
const [first] = needing;
return { action: 'run', ref: first.remoteRef, base: first.base, reason: `${first.remoteRef}, база ${first.base.slice(0, 12)}` };
}
/**
* Окружение набора без переменных git (#633). Хук git запускает с `GIT_DIR`,
* указывающим на репозиторий, а в `GIT_CONFIG_PARAMETERS` — все `-c` пушащего
* (включая credential helper). Юниты создают временные репозитории через
* `git init` / `git -C tmp …`, и с унаследованным `GIT_DIR` каждая такая
* команда пишет в НАСТОЯЩИЙ репозиторий: переинициализация как bare, коммиты
* фикстур на ветку, переименование ветки в dev, user.name в общий config — всё
* это случилось на первом живом прогоне. Набор обязан видеть то же окружение,
* что и при ручном `npm run gate:small`, поэтому все GIT_* снимаются.
*/
export function gateEnv(env = process.env) {
return Object.fromEntries(Object.entries(env).filter(([key]) => !/^GIT_/i.test(key)));
}
const gitOut = (args) => {
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
return result.status === 0 ? result.stdout.trim() : null;
};
/** Режим хука целиком; возвращает код выхода. `runGate(base)` — код выхода набора. */
export function runHook({
stdin,
env = process.env,
log = (line) => console.error(line),
git = gitOut,
runGate = (base) => {
const { cmd, shell } = portableCommand(process.execPath);
return spawnSync(cmd, [resolve(ROOT, 'scripts/gate-small.mjs'), `--base=${base}`],
{ cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'], shell, env: gateEnv(env) }).status ?? 1;
},
} = {}) {
const plan = planHook({
mode: gateMode(env),
refs: parsePushLines(stdin),
headSha: git(['rev-parse', 'HEAD']),
mergeBase: (sha) => git(['merge-base', sha, 'refs/remotes/origin/dev']),
changedFiles: (base, sha) => {
const out = git(['diff', '--name-only', base, sha]);
return out === null ? null : out.split('\n').filter(Boolean);
},
});
if (plan.action === 'skip') {
log(`pre-push-gate: ${plan.reason}`);
return 0;
}
if (plan.action === 'reject') {
log(`pre-push-gate: push отклонён — ${plan.reason}`);
return 1;
}
log(`pre-push-gate: ${plan.reason} — прогоняю npm run gate:small (выключить: HP_PREPUSH_GATE=0)`);
const code = runGate(plan.base);
if (code !== 0) {
log('pre-push-gate: gate:small красный — push отклонён. Починить и повторить;'
+ ' осознанный обход: HP_PREPUSH_GATE=0 git push … (Validate найдёт то же самое позже)');
return 1;
}
log('pre-push-gate: gate:small зелёный');
return 0;
}
// ---- ручной набор (#343) --------------------------------------------------
function manualGate(argv) {
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 { cmd, shell } = portableCommand(command);
const result = spawnSync(cmd, args, { cwd: ROOT, stdio: 'inherit', shell, ...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}`);
return 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Диффа нет — проверять нечего.');
return 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}`);
}
return failed.length ? 1 : 0;
}
if (isMainModule(import.meta.url)) {
const argv = process.argv.slice(2);
process.exitCode = argv.includes('--hook')
? runHook({ stdin: readFileSync(0, 'utf8') })
: manualGate(argv);
}