Files
houseplan-card/scripts/gate-small.mjs

157 lines
9.1 KiB
JavaScript
Raw Permalink 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
// Локальный гейт лёгкого трека одной командой (#479): `npm run gate:small`.
//
// PROCESS §8 перечисляет автору шесть команд, и в #476 они гонялись
// последовательно, вперемешку с гейтами, к задаче не относящимися. Здесь
// обязательная часть §8 начинается параллельно — сборка с typecheck, «новый
// код не добавляет any», выбор смоков по диффу. Юниты читают свежий `dist`,
// поэтому идут после сборки, а не одновременно с очисткой/записью её каталога;
// затем сверяется бандл. Что
// НЕ входит и остаётся по диффу и AC: сами смоки (их список печатается),
// golden, pytest, инварианты модели, check-docs в строгом режиме.
//
// npm run gate:small # база origin/dev
// npm run gate:small -- --base=origin/dev # явная база диапазона
// npm run gate:small -- --smokes # #496: после сборки — bundle-sync и
// # смоки, выбранные по диффу (прямые
// # и зарегистрированные), по два параллельно
//
// Фазы разделены зависимостями (#496): read-only проверки идут параллельно сразу;
// browser-consumers (смоки) — только после подготовки артефактов (build →
// bundle-sync), потому что читают собранное дерево demo/srv/assets.
import { spawn } from 'node:child_process';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { portableCommand } from './spawn-portable.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const npm = 'npm';
export function parseArgs(argv) {
const base = argv.find((a) => a.startsWith('--base='))?.slice('--base='.length) || 'origin/dev';
const smokes = argv.includes('--smokes');
const jobs = Number(argv.find((a) => a.startsWith('--jobs='))?.slice('--jobs='.length) || 2);
return { base, smokes, jobs: Number.isInteger(jobs) && jobs > 0 ? jobs : 2 };
}
/** Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без «широких». */
export function smokesToRun(selection) {
if (!selection || selection.noExecutableDiff) return [];
const names = [...(selection.direct || []), ...(selection.registered || [])]
.map((entry) => (typeof entry === 'string' ? entry : entry.smoke))
.filter(Boolean);
return [...new Set(names)].sort();
}
/** Ограниченный параллелизм: browser-consumers тяжёлые, по умолчанию два разом. */
export async function runLimited(items, limit, worker) {
const results = new Array(items.length);
let next = 0;
const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await worker(items[index], index);
}
});
await Promise.all(lanes);
return results;
}
/** Шаги параллельной read-only/producer-фазы. `base` — начало диапазона диффа. */
export function parallelSteps(base) {
return [
{ name: 'сборка + typecheck (npm run build)', cmd: npm, args: ['run', 'build'] },
{ name: 'новый код не добавляет any', cmd: process.execPath, args: ['scripts/no-new-any.mjs', '--base', base, '--head', 'HEAD'] },
{ name: 'смоки по диффу (smoke-select)', cmd: process.execPath, args: ['scripts/smoke-select.mjs', '--base', base, '--head', 'HEAD', '--json'], informational: true },
];
}
/** Фаза после сборки: consumers свежего dist и проверки готового артефакта. */
export function postBuildSteps() {
return [
{ name: 'юниты (npm test)', cmd: npm, args: ['test'] },
{ name: 'копии бандла совпадают (bundle-tree)', cmd: process.execPath, args: ['scripts/bundle-tree.mjs', 'dist', 'custom_components/houseplan/frontend'], hint: 'npm run bundle:sync' },
{ name: 'бюджет бандла', cmd: npm, args: ['run', 'bundle:budget'] },
];
}
function runStep(step, cwd) {
return new Promise((done) => {
const started = Date.now();
// Оболочка только для npm.cmd на Windows (#496): node/git — напрямую.
const { cmd, shell } = portableCommand(step.cmd);
const child = spawn(cmd, step.args, { cwd, shell, env: process.env });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.stderr.on('data', (chunk) => { out += chunk; });
child.on('close', (code) => done({ ...step, code, out, ms: Date.now() - started }));
child.on('error', (error) => done({ ...step, code: 1, out: String(error), ms: Date.now() - started }));
});
}
export function summarize(results) {
const lines = []; let failed = 0;
for (const r of results) {
const ok = r.code === 0;
if (!ok && !r.informational) failed += 1;
const mark = ok ? 'ok ' : (r.informational ? 'info' : 'FAIL');
lines.push(`${mark} ${String(Math.round(r.ms / 1000)).padStart(4)} с ${r.name}${!ok && r.hint ? ` → ${r.hint}` : ''}`);
}
return { lines, failed };
}
export async function gateSmall({ cwd = ROOT, base = 'origin/dev', smokes = false, jobs = 2, log = console.log } = {}) {
const started = Date.now();
log(`gate:small — база диапазона ${base}; параллельно: сборка, no-new-any, smoke-select; после сборки: юниты и проверки артефакта${smokes ? `; затем bundle-sync и смоки по диффу (×${jobs})` : ''}`);
const parallel = await Promise.all(parallelSteps(base).map((step) => runStep(step, cwd)));
const buildOk = parallel.find((r) => r.args.includes('build'))?.code === 0;
const serial = [];
if (buildOk) for (const step of postBuildSteps()) serial.push(await runStep(step, cwd));
// Browser-consumers — после подготовки артефактов (#496): bundle-sync раскладывает
// собранное дерево в demo/srv/assets, смоки читают его и отказываются на несвежем.
let selected = [];
if (smokes && buildOk) {
const select = parallel.find((r) => r.args.includes('scripts/smoke-select.mjs'));
try { selected = smokesToRun(JSON.parse(select?.out || 'null')); } catch { selected = []; }
if (selected.length) {
serial.push(await runStep({ name: 'подготовка артефактов (bundle-sync)', cmd: process.execPath, args: ['scripts/bundle-sync.mjs'] }, cwd));
if (serial.at(-1).code === 0) {
const runs = await runLimited(selected, jobs, (smoke) => runStep({ name: `смок ${smoke}`, cmd: process.execPath, args: [`demo/${smoke}`] }, cwd));
serial.push(...runs);
}
}
}
const results = [...parallel, ...serial];
const { lines, failed } = summarize(results);
log('');
for (const line of lines) log(line);
const select = parallel.find((r) => r.args.includes('scripts/smoke-select.mjs'));
if (select) {
log('');
let selection = null;
try { selection = JSON.parse(select.out || 'null'); } catch { /* ниже — сырой вывод */ }
if (!selection) log(select.out.trim() || ' (smoke-select ничего не напечатал)');
else if (selection.noExecutableDiff) log('smoke-select: исполняемого frontend-диффа нет — смоки этим диффом не выбираются');
else {
const names = smokesToRun(selection);
log(`smoke-select: прямые и зарегистрированные (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`);
for (const name of names) log(` demo/${name}`);
if ((selection.broad || []).length) log(` «широких» символов: ${selection.broad.length} — решает ревьюер, автоматически не гоняются`);
}
}
for (const r of results) {
if (r.code !== 0 && !r.informational) { log(''); log(`--- ${r.name}`); log(r.out.trim()); }
}
log('');
log(`итого ${Math.round((Date.now() - started) / 1000)} с; упало: ${failed}${buildOk ? '' : ' (сверка бандла пропущена — сборка не прошла)'}`);
return { failed, results };
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const { base, smokes, jobs } = parseArgs(process.argv.slice(2));
gateSmall({ base, smokes, jobs }).then(({ failed }) => { process.exitCode = failed ? 1 : 0; });
}