mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Карточка и редакторский рантайм держали ≈380 неиспользуемых импортов, 56 мёртвых объявлений и дублей типов (warm-boot, LS_*, GLOW_*, debounce, navigate, lruRead — копии карточки в рантайме) и 112 приватных членов карточки, которых не читал никто — делегаты `_editorRuntimeOrThrow()._x()`, оставшиеся от выноса #425, и аксессоры glow-состояния. Всё это снято; в 9 других файлах — по одиночной ошибке. Делегаты и поля, которых касаются браузерные смоки (`card._x(...)`), оставлены и посчитаны отдельно. Гейт `npm run lint:unused` (scripts/unused-locals-gate.mjs, в gate:small и Validate после сборки): `tsc --noUnusedLocals` чист, кроме приватных членов карточки из порта HouseplanEditorHostPort / `host.` (portPrivates) и членов, которых зовёт харнесс (harnessPrivates); храповик по шести числам scripts/monolith-metrics.mjs против scripts/monolith-baseline.json — delegates 260→159, portMembers 350, hostRefs 4948, portPrivates 96, harnessPrivates 107, bundleBytes 2 510 141→2 500 387. `npm run inventory` печатает те же числа. Заморозка 54 тестов, читающих монолит как текст (test/monolith-text-anchors.test.mjs); PROCESS.md §2.7 — правило. Логический исходник для контрактных тестов (test/houseplan-source.mjs) дописывает члены рантайма без делегата в карточке — контракт продукта не зависит от наличия заглушки. Потолки ядер и initial gzip опущены на выигрыш (292 000 → 290 400). Бандл пересобран, три копии синхронны. Issue: #624 User-Visible: no
160 lines
9.6 KiB
JavaScript
160 lines
9.6 KiB
JavaScript
#!/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'] },
|
||
// #624: мёртвый код по noUnusedLocals и храповик связности монолита; после
|
||
// сборки, потому что одно из чисел — размер dist/.
|
||
{ name: 'мёртвый код и связность монолита (lint:unused)', cmd: process.execPath, args: ['scripts/unused-locals-gate.mjs'], hint: 'node scripts/unused-locals-gate.mjs --update при осознанном снижении' },
|
||
];
|
||
}
|
||
|
||
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; });
|
||
}
|