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:
Claude
2026-09-28 22:39:36 +00:00
committed by claude[bot]
parent 8bb43fd379
commit c68d92f674
15 changed files with 470 additions and 141 deletions
+19 -26
View File
@@ -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`,
+15 -4
View File
@@ -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 };
}
+7
View File
@@ -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';
+5 -2
View File
@@ -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 не размечен — добавьте причину в `
+59 -3
View File
@@ -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',
+167
View File
@@ -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);
}
}
+2
View File
@@ -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,
+13 -12
View File
@@ -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');