mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
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
This commit is contained in:
+19
-26
@@ -389,6 +389,14 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
|
||||
* границы полосы; общий бюджет 301 066 Б не меняется.
|
||||
*/
|
||||
export const INITIAL_VIEW_GZIP_CEILING = 301_000;
|
||||
/**
|
||||
* #699 (решение владельца 2026-09-28): полоса — над потолком, а не под ним.
|
||||
* Задача может вырасти не больше чем на полосу над потолком беты, падение её не
|
||||
* красит; «перецентрирование» на задаче ушло — потолок ставит до факта бета
|
||||
* (`node scripts/ratchets.mjs tighten`). Прежде правило было двусторонним,
|
||||
* `[ceiling - band, ceiling]`, и задача у верхней границы поднимала потолок
|
||||
* сама — как #691. Абсолютный бюджет `INITIAL_VIEW_GZIP_BUDGET` остаётся стеной.
|
||||
*/
|
||||
export const INITIAL_VIEW_CEILING_BAND = 2_000;
|
||||
|
||||
/**
|
||||
@@ -535,24 +543,16 @@ export function initialViewCeilingViolation(bytes, {
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return { kind: 'missing', text: 'initial View graph не измерен — потолок проверить нечем' };
|
||||
}
|
||||
if (bytes > ceiling) {
|
||||
if (bytes > ceiling + band) {
|
||||
return {
|
||||
kind: 'grew',
|
||||
over: bytes - ceiling,
|
||||
text: `initial View graph ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
|
||||
text: `initial View graph ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
|
||||
+ ` — больше полосы ${band} B.`
|
||||
+ ' Поднимите потолок в этом же коммите, объяснив рост, либо вынесите код в ленивый'
|
||||
+ ' граф (история: #367 → #474). Молча расти этому графу больше нечем.',
|
||||
};
|
||||
}
|
||||
if (bytes < ceiling - band) {
|
||||
return {
|
||||
kind: 'shrank',
|
||||
under: ceiling - bytes,
|
||||
text: `initial View graph ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
|
||||
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш граф`
|
||||
+ ' отыграет обратно, и это уже происходило (#367, закрыт).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -676,24 +676,17 @@ export function lazyGraphCeilingViolation(bytes, { ceiling, label, band = LAZY_G
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return { kind: 'missing', text: `${label} не измерен — потолок проверить нечем` };
|
||||
}
|
||||
if (bytes > ceiling) {
|
||||
// #699: та же полоса над потолком беты, что у стартового графа.
|
||||
if (bytes > ceiling + band) {
|
||||
return {
|
||||
kind: 'grew',
|
||||
over: bytes - ceiling,
|
||||
text: `${label} ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
|
||||
text: `${label} ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
|
||||
+ ` — больше полосы ${band} B.`
|
||||
+ ' Ленивый граф грузится не в первом кадре, но он всё равно чей-то трафик:'
|
||||
+ ' поднимите потолок в этом же коммите, объяснив рост, либо уменьшите содержимое.',
|
||||
};
|
||||
}
|
||||
if (bytes < ceiling - band) {
|
||||
return {
|
||||
kind: 'shrank',
|
||||
under: ceiling - bytes,
|
||||
text: `${label} ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
|
||||
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш`
|
||||
+ ' граф отыграет обратно.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -827,19 +820,19 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
const headroom = INITIAL_VIEW_GZIP_BUDGET - result.initialViewGzipBytes;
|
||||
const lines = [
|
||||
`initial View: ${result.initialViewGzipBytes} B gzip`
|
||||
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B ±${INITIAL_VIEW_CEILING_BAND},`
|
||||
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B +${INITIAL_VIEW_CEILING_BAND},`
|
||||
+ ` budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`,
|
||||
`initial panel: ${result.initialPanelGzipBytes} B gzip`,
|
||||
`initial panel-only: ${result.initialPanelOnlyGzipBytes} B gzip`
|
||||
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
|
||||
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy onboarding: ${result.lazyOnboardingGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy namespace locales: ${result.lazyNamespaceLocaleGzipBytes} B gzip`
|
||||
+ ` (${NAMESPACE_LOCALE_CHUNKS.length} chunks, по одному грузится на пространство)`,
|
||||
`lazy furniture art: ${result.lazyFurnitureArtGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
|
||||
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
|
||||
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
|
||||
|
||||
@@ -261,17 +261,28 @@ export function collectMetrics(root, { diagnostics = null, distDir = 'dist' } =
|
||||
*/
|
||||
export const BUNDLE_BYTES_BAND = 2_000;
|
||||
|
||||
/** Сравнение с базой: что выросло, что упало. Отсутствующее в базе — рост. */
|
||||
export function compareWithBaseline(current, baseline, { bundleBand = BUNDLE_BYTES_BAND } = {}) {
|
||||
/**
|
||||
* #699 (решение владельца 2026-09-28): полоса над базой беты у всех шести чисел.
|
||||
* Рост `hostRefs` на несколько ссылок больше не блокирует задачу `show`, а
|
||||
* параллельные задачи не конфликтуют на базе и не пересчитывают её после
|
||||
* ребейза. Снижение задачу не красит: базу до факта опускает бета
|
||||
* (`node scripts/ratchets.mjs tighten`).
|
||||
*/
|
||||
export const METRIC_BANDS = Object.freeze({
|
||||
delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND,
|
||||
});
|
||||
|
||||
/** Сравнение с базой: что выросло сверх полосы, что упало. Отсутствующее в базе — рост. */
|
||||
export function compareWithBaseline(current, baseline, { bands = METRIC_BANDS } = {}) {
|
||||
const grown = [];
|
||||
const shrunk = [];
|
||||
for (const name of METRIC_NAMES) {
|
||||
const now = current[name];
|
||||
const base = baseline?.[name];
|
||||
if (now == null) continue;
|
||||
const band = name === 'bundleBytes' ? bundleBand : 0;
|
||||
const band = bands[name] ?? 0;
|
||||
if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });
|
||||
else if (now < base - band) shrunk.push({ name, base, now });
|
||||
else if (now < base) shrunk.push({ name, base, now });
|
||||
}
|
||||
return { grown, shrunk };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Ориентир, а не стена (#699, решение владельца 2026-09-28). Прежний жёсткий
|
||||
* лимит заставлял новую задачу удалять чужой браузерный мутант или уводить свой
|
||||
* свидетель в Node только ради числа (#687, #689). Каждый browser guard и так
|
||||
* обязан иметь строку обоснования в реестре; сверх ориентира `--check`
|
||||
* предупреждает, а не краснеет.
|
||||
*/
|
||||
export const BROWSER_GUARD_LIMIT = 200;
|
||||
export const BROWSER_GUARD_INVENTORY = 'docs/testing-notes/mutation-browser-guards.md';
|
||||
|
||||
|
||||
@@ -42,8 +42,11 @@ export function checkMutationRegistry(selected, { allMutants, root, log = consol
|
||||
const policy = browserGuardPolicy(allMutants, readDocumentedBrowserGuards(root));
|
||||
log(`browser guards: ${policy.count}/${BROWSER_GUARD_LIMIT}`);
|
||||
if (policy.overLimit) {
|
||||
log(`FAIL browser guards: лимит ${BROWSER_GUARD_LIMIT} превышен`);
|
||||
stale++;
|
||||
// #699: ориентир, а не лимит — цена браузерных свидетелей растёт, но
|
||||
// удалять чужой мутант ради числа не нужно.
|
||||
log(`WARN browser guards: ${policy.count} при ориентире ${BROWSER_GUARD_LIMIT} — `
|
||||
+ 'каждый сверх ориентира держится своей строкой обоснования в реестре');
|
||||
warned++;
|
||||
}
|
||||
for (const mutant of policy.missingReasons) {
|
||||
log(`WARN ${mutant.id}: browser guard не размечен — добавьте причину в `
|
||||
|
||||
@@ -10383,15 +10383,71 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'monolith-metrics-baseline-strict',
|
||||
guard: 'node --test --test-name-pattern="#624 храповик" test/monolith-metrics.test.mjs',
|
||||
because: 'the ratchet reddens on a growth of one; a >= comparison lets bundle bytes and '
|
||||
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2)',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: 'the ratchet reddens one unit over the band; a looser comparison lets bundle bytes and '
|
||||
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2; band since #699)',
|
||||
patches: [{
|
||||
file: 'scripts/monolith-metrics.mjs',
|
||||
find: " if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });",
|
||||
replace: " if (base == null || now > base + band + 1) grown.push({ name, base: base ?? null, now }); // mutant: one unit of slack",
|
||||
}],
|
||||
},
|
||||
// #699: полоса над потолком беты вместо двустороннего храповика с нулевым запасом.
|
||||
{
|
||||
id: 'core-band-ignored',
|
||||
guard: 'node --test --test-name-pattern="#699" test/core-file-budget.test.mjs',
|
||||
because: '#699: the core may grow up to 50 lines over the beta ceiling; without the band every '
|
||||
+ 'task behind a neighbour hits a zero-slack ceiling again (#689 after #691)',
|
||||
patches: [{
|
||||
file: 'test/core-file-budget.test.mjs',
|
||||
find: ' if (lines > cap + band) {',
|
||||
replace: ' if (lines > cap) { // mutant: no band',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'initial-view-band-below-ceiling-again',
|
||||
guard: 'node --test --test-name-pattern="#438/#699" test/bundle-assets.test.mjs',
|
||||
because: '#699: the bundle band sits over the beta ceiling; a task no longer re-centres the '
|
||||
+ 'ceiling to grow inside it',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-budget.mjs',
|
||||
find: ' if (bytes > ceiling + band) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
|
||||
replace: ' if (bytes > ceiling) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-band-exact-again',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: '#699: a few more host. references no longer block a show task; the five source '
|
||||
+ 'numbers get a band over the beta baseline',
|
||||
patches: [{
|
||||
file: 'scripts/monolith-metrics.mjs',
|
||||
find: ' const band = bands[name] ?? 0;',
|
||||
replace: " const band = name === 'bundleBytes' ? bands[name] : 0; // mutant: exact again",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-shrink-fails-branch-again',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: '#699: a lower number is fixed at the beta by ratchets tighten; failing the branch '
|
||||
+ 'makes parallel tasks conflict on the baseline again',
|
||||
patches: [{
|
||||
file: 'scripts/unused-locals-gate.mjs',
|
||||
find: " for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);",
|
||||
replace: " fail = true; for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`); // mutant",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ratchet-report-calls-band-tight',
|
||||
guard: 'node --test --test-name-pattern="#699 состояние" test/ratchets.test.mjs',
|
||||
because: '#699: a fact inside the band over the ceiling must be reported for the beta to raise '
|
||||
+ 'or revert, not silently counted as tight',
|
||||
patches: [{
|
||||
file: 'scripts/ratchets.mjs',
|
||||
find: " if (fact > ceiling) return 'over';\n",
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-delegates-return-only',
|
||||
guard: 'node --test --test-name-pattern="#624 делегаты" test/monolith-metrics.test.mjs',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -513,6 +513,8 @@ if (invokedDirectly) {
|
||||
tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues,
|
||||
}).manifest;
|
||||
const existingRelease = releaseView();
|
||||
// #699: храповики с полосой опускает до факта бета — напоминание, не гейт.
|
||||
run(process.execPath, ['scripts/ratchets.mjs', 'report', '--warn'], { allowFailure: true, inherit: true });
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ready: true, tag, version: contract.version, sha, branch, bundleSha256,
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
* который компилятор доказал мёртвым.
|
||||
*
|
||||
* 2. Храповик: шесть чисел `scripts/monolith-metrics.mjs` не растут
|
||||
* относительно `scripts/monolith-baseline.json` (`bundleBytes` — с полосой
|
||||
* ±2 000 Б, как gzip-потолок #438; остальные — точно). Снижение — не ошибка, но
|
||||
* база обязана быть опущена тем же коммитом (`--update`): незафиксированный
|
||||
* выигрыш монолит отыграет обратно первой же правкой. Рост допускается
|
||||
* только с явной записью в issue задачи и правкой базы в том же коммите —
|
||||
* гейт печатает, какое число и на сколько.
|
||||
* относительно `scripts/monolith-baseline.json` больше своей полосы
|
||||
* (`METRIC_BANDS`, #699). Снижение задачу не красит: базу до факта опускает
|
||||
* бета (`node scripts/ratchets.mjs tighten`), иначе выигрыш монолит отыграет
|
||||
* обратно. Рост сверх полосы допускается только с явной записью в issue
|
||||
* задачи и правкой базы в том же коммите — гейт печатает, какое число и на
|
||||
* сколько.
|
||||
*
|
||||
* `bundleBytes` требует собранного `dist/`: гейт стоит после `npm run build`
|
||||
* (в `gate:small` и в job `frontend` Validate). Без сборки число не судится,
|
||||
@@ -31,7 +31,7 @@ import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import {
|
||||
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline,
|
||||
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline, METRIC_BANDS,
|
||||
} from './monolith-metrics.mjs';
|
||||
|
||||
/**
|
||||
@@ -60,15 +60,16 @@ export function decide({ metrics, violations, baseline }) {
|
||||
fail = true;
|
||||
for (const g of grown) {
|
||||
lines.push(`FAIL связность выросла: ${g.name} ${g.base ?? 'нет в базе'} → ${g.now}`
|
||||
+ ' — вернуть или обосновать в issue и поднять базу тем же коммитом');
|
||||
+ ` — больше полосы ${METRIC_BANDS[g.name] ?? 0}; вернуть или обосновать в issue и поднять базу тем же коммитом`);
|
||||
}
|
||||
}
|
||||
if (shrunk.length) {
|
||||
// Снижение без записи в базу — тоже отказ: храповик работает в обе стороны.
|
||||
fail = true;
|
||||
for (const s of shrunk) lines.push(`FAIL связность упала, база не опущена: ${s.name} ${s.base} → ${s.now} — node scripts/unused-locals-gate.mjs --update`);
|
||||
// #699: снижение задачу не красит — базу до факта опускает бета
|
||||
// (`node scripts/ratchets.mjs tighten`). Храповик по-прежнему двусторонний,
|
||||
// но вторая сторона живёт на бете, а не в каждой ветке.
|
||||
for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);
|
||||
}
|
||||
if (!grown.length && !shrunk.length) lines.push('ok все числа равны базе');
|
||||
if (!grown.length && !shrunk.length) lines.push('ok все числа в полосе базы');
|
||||
if (metrics.bundleBytes == null) {
|
||||
fail = true;
|
||||
lines.push('FAIL dist/ не собран — bundleBytes не судится; сначала npm run build');
|
||||
|
||||
Reference in New Issue
Block a user