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
+23 -5
View File
@@ -265,8 +265,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
DOM/CSS paint, измеренной геометрии, trusted pointer/lifecycle или browser
wall-time, а id — попасть в размеченный реестр
`docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check`
показывает число browser guards против лимита 200, краснеет при росте выше
лимита и предупреждает о любом id без browser-обоснования; ревьюер проверяет
показывает число browser guards против ориентира 200 и предупреждает — не
краснеет (#699) — при росте выше него и о любом id без browser-обоснования;
удалять чужой мутант ради числа не нужно; ревьюер проверяет
не только наличие строки, но и невозможность более дешёвого `node --test`.
Считаются **защитные AC без названного свидетеля**, а не мутанты на
@@ -303,9 +304,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
(`scripts/monolith-metrics.mjs`: делегаты, члены порта, `host.`, приватные
члены порта и харнесса, байты `dist/`), база — `scripts/monolith-baseline.json`;
гейт `npm run lint:unused` (в `gate:small` и Validate после сборки) красит
рост любого из них и любой мёртвый код по `noUnusedLocals` вне порта и
харнесса. Снижение фиксируется тем же коммитом
(`node scripts/unused-locals-gate.mjs --update`); рост — только с записью в
рост любого из них сверх полосы (#699: делегаты, члены и приватные порта и
харнесса — 5, `host.` — 25, байты `dist/` — 2 000) и любой мёртвый код по
`noUnusedLocals` вне порта и харнесса. Снижение ветку не красит — базу до
факта опускает бета (§8, «Храповики»); рост сверх полосы — только с записью в
issue задачи и правкой базы в том же коммите.
- **Смок входит в сценарий через публичную поверхность (#629).** DOM с
контрактными хуками, события HA и фикстуры, тестовый фасад `window.__hpTest`
@@ -837,6 +839,22 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты
(`publish-prerelease.yml`, `check-docs --screenshots=strict`).
**Храповики — полоса над потолком беты** (#699, решение владельца 2026-09-28).
Строки двух ядер (`test/core-file-budget.test.mjs`), gzip стартового и ленивых
графов (`scripts/bundle-budget.mjs`) и числа связности монолита
(`scripts/monolith-baseline.json`) судятся одним правилом: потолок — факт
последней беты; ветка задачи краснеет, только если вышла выше потолка больше
чем на полосу (ядро — 50 строк, графы и `dist/` — 2 000 Б, числа монолита — по
`METRIC_BANDS`), а снижение её не красит. Прежде храповики были двусторонними с
нулевым запасом: параллельные задачи конфликтовали на общих числах, пересчитывали
их после ребейза и упирались в потолок, потому что перед ними влили чужую (#689
после #691). Вторая сторона храповика живёт на бете: релиз-менеджер на
кандидате выполняет `node scripts/ratchets.mjs tighten` — потолки опускаются
(и поднимаются на принятый линией рост) до факта одним коммитом вместе с
кандидатом; `npm run release:prerelease` печатает рыхлые потолки
предупреждением. Абсолютный бюджет стартового графа
(`INITIAL_VIEW_GZIP_BUDGET`) остаётся стеной.
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
+10 -7
View File
@@ -448,14 +448,17 @@ golden отображаемая версия идёт через seam `displayVe
- [ ] `node --test test/core-file-budget.test.mjs` — потолки на
`src/houseplan-card.ts` и `src/houseplan-editor-runtime.ts`.
Гейт-храповик: наверх не пускает, вниз требует зафиксировать выигрыш. Если он
покраснел, вариантов два, и оба нормальные — вынести из ядра столько же строк,
сколько добавили, либо поднять потолок отдельным решением, объяснив его в
ревью. Молча поднять не выйдет: число живёт в тесте и попадает в дифф.
Гейт-храповик с полосой (#699): потолок — факт последней беты, ветка может
вырасти над ним на 50 строк. Если он покраснел, вариантов два, и оба
нормальные — вынести из ядра столько же строк, сколько добавили сверх полосы,
либо поднять потолок отдельным решением, объяснив его в ревью. Молча поднять не
выйдет: число живёт в тесте и попадает в дифф.
Вторая половина правила (уменьшение ниже потолка на 250 строк тоже краснеет)
нужна затем, что без неё вынос двух тысяч строк ничего не меняет: потолок
остаётся прежним, и ядро дорастает до него обратно «в рамках бюджета».
Вторая половина правила — выигрыш фиксируется — переехала на бету: без неё
вынос двух тысяч строк ничего не меняет, и ядро дорастает до потолка обратно
«в рамках бюджета». Поэтому на кандидате беты `node scripts/ratchets.mjs
tighten` опускает потолки ядер, графов бандла и базу монолита до факта
(PROCESS.md §8, «Храповики»); ветку задачи уменьшение не красит.
## Локальный CI-совместимый toolchain (#557)
+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');
+34 -41
View File
@@ -879,28 +879,25 @@ test('#438 поставляемый граф лежит внутри полос
assert.equal(violation, null, violation?.text);
});
test('#438 рост выше потолка — отказ с числом и с указанием, что делать', () => {
const grew = initialViewCeilingViolation(292_400, { ceiling: 292_000, band: 2_000 });
test('#438/#699 рост выше полосы над потолком беты — отказ с числом и с указанием, что делать', () => {
const grew = initialViewCeilingViolation(294_400, { ceiling: 292_000, band: 2_000 });
assert.equal(grew.kind, 'grew');
assert.equal(grew.over, 400);
assert.match(grew.text, /выше потолка 292000 B на 400 B/);
assert.equal(grew.over, 2_400);
assert.match(grew.text, /выше потолка беты 292000 B на 2400 B — больше полосы 2000 B/);
assert.match(grew.text, /Поднимите потолок в этом же коммите/);
assert.match(grew.text, /#367/, 'у отказа обязан быть выход, а не только запрет');
// Ровно на потолке — ещё не рост: граница включительная, иначе гейт краснеет
// на равенстве и разбираться идут не с графом, а с гейтом.
assert.equal(initialViewCeilingViolation(292_000, { ceiling: 292_000 }), null);
// Ровно потолок плюс полоса — ещё не рост: граница включительная.
assert.equal(initialViewCeilingViolation(294_000, { ceiling: 292_000, band: 2_000 }), null);
assert.equal(initialViewCeilingViolation(292_400, { ceiling: 292_000, band: 2_000 }), null, 'рост в полосе задачу не красит');
});
test('#438 падение ниже полосы требует опустить потолок', () => {
// Вторая половина храповика, без которой он не храповик: выигрыш, который не
// зафиксировали, отыгрывается обратно молча. Так запас бюджета ушёл с 26 КБ
// до 8.3 КБ за сутки — каждая отдельная строка выглядела нормально.
const shrank = initialViewCeilingViolation(289_500, { ceiling: 292_000, band: 2_000 });
assert.equal(shrank.kind, 'shrank');
assert.equal(shrank.under, 2_500);
assert.match(shrank.text, /Опустите потолок/);
assert.equal(initialViewCeilingViolation(290_000, { ceiling: 292_000, band: 2_000 }), null,
'нижняя граница полосы тоже включительная');
test('#699 падение ниже потолка задачу не красит — потолок опускает бета', () => {
// Вторая половина храповика не исчезла, а переехала на бету:
// `node scripts/ratchets.mjs tighten` опускает потолок до факта кандидата.
assert.equal(initialViewCeilingViolation(289_500, { ceiling: 292_000, band: 2_000 }), null);
assert.equal(initialViewCeilingViolation(100_000, { ceiling: 292_000, band: 2_000 }), null);
assert.equal(lazyGraphCeilingViolation(10, { ceiling: 239_000, label: 'lazy editor graph' }), null);
assert.equal(lazyGraphCeilingViolation(241_001, { ceiling: 239_000, label: 'lazy editor graph' }).kind, 'grew');
assert.equal(initialViewCeilingViolation(NaN).kind, 'missing');
assert.equal(initialViewCeilingViolation(undefined).kind, 'missing');
});
@@ -1030,15 +1027,13 @@ test('#438 CLI действительно применяет потолок, а
assert.equal(inside.status, 0, inside.output);
// #627 AC1: the onboarding graph is printed with its ceiling and band.
assert.match(inside.output, new RegExp(`lazy onboarding: ${LAZY_ONBOARDING_GZIP_CEILING - 1_000} B gzip`
+ ` \\(потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND}\\)`));
const grew = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + 1);
assert.equal(grew.status, 1, grew.output);
assert.match(grew.output, /выше потолка/);
+ ` \\(потолок ${LAZY_ONBOARDING_GZIP_CEILING} B \\+${LAZY_GRAPH_CEILING_BAND}\\)`));
// #699: полоса — над потолком беты; ниже потолка задача не краснеет.
const withinBand = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + 1);
assert.equal(withinBand.status, INITIAL_VIEW_GZIP_CEILING + 1 > INITIAL_VIEW_GZIP_BUDGET ? 1 : 0, withinBand.output);
const shrank = runBudgetCli(INITIAL_VIEW_GZIP_CEILING - INITIAL_VIEW_CEILING_BAND - 1);
assert.equal(shrank.status, 1, shrank.output);
assert.match(shrank.output, /Опустите потолок/);
assert.equal(shrank.status, 0, shrank.output);
// И общий бюджет остаётся внешней стеной: он выше потолка, значит красным
// становится потолок, а не бюджет — но и бюджет обязан уметь падать.
@@ -1064,10 +1059,8 @@ test('#593 потолки ленивых графов — гейт, а не ст
] : []) {
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
assert.equal(violation, null, violation?.text);
// Факт лежит не у края полосы — с тем же запасом, что у стартового графа.
assert.ok(ceiling - bytes > 500, `${label}: сверху меньше 500 Б — это шум`);
assert.ok(bytes - (ceiling - LAZY_GRAPH_CEILING_BAND) > 500,
`${label}: снизу меньше 500 Б — гейт потребует опустить потолок из-за шума`);
// #699: запас сверху — полоса над потолком беты; снизу границы у задачи нет.
assert.ok(ceiling + LAZY_GRAPH_CEILING_BAND - bytes > 500, `${label}: сверху меньше 500 Б — это шум`);
}
// Гейт обязан быть исполняемым и на синтетике, обе стороны — и это не
// зависит от свежести бандла: мутант «потолок никогда не срабатывает»
@@ -1075,13 +1068,12 @@ test('#593 потолки ленивых графов — гейт, а не ст
const grew = lazyGraphCeilingViolation(20_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
assert.equal(grew.kind, 'grew');
assert.equal(grew.over, 2_100);
assert.match(grew.text, /lazy furniture art graph 20000 B gzip выше потолка 17900 B на 2100 B/);
const shrank = lazyGraphCeilingViolation(15_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
assert.equal(shrank.kind, 'shrank');
assert.match(shrank.text, /Опустите потолок/);
// Границы полосы включительные — иначе гейт краснеет на равенстве.
assert.equal(lazyGraphCeilingViolation(17_900, { ceiling: 17_900, label: 'x' }), null);
assert.equal(lazyGraphCeilingViolation(15_900, { ceiling: 17_900, label: 'x' }), null);
assert.match(grew.text, /lazy furniture art graph 20000 B gzip выше потолка беты 17900 B на 2100 B/);
// #699: ниже потолка — не находка ветки, потолок опускает бета.
assert.equal(lazyGraphCeilingViolation(15_000, { ceiling: 17_900, label: 'lazy furniture art graph' }), null);
// Граница полосы включительная — иначе гейт краснеет на равенстве.
assert.equal(lazyGraphCeilingViolation(19_900, { ceiling: 17_900, label: 'x' }), null);
assert.equal(lazyGraphCeilingViolation(19_901, { ceiling: 17_900, label: 'x' }).kind, 'grew');
assert.equal(lazyGraphCeilingViolation(NaN, { ceiling: 17_900, label: 'x' }).kind, 'missing');
// Текст обязан называть граф: «graph выше потолка» не говорит, куда смотреть.
assert.match(lazyGraphCeilingViolation(NaN, { ceiling: 1, label: 'lazy editor graph' }).text,
@@ -1096,14 +1088,15 @@ test('#627 AC1 граф онбординга гейтится тем же пот
const bytes = manifest.lazyOnboardingGzipBytes;
assert.ok(Number.isFinite(bytes) && bytes > 0, 'манифест обязан измерять граф онбординга');
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes)));
const over = bytes - LAZY_GRAPH_CEILING_BAND - 1;
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes - 1)),
new RegExp(`lazy onboarding graph ${bytes} B gzip выше потолка ${bytes - 1} B на 1 B`),
);
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes + LAZY_GRAPH_CEILING_BAND + 1)),
/lazy onboarding graph \d+ B gzip ниже потолка .*Опустите потолок/,
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(over)),
new RegExp(`lazy onboarding graph ${bytes} B gzip выше потолка беты ${over} B на ${bytes - over} B`),
);
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes - LAZY_GRAPH_CEILING_BAND)),
'#699: рост в полосе над потолком беты задачу не красит');
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes + LAZY_GRAPH_CEILING_BAND + 1)),
'#699: ниже потолка — не находка ветки, потолок опускает бета');
// Потолок по умолчанию — поставляемый, и поставляемый граф в его полосе —
// пока бандл свежий (#657); синтетика выше от свежести не зависит.
if (!shippedBundleIsFresh(manifest)) { t.diagnostic(STALE_SHIPPED_BUNDLE); return; }
+30 -30
View File
@@ -14,10 +14,19 @@ import { readFileSync } from 'node:fs';
// Мера — `split('\n').length`, то есть строки плюс завершающий перевод.
// Та же функция и для потолков, и для измерения: две разные меры разошлись бы
// на единицу, и гейт краснел бы на пустом месте (проверено при написании).
const SLACK = 250;
//
// #699 (решение владельца 2026-09-28): полоса вместо точки. Потолок —
// факт на последней бете; задача может вырасти над ним не больше чем на
// CORE_BAND строк, и уменьшение её не красит. Потолки опускает до факта
// релиз-менеджер раз в бету (`node scripts/ratchets.mjs tighten`): пока их
// правила каждая задача, два параллельных ядра конфликтовали на этих числах, а
// задача упиралась в потолок, потому что перед ней влили чужую (#689 после #691).
export const CORE_BAND = 50;
// Потолки. Меняются только вручную и только вместе с объяснением в ревью:
// потолок, который вычисляется от текущего размера, потолком не является.
// С #699 число — факт последней беты: его ставит `ratchets.mjs tighten` на
// кандидате, а строки ниже — история решений, а не расчёт текущего числа.
const CAPS = {
// #485 adds the View-side subscription/render integration seams; the live
// model and rendering themselves remain in dedicated modules.
@@ -59,13 +68,14 @@ const CAPS = {
};
/**
* Храповик: наверх не пускает, вниз — требует зафиксировать выигрыш.
* Храповик с полосой (#699): выше `cap + band` не пускает; выигрыш фиксирует
* бета, а не задача.
*
* Вторая половина важнее первой. Без неё вынос двух тысяч строк ничего не
* изменит: потолок останется прежним, и через полгода ядро дорастёт до него
* обратно — молча и «в рамках бюджета».
* Вторая половина храповика не исчезла, а переехала: без неё вынос двух тысяч
* строк ничего не изменит — потолок останется прежним, и ядро дорастёт до него
* обратно молча. Поэтому `tighten` на каждой бете опускает потолок до факта.
*/
export function coreBudgetViolations(sizes, caps, slack = SLACK) {
export function coreBudgetViolations(sizes, caps, band = CORE_BAND) {
const problems = [];
for (const [file, cap] of Object.entries(caps)) {
const lines = sizes[file];
@@ -73,19 +83,13 @@ export function coreBudgetViolations(sizes, caps, slack = SLACK) {
problems.push({ file, kind: 'missing', text: `${file}: файл не измерен` });
continue;
}
if (lines > cap) {
if (lines > cap + band) {
problems.push({
file, kind: 'grew', over: lines - cap,
text: `${file}: ${lines} строк при потолке ${cap} — выросло на ${lines - cap}.`
text: `${file}: ${lines} строк при потолке беты ${cap} — выросло на ${lines - cap}, больше полосы ${band}.`
+ ' Вынесите столько же в отдельный модуль либо поднимите потолок'
+ ' отдельным решением, объяснив его в ревью.',
});
} else if (lines < cap - slack) {
problems.push({
file, kind: 'shrank', under: cap - lines,
text: `${file}: ${lines} строк при потолке ${cap} — на ${cap - lines} меньше.`
+ ' Опустите потолок: незафиксированный выигрыш ядро отыграет обратно.',
});
}
}
return problems;
@@ -93,37 +97,33 @@ export function coreBudgetViolations(sizes, caps, slack = SLACK) {
const measure = (file) => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').split('\n').length;
test('ядра не выросли выше потолка и не опустились ниже него молча', () => {
test('ядра не выросли выше потолка беты больше чем на полосу (#699)', () => {
const sizes = Object.fromEntries(Object.keys(CAPS).map((file) => [file, measure(file)]));
const problems = coreBudgetViolations(sizes, CAPS);
assert.deepEqual(problems.map((p) => p.text), [], problems.map((p) => p.text).join('\n'));
});
test('рост выше потолка становится нарушением с числом', () => {
test('рост выше полосы становится нарушением с числом', () => {
const [problem] = coreBudgetViolations({ 'a.ts': 1300 }, { 'a.ts': 1000 });
assert.equal(problem.kind, 'grew');
assert.equal(problem.over, 300);
assert.match(problem.text, /выросло на 300/);
assert.match(problem.text, /выросло на 300, больше полосы 50/);
});
test('заметное уменьшение требует опустить потолок', () => {
const [problem] = coreBudgetViolations({ 'a.ts': 700 }, { 'a.ts': 1000 });
assert.equal(problem.kind, 'shrank');
assert.equal(problem.under, 300);
assert.match(problem.text, /Опустите потолок/);
test('#699 уменьшение задачу не красит — потолок опускает бета', () => {
assert.deepEqual(coreBudgetViolations({ 'a.ts': 700 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1 }, { 'a.ts': 1000 }), []);
});
test('изменение в пределах люфта не трогает никого', () => {
test('#699 рост в пределах полосы задачу не красит', () => {
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 800 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 751 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1049 }, { 'a.ts': 1000 }), []);
});
test('границы включительно: ровно потолок и ровно люфт нарушением не считаются', () => {
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 750 }, { 'a.ts': 1000 }), []);
assert.equal(coreBudgetViolations({ 'a.ts': 1001 }, { 'a.ts': 1000 })[0].kind, 'grew');
assert.equal(coreBudgetViolations({ 'a.ts': 749 }, { 'a.ts': 1000 })[0].kind, 'shrank');
test('#699 границы включительно: ровно потолок плюс полоса нарушением не считается', () => {
assert.equal(CORE_BAND, 50, 'полоса ядра — решение владельца 2026-09-28');
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1050 }, { 'a.ts': 1000 }), []);
assert.equal(coreBudgetViolations({ 'a.ts': 1051 }, { 'a.ts': 1000 })[0].kind, 'grew');
});
test('потолки заданы для двух ядер и ни для чего больше', () => {
+14 -9
View File
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BASELINE_FILE, BUNDLE_BYTES_BAND, CARD_FILE, METRIC_NAMES, RUNTIME_FILE, bundleBytes, classifyUnused, collectMetrics, compareWithBaseline, countDelegates,
BASELINE_FILE, BUNDLE_BYTES_BAND, CARD_FILE, METRIC_BANDS, METRIC_NAMES, RUNTIME_FILE, bundleBytes, classifyUnused, collectMetrics, compareWithBaseline, countDelegates,
harnessMemberNames, hostReferences, portMemberNames, readBaseline,
} from '../scripts/monolith-metrics.mjs';
import { baselineFrom, decide } from '../scripts/unused-locals-gate.mjs';
@@ -109,15 +109,19 @@ test('#624 байты dist — сумма всех файлов, включая
}
});
test('#624 храповик: рост на 1 любого числа — красный, снижение без записи базы — тоже, равенство — зелёный', () => {
test('#699 храповик: рост сверх полосы любого числа — красный, в полосе и снижение — зелёные', () => {
const base = { delegates: 10, portMembers: 20, hostRefs: 30, portPrivates: 4, harnessPrivates: 5, bundleBytes: 1000 };
assert.deepEqual(compareWithBaseline({ ...base }, base), { grown: [], shrunk: [] });
assert.deepEqual(METRIC_BANDS, { delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND });
for (const name of METRIC_NAMES.filter((n) => n !== 'bundleBytes')) {
const grown = compareWithBaseline({ ...base, [name]: base[name] + 1 }, base).grown;
assert.deepEqual(grown.map((g) => g.name), [name], `${name} +1 виден как рост`);
const decision = decide({ metrics: { ...base, [name]: base[name] + 1 }, violations: [], baseline: base });
assert.equal(decision.fail, true, `${name} +1 — красный гейт`);
const band = METRIC_BANDS[name];
assert.deepEqual(compareWithBaseline({ ...base, [name]: base[name] + band }, base).grown, [], `${name} +${band} — в полосе`);
const grown = compareWithBaseline({ ...base, [name]: base[name] + band + 1 }, base).grown;
assert.deepEqual(grown.map((g) => g.name), [name], `${name} сверх полосы виден как рост`);
const decision = decide({ metrics: { ...base, [name]: base[name] + band + 1 }, violations: [], baseline: base });
assert.equal(decision.fail, true, `${name} сверх полосы — красный гейт`);
assert.match(decision.lines.join('\n'), new RegExp(`связность выросла: ${name} `));
assert.equal(decide({ metrics: { ...base, [name]: base[name] + band }, violations: [], baseline: base }).fail, false);
}
// bundleBytes — с полосой, как gzip-потолок #438: чужой коммит в dev меняет
// dist на сотни байт, и точное число красило бы каждую ветку после ребейза.
@@ -125,9 +129,10 @@ test('#624 храповик: рост на 1 любого числа — кра
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, base).grown.map((g) => g.name), ['bundleBytes']);
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes - BUNDLE_BYTES_BAND - 1 }, base).shrunk.map((g) => g.name), ['bundleBytes']);
assert.equal(decide({ metrics: { ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, violations: [], baseline: base }).fail, true);
// #699: снижение задачу не красит — базу опускает бета.
const shrunk = decide({ metrics: { ...base, delegates: 9 }, violations: [], baseline: base });
assert.equal(shrunk.fail, true);
assert.match(shrunk.lines.join('\n'), /база не опущена: delegates 10 → 9/);
assert.equal(shrunk.fail, false);
assert.match(shrunk.lines.join('\n'), /ниже базы: delegates 10 → 9 — базу опустит бета/);
assert.equal(decide({ metrics: base, violations: [], baseline: base }).fail, false);
// Отсутствие числа в базе — рост (нельзя обнулить метрику, удалив ключ).
const { portPrivates, ...withoutOne } = base;
@@ -158,6 +163,6 @@ test('#624 живое дерево: база равна текущим числ
// фактический свежий dist отдельно проверяют bundle-budget и lint:unused.
const source = (list) => list.filter((s) => s.name !== 'bundleBytes');
assert.deepEqual(source(grown), [], 'связность выросла — вернуть или обосновать');
assert.deepEqual(source(shrunk), [], 'связность упала — опустить базу: node scripts/unused-locals-gate.mjs --update');
void shrunk; // #699: ниже базы — не ошибка ветки, базу опускает бета (ratchets.mjs tighten)
assert.equal(typeof readFileSync(join(root, BASELINE_FILE), 'utf8'), 'string');
});
+5 -2
View File
@@ -206,12 +206,15 @@ test('#332: каждый гвард реестра классифицирует
assert.ok(rest >= 150, `небраузерных гвардов подозрительно мало: ${rest}`);
});
test('#659: browser guard inventory is reviewed, capped and exact', () => {
test('#659/#699: browser guard inventory is reviewed and exact; the limit is a guideline', () => {
const markdown = readFileSync(join(repoRoot, 'docs/testing-notes/mutation-browser-guards.md'), 'utf8');
const policy = browserGuardPolicy(MUTANTS, documentedBrowserGuards(markdown));
assert.equal(policy.count, BROWSER_GUARD_LIMIT);
assert.ok(policy.count > 0);
assert.deepEqual(policy.missingReasons, []);
assert.deepEqual(policy.staleReasons, []);
assert.equal(BROWSER_GUARD_LIMIT, 200, 'ориентир остаётся числом в выводе --check');
const over = browserGuardPolicy([...MUTANTS, ...Array.from({ length: BROWSER_GUARD_LIMIT + 1 }, (_, i) => ({ id: `x-${i}`, guard: 'node demo/smoke_x.mjs' }))], new Set());
assert.equal(over.overLimit, true, 'сверх ориентира policy это видит — --check предупреждает');
});
test('#659: browser-only mutations reuse one clean bundle unless their patch is bundled', () => {
+67
View File
@@ -0,0 +1,67 @@
// #699, PROCESS.md §8: полоса над потолком беты вместо точки; потолки
// опускает до факта релиз-менеджер одной командой на кандидате.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
BUNDLE_CEILINGS, CORE_BAND, CORE_BUDGET_FILE, BUNDLE_BUDGET_FILE, formatRow, ratchetRows, ratchetState,
readConst, readCoreCaps, rewriteConst, rewriteCoreCaps,
} from '../scripts/ratchets.mjs';
import { METRIC_NAMES } from '../scripts/monolith-metrics.mjs';
const read = (path) => readFileSync(fileURLToPath(new URL(`../${path}`, import.meta.url)), 'utf8');
test('#699 состояние храповика: равен, рыхлый, в полосе, выше полосы, не измерен', () => {
assert.equal(ratchetState(100, 100, 50), 'tight');
assert.equal(ratchetState(90, 100, 50), 'loose');
assert.equal(ratchetState(150, 100, 50), 'over', 'граница полосы включительная — как у гейтов ветки');
assert.equal(ratchetState(151, 100, 50), 'grew');
assert.equal(ratchetState(undefined, 100, 50), 'missing');
assert.equal(ratchetState(100, null, 50), 'missing');
});
test('#699 потолки ядер читаются и переписываются в исходнике теста, комментарии на месте', () => {
const source = read(CORE_BUDGET_FILE);
const caps = readCoreCaps(source);
// Имена ядер здесь не пишутся: тесты, читающие монолит как текст, заморожены (#624).
const [first, second] = Object.keys(caps);
assert.equal(Object.keys(caps).length, 2, 'два ядра');
const next = rewriteCoreCaps(source, { [first]: 12000 });
assert.equal(readCoreCaps(next)[first], 12000);
assert.equal(readCoreCaps(next)[second], caps[second]);
assert.equal(next.split('\n').length, source.split('\n').length, 'меняется только число');
assert.match(next, /2026-09-27, #676/, 'история решений остаётся');
assert.match(source, new RegExp(`^export const CORE_BAND = ${CORE_BAND};$`, 'm'), 'полоса теста и инструмента — одно число');
});
test('#699 потолки графов читаются и переписываются с разрядами', () => {
const source = read(BUNDLE_BUDGET_FILE);
for (const entry of BUNDLE_CEILINGS) assert.ok(readConst(source, entry.name) > 0, entry.name);
const next = rewriteConst(source, 'INITIAL_VIEW_GZIP_CEILING', 300323);
assert.equal(readConst(next, 'INITIAL_VIEW_GZIP_CEILING'), 300323);
assert.match(next, /^export const INITIAL_VIEW_GZIP_CEILING = 300_323;$/m);
assert.throws(() => readConst(source, 'NO_SUCH_CEILING'), /нет export const NO_SUCH_CEILING/);
});
test('#699 отчёт видит все три вида храповиков и называет, что делать', () => {
const rows = ratchetRows({
coreCaps: { 'a.ts': 100 }, coreFacts: { 'a.ts': 90 },
bundleCeilings: Object.fromEntries(BUNDLE_CEILINGS.map((e) => [e.name, 1000])),
bundleFacts: Object.fromEntries(BUNDLE_CEILINGS.map((e) => [e.metric, 1000])),
metrics: Object.fromEntries(METRIC_NAMES.map((n) => [n, 10])),
baseline: Object.fromEntries(METRIC_NAMES.map((n) => [n, n === 'hostRefs' ? 5 : 10])),
});
assert.equal(rows.length, 1 + BUNDLE_CEILINGS.length + METRIC_NAMES.length);
assert.equal(rows.find((r) => r.kind === 'core').state, 'loose');
assert.ok(rows.filter((r) => r.kind === 'bundle').every((r) => r.state === 'tight'));
assert.equal(rows.find((r) => r.name === 'hostRefs').state, 'over');
assert.match(formatRow(rows.find((r) => r.kind === 'core')), /факт 90 · потолок 100 \(-10, полоса \+50\) — рыхлый/);
});
test('#699 публикация беты напоминает о храповиках, но не останавливается на них', () => {
const local = read('scripts/release-prerelease.mjs');
const main = local.slice(local.indexOf('const main = async'));
assert.match(main, /'scripts\/ratchets\.mjs', 'report', '--warn'/);
assert.match(main, /'scripts\/ratchets\.mjs', 'report', '--warn'\], \{ allowFailure: true, inherit: true \}\)/);
});