Files
Claudeandclaude[bot] c68d92f674 process: ratchets get a band over the beta ceiling (#699)
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
2026-09-28 22:39:36 +00:00

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);
}
}