mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Решения владельца: 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
354 lines
20 KiB
JavaScript
354 lines
20 KiB
JavaScript
#!/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);
|
||
}
|