diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 3fa3fae9..d7b25763 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -44,24 +44,57 @@ export const INITIAL_VIEW_GZIP_BUDGET = 300_000; export const LOW_HEADROOM_WARNING_BYTES = 15_000; /** - * Числового храповика здесь больше нет, и это решение, а не упущение (#429). + * Потолок графа с полосой — работающий храповик (#438), не разовый (#429). * - * До #429 функция бросала при `initialViewGzipBytes >= 291 046` — «граф не стал - * больше, чем был на момент закрытия #423». На бете 1.71.0 запас до этого - * порога составлял пятнадцать байт, на момент правки — сто четыре. Пятнадцать - * байт gzip меньше одной строки локали: первый же посторонний коммит получил бы - * красный CI с сообщением про копирайт формы поддержки, к которому не имеет - * отношения. + * История в двух шагах. #429 снял `SUPPORT_LAZY_INITIAL_BASELINE_BYTES = 291046` + * — порог, привязанный к критерию приёмки #423, с пятнадцатью байтами запаса и + * сообщением про копирайт формы поддержки. Снять было правильно: он обвинял бы + * не ту задачу. Но снят он оказался ровно на том релизе, где сработал бы: * - * Гейт, обвиняющий не ту задачу, — худший вид гейта: его выключают, не - * разбираясь, и вместе с ним выключают проверку владения графом, которая как - * раз долговечна. Поэтому число снято, а проверка владения осталась. + * v1.71.0-beta.1 291 031 снятый порог 291 046 beta.2 291 069 * - * Что именно было снято по существу: «граф не вырос» — это критерий приёмки на - * момент задачи, а не свойство продукта. Свойство продукта охраняет общий - * бюджет (`INITIAL_VIEW_GZIP_BUDGET`) и предупреждение о запасе; они судят - * размер целиком и не привязаны к чужому issue. + * То есть рост произошёл внутри той же беты, уже после снятия, и заметить его + * стало нечем: единственным оставшимся сигналом был `lowHeadroomWarning`, а он + * горит постоянно — третий аудит подряд, и третий раз без реакции. + * + * Почему полоса, а не точное число. Метрика — gzip, и она не монотонна по + * исходнику: на beta.2 initial-чанк стал МЕНЬШЕ на 344 сырых байта и при этом + * на 40 байт больше в сжатом виде (переименования минификатора и контекст + * сжатия). Точный храповик по gzip краснел бы на коммитах, которые код + * сокращают, — та же лотерея, о которой предупреждает комментарий к бюджету + * выше: красным станет последний пушнувший, а не тот, кто вырастил граф. + * + * Поэтому правило двустороннее и с полосой, как у ядер (#425): значение обязано + * лежать в `[ceiling - band, ceiling]`. Рост выше потолка — отказ с числом: + * поднимайте потолок в том же коммите с объяснением либо выносите код в + * ленивый граф (#367). Падение ниже полосы — тоже отказ: незафиксированный + * выигрыш отыгрывается обратно молча, и это ровно то, что случилось с запасом + * бюджета (26 КБ → 8.3 КБ за сутки). + * + * Полоса 2 000 Б — примерно одна средняя фича в initial-графе и заметно больше + * колебаний метрики от переименований (наблюдаемые единицы-десятки байт). До + * стены 300 000 остаётся четыре явных шага вместо двухсот незаметных. + * + * Потолок поставлен так, чтобы измеренный факт лежал ближе к середине полосы: + * 291 069 при потолке 292 000 — это 931 Б до отказа сверху и 1 069 Б снизу. + * Иначе одна из сторон срабатывает на десятках байт, то есть на шуме: правило + * должно требовать решения от РЕАЛЬНОГО изменения, а не от переименования. */ +export const INITIAL_VIEW_GZIP_CEILING = 292_000; +export const INITIAL_VIEW_CEILING_BAND = 2_000; + +/** + * До какого потолка предупреждение о запасе погашено владельцем. + * + * `null` — не погашено, и это текущее состояние: запас 8 931 Б при пороге + * 15 000, долг живёт в #367. Предупреждение, которое нельзя погасить, читается + * как выключенное, поэтому его можно погасить — одной строкой здесь, со + * ссылкой на решение. Признание привязано к ЗНАЧЕНИЮ потолка: как только + * потолок поднимут, оно перестаёт покрывать, и вопрос возвращается ровно + * тогда, когда граф снова вырос. + */ +export const LOW_HEADROOM_ACKNOWLEDGED_CEILING = null; + export const SUPPORT_LAZY_MARKERS = [ 'Contact details (email/tg/WhatsApp), optional.', 'Контакт для связи (email/tg/WhatsApp), необязательно.', @@ -95,15 +128,60 @@ export function assertSupportBundleOwnership( } } -/** Тревога о запасе: `null`, пока его хватает. */ -export function lowHeadroomWarning(headroom, threshold = LOW_HEADROOM_WARNING_BYTES) { +/** + * Потолок графа: `null`, пока значение внутри полосы. + * + * Возвращает находку, а не бросает: вызывающий решает, что с ней делать, и + * тест может проверить обе стороны, не ловя исключение. + */ +export function initialViewCeilingViolation(bytes, { + ceiling = INITIAL_VIEW_GZIP_CEILING, + band = INITIAL_VIEW_CEILING_BAND, +} = {}) { + if (!Number.isFinite(bytes)) { + return { kind: 'missing', text: 'initial View graph не измерен — потолок проверить нечем' }; + } + if (bytes > ceiling) { + return { + kind: 'grew', + over: bytes - ceiling, + text: `initial View graph ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.` + + ' Поднимите потолок в этом же коммите, объяснив рост, либо вынесите код в ленивый' + + ' граф (#367). Молча расти этому графу больше нечем.', + }; + } + 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; +} + +/** Тревога о запасе: `null`, пока его хватает или пока долг признан. */ +export function lowHeadroomWarning(headroom, { + threshold = LOW_HEADROOM_WARNING_BYTES, + ceiling = INITIAL_VIEW_GZIP_CEILING, + acknowledgedCeiling = LOW_HEADROOM_ACKNOWLEDGED_CEILING, +} = {}) { if (!Number.isFinite(headroom) || headroom >= threshold) return null; if (headroom < 0) { return `бюджет превышен на ${-headroom} Б — гейт уже красный`; } + // Превышение бюджета не гасится признанием: там уже отказ, а не тревога. + if (Number.isFinite(acknowledgedCeiling) && ceiling <= acknowledgedCeiling) return null; + const stale = Number.isFinite(acknowledgedCeiling) + ? ` Признание долга покрывает потолок до ${acknowledgedCeiling} Б, а он уже ${ceiling} Б —` + + ' граф вырос с тех пор, вопрос вернулся.' + : ' Погасить можно решением владельца: LOW_HEADROOM_ACKNOWLEDGED_CEILING в' + + ' scripts/bundle-budget.mjs. Пока не погашено — это долг (#367).'; return `запас бюджета ${headroom} Б, меньше порога ${threshold} Б:` + ' следующая средняя фича упрётся в стену. Рекалибровка это не лечит —' - + ' смотрите ленивые графы (#367)'; + + ' смотрите ленивые графы (#367).' + stale; } export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET) { @@ -143,10 +221,13 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8')); const result = assertBundleBudget(manifest); assertSupportBundleOwnership(manifest); + const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes); + if (ceiling) throw new Error(ceiling.text); const headroom = INITIAL_VIEW_GZIP_BUDGET - result.initialViewGzipBytes; const lines = [ `initial View: ${result.initialViewGzipBytes} B gzip` - + ` (budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`, + + ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B ±${INITIAL_VIEW_CEILING_BAND},` + + ` budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`, `lazy editor: ${result.lazyEditorGzipBytes} B gzip`, `lazy locale: ${result.lazyLocaleGzipBytes} B gzip`, ]; @@ -161,6 +242,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { `| граф | gzip | бюджет | запас |`, `|---|---|---|---|`, `| initial View | ${result.initialViewGzipBytes} B | ${INITIAL_VIEW_GZIP_BUDGET} B | ${headroom} B |`, + `| потолок (#438) | ${INITIAL_VIEW_GZIP_CEILING} B | полоса ${INITIAL_VIEW_CEILING_BAND} B |` + + ` ${INITIAL_VIEW_GZIP_CEILING - result.initialViewGzipBytes} B до потолка |`, ...(warning ? ['', `> ${warning}`] : []), '', ].join('\n')); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 171ca211..d3fbd8ff 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -4997,6 +4997,19 @@ const MUTANT_DEFINITIONS = [ replace: ' stable = True\n', }], }, + { + id: 'initial-view-ceiling-unplugged', + guard: 'node --test test/bundle-assets.test.mjs', + because: 'the initial-View ceiling must be ENFORCED by the CLI, not merely declared: this ' + + 'gate has been silently removable twice (#429 took the old ratchet out on the very ' + + 'release where it would have fired, and the growth of #438 went unnoticed for a beta)', + patches: [{ + file: 'scripts/bundle-budget.mjs', + find: ' const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes);\n' + + ' if (ceiling) throw new Error(ceiling.text);\n', + replace: '', + }], + }, { id: 'decor-oversize-hides-the-downscale-action', guard: 'node --test test/decor-image-upload.test.mjs', diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index ea4ff07f..1e547801 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -8,8 +10,11 @@ import { buildBundleManifest, buildFingerprintPlugin, editorRuntimeRetryUrlPlugin, } from '../scripts/bundle-manifest.mjs'; import { - INITIAL_VIEW_GZIP_BUDGET, LOW_HEADROOM_WARNING_BYTES, - assertBundleBudget, assertSupportBundleOwnership, lowHeadroomWarning, + INITIAL_VIEW_CEILING_BAND, INITIAL_VIEW_GZIP_BUDGET, INITIAL_VIEW_GZIP_CEILING, + LOW_HEADROOM_ACKNOWLEDGED_CEILING, LOW_HEADROOM_WARNING_BYTES, + SUPPORT_LAZY_MARKERS, + assertBundleBudget, assertSupportBundleOwnership, initialViewCeilingViolation, + lowHeadroomWarning, } from '../scripts/bundle-budget.mjs'; import { compareBundleTrees, sha256Bytes, verifyBundleTree } from '../scripts/bundle-tree.mjs'; import { @@ -290,13 +295,153 @@ test('#429 проверка владения не судит размер гра `размер ${initialViewGzipBytes} не должен влиять на проверку владения`, ); } - // Размер по-прежнему охраняется — но общим бюджетом, а не чужим номером. + // Размер по-прежнему охраняется — но потолком и бюджетом, а не чужим + // номером. Число берётся из поставляемого манифеста: захардкоженный запас + // в тесте выглядел бы измерением, не будучи им (#438). + const shipped = JSON.parse( + readFileSync(new URL('../dist/houseplan-assets.json', import.meta.url), 'utf8'), + ).initialViewGzipBytes; assert.match( - lowHeadroomWarning(9058) || '', - /запас бюджета 9058 Б/, - 'предупреждение о запасе остаётся единственным честным сигналом о размере', + lowHeadroomWarning(INITIAL_VIEW_GZIP_BUDGET - shipped) || '', + new RegExp(`запас бюджета ${INITIAL_VIEW_GZIP_BUDGET - shipped} Б`), + 'предупреждение о запасе остаётся честным сигналом о размере', ); } finally { rmSync(temp, { recursive: true, force: true }); } }); + + +// --- #438: потолок графа с полосой ------------------------------------------ + +test('#438 поставляемый граф лежит внутри полосы потолка', () => { + // Гейт живёт и здесь, а не только в `npm run bundle:budget`: манифест + // закоммичен, значит проверка стоит ровно там, где её увидит любой прогон + // тестов. Рост, который не заметили в бете.2, краснел бы на этом тесте. + const manifest = JSON.parse( + readFileSync(new URL('../dist/houseplan-assets.json', import.meta.url), 'utf8'), + ); + const violation = initialViewCeilingViolation(manifest.initialViewGzipBytes); + assert.equal(violation, null, violation?.text); +}); + +test('#438 рост выше потолка — отказ с числом и с указанием, что делать', () => { + const grew = initialViewCeilingViolation(292_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.match(grew.text, /Поднимите потолок в этом же коммите/); + assert.match(grew.text, /#367/, 'у отказа обязан быть выход, а не только запрет'); + // Ровно на потолке — ещё не рост: граница включительная, иначе гейт краснеет + // на равенстве и разбираться идут не с графом, а с гейтом. + assert.equal(initialViewCeilingViolation(292_000, { ceiling: 292_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, + 'нижняя граница полосы тоже включительная'); + assert.equal(initialViewCeilingViolation(NaN).kind, 'missing'); + assert.equal(initialViewCeilingViolation(undefined).kind, 'missing'); +}); + +test('#438 полоса шире наблюдаемого шума метрики', () => { + // gzip не монотонен по исходнику: на beta.2 initial-чанк стал меньше на 344 + // сырых байта и на 40 байт больше в сжатом виде. Полоса обязана быть заметно + // шире таких колебаний, иначе гейт краснеет на коммитах, сокращающих код, — + // и красным станет последний пушнувший, а не тот, кто вырастил граф. + assert.ok(INITIAL_VIEW_CEILING_BAND >= 1_000, + 'полоса меньше килобайта превращает потолок в лотерею'); + // И потолок обязан оставаться под общим бюджетом: иначе он ничего не значит. + assert.ok(INITIAL_VIEW_GZIP_CEILING < INITIAL_VIEW_GZIP_BUDGET); + // Факт лежит не у края полосы: до отказа есть место в обе стороны. + const shipped = JSON.parse( + readFileSync(new URL('../dist/houseplan-assets.json', import.meta.url), 'utf8'), + ).initialViewGzipBytes; + assert.ok(INITIAL_VIEW_GZIP_CEILING - shipped > 500, 'сверху меньше 500 Б — это шум'); + assert.ok(shipped - (INITIAL_VIEW_GZIP_CEILING - INITIAL_VIEW_CEILING_BAND) > 500, + 'снизу меньше 500 Б — гейт потребует опустить потолок из-за шума'); +}); + +test('#438 предупреждение о запасе можно погасить, и повышение потолка его возвращает', () => { + const headroom = LOW_HEADROOM_WARNING_BYTES - 1; + // Не погашено — горит и говорит, чем гасится. Это текущее состояние. + assert.equal(LOW_HEADROOM_ACKNOWLEDGED_CEILING, null, + 'долг #367 пока не признан — состояние честное, а не забытое'); + assert.match(lowHeadroomWarning(headroom, { acknowledgedCeiling: null }), + /Погасить можно решением владельца/); + // Признано ровно для этого потолка — молчит. + assert.equal( + lowHeadroomWarning(headroom, { ceiling: 292_000, acknowledgedCeiling: 292_000 }), null, + ); + // Потолок подняли — признание перестало покрывать, вопрос вернулся. + assert.match( + lowHeadroomWarning(headroom, { ceiling: 294_000, acknowledgedCeiling: 292_000 }), + /покрывает потолок до 292000 Б, а он уже 294000 Б/, + ); + // Превышение бюджета признанием не гасится: там уже отказ, а не тревога. + assert.match( + lowHeadroomWarning(-10, { ceiling: 292_000, acknowledgedCeiling: 292_000 }), + /бюджет превышен на 10 Б/, + ); +}); + +/** + * Прогон настоящего CLI бюджета в подставном дереве (#438). + * + * Статическая проверка «в main вызывается initialViewCeilingViolation» была бы + * тем самым циклическим доказательством, за которое #430 снял циклический тест + * гарда benchmark. Поэтому здесь запуск: манифест кладётся на диск, скрипт + * исполняется процессом, читается код возврата. Снятый из main вызов потолка + * при этом краснеет — проверено (без этого теста мутация проходила молча). + */ +const runBudgetCli = (initialViewGzipBytes) => { + const dir = mkdtempSync(join(tmpdir(), 'houseplan-budget-cli-')); + try { + mkdirSync(join(dir, 'dist')); + writeFileSync(join(dir, 'dist/initial.js'), 'view graph without support copy'); + writeFileSync(join(dir, 'dist/editor.js'), SUPPORT_LAZY_MARKERS.join('\n')); + writeFileSync(join(dir, 'dist/locale.js'), 'lazy locale dictionary'); + writeFileSync(join(dir, 'dist/houseplan-assets.json'), JSON.stringify({ + schema: 1, + files: [], + initialViewFiles: ['initial.js'], + initialViewGzipBytes, + lazyEditorFiles: ['editor.js'], + lazyEditorGzipBytes: 1000, + lazyLocaleFiles: ['locale.js'], + lazyLocaleGzipBytes: 100, + lazyOnboardingFiles: [], + })); + const script = fileURLToPath(new URL('../scripts/bundle-budget.mjs', import.meta.url)); + const run = spawnSync(process.execPath, [script], { cwd: dir, encoding: 'utf8' }); + return { status: run.status, output: `${run.stdout || ''}${run.stderr || ''}` }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}; + +test('#438 CLI действительно применяет потолок, а не только объявляет его', () => { + const inside = runBudgetCli(INITIAL_VIEW_GZIP_CEILING - 500); + assert.equal(inside.status, 0, inside.output); + + const grew = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + 1); + assert.equal(grew.status, 1, grew.output); + assert.match(grew.output, /выше потолка/); + + const shrank = runBudgetCli(INITIAL_VIEW_GZIP_CEILING - INITIAL_VIEW_CEILING_BAND - 1); + assert.equal(shrank.status, 1, shrank.output); + assert.match(shrank.output, /Опустите потолок/); + + // И общий бюджет остаётся внешней стеной: он выше потолка, значит красным + // становится потолок, а не бюджет — но и бюджет обязан уметь падать. + const overBudget = runBudgetCli(INITIAL_VIEW_GZIP_BUDGET + 1); + assert.equal(overBudget.status, 1, overBudget.output); + assert.match(overBudget.output, /exceeds 300000 B budget/); +});