diff --git a/docs/TESTING.md b/docs/TESTING.md index e0d09274..3e979dd3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3403,8 +3403,12 @@ require hands on real hardware — they remain for the human pass. `getConfigElement()` still returns `houseplan-card-editor` [auto: `smoke_lazy_editor_chunk`]. - [ ] `bundle:budget` follows transitive static imports and keeps initial View - at or below 282000 B gzip (#352: ~10% headroom over the calibrated fact, - printed with the trend on every run). Bundle sync, demo freshness, CI artifacts and + at or below 300000 B gzip (#352: ~10% headroom over the calibrated fact, + printed with the trend on every run; recalibrated in #367 from 282000 after + the fact moved 255 993 → 273 697 B, of which +14 KB came in a single step — + furniture plan-art in the eager graph. Recalibration records the growth, it + does not fix it: the lever is a lazy graph. A warning fires while headroom + is still 15000 B, two average features before the wall). Bundle sync, demo freshness, CI artifacts and release zip validation fail when any manifest-listed asset is missing or its SHA-256 differs [auto: `bundle-assets.test`, `bundle-freshness.test`, release-contract tests]. diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index e7523f29..524835e8 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -11,8 +11,48 @@ import { pathToFileURL } from 'node:url'; // the bundle (5740324b was exactly that). The ceiling therefore keeps a // deliberate ~10% allowance over the measured fact; the fact and headroom // are printed on every run so the trend is visible long before the wall. -// Measured fact at calibration (v1.69.0-beta.1): 255 993 B gzip. -export const INITIAL_VIEW_GZIP_BUDGET = 282_000; +// +// #367, рекалибровка 29.08. Запас съеден с 26 КБ до 8.3 КБ, и это не диффузное +// расползание, а один шаг плюс обычная работа. Факт по документам код-ревью: +// +// #317 256 127 · #318 256 091 · #341 256 046 · #354 257 212 · #159 256 828 +// #357 271 143 ← +14 КБ за один заход +// #20 271 455 · #361 272 848 · #359 272 469 · #360 273 697 ← текущий факт +// +// Шаг на #357 — plan-art мебели: 44 top-view символа в eager-графе, которые +// платит КАЖДЫЙ план, включая планы без единого предмета мебели. Остальные +// приросты по килобайту и относятся к обычным фичам. +// +// Поэтому запись честная: рекалибровка НИЧЕГО не ускоряет и ничего не чинит. +// Она фиксирует, что 273 697 Б — новая норма, и возвращает рабочий запас, чтобы +// гейт снова красил того, кто вырастил бандл, а не того, кто пушнул последним. +// Настоящий рычаг остаётся прежним и описан в #367: вынести plan-art мебели в +// ленивый чанк (−13.6 КБ, вариант 1) либо русский словарь (вариант 2). +// +// Measured fact at recalibration (dev @ 360, 29.08.2026): 273 697 B gzip. +// 273 697 × 1.10 = 301 067 — потолок 300 000 держится внутри правила ~10%. +export const INITIAL_VIEW_GZIP_BUDGET = 300_000; + +/** + * Порог, ниже которого запас перестаёт быть запасом (#367). + * + * Прежняя редакция полагалась на то, что человек заметит тренд в выводе. За + * сутки запас ушёл с 26 КБ до 8.3 КБ, и не заметил никто — потому что каждая + * отдельная строка выглядела нормально. Предупреждение срабатывает за две + * средние фичи до стены, а не после неё. + */ +export const LOW_HEADROOM_WARNING_BYTES = 15_000; + +/** Тревога о запасе: `null`, пока его хватает. */ +export function lowHeadroomWarning(headroom, threshold = LOW_HEADROOM_WARNING_BYTES) { + if (!Number.isFinite(headroom) || headroom >= threshold) return null; + if (headroom < 0) { + return `бюджет превышен на ${-headroom} Б — гейт уже красный`; + } + return `запас бюджета ${headroom} Б, меньше порога ${threshold} Б:` + + ' следующая средняя фича упрётся в стену. Рекалибровка это не лечит —' + + ' смотрите ленивые графы (#367)'; +} export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET) { if (manifest?.schema !== 1 || !Array.isArray(manifest.files)) { @@ -58,6 +98,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { `lazy locale: ${result.lazyLocaleGzipBytes} B gzip`, ]; for (const line of lines) console.log(line); + const warning = lowHeadroomWarning(headroom); + if (warning) console.log(`::warning::${warning}`); // #352: the trend belongs where humans look — the run summary. if (process.env.GITHUB_STEP_SUMMARY) { const { appendFileSync } = await import('node:fs'); @@ -66,6 +108,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { `| граф | gzip | бюджет | запас |`, `|---|---|---|---|`, `| initial View | ${result.initialViewGzipBytes} B | ${INITIAL_VIEW_GZIP_BUDGET} B | ${headroom} B |`, + ...(warning ? ['', `> ${warning}`] : []), '', ].join('\n')); } diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 10ada033..7ace708c 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -1308,6 +1308,18 @@ const MUTANT_DEFINITIONS = [ replace: ' if (false) {', }], }, + { + id: 'budget-warning-never-fires', + guard: 'node --test --test-name-pattern="срабатывает до стены" test/bundle-assets.test.mjs', + because: 'прежняя редакция полагалась на то, что человек заметит тренд в выводе: за сутки ' + + 'запас ушёл с 26 КБ до 8.3 КБ, и не заметил никто — каждая отдельная строка выглядела ' + + 'нормально (#367)', + patches: [{ + file: 'scripts/bundle-budget.mjs', + find: ' if (!Number.isFinite(headroom) || headroom >= threshold) return null;', + replace: ' if (true) return null;', + }], + }, { id: 'review-doc-guard-matches-by-substring', guard: 'node --test --test-name-pattern="соседний каталог" test/review-doc-guard.test.mjs', diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index 29d338cf..c4cc91a2 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -7,7 +7,9 @@ import test from 'node:test'; import { buildBundleManifest, buildFingerprintPlugin, editorRuntimeRetryUrlPlugin, } from '../scripts/bundle-manifest.mjs'; -import { assertBundleBudget } from '../scripts/bundle-budget.mjs'; +import { + INITIAL_VIEW_GZIP_BUDGET, LOW_HEADROOM_WARNING_BYTES, assertBundleBudget, lowHeadroomWarning, +} from '../scripts/bundle-budget.mjs'; import { compareBundleTrees, sha256Bytes, verifyBundleTree } from '../scripts/bundle-tree.mjs'; import { minifyCssText, minifyStaticCssTemplates, @@ -196,3 +198,41 @@ test('bundle tree verification rejects orphan chunks (#353 AC4)', async () => { /orphan bundle asset: houseplan-assets\/junk-old\.js/, ); }); + +// #367. Рекалибровка после того, как запас ушёл с 26 КБ до 8.3 КБ за сутки. +// Она ничего не ускоряет — она фиксирует новую норму и возвращает рабочий +// запас, чтобы гейт красил того, кто вырастил бандл, а не того, кто пушнул +// последним. Настоящий рычаг — ленивые графы, и о нём напоминает сам текст +// предупреждения. + +test('потолок держится внутри правила ~10% над измеренным фактом (#352, #367)', () => { + // Факт на момент рекалибровки — 273 697 Б gzip (dev @ 360). + const fact = 273_697; + assert.ok(INITIAL_VIEW_GZIP_BUDGET > fact, 'потолок ниже факта сделал бы гейт вечно красным'); + const allowance = (INITIAL_VIEW_GZIP_BUDGET - fact) / fact; + assert.ok(allowance <= 0.10 + 1e-9, `надбавка ${(allowance * 100).toFixed(1)}% больше правила 10%`); + assert.ok(allowance > 0.05, 'надбавка меньше 5% возвращает лотерею «красит последний коммит»'); +}); + +test('тревога о запасе срабатывает до стены, а не после (#367)', () => { + // Порог выбран так, чтобы предупреждение приходило примерно за две средние + // фичи до потолка: обычный прирост — около килобайта за задачу. + assert.equal(lowHeadroomWarning(LOW_HEADROOM_WARNING_BYTES), null); + assert.equal(lowHeadroomWarning(LOW_HEADROOM_WARNING_BYTES + 1), null); + const warning = lowHeadroomWarning(LOW_HEADROOM_WARNING_BYTES - 1); + assert.match(warning, /меньше порога/); + // Предупреждение обязано называть лечение: без этого следующий читатель + // поднимет потолок ещё раз и назовёт это решением. + assert.match(warning, /Рекалибровка это не лечит/); + assert.match(warning, /#367/); +}); + +test('превышенный бюджет описывается как превышение, а не как малый запас (#367)', () => { + assert.match(lowHeadroomWarning(-42), /превышен на 42 Б/); + assert.equal(lowHeadroomWarning(Number.NaN), null); +}); + +test('запас на момент рекалибровки выше порога тревоги (#367)', () => { + // Иначе рекалибровка была бы бессмысленной: гейт сразу же начал бы кричать. + assert.ok(INITIAL_VIEW_GZIP_BUDGET - 273_697 > LOW_HEADROOM_WARNING_BYTES); +});