From c68d92f6745e498f89148a7938ec86d15a7d7e6a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:36:51 +0300 Subject: [PATCH] process: ratchets get a band over the beta ceiling (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- PROCESS.md | 28 ++++- docs/TESTING.md | 17 +-- scripts/bundle-budget.mjs | 45 ++++---- scripts/monolith-metrics.mjs | 19 +++- scripts/mutation-browser-policy.mjs | 7 ++ scripts/mutation-registry-check.mjs | 7 +- scripts/mutation-registry.mjs | 62 ++++++++++- scripts/ratchets.mjs | 167 ++++++++++++++++++++++++++++ scripts/release-prerelease.mjs | 2 + scripts/unused-locals-gate.mjs | 25 +++-- test/bundle-assets.test.mjs | 75 ++++++------- test/core-file-budget.test.mjs | 60 +++++----- test/monolith-metrics.test.mjs | 23 ++-- test/mutation-gate.test.mjs | 7 +- test/ratchets.test.mjs | 67 +++++++++++ 15 files changed, 470 insertions(+), 141 deletions(-) create mode 100644 scripts/ratchets.mjs create mode 100644 test/ratchets.test.mjs diff --git a/PROCESS.md b/PROCESS.md index 0ee91251..249da7e2 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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`, diff --git a/docs/TESTING.md b/docs/TESTING.md index 385469ad..102cf5f2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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) diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 5695cbe8..41eeb91d 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -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`, diff --git a/scripts/monolith-metrics.mjs b/scripts/monolith-metrics.mjs index 50b8b5e8..a16c6ee9 100644 --- a/scripts/monolith-metrics.mjs +++ b/scripts/monolith-metrics.mjs @@ -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 }; } diff --git a/scripts/mutation-browser-policy.mjs b/scripts/mutation-browser-policy.mjs index b6699baa..bdb641ea 100644 --- a/scripts/mutation-browser-policy.mjs +++ b/scripts/mutation-browser-policy.mjs @@ -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'; diff --git a/scripts/mutation-registry-check.mjs b/scripts/mutation-registry-check.mjs index 9108fb82..ddcf0b13 100644 --- a/scripts/mutation-registry-check.mjs +++ b/scripts/mutation-registry-check.mjs @@ -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 не размечен — добавьте причину в ` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 2e2723f5..479ecb9c 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/scripts/ratchets.mjs b/scripts/ratchets.mjs new file mode 100644 index 00000000..9cff18bd --- /dev/null +++ b/scripts/ratchets.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); + } +} diff --git a/scripts/release-prerelease.mjs b/scripts/release-prerelease.mjs index 38007cf8..cd07e72e 100644 --- a/scripts/release-prerelease.mjs +++ b/scripts/release-prerelease.mjs @@ -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, diff --git a/scripts/unused-locals-gate.mjs b/scripts/unused-locals-gate.mjs index 2a2415d0..0ac1f237 100644 --- a/scripts/unused-locals-gate.mjs +++ b/scripts/unused-locals-gate.mjs @@ -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'); diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index e009c091..5602c87a 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -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; } diff --git a/test/core-file-budget.test.mjs b/test/core-file-budget.test.mjs index 2528f6c5..5f1c8ca2 100644 --- a/test/core-file-budget.test.mjs +++ b/test/core-file-budget.test.mjs @@ -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('потолки заданы для двух ядер и ни для чего больше', () => { diff --git a/test/monolith-metrics.test.mjs b/test/monolith-metrics.test.mjs index 987d80e4..85a68696 100644 --- a/test/monolith-metrics.test.mjs +++ b/test/monolith-metrics.test.mjs @@ -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'); }); diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 7649e207..184fc2b8 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -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', () => { diff --git a/test/ratchets.test.mjs b/test/ratchets.test.mjs new file mode 100644 index 00000000..18f1efa5 --- /dev/null +++ b/test/ratchets.test.mjs @@ -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 \}\)/); +});