infra: local contour — pre-push gate:small by default, sandbox bootstrap, test:chunk

- .githooks/pre-push + scripts/pre-push-gate.mjs --hook: gate:small runs by
  default for issue/* branches with an executable diff; C/D-only diffs and
  non-issue refs are skipped, HP_PREPUSH_GATE=0 turns it off, =1 forces it.
  A non-HEAD push with an executable diff is rejected (the gate checks the
  working tree). The gate runs with every GIT_* variable removed: git gives
  hooks GIT_DIR (and GIT_CONFIG_PARAMETERS with the pusher's -c), and unit
  tests that `git init` temporary repositories otherwise write into the real
  one — seen on the first live run. The manual #343 mode is unchanged.
- scripts/sandbox-bootstrap.sh: idempotent worktree / deps / chromium (npm
  @sparticuz/chromium@152.0.0 + Playwright shims) / bundle / check steps,
  no owner paths or credentials.
- scripts/test-chunk.mjs, npm run test:chunk -- N/M: round-robin over
  test/*.test.mjs sorted by code point.
- Tests, seven mutants, docs/DEVELOPMENT.md «Локальный контур за 5 минут»,
  docs/TESTING.md. package.json changed → bundle rebuilt (fingerprint input).

Issue: #633
User-Visible: no
This commit is contained in:
Claude
2026-09-24 05:00:45 +03:00
parent 4de5c0c0b5
commit af62bd4c64
56 changed files with 1452 additions and 519 deletions
-1
View File
@@ -68,7 +68,6 @@ export const NOT_AN_INPUT = [
['demo/downgrade_open_passage.mjs', 'ручной инструмент миграции фикстур'],
['scripts/dev/**', 'локальные утилиты разработчика'],
['scripts/install-hooks.mjs', 'установка git-хуков при npm ci'],
['scripts/pre-push-gate.mjs', 'локальный pre-push набор (HP_PREPUSH_GATE), в CI не исполняется'],
['scripts/golden-accept.mjs', 'приёмка эталонов человеком, после прогона (#344)'],
['scripts/golden-container.mjs', 'локальная съёмка в пиновом образе (#334), ручной запуск'],
['scripts/inventory.mjs', 'отчёт для аудита, не гейт'],
+79
View File
@@ -10148,6 +10148,85 @@ const MUTANT_DEFINITIONS = [
replace: ' if (body !== null) continue; // mutant: only the file counts',
}],
},
// #633: локальный контур — хук pre-push гонит gate:small, деление юнитов на части.
{
id: 'pre-push-hook-ignores-red-gate-small',
guard: 'node --test test/pre-push-gate.test.mjs',
because: 'хук, который печатает «красный» и всё равно пускает push, — ровно то, что #633 AC1 '
+ 'запрещает: ошибка gate:small снова всплывает в Validate через полчаса',
patches: [{
file: 'scripts/pre-push-gate.mjs',
find: ' const code = runGate(plan.base);',
replace: ' const code = runGate(plan.base) && 0; // mutant: red gate passes',
}],
},
{
id: 'pre-push-gate-inherits-git-dir',
guard: 'node --test test/pre-push-gate.test.mjs',
because: 'хук git запускает набор с GIT_DIR репозитория; юниты с временными репозиториями '
+ 'тогда пишут в настоящий — bare-флаг, коммиты фикстур, ветка dev перезаписана (#633, '
+ 'первый живой прогон)',
patches: [{
file: 'scripts/pre-push-gate.mjs',
find: " { cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'], shell, env: gateEnv(env) }).status ?? 1;",
replace: " { cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'], shell, env }).status ?? 1;",
}],
},
{
id: 'pre-push-shell-drops-gate-status',
guard: 'node --test test/pre-push-gate.test.mjs',
because: 'pre-push-gate.mjs может вернуть 1, но решает код выхода самого .githooks/pre-push: '
+ 'потерянный статус в шелле пропускает красный gate:small (#633 AC1)',
patches: [{
file: '.githooks/pre-push',
find: ' gate_status=1\n',
replace: ' gate_status=0\n',
}],
},
{
id: 'pre-push-hook-misses-issue-branches',
guard: 'node --test test/pre-push-gate.test.mjs',
because: 'префикс веток задач — единственное, что включает набор по умолчанию; опечатка в нём '
+ 'молча возвращает opt-in, который #633 отменил',
patches: [{
file: 'scripts/pre-push-gate.mjs',
find: " && (mode === 'force' || ref.remoteRef.startsWith('refs/heads/issue/')));",
replace: " && (mode === 'force' || ref.remoteRef.startsWith('refs/heads/issues/')));",
}],
},
{
id: 'pre-push-docs-filter-swallows-executable-diff',
guard: 'node --test test/pre-push-gate.test.mjs',
because: 'пропуск «только C/D» должен требовать, чтобы ВСЕ файлы были документами; some вместо '
+ 'every пропускает код, приложенный к одной строке документации (#633)',
patches: [{
file: 'scripts/pre-push-gate.mjs',
find: " return files.length > 0 && files.every((file) => ['C', 'D'].includes(classify(file)));",
replace: " return files.length > 0 && files.some((file) => ['C', 'D'].includes(classify(file)));",
}],
},
{
id: 'pre-push-hook-judges-foreign-tree',
guard: 'node --test test/pre-push-gate.test.mjs',
because: 'gate:small проверяет рабочее дерево; пуш не-HEAD ветки без отказа получает зелёный '
+ 'вердикт чужому коду (#633)',
patches: [{
file: 'scripts/pre-push-gate.mjs',
find: ' const foreign = needing.filter((ref) => ref.localSha !== headSha);',
replace: ' const foreign = needing.filter(() => false); // mutant: any tree will do',
}],
},
{
id: 'test-chunk-drops-last-file',
guard: 'node --test test/test-chunk.test.mjs',
because: 'деление, теряющее файл, даёт зелёные части при непрогнанном тесте — ровно то, от '
+ 'чего #633 AC3 требует «каждый файл ровно в одной части»',
patches: [{
file: 'scripts/test-chunk.mjs',
find: ' return sorted.filter((_, i) => i % total === index - 1);',
replace: ' return sorted.filter((_, i) => i % total === index - 1 && i < sorted.length - 1);',
}],
},
{
id: 'review-returns-task-on-cancelled-dispatch',
guard: 'node --test test/ci-proof.test.mjs test/validate-gate.test.mjs',
+295 -134
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
/**
* Локальный набор гейтов перед пушем (#343).
* Локальный набор гейтов перед пушем (#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
@@ -24,166 +25,326 @@
* ночное расписание (#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` внутри каждого смока
* сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка.
*/
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
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 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 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;
};
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));
/** Режим хука целиком; возвращает код выхода. `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;
}
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(' ')}` };
};
// ---- ручной набор (#343) --------------------------------------------------
const capture = (command, args) => spawnSync(command, args, { cwd: ROOT, encoding: 'utf8' });
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 steps = [];
const skipped = [];
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));
// ---- приведена ли ветка к 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}, ребейз не нужен`);
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 counted = capture('git', ['rev-list', '--count', `${head}..${base}`]);
const advice = rebaseAdvice({ behind: Number(counted.stdout.trim()), base });
if (advice) skipped.push(advice);
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 (запустите его вручную)');
// ---- что тронуто ----------------------------------------------------------
const diff = capture('git', ['diff', '--name-only', `${base}..${head}`]);
if (diff.status !== 0) {
console.error(`git diff ${base}..${head} не удался:\n${diff.stderr}`);
return 2;
}
if (parsed?.noExecutableDiff) {
console.log('\n── Смоки\n исполняемого кода дифф не трогает — смоки не требуются');
} else if (parsed?.unproven) {
skipped.push('смоки — дифф исполняемый, но связь ни с одним смоком не доказана.'
+ ' Это НЕ «проверять нечего»: молчание здесь стоило #234 бета-блокирующего'
+ ' регресса. Либо назовите смок в scripts/smoke-links.mjs, либо напишите новый');
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;
}
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 = [];
// ---- типы и юниты: всегда -------------------------------------------------
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 (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}`);
// ---- мутанты по диффу -----------------------------------------------------
if (flag('no-mutants')) {
skipped.push('мутанты — запрошено --no-mutants');
} else {
steps.push(run('Мутанты по диффу', 'node',
['scripts/mutation-gate.mjs', `--changed=${base}..${head}`]));
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;
}
// ---- вердикт --------------------------------------------------------------
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}`);
if (isMainModule(import.meta.url)) {
const argv = process.argv.slice(2);
process.exitCode = argv.includes('--hook')
? runHook({ stdin: readFileSync(0, 'utf8') })
: manualGate(argv);
}
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);
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# Локальный контур в Linux-песочнице агента одной командой (#633).
#
# bash scripts/sandbox-bootstrap.sh # всё по порядку
# bash scripts/sandbox-bootstrap.sh <шаг> # worktree | deps | chromium | bundle | check
# HP_BRANCH=issue/633-local-contour bash scripts/sandbox-bootstrap.sh
#
# Идемпотентен: готовый шаг узнаётся и пропускается, поэтому при обрыве (лимит
# одной команды песочницы ≈ 3 мин, перезапуск песочницы стирает /tmp) скрипт
# просто запускают ещё раз. Каждый шаг по отдельности укладывается в 3 минуты;
# `all` на холодной песочнице может не уложиться — тогда повторить, сделанное
# не переделывается. Скрипт не знает ни путей владельца, ни учётных данных: всё
# машинно-специфичное приходит переменными окружения.
#
# HP_CLONE клон, из которого заводится worktree (по умолчанию —
# репозиторий, где лежит этот скрипт)
# HP_WORKTREE куда положить worktree (/tmp/hp-wt)
# HP_BRANCH ветка worktree; если есть на origin — берётся оттуда,
# иначе создаётся от HP_REF. Без неё — detached HEAD
# HP_REF от чего заводить (origin/dev)
# HP_SHARED_NODE_MODULES готовый node_modules с тем же package-lock.json:
# ссылка на него вместо npm ci (параллельные сессии)
# HP_CHROMIUM_DIR куда распаковать Chromium (/tmp/chr)
# HP_CHROMIUM_PKG npm-пакет с Chromium (@sparticuz/chromium@152.0.0)
#
# Почему Chromium из npm, а не `npx playwright install`: CDN Playwright
# (cdn.playwright.dev) закрыт allowlist'ом песочницы, а реестр npm открыт.
# Пакет содержит brotli-архив бинаря и его библиотек; Playwright получает шим по
# своему ожидаемому пути, который запускает этот бинарь. Существующий настоящий
# браузер по этому пути не трогается — шим узнаётся по маркеру.
#
# Golden этим браузером не снимаются (#455, только Linux CI); смоки и
# `golden:verify` как совет — да.
set -euo pipefail
STEP="${1:-all}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLONE="${HP_CLONE:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)}"
WT="${HP_WORKTREE:-/tmp/hp-wt}"
REF="${HP_REF:-origin/dev}"
BRANCH="${HP_BRANCH:-}"
CHR="${HP_CHROMIUM_DIR:-/tmp/chr}"
CHR_PKG="${HP_CHROMIUM_PKG:-@sparticuz/chromium@152.0.0}"
SHIM_MARK="# hp-sandbox-bootstrap chromium shim"
say() { printf '%s\n' "$*"; }
die() { printf 'sandbox-bootstrap: %s\n' "$*" >&2; exit 1; }
step_worktree() {
if git -C "$WT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
say "worktree: $WT уже есть ($(git -C "$WT" rev-parse --abbrev-ref HEAD) @ $(git -C "$WT" rev-parse --short HEAD)) — не трогаю"
return 0
fi
[ -e "$WT" ] && die "$WT существует, но это не рабочее дерево git — уберите его или задайте HP_WORKTREE"
git -C "$CLONE" fetch -q origin
if [ -n "$BRANCH" ]; then
local from="$REF"
if [ -z "${HP_REF:-}" ] && git -C "$CLONE" rev-parse -q --verify "refs/remotes/origin/$BRANCH" >/dev/null; then
from="origin/$BRANCH"
fi
git -C "$CLONE" worktree add -q -B "$BRANCH" "$WT" "$from"
say "worktree: $WT на $BRANCH от $from"
else
git -C "$CLONE" worktree add -q --detach "$WT" "$REF"
say "worktree: $WT, detached на $REF"
fi
}
lock_digest() { sha256sum "$WT/package-lock.json" | cut -d' ' -f1; }
step_deps() {
[ -f "$WT/package-lock.json" ] || die "нет $WT/package-lock.json — сначала шаг worktree"
local stamp="$WT/node_modules/.hp-bootstrap-lock"
if [ -n "${HP_SHARED_NODE_MODULES:-}" ]; then
[ -x "$HP_SHARED_NODE_MODULES/.bin/tsc" ] || die "HP_SHARED_NODE_MODULES=$HP_SHARED_NODE_MODULES без .bin/tsc"
if [ ! -e "$WT/node_modules" ]; then ln -s "$HP_SHARED_NODE_MODULES" "$WT/node_modules"; fi
say "deps: node_modules -> $HP_SHARED_NODE_MODULES (общий; package-lock обязан совпадать)"
elif [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(lock_digest)" ] && [ -x "$WT/node_modules/.bin/tsc" ]; then
say "deps: node_modules соответствует package-lock.json — npm ci не нужен"
else
# --ignore-scripts: postinstall Playwright полез бы на закрытый CDN, а
# prepare (установка хуков) выполняется ниже явно и только при нужде.
(cd "$WT" && npm ci --ignore-scripts --no-audit --no-fund >/tmp/hp-bootstrap-npm-ci.log 2>&1) \
|| { tail -20 /tmp/hp-bootstrap-npm-ci.log >&2; die "npm ci не удался (лог: /tmp/hp-bootstrap-npm-ci.log)"; }
lock_digest >"$stamp"
say "deps: npm ci — ok"
fi
if [ "$(git -C "$WT" config --get core.hooksPath || true)" != ".githooks" ]; then
(cd "$WT" && node scripts/install-hooks.mjs)
fi
}
# Пути, по которым установленный Playwright ищет свои браузеры.
playwright_targets() {
(cd "$WT" && node --input-type=module -e "
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
const require = createRequire(join(process.cwd(), 'package.json'));
const { chromium } = require('playwright');
const full = chromium.executablePath();
const root = dirname(dirname(dirname(full)));
// browsers.json не экспортирован пакетом — читается по пути от его корня.
const core = dirname(require.resolve('playwright-core'));
const { browsers } = JSON.parse(readFileSync(join(core, 'browsers.json'), 'utf8'));
const rev = browsers.find((b) => b.name === 'chromium-headless-shell').revision;
const shell = join(root, 'chromium_headless_shell-' + rev);
console.log([full,
join(shell, 'chrome-linux64', 'chrome-headless-shell'),
join(shell, 'chrome-headless-shell-linux64', 'chrome-headless-shell')].join('\n'));
")
}
brotli_out() { node -e "const z=require('zlib'),fs=require('fs');fs.writeFileSync(process.argv[2],z.brotliDecompressSync(fs.readFileSync(process.argv[1])));" "$1" "$2"; }
step_chromium() {
[ -x "$WT/node_modules/.bin/tsc" ] || die "нет node_modules — сначала шаг deps"
if [ ! -x "$CHR/bin/chromium" ]; then
mkdir -p "$CHR/bin"
local tgz
tgz="$(ls "$CHR"/*.tgz 2>/dev/null | head -1 || true)"
if [ -z "$tgz" ]; then
(cd "$CHR" && npm pack --silent "$CHR_PKG" >/dev/null) || die "npm pack $CHR_PKG не удался"
tgz="$(ls "$CHR"/*.tgz | head -1)"
fi
tar xzf "$tgz" -C "$CHR"
brotli_out "$CHR/package/bin/chromium.br" "$CHR/bin/chromium.tmp"
for archive in "$CHR"/package/bin/*.tar.br; do
[ -f "$archive" ] || continue
brotli_out "$archive" "$CHR/x.tar"
tar xf "$CHR/x.tar" -C "$CHR/bin" 2>/dev/null || true
rm -f "$CHR/x.tar"
done
chmod +x "$CHR/bin/chromium.tmp"
mv "$CHR/bin/chromium.tmp" "$CHR/bin/chromium" # последним: наличие = распаковка завершена
say "chromium: распакован $CHR_PKG в $CHR/bin"
else
say "chromium: $CHR/bin/chromium уже есть"
fi
local targets target
targets="$(playwright_targets)" || die "не удалось узнать пути браузеров Playwright"
[ -n "$targets" ] || die "Playwright не назвал ни одного пути браузера"
while IFS= read -r target; do
[ -n "$target" ] || continue
if [ -e "$target" ] && ! grep -q "$SHIM_MARK" "$target" 2>/dev/null; then
say "chromium: $target — настоящий браузер, шим не ставлю"
continue
fi
mkdir -p "$(dirname "$target")"
printf '#!/bin/bash\n%s\nexport LD_LIBRARY_PATH="%s/bin/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"\nexec "%s/bin/chromium" --headless=new "$@"\n' \
"$SHIM_MARK" "$CHR" "$CHR" >"$target"
chmod +x "$target"
done <<<"$targets"
say "chromium: шимы Playwright на месте"
}
step_bundle() {
[ -x "$WT/node_modules/.bin/tsc" ] || die "нет node_modules — сначала шаг deps"
(cd "$WT" && npm run --silent bundle:sync >/tmp/hp-bootstrap-bundle.log 2>&1) \
|| { tail -30 /tmp/hp-bootstrap-bundle.log >&2; die "npm run bundle:sync не удался (лог: /tmp/hp-bootstrap-bundle.log)"; }
say "bundle: npm run bundle:sync — ok (dist, custom_components/houseplan/frontend, demo/srv/assets)"
}
step_check() {
(cd "$WT" && node --input-type=module -e "
import { createRequire } from 'node:module';
import { join } from 'node:path';
const require = createRequire(join(process.cwd(), 'package.json'));
const { chromium } = require('playwright');
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent('<p id=ok>houseplan</p>');
const text = await page.textContent('#ok');
const version = browser.version();
await browser.close();
if (text !== 'houseplan') { console.error('playwright: страница не отрисовалась'); process.exit(1); }
console.log('check: playwright открыл страницу, Chromium ' + version);
") || die "проверка Playwright не прошла — повторите шаг chromium"
say "готово: cd $WT && node demo/smoke_edge_cases.mjs"
}
case "$STEP" in
worktree) step_worktree ;;
deps) step_deps ;;
chromium) step_chromium ;;
bundle) step_bundle ;;
check) step_check ;;
all) step_worktree; step_deps; step_chromium; step_bundle; step_check ;;
-h|--help|help) sed -n '2,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
*) die "неизвестный шаг «$STEP»: worktree | deps | chromium | bundle | check | all" ;;
esac
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
// Полный набор юнитов частями (#633): `npm run test:chunk -- N/M`.
//
// Зачем. Одна bash-команда песочницы агента живёт ≈ 3 минуты, а `npm test`
// идёт около 2,5 — под нагрузкой параллельных сессий больше. Раньше набор резали
// руками (`ls | split -n l/6`), и каждая сессия изобретала своё деление.
// Здесь оно одно и детерминированное: одинаковые N/M на любой машине дают один и
// тот же список файлов, а M частей вместе — каждый файл ровно по одному разу.
//
// Правило деления — round-robin по отсортированному имени: файл с индексом i
// (0-based, сортировка по коду символов, не по локали) попадает в часть i % M + 1.
// Выбрано против деления по суммарному размеру: размер меняется с каждой
// правкой теста, и файл кочевал бы между частями — «упало в 3/6» переставало бы
// означать одно и то же вчера и сегодня. Round-robin сдвигает файлы только при
// добавлении или удалении теста, а части и так выходят близкими по длине, потому
// что соседние по имени тесты одной подсистемы разносятся по разным частям.
//
// npm run test:chunk -- 2/6 # сборка тестов + вторая шестая
// npm run test:chunk -- 2/6 --no-build # сборка уже сделана (прошлой частью)
// npm run test:chunk -- 2/6 --list # только напечатать файлы
//
// Сборка тестов (`tsc -p tsconfig.test.json && fix-test-build`) по умолчанию
// идёт в каждой части: часть обязана быть самодостаточной, иначе 3/6 после
// перезапуска песочницы читает несуществующий test-build/ и падает не по делу.
import { spawnSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMainModule, portableCommand } from './spawn-portable.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** `"N/M"` → `{ index, total }`; бросает на неверном вводе, а не угадывает. */
export function parseChunk(spec) {
const match = /^(\d+)\/(\d+)$/.exec(String(spec ?? '').trim());
if (!match) throw new Error(`ожидается N/M (например 2/6), получено «${spec ?? ''}»`);
const index = Number(match[1]);
const total = Number(match[2]);
if (total < 1 || index < 1 || index > total) {
throw new Error(`часть ${index} из ${total} не существует: нужно 1 ≤ N ≤ M`);
}
return { index, total };
}
/** Тестовые файлы набора `npm test` (`test/*.test.mjs`), отсортированные по коду символов. */
export function listTestFiles(dir = join(ROOT, 'test')) {
return readdirSync(dir).filter((name) => name.endsWith('.test.mjs'))
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.map((name) => `test/${name}`);
}
/** Часть `index` из `total`: round-robin по отсортированному списку. */
export function chunkFiles(files, index, total) {
const sorted = [...files].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return sorted.filter((_, i) => i % total === index - 1);
}
function main(argv) {
const spec = argv.find((arg) => !arg.startsWith('--'));
let chunk;
try {
chunk = parseChunk(spec);
} catch (error) {
console.error(`test:chunk: ${error.message}\n npm run test:chunk -- 2/6 [--no-build] [--list]`);
return 2;
}
const files = chunkFiles(listTestFiles(), chunk.index, chunk.total);
console.log(`test:chunk ${chunk.index}/${chunk.total}: файлов ${files.length}`);
if (argv.includes('--list')) {
for (const file of files) console.log(` ${file}`);
return 0;
}
if (!files.length) return 0;
const run = (command, args) => {
const { cmd, shell } = portableCommand(command);
return spawnSync(cmd, args, { cwd: ROOT, stdio: 'inherit', shell }).status ?? 1;
};
if (!argv.includes('--no-build')) {
const built = run('npx', ['tsc', '-p', 'tsconfig.test.json']);
if (built !== 0) return built;
const fixed = run(process.execPath, ['scripts/fix-test-build.mjs']);
if (fixed !== 0) return fixed;
}
return run(process.execPath, ['--test', ...files]);
}
if (isMainModule(import.meta.url)) process.exitCode = main(process.argv.slice(2));