mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
process: ratchets get a band over the beta ceiling (#699)
Two-sided ratchets with zero slack made parallel tasks conflict on shared numbers, recompute them after every rebase and hit a ceiling because a neighbour merged first (#689 after #691). - Core lines (test/core-file-budget.test.mjs): a branch may grow up to CORE_BAND = 50 lines over the beta ceiling; shrinking no longer fails it. - Bundle graphs (bundle-budget.mjs): initial View and lazy graphs fail only above ceiling + 2 000 B; below the ceiling is not a branch finding. The absolute INITIAL_VIEW_GZIP_BUDGET stays the wall. - Monolith numbers (monolith-metrics.mjs, unused-locals-gate.mjs): METRIC_BANDS — 5 for delegates, port members and privates, 25 for host. refs, 2 000 B for dist/; a lower number is reported, not failed. - Browser mutation guards: 200 is a guideline — mutation-gate --check warns above it instead of failing; every guard still needs its reason line. - scripts/ratchets.mjs: `report [--warn]` and `tighten` — on the beta candidate the release manager sets every ceiling to the fact in one commit; release:prerelease prints loose ceilings as a warning. Canon: PROCESS.md §3 (browser guards, monolith numbers) and §8 «Храповики»; docs/TESTING.md. Issue: #699 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
+23
-5
@@ -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
@@ -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
@@ -389,6 +389,14 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
|
||||
* границы полосы; общий бюджет 301 066 Б не меняется.
|
||||
*/
|
||||
export const INITIAL_VIEW_GZIP_CEILING = 301_000;
|
||||
/**
|
||||
* #699 (решение владельца 2026-09-28): полоса — над потолком, а не под ним.
|
||||
* Задача может вырасти не больше чем на полосу над потолком беты, падение её не
|
||||
* красит; «перецентрирование» на задаче ушло — потолок ставит до факта бета
|
||||
* (`node scripts/ratchets.mjs tighten`). Прежде правило было двусторонним,
|
||||
* `[ceiling - band, ceiling]`, и задача у верхней границы поднимала потолок
|
||||
* сама — как #691. Абсолютный бюджет `INITIAL_VIEW_GZIP_BUDGET` остаётся стеной.
|
||||
*/
|
||||
export const INITIAL_VIEW_CEILING_BAND = 2_000;
|
||||
|
||||
/**
|
||||
@@ -535,24 +543,16 @@ export function initialViewCeilingViolation(bytes, {
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return { kind: 'missing', text: 'initial View graph не измерен — потолок проверить нечем' };
|
||||
}
|
||||
if (bytes > ceiling) {
|
||||
if (bytes > ceiling + band) {
|
||||
return {
|
||||
kind: 'grew',
|
||||
over: bytes - ceiling,
|
||||
text: `initial View graph ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
|
||||
text: `initial View graph ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
|
||||
+ ` — больше полосы ${band} B.`
|
||||
+ ' Поднимите потолок в этом же коммите, объяснив рост, либо вынесите код в ленивый'
|
||||
+ ' граф (история: #367 → #474). Молча расти этому графу больше нечем.',
|
||||
};
|
||||
}
|
||||
if (bytes < ceiling - band) {
|
||||
return {
|
||||
kind: 'shrank',
|
||||
under: ceiling - bytes,
|
||||
text: `initial View graph ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
|
||||
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш граф`
|
||||
+ ' отыграет обратно, и это уже происходило (#367, закрыт).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -676,24 +676,17 @@ export function lazyGraphCeilingViolation(bytes, { ceiling, label, band = LAZY_G
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return { kind: 'missing', text: `${label} не измерен — потолок проверить нечем` };
|
||||
}
|
||||
if (bytes > ceiling) {
|
||||
// #699: та же полоса над потолком беты, что у стартового графа.
|
||||
if (bytes > ceiling + band) {
|
||||
return {
|
||||
kind: 'grew',
|
||||
over: bytes - ceiling,
|
||||
text: `${label} ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
|
||||
text: `${label} ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
|
||||
+ ` — больше полосы ${band} B.`
|
||||
+ ' Ленивый граф грузится не в первом кадре, но он всё равно чей-то трафик:'
|
||||
+ ' поднимите потолок в этом же коммите, объяснив рост, либо уменьшите содержимое.',
|
||||
};
|
||||
}
|
||||
if (bytes < ceiling - band) {
|
||||
return {
|
||||
kind: 'shrank',
|
||||
under: ceiling - bytes,
|
||||
text: `${label} ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
|
||||
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш`
|
||||
+ ' граф отыграет обратно.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -827,19 +820,19 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
const headroom = INITIAL_VIEW_GZIP_BUDGET - result.initialViewGzipBytes;
|
||||
const lines = [
|
||||
`initial View: ${result.initialViewGzipBytes} B gzip`
|
||||
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B ±${INITIAL_VIEW_CEILING_BAND},`
|
||||
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B +${INITIAL_VIEW_CEILING_BAND},`
|
||||
+ ` budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`,
|
||||
`initial panel: ${result.initialPanelGzipBytes} B gzip`,
|
||||
`initial panel-only: ${result.initialPanelOnlyGzipBytes} B gzip`
|
||||
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
|
||||
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy onboarding: ${result.lazyOnboardingGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy namespace locales: ${result.lazyNamespaceLocaleGzipBytes} B gzip`
|
||||
+ ` (${NAMESPACE_LOCALE_CHUNKS.length} chunks, по одному грузится на пространство)`,
|
||||
`lazy furniture art: ${result.lazyFurnitureArtGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
|
||||
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
|
||||
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
|
||||
|
||||
@@ -261,17 +261,28 @@ export function collectMetrics(root, { diagnostics = null, distDir = 'dist' } =
|
||||
*/
|
||||
export const BUNDLE_BYTES_BAND = 2_000;
|
||||
|
||||
/** Сравнение с базой: что выросло, что упало. Отсутствующее в базе — рост. */
|
||||
export function compareWithBaseline(current, baseline, { bundleBand = BUNDLE_BYTES_BAND } = {}) {
|
||||
/**
|
||||
* #699 (решение владельца 2026-09-28): полоса над базой беты у всех шести чисел.
|
||||
* Рост `hostRefs` на несколько ссылок больше не блокирует задачу `show`, а
|
||||
* параллельные задачи не конфликтуют на базе и не пересчитывают её после
|
||||
* ребейза. Снижение задачу не красит: базу до факта опускает бета
|
||||
* (`node scripts/ratchets.mjs tighten`).
|
||||
*/
|
||||
export const METRIC_BANDS = Object.freeze({
|
||||
delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND,
|
||||
});
|
||||
|
||||
/** Сравнение с базой: что выросло сверх полосы, что упало. Отсутствующее в базе — рост. */
|
||||
export function compareWithBaseline(current, baseline, { bands = METRIC_BANDS } = {}) {
|
||||
const grown = [];
|
||||
const shrunk = [];
|
||||
for (const name of METRIC_NAMES) {
|
||||
const now = current[name];
|
||||
const base = baseline?.[name];
|
||||
if (now == null) continue;
|
||||
const band = name === 'bundleBytes' ? bundleBand : 0;
|
||||
const band = bands[name] ?? 0;
|
||||
if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });
|
||||
else if (now < base - band) shrunk.push({ name, base, now });
|
||||
else if (now < base) shrunk.push({ name, base, now });
|
||||
}
|
||||
return { grown, shrunk };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Ориентир, а не стена (#699, решение владельца 2026-09-28). Прежний жёсткий
|
||||
* лимит заставлял новую задачу удалять чужой браузерный мутант или уводить свой
|
||||
* свидетель в Node только ради числа (#687, #689). Каждый browser guard и так
|
||||
* обязан иметь строку обоснования в реестре; сверх ориентира `--check`
|
||||
* предупреждает, а не краснеет.
|
||||
*/
|
||||
export const BROWSER_GUARD_LIMIT = 200;
|
||||
export const BROWSER_GUARD_INVENTORY = 'docs/testing-notes/mutation-browser-guards.md';
|
||||
|
||||
|
||||
@@ -42,8 +42,11 @@ export function checkMutationRegistry(selected, { allMutants, root, log = consol
|
||||
const policy = browserGuardPolicy(allMutants, readDocumentedBrowserGuards(root));
|
||||
log(`browser guards: ${policy.count}/${BROWSER_GUARD_LIMIT}`);
|
||||
if (policy.overLimit) {
|
||||
log(`FAIL browser guards: лимит ${BROWSER_GUARD_LIMIT} превышен`);
|
||||
stale++;
|
||||
// #699: ориентир, а не лимит — цена браузерных свидетелей растёт, но
|
||||
// удалять чужой мутант ради числа не нужно.
|
||||
log(`WARN browser guards: ${policy.count} при ориентире ${BROWSER_GUARD_LIMIT} — `
|
||||
+ 'каждый сверх ориентира держится своей строкой обоснования в реестре');
|
||||
warned++;
|
||||
}
|
||||
for (const mutant of policy.missingReasons) {
|
||||
log(`WARN ${mutant.id}: browser guard не размечен — добавьте причину в `
|
||||
|
||||
@@ -10383,15 +10383,71 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'monolith-metrics-baseline-strict',
|
||||
guard: 'node --test --test-name-pattern="#624 храповик" test/monolith-metrics.test.mjs',
|
||||
because: 'the ratchet reddens on a growth of one; a >= comparison lets bundle bytes and '
|
||||
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2)',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: 'the ratchet reddens one unit over the band; a looser comparison lets bundle bytes and '
|
||||
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2; band since #699)',
|
||||
patches: [{
|
||||
file: 'scripts/monolith-metrics.mjs',
|
||||
find: " if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });",
|
||||
replace: " if (base == null || now > base + band + 1) grown.push({ name, base: base ?? null, now }); // mutant: one unit of slack",
|
||||
}],
|
||||
},
|
||||
// #699: полоса над потолком беты вместо двустороннего храповика с нулевым запасом.
|
||||
{
|
||||
id: 'core-band-ignored',
|
||||
guard: 'node --test --test-name-pattern="#699" test/core-file-budget.test.mjs',
|
||||
because: '#699: the core may grow up to 50 lines over the beta ceiling; without the band every '
|
||||
+ 'task behind a neighbour hits a zero-slack ceiling again (#689 after #691)',
|
||||
patches: [{
|
||||
file: 'test/core-file-budget.test.mjs',
|
||||
find: ' if (lines > cap + band) {',
|
||||
replace: ' if (lines > cap) { // mutant: no band',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'initial-view-band-below-ceiling-again',
|
||||
guard: 'node --test --test-name-pattern="#438/#699" test/bundle-assets.test.mjs',
|
||||
because: '#699: the bundle band sits over the beta ceiling; a task no longer re-centres the '
|
||||
+ 'ceiling to grow inside it',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-budget.mjs',
|
||||
find: ' if (bytes > ceiling + band) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
|
||||
replace: ' if (bytes > ceiling) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-band-exact-again',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: '#699: a few more host. references no longer block a show task; the five source '
|
||||
+ 'numbers get a band over the beta baseline',
|
||||
patches: [{
|
||||
file: 'scripts/monolith-metrics.mjs',
|
||||
find: ' const band = bands[name] ?? 0;',
|
||||
replace: " const band = name === 'bundleBytes' ? bands[name] : 0; // mutant: exact again",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-shrink-fails-branch-again',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: '#699: a lower number is fixed at the beta by ratchets tighten; failing the branch '
|
||||
+ 'makes parallel tasks conflict on the baseline again',
|
||||
patches: [{
|
||||
file: 'scripts/unused-locals-gate.mjs',
|
||||
find: " for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);",
|
||||
replace: " fail = true; for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`); // mutant",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ratchet-report-calls-band-tight',
|
||||
guard: 'node --test --test-name-pattern="#699 состояние" test/ratchets.test.mjs',
|
||||
because: '#699: a fact inside the band over the ceiling must be reported for the beta to raise '
|
||||
+ 'or revert, not silently counted as tight',
|
||||
patches: [{
|
||||
file: 'scripts/ratchets.mjs',
|
||||
find: " if (fact > ceiling) return 'over';\n",
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-delegates-return-only',
|
||||
guard: 'node --test --test-name-pattern="#624 делегаты" test/monolith-metrics.test.mjs',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Храповики беты (#699, PROCESS.md §8).
|
||||
*
|
||||
* node scripts/ratchets.mjs report [--warn] # факт против потолков
|
||||
* node scripts/ratchets.mjs tighten # потолки := факт кандидата
|
||||
*
|
||||
* С #699 задача может вырасти над потолком беты в пределах полосы, а снижение
|
||||
* её не красит. Вторая сторона храповика живёт здесь: релиз-менеджер на
|
||||
* кандидате беты опускает потолки до факта одной командой, и выигрыш,
|
||||
* накопленный линией, фиксируется одним коммитом, а не правкой общих чисел в
|
||||
* каждой ветке — на них параллельные задачи и конфликтовали.
|
||||
*
|
||||
* Потолки лежат там же, где их читают гейты: строки ядер — в
|
||||
* `test/core-file-budget.test.mjs` (CAPS), gzip-графы — в
|
||||
* `scripts/bundle-budget.mjs`, числа связности — в
|
||||
* `scripts/monolith-baseline.json`. Бандл и `bundleBytes` меряются по
|
||||
* собранному `dist/`: на кандидате он закоммичен свежим (`npm run bundle:release`).
|
||||
*
|
||||
* `report --warn` печатает `::warning::` на рыхлые и вышедшие в полосу потолки
|
||||
* и всегда выходит с 0: это напоминание публикации беты, а не её гейт.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import {
|
||||
INITIAL_VIEW_CEILING_BAND, LAZY_GRAPH_CEILING_BAND,
|
||||
} from './bundle-budget.mjs';
|
||||
import {
|
||||
BASELINE_FILE, METRIC_BANDS, METRIC_NAMES, collectMetrics, readBaseline,
|
||||
} from './monolith-metrics.mjs';
|
||||
|
||||
export const CORE_BUDGET_FILE = 'test/core-file-budget.test.mjs';
|
||||
export const BUNDLE_BUDGET_FILE = 'scripts/bundle-budget.mjs';
|
||||
/** Полоса ядра — та же, что у теста (#699); тест сверяет, что числа не разошлись. */
|
||||
export const CORE_BAND = 50;
|
||||
|
||||
export const BUNDLE_CEILINGS = Object.freeze([
|
||||
{ name: 'INITIAL_VIEW_GZIP_CEILING', metric: 'initialViewGzipBytes', label: 'initial View', band: INITIAL_VIEW_CEILING_BAND },
|
||||
{ name: 'LAZY_EDITOR_GZIP_CEILING', metric: 'lazyEditorGzipBytes', label: 'lazy editor', band: LAZY_GRAPH_CEILING_BAND },
|
||||
{ name: 'LAZY_ONBOARDING_GZIP_CEILING', metric: 'lazyOnboardingGzipBytes', label: 'lazy onboarding', band: LAZY_GRAPH_CEILING_BAND },
|
||||
{ name: 'LAZY_FURNITURE_ART_GZIP_CEILING', metric: 'lazyFurnitureArtGzipBytes', label: 'lazy furniture art', band: LAZY_GRAPH_CEILING_BAND },
|
||||
]);
|
||||
|
||||
const capsBlock = (source) => {
|
||||
const start = source.indexOf('const CAPS = {');
|
||||
if (start < 0) throw new Error(`${CORE_BUDGET_FILE}: нет блока const CAPS`);
|
||||
const end = source.indexOf('};', start);
|
||||
return { start, end, text: source.slice(start, end) };
|
||||
};
|
||||
|
||||
/** Потолки ядер из исходника теста: `'src/x.ts': 12891,`. */
|
||||
export function readCoreCaps(source) {
|
||||
const caps = {};
|
||||
for (const match of capsBlock(source).text.matchAll(/^\s*'([^']+)':\s*(\d+),\s*$/gm)) caps[match[1]] = Number(match[2]);
|
||||
return caps;
|
||||
}
|
||||
|
||||
/** Тот же исходник с новыми числами; комментарии и порядок — как были. */
|
||||
export function rewriteCoreCaps(source, facts) {
|
||||
const { start, end, text } = capsBlock(source);
|
||||
const next = text.replace(/^(\s*)'([^']+)':\s*(\d+),(\s*)$/gm,
|
||||
(line, indent, file, value, tail) => (file in facts ? `${indent}'${file}': ${facts[file]},${tail}` : line));
|
||||
return source.slice(0, start) + next + source.slice(end);
|
||||
}
|
||||
|
||||
/** `301_000` → 301000. */
|
||||
export function readConst(source, name) {
|
||||
const match = new RegExp(`^export const ${name} = ([\\d_]+);$`, 'm').exec(source);
|
||||
if (!match) throw new Error(`${BUNDLE_BUDGET_FILE}: нет export const ${name}`);
|
||||
return Number(match[1].replaceAll('_', ''));
|
||||
}
|
||||
|
||||
const grouped = (value) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '_');
|
||||
|
||||
export function rewriteConst(source, name, value) {
|
||||
readConst(source, name);
|
||||
return source.replace(new RegExp(`^export const ${name} = [\\d_]+;$`, 'm'), `export const ${name} = ${grouped(value)};`);
|
||||
}
|
||||
|
||||
/** Состояние одного храповика относительно потолка беты и полосы. */
|
||||
export function ratchetState(fact, ceiling, band) {
|
||||
if (!Number.isFinite(fact) || !Number.isFinite(ceiling)) return 'missing';
|
||||
if (fact > ceiling + band) return 'grew';
|
||||
if (fact > ceiling) return 'over';
|
||||
if (fact < ceiling) return 'loose';
|
||||
return 'tight';
|
||||
}
|
||||
|
||||
export function ratchetRows({ coreFacts = {}, coreCaps = {}, bundleFacts = {}, bundleCeilings = {}, metrics = {}, baseline = {} }) {
|
||||
const rows = [];
|
||||
for (const [file, cap] of Object.entries(coreCaps)) {
|
||||
rows.push({ kind: 'core', name: file, fact: coreFacts[file], ceiling: cap, band: CORE_BAND });
|
||||
}
|
||||
for (const entry of BUNDLE_CEILINGS) {
|
||||
rows.push({ kind: 'bundle', name: entry.label, key: entry.name, fact: bundleFacts[entry.metric], ceiling: bundleCeilings[entry.name], band: entry.band });
|
||||
}
|
||||
for (const name of METRIC_NAMES) {
|
||||
rows.push({ kind: 'monolith', name, fact: metrics[name], ceiling: baseline?.[name], band: METRIC_BANDS[name] ?? 0 });
|
||||
}
|
||||
return rows.map((row) => ({ ...row, state: ratchetState(row.fact, row.ceiling, row.band) }));
|
||||
}
|
||||
|
||||
const STATE_TEXT = {
|
||||
tight: 'равен факту',
|
||||
loose: 'рыхлый — опустить до факта',
|
||||
over: 'факт в полосе над потолком — поднять до факта или вернуть',
|
||||
grew: 'факт выше полосы — гейт ветки обязан был покраснеть',
|
||||
missing: 'не измерен',
|
||||
};
|
||||
|
||||
export function formatRow(row) {
|
||||
const delta = Number.isFinite(row.fact) && Number.isFinite(row.ceiling) ? row.fact - row.ceiling : null;
|
||||
const sign = delta == null ? '' : ` (${delta > 0 ? '+' : ''}${delta}, полоса +${row.band})`;
|
||||
return `${row.kind.padEnd(8)} ${row.name}: факт ${row.fact ?? '—'} · потолок ${row.ceiling ?? '—'}${sign} — ${STATE_TEXT[row.state]}`;
|
||||
}
|
||||
|
||||
function measure(root) {
|
||||
const coreSource = readFileSync(resolve(root, CORE_BUDGET_FILE), 'utf8');
|
||||
const bundleSource = readFileSync(resolve(root, BUNDLE_BUDGET_FILE), 'utf8');
|
||||
const coreCaps = readCoreCaps(coreSource);
|
||||
const coreFacts = Object.fromEntries(Object.keys(coreCaps).map((file) => [
|
||||
file, existsSync(resolve(root, file)) ? readFileSync(resolve(root, file), 'utf8').split('\n').length : undefined,
|
||||
]));
|
||||
const manifestPath = resolve(root, 'dist/houseplan-assets.json');
|
||||
const bundleFacts = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
|
||||
const bundleCeilings = Object.fromEntries(BUNDLE_CEILINGS.map((entry) => [entry.name, readConst(bundleSource, entry.name)]));
|
||||
const { metrics } = collectMetrics(root);
|
||||
const baseline = readBaseline(root) || {};
|
||||
return { coreSource, bundleSource, coreCaps, coreFacts, bundleFacts, bundleCeilings, metrics, baseline };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const [command] = process.argv.slice(2);
|
||||
const root = process.cwd();
|
||||
try {
|
||||
const m = measure(root);
|
||||
const rows = ratchetRows(m);
|
||||
if (command === 'report') {
|
||||
const warn = process.argv.includes('--warn');
|
||||
for (const row of rows) console.log(formatRow(row));
|
||||
const attention = rows.filter((row) => row.state !== 'tight');
|
||||
if (warn) {
|
||||
for (const row of attention) console.log(`::warning::храповик ${row.kind} ${row.name}: ${STATE_TEXT[row.state]} — node scripts/ratchets.mjs tighten`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(rows.some((row) => row.state === 'grew' || row.state === 'missing') ? 1 : 0);
|
||||
} else if (command === 'tighten') {
|
||||
const missing = rows.filter((row) => row.state === 'missing');
|
||||
if (missing.length) throw new Error(`не измерено: ${missing.map((row) => `${row.kind} ${row.name}`).join(', ')} — сначала npm run build`);
|
||||
const coreFacts = Object.fromEntries(rows.filter((row) => row.kind === 'core').map((row) => [row.name, row.fact]));
|
||||
writeFileSync(resolve(root, CORE_BUDGET_FILE), rewriteCoreCaps(m.coreSource, coreFacts));
|
||||
let bundleSource = m.bundleSource;
|
||||
for (const row of rows.filter((r) => r.kind === 'bundle')) bundleSource = rewriteConst(bundleSource, row.key, row.fact);
|
||||
writeFileSync(resolve(root, BUNDLE_BUDGET_FILE), bundleSource);
|
||||
const baseline = Object.fromEntries(METRIC_NAMES.map((name) => [name, m.metrics[name]]));
|
||||
writeFileSync(resolve(root, BASELINE_FILE), `${JSON.stringify(baseline, null, 2)}\n`);
|
||||
for (const row of rows.filter((r) => r.state !== 'tight')) console.log(`${row.kind} ${row.name}: ${row.ceiling} → ${row.fact}`);
|
||||
console.log(`потолки опущены до факта: ${CORE_BUDGET_FILE}, ${BUNDLE_BUDGET_FILE}, ${BASELINE_FILE} — закоммитить вместе с кандидатом беты`);
|
||||
} else {
|
||||
throw new Error('usage: ratchets.mjs report [--warn] | tighten');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(command === 'report' && process.argv.includes('--warn') ? 0 : 1);
|
||||
}
|
||||
}
|
||||
@@ -513,6 +513,8 @@ if (invokedDirectly) {
|
||||
tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues,
|
||||
}).manifest;
|
||||
const existingRelease = releaseView();
|
||||
// #699: храповики с полосой опускает до факта бета — напоминание, не гейт.
|
||||
run(process.execPath, ['scripts/ratchets.mjs', 'report', '--warn'], { allowFailure: true, inherit: true });
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ready: true, tag, version: contract.version, sha, branch, bundleSha256,
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
* который компилятор доказал мёртвым.
|
||||
*
|
||||
* 2. Храповик: шесть чисел `scripts/monolith-metrics.mjs` не растут
|
||||
* относительно `scripts/monolith-baseline.json` (`bundleBytes` — с полосой
|
||||
* ±2 000 Б, как gzip-потолок #438; остальные — точно). Снижение — не ошибка, но
|
||||
* база обязана быть опущена тем же коммитом (`--update`): незафиксированный
|
||||
* выигрыш монолит отыграет обратно первой же правкой. Рост допускается
|
||||
* только с явной записью в issue задачи и правкой базы в том же коммите —
|
||||
* гейт печатает, какое число и на сколько.
|
||||
* относительно `scripts/monolith-baseline.json` больше своей полосы
|
||||
* (`METRIC_BANDS`, #699). Снижение задачу не красит: базу до факта опускает
|
||||
* бета (`node scripts/ratchets.mjs tighten`), иначе выигрыш монолит отыграет
|
||||
* обратно. Рост сверх полосы допускается только с явной записью в issue
|
||||
* задачи и правкой базы в том же коммите — гейт печатает, какое число и на
|
||||
* сколько.
|
||||
*
|
||||
* `bundleBytes` требует собранного `dist/`: гейт стоит после `npm run build`
|
||||
* (в `gate:small` и в job `frontend` Validate). Без сборки число не судится,
|
||||
@@ -31,7 +31,7 @@ import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import {
|
||||
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline,
|
||||
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline, METRIC_BANDS,
|
||||
} from './monolith-metrics.mjs';
|
||||
|
||||
/**
|
||||
@@ -60,15 +60,16 @@ export function decide({ metrics, violations, baseline }) {
|
||||
fail = true;
|
||||
for (const g of grown) {
|
||||
lines.push(`FAIL связность выросла: ${g.name} ${g.base ?? 'нет в базе'} → ${g.now}`
|
||||
+ ' — вернуть или обосновать в issue и поднять базу тем же коммитом');
|
||||
+ ` — больше полосы ${METRIC_BANDS[g.name] ?? 0}; вернуть или обосновать в issue и поднять базу тем же коммитом`);
|
||||
}
|
||||
}
|
||||
if (shrunk.length) {
|
||||
// Снижение без записи в базу — тоже отказ: храповик работает в обе стороны.
|
||||
fail = true;
|
||||
for (const s of shrunk) lines.push(`FAIL связность упала, база не опущена: ${s.name} ${s.base} → ${s.now} — node scripts/unused-locals-gate.mjs --update`);
|
||||
// #699: снижение задачу не красит — базу до факта опускает бета
|
||||
// (`node scripts/ratchets.mjs tighten`). Храповик по-прежнему двусторонний,
|
||||
// но вторая сторона живёт на бете, а не в каждой ветке.
|
||||
for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);
|
||||
}
|
||||
if (!grown.length && !shrunk.length) lines.push('ok все числа равны базе');
|
||||
if (!grown.length && !shrunk.length) lines.push('ok все числа в полосе базы');
|
||||
if (metrics.bundleBytes == null) {
|
||||
fail = true;
|
||||
lines.push('FAIL dist/ не собран — bundleBytes не судится; сначала npm run build');
|
||||
|
||||
+34
-41
@@ -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; }
|
||||
|
||||
@@ -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('потолки заданы для двух ядер и ни для чего больше', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 \}\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user