mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Two-sided ratchets with zero slack made parallel tasks conflict on shared numbers, recompute them after every rebase and hit a ceiling because a neighbour merged first (#689 after #691). - Core lines (test/core-file-budget.test.mjs): a branch may grow up to CORE_BAND = 50 lines over the beta ceiling; shrinking no longer fails it. - Bundle graphs (bundle-budget.mjs): initial View and lazy graphs fail only above ceiling + 2 000 B; below the ceiling is not a branch finding. The absolute INITIAL_VIEW_GZIP_BUDGET stays the wall. - Monolith numbers (monolith-metrics.mjs, unused-locals-gate.mjs): METRIC_BANDS — 5 for delegates, port members and privates, 25 for host. refs, 2 000 B for dist/; a lower number is reported, not failed. - Browser mutation guards: 200 is a guideline — mutation-gate --check warns above it instead of failing; every guard still needs its reason line. - scripts/ratchets.mjs: `report [--warn]` and `tighten` — on the beta candidate the release manager sets every ceiling to the fact in one commit; release:prerelease prints loose ceilings as a warning. Canon: PROCESS.md §3 (browser guards, monolith numbers) and §8 «Храповики»; docs/TESTING.md. Issue: #699 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
168 lines
9.4 KiB
JavaScript
168 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Храповики беты (#699, PROCESS.md §8).
|
|
*
|
|
* node scripts/ratchets.mjs report [--warn] # факт против потолков
|
|
* node scripts/ratchets.mjs tighten # потолки := факт кандидата
|
|
*
|
|
* С #699 задача может вырасти над потолком беты в пределах полосы, а снижение
|
|
* её не красит. Вторая сторона храповика живёт здесь: релиз-менеджер на
|
|
* кандидате беты опускает потолки до факта одной командой, и выигрыш,
|
|
* накопленный линией, фиксируется одним коммитом, а не правкой общих чисел в
|
|
* каждой ветке — на них параллельные задачи и конфликтовали.
|
|
*
|
|
* Потолки лежат там же, где их читают гейты: строки ядер — в
|
|
* `test/core-file-budget.test.mjs` (CAPS), gzip-графы — в
|
|
* `scripts/bundle-budget.mjs`, числа связности — в
|
|
* `scripts/monolith-baseline.json`. Бандл и `bundleBytes` меряются по
|
|
* собранному `dist/`: на кандидате он закоммичен свежим (`npm run bundle:release`).
|
|
*
|
|
* `report --warn` печатает `::warning::` на рыхлые и вышедшие в полосу потолки
|
|
* и всегда выходит с 0: это напоминание публикации беты, а не её гейт.
|
|
*/
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { isMainModule } from './spawn-portable.mjs';
|
|
import {
|
|
INITIAL_VIEW_CEILING_BAND, LAZY_GRAPH_CEILING_BAND,
|
|
} from './bundle-budget.mjs';
|
|
import {
|
|
BASELINE_FILE, METRIC_BANDS, METRIC_NAMES, collectMetrics, readBaseline,
|
|
} from './monolith-metrics.mjs';
|
|
|
|
export const CORE_BUDGET_FILE = 'test/core-file-budget.test.mjs';
|
|
export const BUNDLE_BUDGET_FILE = 'scripts/bundle-budget.mjs';
|
|
/** Полоса ядра — та же, что у теста (#699); тест сверяет, что числа не разошлись. */
|
|
export const CORE_BAND = 50;
|
|
|
|
export const BUNDLE_CEILINGS = Object.freeze([
|
|
{ name: 'INITIAL_VIEW_GZIP_CEILING', metric: 'initialViewGzipBytes', label: 'initial View', band: INITIAL_VIEW_CEILING_BAND },
|
|
{ name: 'LAZY_EDITOR_GZIP_CEILING', metric: 'lazyEditorGzipBytes', label: 'lazy editor', band: LAZY_GRAPH_CEILING_BAND },
|
|
{ name: 'LAZY_ONBOARDING_GZIP_CEILING', metric: 'lazyOnboardingGzipBytes', label: 'lazy onboarding', band: LAZY_GRAPH_CEILING_BAND },
|
|
{ name: 'LAZY_FURNITURE_ART_GZIP_CEILING', metric: 'lazyFurnitureArtGzipBytes', label: 'lazy furniture art', band: LAZY_GRAPH_CEILING_BAND },
|
|
]);
|
|
|
|
const capsBlock = (source) => {
|
|
const start = source.indexOf('const CAPS = {');
|
|
if (start < 0) throw new Error(`${CORE_BUDGET_FILE}: нет блока const CAPS`);
|
|
const end = source.indexOf('};', start);
|
|
return { start, end, text: source.slice(start, end) };
|
|
};
|
|
|
|
/** Потолки ядер из исходника теста: `'src/x.ts': 12891,`. */
|
|
export function readCoreCaps(source) {
|
|
const caps = {};
|
|
for (const match of capsBlock(source).text.matchAll(/^\s*'([^']+)':\s*(\d+),\s*$/gm)) caps[match[1]] = Number(match[2]);
|
|
return caps;
|
|
}
|
|
|
|
/** Тот же исходник с новыми числами; комментарии и порядок — как были. */
|
|
export function rewriteCoreCaps(source, facts) {
|
|
const { start, end, text } = capsBlock(source);
|
|
const next = text.replace(/^(\s*)'([^']+)':\s*(\d+),(\s*)$/gm,
|
|
(line, indent, file, value, tail) => (file in facts ? `${indent}'${file}': ${facts[file]},${tail}` : line));
|
|
return source.slice(0, start) + next + source.slice(end);
|
|
}
|
|
|
|
/** `301_000` → 301000. */
|
|
export function readConst(source, name) {
|
|
const match = new RegExp(`^export const ${name} = ([\\d_]+);$`, 'm').exec(source);
|
|
if (!match) throw new Error(`${BUNDLE_BUDGET_FILE}: нет export const ${name}`);
|
|
return Number(match[1].replaceAll('_', ''));
|
|
}
|
|
|
|
const grouped = (value) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '_');
|
|
|
|
export function rewriteConst(source, name, value) {
|
|
readConst(source, name);
|
|
return source.replace(new RegExp(`^export const ${name} = [\\d_]+;$`, 'm'), `export const ${name} = ${grouped(value)};`);
|
|
}
|
|
|
|
/** Состояние одного храповика относительно потолка беты и полосы. */
|
|
export function ratchetState(fact, ceiling, band) {
|
|
if (!Number.isFinite(fact) || !Number.isFinite(ceiling)) return 'missing';
|
|
if (fact > ceiling + band) return 'grew';
|
|
if (fact > ceiling) return 'over';
|
|
if (fact < ceiling) return 'loose';
|
|
return 'tight';
|
|
}
|
|
|
|
export function ratchetRows({ coreFacts = {}, coreCaps = {}, bundleFacts = {}, bundleCeilings = {}, metrics = {}, baseline = {} }) {
|
|
const rows = [];
|
|
for (const [file, cap] of Object.entries(coreCaps)) {
|
|
rows.push({ kind: 'core', name: file, fact: coreFacts[file], ceiling: cap, band: CORE_BAND });
|
|
}
|
|
for (const entry of BUNDLE_CEILINGS) {
|
|
rows.push({ kind: 'bundle', name: entry.label, key: entry.name, fact: bundleFacts[entry.metric], ceiling: bundleCeilings[entry.name], band: entry.band });
|
|
}
|
|
for (const name of METRIC_NAMES) {
|
|
rows.push({ kind: 'monolith', name, fact: metrics[name], ceiling: baseline?.[name], band: METRIC_BANDS[name] ?? 0 });
|
|
}
|
|
return rows.map((row) => ({ ...row, state: ratchetState(row.fact, row.ceiling, row.band) }));
|
|
}
|
|
|
|
const STATE_TEXT = {
|
|
tight: 'равен факту',
|
|
loose: 'рыхлый — опустить до факта',
|
|
over: 'факт в полосе над потолком — поднять до факта или вернуть',
|
|
grew: 'факт выше полосы — гейт ветки обязан был покраснеть',
|
|
missing: 'не измерен',
|
|
};
|
|
|
|
export function formatRow(row) {
|
|
const delta = Number.isFinite(row.fact) && Number.isFinite(row.ceiling) ? row.fact - row.ceiling : null;
|
|
const sign = delta == null ? '' : ` (${delta > 0 ? '+' : ''}${delta}, полоса +${row.band})`;
|
|
return `${row.kind.padEnd(8)} ${row.name}: факт ${row.fact ?? '—'} · потолок ${row.ceiling ?? '—'}${sign} — ${STATE_TEXT[row.state]}`;
|
|
}
|
|
|
|
function measure(root) {
|
|
const coreSource = readFileSync(resolve(root, CORE_BUDGET_FILE), 'utf8');
|
|
const bundleSource = readFileSync(resolve(root, BUNDLE_BUDGET_FILE), 'utf8');
|
|
const coreCaps = readCoreCaps(coreSource);
|
|
const coreFacts = Object.fromEntries(Object.keys(coreCaps).map((file) => [
|
|
file, existsSync(resolve(root, file)) ? readFileSync(resolve(root, file), 'utf8').split('\n').length : undefined,
|
|
]));
|
|
const manifestPath = resolve(root, 'dist/houseplan-assets.json');
|
|
const bundleFacts = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
|
|
const bundleCeilings = Object.fromEntries(BUNDLE_CEILINGS.map((entry) => [entry.name, readConst(bundleSource, entry.name)]));
|
|
const { metrics } = collectMetrics(root);
|
|
const baseline = readBaseline(root) || {};
|
|
return { coreSource, bundleSource, coreCaps, coreFacts, bundleFacts, bundleCeilings, metrics, baseline };
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
const [command] = process.argv.slice(2);
|
|
const root = process.cwd();
|
|
try {
|
|
const m = measure(root);
|
|
const rows = ratchetRows(m);
|
|
if (command === 'report') {
|
|
const warn = process.argv.includes('--warn');
|
|
for (const row of rows) console.log(formatRow(row));
|
|
const attention = rows.filter((row) => row.state !== 'tight');
|
|
if (warn) {
|
|
for (const row of attention) console.log(`::warning::храповик ${row.kind} ${row.name}: ${STATE_TEXT[row.state]} — node scripts/ratchets.mjs tighten`);
|
|
process.exit(0);
|
|
}
|
|
process.exit(rows.some((row) => row.state === 'grew' || row.state === 'missing') ? 1 : 0);
|
|
} else if (command === 'tighten') {
|
|
const missing = rows.filter((row) => row.state === 'missing');
|
|
if (missing.length) throw new Error(`не измерено: ${missing.map((row) => `${row.kind} ${row.name}`).join(', ')} — сначала npm run build`);
|
|
const coreFacts = Object.fromEntries(rows.filter((row) => row.kind === 'core').map((row) => [row.name, row.fact]));
|
|
writeFileSync(resolve(root, CORE_BUDGET_FILE), rewriteCoreCaps(m.coreSource, coreFacts));
|
|
let bundleSource = m.bundleSource;
|
|
for (const row of rows.filter((r) => r.kind === 'bundle')) bundleSource = rewriteConst(bundleSource, row.key, row.fact);
|
|
writeFileSync(resolve(root, BUNDLE_BUDGET_FILE), bundleSource);
|
|
const baseline = Object.fromEntries(METRIC_NAMES.map((name) => [name, m.metrics[name]]));
|
|
writeFileSync(resolve(root, BASELINE_FILE), `${JSON.stringify(baseline, null, 2)}\n`);
|
|
for (const row of rows.filter((r) => r.state !== 'tight')) console.log(`${row.kind} ${row.name}: ${row.ceiling} → ${row.fact}`);
|
|
console.log(`потолки опущены до факта: ${CORE_BUDGET_FILE}, ${BUNDLE_BUDGET_FILE}, ${BASELINE_FILE} — закоммитить вместе с кандидатом беты`);
|
|
} else {
|
|
throw new Error('usage: ratchets.mjs report [--warn] | tighten');
|
|
}
|
|
} catch (error) {
|
|
console.error(`::error::${error.message}`);
|
|
process.exit(command === 'report' && process.argv.includes('--warn') ? 0 : 1);
|
|
}
|
|
}
|