rewrite 2026-09 and 2027 articles for reader-facing quality
Build and deploy / deploy (push) Successful in 18s

This commit is contained in:
2026-07-31 22:26:56 +03:00
parent 3bfc3f21c4
commit 440c8721dc
69 changed files with 4424 additions and 3533 deletions
+249 -177
View File
@@ -1,206 +1,278 @@
function escapeHtml(value) { return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;'); }
import { createHash } from 'node:crypto';
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); }
function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; }
function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
function bodyText(html) { return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
}
const REFERENCES = deepFreeze({
webpackCaching: { title: 'webpack — Caching guide (immutable source snapshot)', url: 'https://github.com/webpack/webpack.js.org/blob/b7fa07c852d24449e9445717c41395bdd2ca4784/src/content/guides/caching.mdx', version: 'webpack.js.org commit b7fa07c852d24449e9445717c41395bdd2ca4784, resolved at source cutoff 2026-07-31' },
githubCache: { title: 'actions/cache v4.2.3 — immutable README', url: 'https://github.com/actions/cache/blob/5a3ec84eff668545956fd18022155c47e93e2684/README.md', version: 'actions/cache v4.2.3 commit 5a3ec84eff668545956fd18022155c47e93e2684, pinned before source cutoff 2026-07-31' },
slsa: { title: 'SLSA v1.0 — Provenance', url: 'https://slsa.dev/spec/v1.0/provenance', version: 'SLSA specification v1.0, pinned before source cutoff 2026-07-31' },
});
function sources(entries) { return '<ul>' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>'; }).join('') + '</ul>'; }
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const FIXED_BUILD_CASES = deepFreeze({
'configuration-hand-off-v1': {
id: 'configuration-hand-off-v1', planDate: '2027-04', sourceCutoff: '2026-07-31',
leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' },
rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' },
comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' },
historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' },
requestedConclusion: 'synthetic-plan-hand-off',
boundary: 'Fixed in-memory planning literal. No repository, configuration, CI artifact, build, cache, bundle, timing, migration, release, tool, environment, clock, network, secret, telemetry, customer data or production system is read, created, changed or inferred.',
const REFERENCES = Object.freeze({
webpackCache: {
title: 'webpack 5 Guide: Caching',
url: 'https://webpack.js.org/guides/caching/',
version: 'webpack 5 guide, checked 31 July 2026',
},
'undated-future-scenario-v1': {
id: 'undated-future-scenario-v1', planDate: '', sourceCutoff: '2026-07-31',
leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' }, comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.',
webpackConfig: {
title: 'webpack 5 Configuration: cache',
url: 'https://webpack.js.org/configuration/cache/',
version: 'webpack 5 configuration reference, checked 31 July 2026',
},
'missing-comparable-condition-v1': {
id: 'missing-comparable-condition-v1', planDate: '2027-04', sourceCutoff: '2026-07-31',
leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' }, comparableCondition: { id: '', state: 'missing' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.',
esbuild: {
title: 'esbuild API: Metafile',
url: 'https://esbuild.github.io/api/#metafile',
version: 'esbuild API documentation, checked 31 July 2026',
},
'hidden-configuration-or-cache-assumption-v1': {
id: 'hidden-configuration-or-cache-assumption-v1', planDate: '2027-04', sourceCutoff: '2026-07-31',
leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'hidden', state: 'hidden-assumption' }, comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'assumed', bundleSize: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.',
vite: {
title: 'Vite Guide: Dependency Pre-Bundling',
url: 'https://vite.dev/guide/dep-pre-bundling.html',
version: 'Vite guide, checked 31 July 2026',
},
'disallowed-positive-result-v1': {
id: 'disallowed-positive-result-v1', planDate: '2027-04', sourceCutoff: '2026-07-31',
leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' }, comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' }, requestedConclusion: 'build-improved', boundary: 'Negative fixed literal only.',
sourceMap: {
title: 'MDN: SourceMap HTTP header',
url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/SourceMap',
version: 'MDN Web Docs, page modified 21 November 2025',
},
});
export function createFixedBuildCase(id = 'configuration-hand-off-v1') { const value = FIXED_BUILD_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; }
function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); }
export function assessFixedBuildPlan(input) {
if (!Object.values(FIXED_BUILD_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case');
if (input.planDate !== '2027-04' || input.sourceCutoff !== '2026-07-31') return stop('stop-undated-future-scenario-or-cutoff', 'april-2027-plan-and-july-2026-cutoff-are-required', 'name-2027-04-and-2026-07-31');
if (!input.leftConfiguration?.id || input.leftConfiguration.state !== 'named-synthetic' || !input.rightConfiguration?.id || input.rightConfiguration.state !== 'named-synthetic') return stop('stop-hidden-configuration-or-cache-assumption', 'each-side-must-be-a-named-synthetic-configuration', 'name-both-configuration-placeholders-without-claiming-real-config');
if (!input.comparableCondition?.id || input.comparableCondition.state !== 'not-collected') return stop('stop-missing-comparable-condition', 'a-named-comparable-condition-must-remain-not-collected', 'name-the-future-comparison-condition');
const artifacts = input.historicalArtifacts;
if (!artifacts || !['config', 'ci', 'buildTiming', 'cacheHit', 'bundleSize'].every((key) => artifacts[key] === 'not-collected')) return stop('stop-hidden-configuration-or-cache-assumption', 'historical-artifacts-and-cache-assumptions-must-remain-not-collected', 'do-not-infer-config-ci-cache-or-result');
if (input.requestedConclusion !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'future-plan-cannot-claim-a-better-build-or-migration-result', 'use-synthetic-plan-hand-off');
return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, leftConfiguration: deepFreeze(cloneFixed(input.leftConfiguration)), rightConfiguration: deepFreeze(cloneFixed(input.rightConfiguration)), comparableCondition: deepFreeze(cloneFixed(input.comparableCondition)), historicalArtifacts: deepFreeze(cloneFixed(artifacts)), productionEffect: 'not-attempted', nextAction: 'give-the-named-synthetic-comparison-question-to-a-future-evidence-owner' });
}
export function runFixedBuildFixture() {
const expected = [['configuration-hand-off-v1', 'synthetic-plan-hand-off'], ['undated-future-scenario-v1', 'stop-undated-future-scenario-or-cutoff'], ['missing-comparable-condition-v1', 'stop-missing-comparable-condition'], ['hidden-configuration-or-cache-assumption-v1', 'stop-hidden-configuration-or-cache-assumption'], ['disallowed-positive-result-v1', 'stop-disallowed-positive-result']];
const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessFixedBuildPlan(createFixedBuildCase(id)).status }));
const sample = createFixedBuildCase();
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.historicalArtifacts), checks: deepFreeze(checks) });
function sources(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const reference = REFERENCES[key];
return '<li><a href="' + reference.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(reference.title) + '</a> — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); }
const refs = [
{ key: 'webpackCaching', use: 'Официальный материал задаёт словарь output, content hash и cache как свойств конфигурации.', boundary: 'Не является данными о данном приложении, CI, артефакте, времени или размере.' },
{ key: 'githubCache', use: 'Официальный материал нужен только для различения cache key, restore и save как частей будущего условия.', boundary: 'Не подтверждает наличие workflow, cache hit, runner или результат запуска.' },
{ key: 'slsa', use: 'Спецификация помогает отделить вопрос о provenance артефакта от самого артефакта.', boundary: 'Не утверждает, что provenance создана, проверена или применена здесь.' },
];
export function compareBuildRuns(input) {
const baseline = input?.baseline;
const candidate = input?.candidate;
if (!baseline || !candidate || baseline.inputFingerprint !== candidate.inputFingerprint) return { comparable: false, reason: 'входы сборки различаются' };
const deltaMs = candidate.durationMs - baseline.durationMs;
const deltaBytes = candidate.outputBytes - baseline.outputBytes;
return { comparable: true, deltaMs, deltaBytes, faster: deltaMs < 0, smaller: deltaBytes < 0 };
}
const practice = revision({ slug: 'editorial-2027-04-practice-build-evolution', title: 'Эволюция frontend-сборки: сначала сравнить конфигурации', categories: ['Frontend', 'Инженерные практики'], cover: '/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', excerpt: 'План на апрель 2027: как ставить вопрос о frontend-сборке без легенды о более быстром новом инструменте.', readingMinutes: 22 }, [
p('Это план/сценарий на 2027-04 с source cutoff 2026-07-31, не отчёт о сделанной миграции frontend-сборки. Дорогая ошибка начинается с удобной фразы: «новая сборка лучше старой». В неё одним словом складывают другой тип приложения, другой вход, другую оптимизацию, иное разбиение output и новый способ доставки. Цена не в терминологии: команда тратит недели на перенос, а потом не может сказать, что именно стало объектом сравнения и где возникла разница.'),
p('Вторая стоимость появляется, когда будущую CI-картину дорисовывают задним числом: якобы был cache hit, артефакт оказался меньше, job закончилась быстрее или переход уже завершён. На редакторскую дату апрель 2027 ещё впереди; у P110 нет config, CI artifact, build timing, cache hit, bundle size, migration, tool release или результата. Единственный разрешённый положительный выход — <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>. Остальное в этом тексте — вопрос, а не наблюдение.'),
h2('Сравнивать не ярлыки, а действующие условия'),
p('Название сборщика слишком грубо для инженерного решения. Оно не показывает, какие entry points существуют, какие transforms включены, куда попадает CSS, какие внешние зависимости считаются частью output и какую цель преследует сборка. Два процесса с одинаковой надписью могут различаться сильнее, чем два процесса с разными названиями. Поэтому практический старт — назвать левую и правую configuration как synthetic placeholders, а не объявить один инструмент «устаревшим».'),
p('В этом материале <code>named-synthetic-baseline-configuration</code> и <code>named-synthetic-candidate-configuration</code> не являются файлами, package manifest или fragment реального pipeline. Это фиксированные in-memory literals, запрещающие подмену вопроса правдоподобными деталями. Они помогают сформулировать будущему owner задачу: восстановить provenance каждого условия, если для этого появится отдельный scope. Пока доказательств нет, даже слово «baseline» здесь не означает существующую сборку.'),
figure('/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', 'Русская временная схема: от именования двух synthetic конфигураций через условия сравнения к future evidence hand-off; красные остановки запрещают недатированный сценарий и вывод о преимуществе.', 'Схема описывает плановый порядок вопросов на 2027-04. Она не содержит конфигурации проекта, CI-лога, артефакта или измерения.'),
table('Что требуется сопоставить до разговора о замене', ['Плоскость', 'Допустимое имя в плане', 'Чего это не доказывает', 'Будущий вопрос'], [
['Вход', 'named synthetic application shape', 'что приложения одинаковы', 'какой route и input сравниваем'],
['Configuration', 'baseline / candidate placeholder', 'что известны flags и plugins', 'какие параметры имели provenance'],
['Output', 'artifact question', 'размер или состав файла', 'какой manifest и способ подсчёта нужен'],
['CI', 'comparable condition question', 'runner, очередь или timing', 'какая среда считается одинаковой'],
['Итог', 'synthetic-plan-hand-off', 'улучшение или завершение миграции', 'нужен ли authorised evidence scope'],
export function makeDependencyCacheKey(input) {
const payload = [input?.lockfile, input?.config, input?.runtime, input?.sourceDigest].map((value) => String(value ?? '')).join('\n');
return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}
export function summarizeBundleDiff(before, after) {
const names = new Set([...Object.keys(before?.inputs ?? {}), ...Object.keys(after?.inputs ?? {})]);
return [...names].map((name) => {
const previous = Number(before?.inputs?.[name]?.bytes ?? 0);
const current = Number(after?.inputs?.[name]?.bytes ?? 0);
return { name, before: previous, after: current, delta: current - previous };
}).filter((item) => item.delta !== 0).sort((left, right) => right.delta - left.delta);
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const practice = revision({
slug: 'editorial-2027-04-practice-build-evolution',
title: 'Сравнение frontend-сборок: зафиксировать вход до секунд',
categories: ['Frontend', 'Сборка'],
cover: '/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg',
excerpt: 'Как сравнить baseline и candidate так, чтобы разница времени и размера относилась к одному входу.',
readingMinutes: 15,
}, [
p('Проблема сравнения frontend-сборок обычно начинается с красивой цифры: новый инструмент собрал проект быстрее на локальной машине. Но у baseline мог быть холодный cache, другой lockfile, другой набор entry-файлов или неполный output. Цена неверного сравнения — перенос нескольких дней работы ради ускорения, которое исчезает при первом чистом запуске. Сначала фиксируем вход, потом измеряем секунды.'),
p('У сборки есть как минимум пять независимых частей: исходный граф, конфигурация, окружение, состояние cache и состав артефакта. Изменение любой части меняет смысл результата. Поэтому один замер duration не отвечает на вопрос «какой сборщик лучше». Он отвечает только на вопрос «сколько занял этот запуск при таких условиях». Таблица условий должна быть частью отчёта рядом с числом.'),
h2('Сравниваем не инструменты, а одинаковую работу'),
p('Baseline и candidate должны получить один commit, одинаковый lockfile, одинаковые entry и одинаковую команду выхода. Если одна сборка делает minify, а другая — development output, секунды нельзя складывать в общий вывод. Точно так же размер файла сравним только при одинаковом составе chunks, compression и правилах публикации. Иначе оптимизация одного слоя будет выглядеть как преимущество всего pipeline.'),
p('В webpack contenthash и deterministic module ids помогают сохранить стабильные имена и локализовать изменение, но это не делает два разных toolchain эквивалентными. В esbuild metafile можно посмотреть, какие input вошли в output и сколько байт они заняли. Оба документа подсказывают важный порядок: записать конфигурацию и состав результата, а не оставлять только итоговую цифру.'),
figure('/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', 'Последовательность сравнения frontend-сборки: один вход, одинаковая конфигурация, отдельный cache state, замер и состав артефакта.', 'Схема показывает, что секундомер включается после фиксации условий. Изменение входа возвращает сравнение к началу.'),
table('Минимальная карточка сравнения сборки', ['Слой', 'Зафиксировать', 'Пример различия', 'Почему это важно'], [
['Input', 'commit, lockfile, entry', 'feature branch вместо tag', 'меняется dependency graph'],
['Config', 'mode, flags, plugins', 'minify включён только у candidate', 'числа относятся к разным работам'],
['Environment', 'Node, OS, CPU', 'разные версии Node', 'разный runtime и cache'],
['Cache', 'cold/warm, directory, key', 'candidate повторный', 'ускорение может быть cache effect'],
['Output', 'chunks, source maps, compression', 'один bundle не учтён', 'размер и delivery несопоставимы'],
]),
h2('Учебное сравнение двух запусков'),
p('Функция ниже получает два уже записанных запуска. Она сначала проверяет inputFingerprint, затем считает разницу времени и размера. Значения учебного набора не являются результатом измерения проекта; они показывают формат карточки и ожидаемый результат. Если fingerprint различается, функция возвращает несопоставимость и не вычисляет победителя.'),
code(`import { compareBuildRuns } from './upgrade-2027-04.mjs';
const baseline = { inputFingerprint: 'src-42', durationMs: 420, outputBytes: 180000 };
const candidate = { inputFingerprint: 'src-42', durationMs: 380, outputBytes: 176000 };
const changedInput = { inputFingerprint: 'src-43', durationMs: 350, outputBytes: 174000 };
console.log(compareBuildRuns({ baseline, candidate }));
console.log(compareBuildRuns({ baseline, candidate: changedInput }));
// comparable: true, deltaMs: -40, deltaBytes: -4000
// comparable: false, reason: 'входы сборки различаются'`),
p('Первый результат говорит только о заданном учебном наборе: candidate быстрее на 40 мс и меньше на 4000 байт при одинаковом fingerprint. Второй запуск не превращается в преимущество, даже если его цифры лучше, потому что input другой. В реальном отчёте добавьте число повторов, медиану или p95, cold/warm state и полный список команд.'),
h2('Секунды требуют распределения, а не одного числа'),
p('Один запуск может попасть на сборку cache, фоновое обновление пакетов или шум CPU. Для локального сравнения отделяйте холодный и тёплый запуск, а в CI фиксируйте runner. Не смешивайте время установки зависимостей с временем bundling, если цель — сравнить именно сборку. Если пользовательский эффект — время до первого экрана, добавьте отдельный браузерный замер; build duration является только одной частью пути.'),
p('Размер также нужно разложить. Общий bundle вырос — это симптом, но причина может быть в новой библиотеке, переставшем работать tree-shaking, source map или duplicated dependency. Метafile и список chunks дают материал для следующего шага. Без него команда спорит о сборщике, хотя изменение находится в одном input-модуле.'),
h2('Действия по порядку'),
ol([
'Зафиксировать commit, lockfile, entry points, mode, flags, Node/OS и runner.',
'Очистить или явно сохранить cache state; отдельно записать cold и warm серии.',
'Запустить baseline и candidate на одинаковом наборе и сохранить raw output, duration и exit code.',
'Сравнить fingerprint, распределение времени, chunks и input bytes; несовпоставимый запуск исключить.',
'Сформулировать вывод с единицами и границей: «на этом наборе и runner»; затем проверить пользовательский путь отдельным замером.',
]),
h2('Configuration — это контракт, а не фон'),
p('Практическая карта должна отделять намерение от механизма. Намерение можно назвать без факта: например, будущая сторона сравнения должна отвечать на один и тот же вопрос о доставляемом клиентском output. Механизм требует списка условий: source revision, dependency resolution, target environment, transform chain, optimisation policy, asset treatment и правила очистки output. Пока ни одно из этих значений не собрано, их нельзя заменить общими словами «обычная production-сборка».'),
p('Это ограничение защищает и от симметричной ошибки. Не следует защищать прошлый путь только потому, что у него привычный config. Привычность не свидетельствует о неизменности входов, качества кэша или пригодности артефакта. Сравнение начинается с симметричного пробела знаний: обе стороны получают явные placeholders, а доказательства для обеих должны иметь одинаково читаемую provenance. Иначе новая сторона обязана раскрывать условия, а старая остаётся легендой.'),
h2('Почему cache нельзя считать результатом'),
p('Кэш может участвовать в будущем условии, но не должен становиться украшением текста. Cache key, restore source, момент сохранения, доступность workspace и холодное состояние способны менять ход job; из этого не следует ни конкретная семантика CI, ни будущий cache hit. Даже если инструментальная документация описывает caching, она описывает возможности и ограничения механизма, а не событие в чужом workflow. В P110 поле <code>cacheHit</code> оставлено <code>not-collected</code>.'),
p('Тот же принцип работает для artifact. Bundle size — это не свойство ярлыка и не обязательный признак прогресса. Нужно заранее согласовать, что считается output, как обходятся source map, сжатие, generated assets, code splitting и служебные файлы. Нельзя называть число, пока не определены единица, состав и момент сбора. Меньшее значение без договора способно скрыть вынесенный ресурс, изменённый путь или просто другую границу подсчёта.'),
h2('Runnable пример проверяет границу утверждения'),
code("import { createFixedBuildCase, assessFixedBuildPlan } from './upgrade-2027-04.mjs';\n\nconst question = createFixedBuildCase('configuration-hand-off-v1');\nconst handoff = assessFixedBuildPlan(question);\nconst planOnly = handoff.status === 'synthetic-plan-hand-off'\n && handoff.productionEffect === 'not-attempted';\nconsole.log(planOnly ? 'question-contract' : 'reject');\n// question-contract"),
p('Пример буквально запускается только над named fixed in-memory literal. Factory сначала делает JSON clone, затем рекурсивный deep freeze; caller не получает общую изменяемую ссылку. Evaluator принимает лишь JSON-равный известный literal и fail-closed отклоняет произвольный object. Код не читает конфигурацию, не вызывает CI, не создаёт артефакт, не запускает build и не смотрит часы, environment, сеть, secrets или telemetry. Поэтому его output — контроль честности hand-off, не synthetic benchmark.'),
h2('Как читать старую и новую сторону без ретроспективы'),
p('Когда будущая работа всё-таки получит доступ к материалам, полезно не превращать их в один огромный diff. Сначала читают договорённость о входе: откуда берётся исходный код, какие generated parts считаются входом, какая зависимость фиксируется и что не входит в вопрос. Потом отдельно смотрят преобразования и output contract. Такое разбиение не гарантирует, что сравнение возможно, зато быстро показывает, где одна из сторон описана лишь частично. До появления материалов это не инструкция на конкретные команды, а порядок будущего чтения.'),
p('Слово «новая» тоже нуждается в разборе. Оно может означать другой toolchain, но также другой layout проекта, новый способ разделять код, иной формат статических файлов или иной контракт с платформой доставки. Если эти изменения свернуть в один коммит и один вывод, невозможно определить, что именно следует откатывать при проблеме. Плановая карточка поэтому не требует доказать превосходство candidate. Она требует назвать, какие независимые изменения вообще могут существовать и какие из них нельзя скрывать за общим названием migration.'),
h2('Одинаковый вопрос не обязан иметь одинаковый ответ'),
p('Comparable condition не пытается стереть реальность двух подходов. Условия могут позволять разную структуру output, если именно эта разница входит в заранее названный вопрос. Недопустимо другое: одной стороне разрешают исключить ресурс из подсчёта, а другой нет; одной стороне дают подготовленное состояние, а для другой измеряют первое получение зависимостей. В таких случаях корректный conclusion не «кто-то проиграл», а «собранные записи не отвечают на один и тот же вопрос». Это ценный отрицательный результат будущего evidence scope.'),
p('Нужна также граница между сравнением для пользователя и сравнением для разработчика. Стабильность хеша, размер доставляемого набора, длительность локального rebuild и длительность CI job могут быть отдельными вопросами с разными input и ценой. Склеить их в слово «быстрее» — значит дать метрике чужого уровня решать архитектуру. P110 не выбирает, какой из вопросов важнее. Он требует, чтобы future owner не использовал сигнал одного уровня как доказательство результата на другом.'),
h2('Честная запись о том, чего нет'),
p('Иногда команда опасается, что поле <code>not-collected</code> выглядит как незавершённость. В плановом документе это наоборот полезная информация: она сообщает, что автор видел необходимость evidence, но не получил права или материала его собрать. Такое поле делает hand-off проверяемым: поздний читатель видит, что отсутствует именно config, CI artifact или cache observation, а не всё знание о задаче. Вымышленный ноль, примерный процент или безымянный «успех» были бы хуже, потому что создавали бы ложный объект для обсуждения.'),
p('Порог для перехода к данным следует назвать заранее: новый scope должен позволять читать конкретные inputs, сохранять их provenance и обсуждать последствия их публикации. Без этого даже аккуратная команда рискует вынести служебные детали в редакционную статью или, наоборот, заменить их обобщением, которое больше нельзя проверить. В текущем plan такого порога нет. Он заканчивается не началом работы с системой, а точной причиной, по которой следующему владельцу потребуется отдельное решение.'),
h2('Порядок действий для будущего владельца'),
ol(['Сохранить пару <code>planDate: 2027-04</code> и <code>sourceCutoff: 2026-07-31</code>; недатированный future scenario остановить.', 'Назвать две стороны как placeholders и не подставлять реальный config без отдельного разрешения.', 'Определить один named comparable condition: input, границу output и среду должны можно было прочитать у обеих сторон.', 'Запросить provenance historical config и CI artifact; до получения оставить все поля <code>not-collected</code>.', 'Отдельно решить, нужен ли build-запуск; если нужен, документировать методику до запуска, а не после числа.', 'Передать question contract future evidence owner без winner, migration claim и production effect.']),
h2('Четыре стоп-сигнала в практической карточке'),
p('Практическая карточка должна быть способна назвать свою причину остановки. <code>undated-future-scenario-v1</code> не даёт спрятать обещание за неопределённым «потом». <code>missing-comparable-condition-v1</code> не позволяет выдать два похожих запуска за одну проверку. <code>hidden-configuration-or-cache-assumption-v1</code> не заменяет неизвестный config или cache удобным default. Наконец, <code>disallowed-positive-result-v1</code> закрывает «сборка улучшена», пока нет отдельного evidence record. Эти labels не описывают инцидент; они делают видимой форму ошибки в плановом документе.'),
h2('Ограничения и следующий шаг'),
p('Этот practice не говорит, какой bundler выбрать, как переписать config, какую версию выпустить или какие шаги CI использовать. Он не оценивает совместимость, безопасность, лицензии, доступность plugin, стоимость runner и риск миграции. Официальные источники внизу дают язык для разговора о caching и provenance, но не поставляют ни одного local artifact. Их нельзя использовать как замену историческому config или записи запуска.'),
p('Следующий шаг — не «начать миграцию», а решить, есть ли полномочия собрать доказательства для названного condition. Если такие полномочия появятся, новый материал обязан явно отделить реальные inputs, метод и результат от этого апрельского плана. Если нет, корректно оставить hand-off незакрытым. Это менее эффектно, чем обещание эволюции, но дешевле, чем сделать архитектурное решение на основании названия инструмента.'),
], refs);
p('Учебная функция не запускает сборщики и не устраняет шум измерений. Fingerprint должен включать все входы, которые влияют на output; короткий hash без методики легко создаёт ложное совпадение. Разные плагины могут менять дерево зависимостей, а одинаковый размер не означает одинаковое выполнение в браузере. Нельзя обещать эффект для пользователей по двум локальным числам.'),
p('Следующий шаг — сделать небольшой JSON-отчёт с условиями, серией запусков и ссылкой на metafile. Проверьте его на одном изменении, которое добавляет известный модуль. Готовность сравнения определяется тем, что другой инженер может повторить вход, увидеть различие и понять, к какому слою относится изменение.'),
], [
{ key: 'webpackCache', use: 'Contenthash, deterministic module ids и условия сравнения взяты из руководства webpack 5.', boundary: 'Документация не даёт результатов конкретного проекта и не заменяет серию измерений на одном runner.' },
{ key: 'esbuild', use: 'Metafile используется как JSON-источник inputs, outputs и bytesInOutput для разбора артефакта.', boundary: 'Формат метафайла описывает состав сборки, но не пользовательскую скорость загрузки.' },
{ key: 'webpackConfig', use: 'Режимы memory/filesystem cache учитываются как отдельная часть условий запуска.', boundary: 'Страница не определяет cache state конкретного CI и не сравнивает инструменты между собой.' },
]);
const mechanism = revision({ slug: 'editorial-2027-04-mechanism-build-evolution', title: 'Эволюция frontend-сборки: сравнимость кэша, CI и артефакта', categories: ['Frontend', 'Сборка'], cover: '/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', excerpt: 'План на апрель 2027: механизм честного сравнения конфигураций без выдуманного времени сборки и cache hit.', readingMinutes: 23 }, [
p('План/сценарий на 2027-04 с source cutoff 2026-07-31 разбирает механизм сравнимости, а не удачную оптимизацию. Конкретная проблема: два build запускают с разными входами и называют разницу временем сборки. У одного есть предварительно подготовленный workspace, у другого меняется dependency graph; один публикует часть output, другой считает полный каталог. Цена такого сравнения — ложная причинность: следующий change закрепляет случайное преимущество и усложняет откат.'),
p('Вторая проблема — неразличённые уровни evidence. Config, CI trace, cache state, artifact manifest и итоговый вывод выглядят как один отчёт, хотя отвечают на разные вопросы. В будущем апреле 2027 они не собраны: нет run, CI artifact, timing, cache hit, bundle size, migration, release или production outcome. Позитивный результат здесь запрещён; evaluator может вернуть только <code>synthetic-plan-hand-off</code>, а productionEffect остаётся <code>not-attempted</code>.'),
h2('Сравнимость сначала строят, затем измеряют'),
p('Сравнимость — не обещание равных чисел и не настройка одного инструмента. Это явное правило, по которому future owner сможет решить, относятся ли две записи к одному вопросу. В минимальном виде правило связывает форму входа, границу config, способ получения зависимостей, состояние cache, runner context, состав output и метод фиксации artefact. Отсутствие одного звена не делает результат «приблизительно честным»: оно меняет смысл inference.'),
p('Особенно опасно оставлять скрытыми параметры, которые кажутся техническим шумом. Смена target, optimisation mode, lockfile, source map policy или restore key может изменить ход обработки раньше, чем появится финальный файл. Перечисление не означает, что такие значения существуют в P110; это список классов вопросов для будущей методики. Именно поэтому fixed literal не содержит правдоподобных flags. Он хранит только named placeholders и <code>not-collected</code>.'),
figure('/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', 'Русская матрица сопоставляет вход, configuration, cache, CI и artifact с требуемой future provenance; красная строка останавливает скрытое условие и положительный вывод.', 'Матрица показывает логическую полноту будущего сравнения. В ячейках нет реальных значений, timing, cache hit или размера bundle.'),
table('Матрица inference без измеренного результата', ['Слой', 'Что можно назвать сейчас', 'Какое evidence потребуется потом', 'Какой вывод пока запрещён'], [
['Input', 'synthetic application shape', 'зафиксированная форма входа', 'что приложения равны'],
['Configuration', 'два named placeholders', 'полная provenance параметров', 'что замена эквивалентна'],
['Cache', 'state not-collected', 'ключ, источник, холодный/тёплый режим', 'что был hit или выигрыш'],
['CI', 'comparable condition question', 'runner и порядок шагов', 'что timing сравним'],
['Artifact', 'artifact question', 'manifest и метод подсчёта', 'что bundle меньше или лучше'],
const mechanism = revision({
slug: 'editorial-2027-04-mechanism-build-evolution',
title: 'Кэш сборки: ключ определяет, что именно вы повторяете',
categories: ['Frontend', 'Сборка'],
cover: '/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg',
excerpt: 'Почему cache hit не равен корректному результату и какие входы должны участвовать в ключе оптимизации.',
readingMinutes: 16,
}, [
p('Проблема cache в сборке появляется в двух противоположных видах. Кэш всегда промахивается, и команда считает сборку медленной. Или кэш попадает, но после изменения lockfile, конфигурации или linked package остаётся старый output. Цена одинаково неприятна: либо CI тратит время на повторную работу, либо браузер и разработчик видят результат, который не соответствует исходникам.'),
p('Кэш не хранит абстрактное «состояние проекта». Он хранит результат конкретной функции от входов. Если ключ не включает lockfile, релевантную конфигурацию, runtime и исходный граф, система не может понять, что результат устарел. Если ключ включает лишние шумные поля, повторяемость исчезает. Поэтому механизм нужно обсуждать как контракт ключа, значения и правила invalidation.'),
h2('Что входит в ключ'),
p('Vite описывает несколько источников invalidation pre-bundling: lockfile, patches, релевантные поля конфигурации и <code>NODE_ENV</code>. Для linked dependency важен способ разрешения пакета и необходимость повторной оптимизации. Это пример хорошей инженерной границы: в документации названы не только кнопка «force», но и данные, по которым система принимает решение.'),
p('В webpack 5 cache может быть memory или filesystem. Эти режимы различаются временем жизни и стоимостью сериализации. Даже корректный ключ не спасёт, если два job используют одну директорию с разными правами или разными версиями Node. Состояние cache нужно видеть в отчёте: hit/miss, key, location, runtime и причина invalidation.'),
figure('/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', 'Матрица ключа frontend-кэша: lockfile, конфигурация, runtime и исходный digest образуют вход, а изменение любого слоя инвалидирует результат.', 'Диаграмма отделяет входы ключа от результата cache. Красная ветка означает, что неполный ключ нельзя считать доказательством корректного повтора.'),
table('Состав ключа и последствия пропуска', ['Вход', 'Что меняется', 'Признак устаревания', 'Контроль'], [
['Lockfile', 'версии и граф зависимостей', 'новый package version', 'hash lockfile'],
['Config', 'plugins, target, aliases', 'другой output', 'нормализованный config digest'],
['Runtime', 'Node, bundler, platform', 'разное поведение cache', 'версия и ABI окружения'],
['Source', 'код и linked package', 'изменённый модуль', 'commit/source digest'],
['Cache location', 'общая или локальная область', 'чужой результат', 'namespace и права'],
]),
h2('Кэш меняет эксперимент, а не только скорость'),
p('В разговоре о CI cache часто появляется как уже понятная оптимизация. Для inference этого недостаточно. Нужно отделить cache, который ускоряет повторное получение зависимостей, от промежуточных результатов transform и от сохранённого output. Их границы, ключи и invalidation могут быть различны. Без этой карты фраза «второй запуск быстрее» ничего не говорит: возможно, менялась не configuration, а история рабочего каталога или очередь runner.'),
p('Даже корректно названный cache condition не позволяет перейти к будущему результату. Сначала owner должен решить, сравниваются холодные состояния, повторные состояния или оба режима; затем описать одинаковый порядок подготовки для двух сторон. Это не приглашение выполнить команды в данном draft. Это требование к будущему доказательству. P110 не создаёт key, directory, workflow или результат restore, поэтому evaluator stop-веткой закрывает любое значение, отличное от <code>not-collected</code>.'),
h2('CI timing не существует без границы времени'),
p('Время job нельзя считать цельным свойством сборщика. В него могут войти ожидание, checkout, сеть, dependency install, transform, архивирование, публикация, cleanup и отчётность. Полезный будущий протокол обязан назвать начало и конец отсчёта, а также перечислить, какие внешние фазы исключены. Иначе одна сторона получает полное wall-clock время, а другая — только узкий участок, и цифры становятся риторикой.'),
p('Здесь нельзя улучшить текст выдуманным диапазоном или synthetic milliseconds. Synthetic shape допустима только как форма поля, не как измеренное значение. Она может сказать «будущий timing evidence должен иметь границу», но не «ожидаемая сборка займёт X». Такое различение важно: правдоподобная цифра переживёт draft и станет ложным требованием. Отсутствующее число менее опасно, потому что не имитирует наблюдение.'),
h2('Artifact сравнивают по составу и provenance'),
p('Artifact — не синоним одного zip, каталога или bundle. Прежде чем говорить о размере, будущей проверке надо выбрать состав: входят ли HTML, JavaScript, CSS, map, статические ресурсы, generated metadata и compression representation. Затем необходим способ связать состав с configuration и input. SLSA provenance полезна как терминология происхождения, но сама спецификация не сообщает, что у P110 существовал или проверялся какой-либо attestation.'),
p('Из этого следует предел inference. Одинаковый filename не подтверждает одинаковое содержимое; разный hash не сообщает причину; один manifest без условия не доказывает преимущество. Будущий owner может получить материал, который опровергает исходную гипотезу или показывает, что вопросы несопоставимы. Это хороший результат evidence scope, но он не является результатом текущего плана. Current output остаётся передачей необходимого question contract.'),
h2('Почему один diff не образует контроль'),
p('Configuration diff полезен как указатель, но не как контроль эксперимента. Он показывает, что текст параметров отличается, однако не гарантирует одинаковую зависимостную базу, порядок подготовки, окружение runner или состав потребляемого output. Обратное также верно: небольшой diff может скрыть существенную разницу за внешним default либо за изменённой версией зависимости. Поэтому будущему reviewer потребуется не только сравнение файлов, а карта того, откуда каждая существенная настройка пришла и на какую часть процесса могла воздействовать.'),
p('Эта карта не обязана быть огромной. Её ценность в том, что она даёт каждому утверждению адрес: вход относится к source boundary, cache — к состоянию повторного использования, CI — к выполнению workflow, artifact — к выходу. Если адрес отсутствует, утверждение нельзя усилить знаком процента или словом «стабильнее». В P110 намеренно нет таких процентов. Неопределённость остаётся явной, пока будущая работа не свяжет её с конкретным разрешённым evidence.'),
h2('Граница между измерением и интерпретацией'),
p('Даже реальное значение не говорит само за себя. Метод может сообщить длительность выбранного участка, но не объяснить, почему она изменилась; manifest может показать состав, но не указать, какой элемент приемлем для пользователя; cache event может быть записан, но не сообщить, повторится ли он в другом контексте. Будущий документ обязан отделить raw observation от interpretation и перечислить альтернативные объяснения. Этот separation нужен до того, как команда выберет язык итогового сообщения.'),
p('Для апрельского сценария предел ещё строже: отсутствуют и raw observation, и метод. Поэтому нельзя написать даже осторожное «вероятно». Вероятность без population, условий и полученного значения быстро становится скрытым прогнозом. Mechanism article оставляет только структуру будущего вывода: сначала доказать, что стороны отвечают на одно и то же условие, затем прочитать материал, и лишь после этого решить, имеет ли смысл объяснение.'),
h2('Что делать с несопоставимостью'),
p('Несопоставимость не следует маскировать нормализацией, которую выбрали после результата. Если один путь требует другого deployment contract или иной ownership boundary, возможно, это два независимых инженерных решения, а не кандидаты одной гонки. Тогда future owner может подготовить два отдельных decision records либо отказаться от количественного сравнения. Такой выход не означает провал сборки или плана; он сообщает, что первоначальная форма вопроса была слишком широкой.'),
p('Честный stop экономит и время, и доверие. Он не запрещает продолжить исследование, но запрещает превращать накопленные полуфакты в готовую рекомендацию. Именно это делает fixed validator частью редакционной дисциплины: он не знает, как собрать artefact, зато не даёт модели случайно принять отсутствие comparable condition за основание писать о прогрессе.'),
h2('Безопасный literal вместо скрытой методики'),
code("import { createFixedBuildCase, assessFixedBuildPlan } from './upgrade-2027-04.mjs';\n\nconst plan = createFixedBuildCase('missing-comparable-condition-v1');\nconsole.log(assessFixedBuildPlan(plan));\n// { status: 'stop-missing-comparable-condition', productionEffect: 'not-attempted', ... }"),
p('Этот runnable example доказывает только реакцию validator на известный fixed literal. JSON clone отделяет объект caller от шаблона, deep freeze защищает вложенные historicalArtifacts, а JSON-equality не позволяет тихо добавить cache assumption. Fail-closed важен не как стилистика: если условие не названо, система возвращает stop вместо попытки выбрать «разумное» default. Модуль не запускает package manager, build, cache restore, CI job или анализ файлов.'),
h2('Последовательность построения доказательства'),
ol(['Назвать один вопрос, который будет одинаков для обеих сторон, и отметить его как synthetic до authorised scope.', 'Зафиксировать две configuration boundaries без инструментальных версий, flags и файлов, которых нет в evidence.', 'Сформулировать cache states и временную границу как будущие условия, не как уже известную методику.', 'Определить состав artifact и provenance chain, не приписывая manifest или attestation текущему плану.', 'Собрать evidence только после отдельного разрешения и сохранить raw inputs рядом с методом.', 'Разрешить итоговый inference только новому owner; P110 передаёт ему вопрос, а не результат.']),
h2('Полная карта отказов evaluator'),
p('В механизме важен не только accepted branch. Case <code>undated-future-scenario-v1</code> останавливает документ до обсуждения условий; <code>missing-comparable-condition-v1</code> показывает, что отсутствие контрольной рамки не восполняется похожестью; <code>hidden-configuration-or-cache-assumption-v1</code> не допускает молчаливой методики; <code>disallowed-positive-result-v1</code> не пропускает conclusion о пользе. Такой набор не моделирует запуск и не говорит о будущем tool release. Он проверяет, что логическая форма hand-off не превратится в report после одного удачного слова.'),
h2('Ограничения механизма и следующий шаг'),
p('Механизм не заменяет performance budget, security review, reproducibility policy или договорённость команды о стоимости CI. Он не утверждает, что cache полезен, что один bundle важнее другого или что временная граница уже выбрана. Документация webpack и GitHub описывает свои механизмы, но не даёт универсальную метрику для неизвестного приложения. Тем более она не подтверждает факты про апрель 2027.'),
p('Следующий шаг — future evidence owner решает, достаточно ли конкретен comparable condition для отдельной работы. Если нет, честный исход — расширить или закрыть вопрос без запуска. Если да, он создаёт новый dated artefact с provenance, методикой и ограничениями, не переписывая этот draft как retrospective. Именно это разделение удерживает эволюцию сборки от подмены: сначала сравнимость, затем данные, после этого — возможно — вывод.'),
], refs);
h2('Локальный ключ с прозрачными входами'),
p('В примере используется SHA-256 и фиксированный порядок четырёх значений. Результат — короткий идентификатор, который можно поместить в имя cache namespace. Учебный код не знает, какие поля нужны конкретному bundler: он показывает главное правило — каждый источник изменения должен иметь явное место в ключе, а delimiter не должен позволять склеить разные наборы в одну строку.'),
code(`import { makeDependencyCacheKey } from './upgrade-2027-04.mjs';
const field = revision({ slug: 'editorial-2027-04-field-build-evolution', title: 'Эволюция frontend-сборки: передача синтетического evidence hand-off', categories: ['Frontend', 'Практика команд'], cover: '/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', excerpt: 'План на апрель 2027: как передать вопрос о сборке следующему владельцу, не превратив план в фиктивный build report.', readingMinutes: 21 }, [
p('Этот field-текст — явный план/сценарий на 2027-04 с source cutoff 2026-07-31. Его проблема не в отсутствии ещё одного dashboard, а в опасной передаче контекста: будущая команда получает фразу «сборка стала лучше», но не получает исходный config, условие CI или состав артефакта. Цена — повторная работа и ложная операционная память. Следующий инженер вынужден либо верить легенде, либо воспроизводить решение с нуля.'),
p('Вторая цена возникает, когда незаполненную карточку называют полевым отчётом. Для P110 не существуют собранные historical config/CI artifacts, build timing, cache hit, bundle size, migration, tool release или результат. Очередь требует уметь мыслить их shape, но не выдумывать content. Поэтому hand-off не способен завершиться report, approval или production change: допустим только <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>.'),
h2('Передавать вопрос вместе с запретами'),
p('Хороший hand-off короче плохого отчёта, потому что не маскирует отсутствующие данные. Он содержит дату, source cutoff, два named synthetic configuration placeholders, named comparable condition и список классов evidence, которые остаются <code>not-collected</code>. Рядом нужны запреты: не считать cache hit известным, не называть artifact маленьким, не объявлять миграцию завершённой. Эти отрицательные условия важны: без них новый владелец может принять форму карточки за заполненный факт.'),
p('Такая передача не перекладывает техническое мышление на следующего человека. Она оставляет ему проверяемую границу решения. Owner видит, что вопрос относится к сравнению configuration, а не к рейтингу инструментов; что CI и cache — потенциальные условия, а не фон; что historical artifacts запрошены, но не получены. Он может выбрать другой путь: уточнить condition, запросить разрешение, отказаться от измерения или доказать, что сравнение не нужно. Никакой из этих вариантов не задним числом делает P110 report.'),
figure('/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', 'Русская петля передачи evidence: synthetic вопрос проходит дату, две конфигурации и comparable condition, затем либо останавливается при скрытом предположении, либо передаётся future owner без production effect.', 'Петля показывает границы hand-off. Она не изображает реальную CI задачу, config, cache restore, артефакт или итог измерения.'),
table('Минимальная карточка передачи без фальшивого отчёта', ['Поле', 'Допустимое состояние сейчас', 'Кому адресован вопрос', 'Недопустимая подмена'], [
['Дата и cutoff', '2027-04 / 2026-07-31', 'редактору и future owner', 'недатированный сценарий'],
['Стороны', 'named synthetic placeholders', 'владельцу сравнения', 'реальные flags без provenance'],
['Condition', 'named, not-collected', 'автору методики', 'похожая, но другая среда'],
['Исторический evidence', 'config/CI/timing/cache/artifact not-collected', 'разрешённому сборщику данных', 'вымышленный build report'],
['Вывод', 'synthetic-plan-hand-off', 'следующему решающему', 'improved, migrated, released'],
const base = {
lockfile: 'lock-v1',
config: 'target=es2022;minify=true',
runtime: 'node-24',
sourceDigest: 'src-001',
};
console.log(makeDependencyCacheKey(base));
console.log(makeDependencyCacheKey({ ...base, lockfile: 'lock-v2' }));
// 16-символьный key
// другой key после изменения lockfile`),
p('Ожидаемый результат важнее конкретных hex-значений: замена lockfile меняет key, а повтор одного объекта даёт тот же key. Перед реальным использованием нормализуйте конфигурацию и закрепите кодировку. Не добавляйте timestamp, случайный UUID или абсолютный путь, если они не являются частью результата: такие поля превратят каждый запуск в промах.'),
h2('Cache hit не измеряет качество результата'),
p('Попадание в cache говорит, что найден результат с совпавшим ключом. Оно не говорит, что ключ полный, output опубликован, source map соответствует bundle или браузер получил свежий файл. Для dev-server это особенно заметно: Vite может жёстко кэшировать resolved dependency requests, а локальная правка linked package потребует явного re-bundle. В отчёте отделяйте cache state от проверки содержимого.'),
p('Промах тоже не всегда ошибка. Изменение lockfile должно инвалидировать dependency cache. Слишком агрессивное reuse иногда дешевле, чем сложная логика восстановления, если сборка короткая. Решение зависит от стоимости работы и риска устаревшего результата. Назовите обе величины: секунды cache miss и ущерб от неправильного hit.'),
h2('Действия по порядку'),
ol([
'Выписать все поля, которые меняют dependency graph, transform, target или состав output.',
'Нормализовать значения и собрать deterministic key; исключить случайные и абсолютные поля.',
'Проверить hit и miss на изменении lockfile, конфигурации, runtime и одного исходного модуля.',
'Сохранить key, cache location, режим cold/warm и причину invalidation в техническом отчёте.',
'Проверить содержимое output и source map после hit; одного совпавшего key недостаточно.',
]),
h2('Synthetic shape полезна, если не похожа на данные'),
p('Синтетическая форма должна быть достаточно конкретной, чтобы validator мог отклонить ошибку, и достаточно пустой, чтобы не напоминать секретный production snapshot. Поэтому literal называет classes: <code>config</code>, <code>ci</code>, <code>buildTiming</code>, <code>cacheHit</code>, <code>bundleSize</code>. У каждого значение <code>not-collected</code>. Названия не сообщают формат файла, имя job, ключ cache, число байтов или миллисекунды. Это не неполнота реализации, а сознательная граница редакционного scope.'),
p('Не нужно лечить эту границу более реалистичными placeholders вроде вымышленных runner image, branch или JSON manifest. Они выглядят безобидно, но создают фиктивную provenance: читатель начинает искать их в системе или переносить в настоящий pipeline. Если future owner действительно получит artefact, он обязан зафиксировать его происхождение в новом разрешённом документе. Апрельский текст может дать ему вопрос «какой artefact нужен», но не адрес, имя или содержимое этого artefact.'),
h2('Negative branches экономят время команды'),
p('Fail-closed в hand-off означает, что неполный input не становится допустимым за счёт догадки. Недатированный future scenario, отсутствующий comparable condition, hidden configuration или assumed cache приводят к отдельному stop status. Это не бюрократическая кара. Каждая ветка показывает следующему owner, какую именно неопределённость нельзя перескочить. Она лучше общей фразы «нужно больше данных», потому что не приглашает заполнить пробел интуицией.'),
p('Отдельная ветка защищает от положительного результата. Фраза <code>build-improved</code> остановлена не потому, что улучшение невозможно, а потому, что не существует ни метода, ни данных, ни времени, к которым её можно привязать. В перспективе evidence может показать как выгодный, так и нейтральный или отрицательный исход. Хорошая карточка передаёт право получить любой из этих ответов; плохая заранее требует подтвердить историю про прогресс.'),
h2('Пример не имитирует CI'),
code("import { createFixedBuildCase, assessFixedBuildPlan } from './upgrade-2027-04.mjs';\n\nconst handOff = assessFixedBuildPlan(createFixedBuildCase());\nconsole.log(JSON.stringify({ next: handOff.nextAction, effect: handOff.productionEffect }));\n// {\"next\":\"give-the-named-synthetic-comparison-question-to-a-future-evidence-owner\",\"effect\":\"not-attempted\"}"),
p('Этот example запускается в Node как чистая проверка exports. JSON clone не позволяет одному запуску испортить fixed literal для другого, recursive deep freeze блокирует изменения во вложенных объектах, а evaluator принимает только известные cases. У него нет чтения disk, env, clock, network, CI API, cache storage или build process. Следовательно, строка в консоли не является логом pipeline; она подтверждает лишь то, что документ умеет отказать при неправильной форме hand-off.'),
h2('Кому принадлежит следующий вывод'),
p('Редактор статьи не должен незаметно стать владельцем будущего CI-эксперимента. У него нет ни контекста доступа, ни права выбирать исходные данные, ни возможности договориться о рисках публикации. Поэтому полезно адресовать hand-off не абстрактной «команде», а роли future evidence owner: человеку или группе, которым новый scope явно даст право собрать и интерпретировать материалы. Эта роль может не появиться вовсе, и план не становится от этого ошибочным.'),
p('Передаваемая карточка должна помочь этой роли задать первые вопросы: какой decision она обслуживает, какие данные допустимо читать, где будет храниться raw evidence, кто проверит comparison condition и какой итог считается честным, включая отказ от измерения. Ответы не вписываются заранее в значения literal. Иначе технический долг hand-off превращается в организационный долг: owner формально назначен, но уже связан неподтверждёнными предпосылками.'),
h2('Как не превратить документ в очередь задач'),
p('У подобного сценария есть соблазн закончиться списком операций: «сделать workflow», «настроить cache», «снять bundle», «опубликовать отчёт». В P110 эти глаголы опасны, потому что подразумевают существующие интеграции и согласованные права. Более точный порядок сохраняет форму вопроса: запросить authorisation, определить allowed evidence, сформировать метод, после этого собрать данные. Пока первый шаг не выполнен, следующие остаются не работой, а возможными обязанностями другого scope.'),
p('Это ограничение не снижает практическую ценность field-статьи. Напротив, получатель видит, что ему не нужно верить в прошлый запуск и не требуется защищать заранее обещанное улучшение. Он может начать с проверки того, существует ли вообще decision, ради которого стоит платить за доказательство. Если такого decision нет, лучший hand-off — закрыть сценарий без migration story и сохранить отсутствие production effect как честный итог.'),
h2('Слова, которые стоит остановить до публикации'),
p('Перед передачей полезно вычеркнуть конструкции «получили cache hit», «сократили build», «уменьшили bundle», «перешли на новую сборку» и «подтвердили эффект». В реальном отчёте эти слова могут быть точными при наличии датированных материалов. Здесь они делают из будущего сценария вымышленное прошлое. Безопасные формулировки скучнее: «требуется определить», «не собрано», «нужна provenance», «может быть передано». Но именно они не дают следующему читателю спутать план с данными.'),
p('Тон также важен для практики команды. Не нужно оправдывать отсутствие результата длинными оговорками или обвинять будущий owner в задержке. Достаточно назвать границу и следующее решение. Такой текст не делает неопределённость красивой; он делает её видимой и ограниченной. Если позже появится проверяемый материал, он будет сильнее именно потому, что не вырос из текста, где conclusion уже был объявлен до evidence.'),
h2('Порядок передачи будущему evidence owner'),
ol(['Проверить temporal boundary: только 2027-04 и cutoff 2026-07-31 допускают будущий сценарий.', 'Проверить, что левая и правая стороны названы как synthetic configuration, а не как реальные конфиги.', 'Остановить карточку, если comparable condition не имеет имени или скрыта за словом «аналогично».', 'Оставить каждый класс historical evidence в <code>not-collected</code> до нового authorisation и provenance.', 'Не допускать слова о hit, timing, size, migration или release как о свершившихся событиях.', 'Передать named question следующему owner вместе с причиной, почему productionEffect не пытались получить.']),
h2('Ограничения и следующий шаг'),
p('Полевой hand-off не создаёт ticket, job, access, owner assignment, CI workflow или хранилище артефактов. Он не оценивает, кто будет оплачивать runner, допустим ли доступ к историческому config, можно ли публиковать manifest и нужен ли компании сам эксперимент. Официальные источники помогают не путать caching и provenance с фактами, но не снимают эти организационные вопросы. У текста нет скрытого обещания, что данные станут доступны в апреле 2027.'),
p('Следующий шаг прост и ограничен: будущий owner принимает или отклоняет evidence question в новом scope. При принятии он обязан сформировать отдельный dated record с реальными inputs, методикой, raw artifacts и выводом, включая возможность отсутствия улучшения. При отклонении hand-off остаётся корректным незавершённым планом. В обоих случаях P110 сохраняет полезное: он не превращает красивую историю об эволюции frontend-сборки в ложное свидетельство.'),
], refs);
p('Функция делает hash строки и не знает о том, как bundler нормализует config, разрешает symlink или читает lockfile. Разный порядок полей может дать лишний miss, а забытый plugin — неправильный hit. Кэш файловой системы зависит от прав, диска и версии сериализации. Нельзя переносить key между toolchain без проверки семантики входов.'),
p('Следующий шаг — добавить тест invalidation для каждого входа и отдельный тест на source map и output после cache hit. В CI выводите первые символы key, но не секреты и содержимое приватного source. Если причина miss неизвестна, сначала расширьте диагностику ключа, а не включайте постоянный <code>force</code>.'),
], [
{ key: 'vite', use: 'Источники invalidation Vite dependency pre-bundling и поведение force/linked dependency.', boundary: 'Руководство относится к Vite dependency optimizer и не является универсальным контрактом любого bundler.' },
{ key: 'webpackConfig', use: 'Режимы memory и filesystem cache webpack 5 используются для различения времени жизни результата.', boundary: 'Справочник не знает cache directory, права и runtime конкретного CI.' },
{ key: 'webpackCache', use: 'Стабильные output names и детерминированные ids используются как пример отделения результата от случайности.', boundary: 'Руководство не доказывает корректность неполного cache key в другом проекте.' },
]);
const field = revision({
slug: 'editorial-2027-04-field-build-evolution',
title: 'Разбор bundle: найти источник роста без гадания',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg',
excerpt: 'Полевой маршрут от выросшего JavaScript-файла к конкретному input, chunk и проверке source map.',
readingMinutes: 15,
}, [
p('Проблема bundle-анализа начинается с общей цифры: JavaScript-артефакт вырос на 180 КБ. Если сразу удалить большую библиотеку, можно убрать не ту причину. Рост мог появиться из-за новой точки входа, отключённого tree-shaking, дубликата зависимости или включённой source map. Цена неверного действия — регресс функциональности и новый спор о том, какая оптимизация вообще дала эффект.'),
p('Полевой разбор должен свести изменение output к input. Для этого сохраняем два metafile или отчёта сборки, нормализуем пути и считаем delta bytes для каждого входа. Затем проверяем, в какой output попал input и почему. Только после этого выбираем действие: убрать импорт, изменить split, проверить dependency version или оставить рост как осознанную стоимость.'),
h2('Сначала ищем изменение, а не виновника'),
p('esbuild metafile содержит inputs и outputs, включая количество байт input, попавших в output. Это удобнее, чем смотреть только на размер файла: можно увидеть, что добавилось, исчезло или изменилось. Но bytes в metafile — размер вклада в артефакт, а не обязательно размер передачи по сети и не время выполнения. Для пользовательской скорости нужны отдельные browser measurements.'),
p('Source map помогает связать сжатый или преобразованный JavaScript с исходным модулем. При этом карта сама может быть большой и не должна случайно попасть в production response. HTTP <code>SourceMap</code> header и annotation имеют правила, по которым DevTools находит карту. Проверяйте, что путь доступен только в нужной среде и что карта соответствует именно этому bundle.'),
figure('/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', 'Цикл разбора роста bundle: сравнить metafile, найти input с delta, проверить chunk и source map, затем повторить сборку.', 'Схема ведёт от общего симптома к конкретному input. Изменение кода выполняется после проверки состава артефакта, а не по размеру одного файла.'),
table('Матрица разбора роста bundle', ['Наблюдение', 'Где искать', 'Проверка', 'Действие'], [
['Новый большой input', 'metafile.inputs', 'кто импортирует модуль', 'разделить или удалить импорт'],
['Старый input вырос', 'delta bytes и chunk', 'tree-shaking и настройки minify', 'проверить export и plugin'],
['Дубликат зависимости', 'пути разных версий', 'resolver и lockfile', 'свести версию или alias'],
['Source map выросла', 'output и response', 'включён ли dev artifact', 'разделить delivery и debug'],
['Metafile стабилен', 'browser resource timing', 'gzip/brotli, cache, transfer', 'измерить пользовательский путь'],
]),
h2('Учебный diff двух metafile'),
p('Функция принимает минимальный фрагмент esbuild-подобного JSON: имя input и его bytes. Она объединяет имена из двух сборок, считает delta и сортирует рост сверху. Так инженер получает список конкретных файлов для code search. Данные ниже учебные; в рабочем отчёте рядом с diff сохраняйте commit, command, target и output name.'),
code(`import { summarizeBundleDiff } from './upgrade-2027-04.mjs';
const before = { inputs: {
'src/app.js': { bytes: 12000 },
'src/table.js': { bytes: 8000 },
'node_modules/date.js': { bytes: 5000 },
} };
const after = { inputs: {
'src/app.js': { bytes: 12000 },
'src/table.js': { bytes: 11000 },
'node_modules/date.js': { bytes: 5000 },
'node_modules/chart.js': { bytes: 42000 },
} };
console.log(summarizeBundleDiff(before, after));
// chart.js +42000; table.js +3000`),
p('Результат даёт два адреса: новая chart.js и выросшая table.js. Это ещё не решение. Для chart.js нужно найти entry/import и проверить split; для table.js — посмотреть, почему изменился export или transform. Если общий output вырос меньше суммы input delta из-за компрессии и tree-shaking, это нормально: diff направляет исследование, но не заменяет финальный artefact и браузерный замер.'),
h2('От bytes к пользовательскому эффекту'),
p('Большой input может не попасть в первый экран, а маленький модуль — блокировать критический маршрут. Поэтому после статического diff смотрите chunk graph и network resource timing. Transfer size зависит от compression и cache; decoded body size — другой показатель. Cross-origin ресурс может вернуть нулевой transferSize без Timing-Allow-Origin. Эти ограничения нужно написать рядом с числом, иначе bytes начинают выглядеть как latency.'),
p('В source map ищите исходный модуль, но проверяйте соответствие commit. Старая карта при новом bundle создаёт ложную навигацию в DevTools и увеличивает время разбора следующей ошибки. Для production обычно ограничивают доступ к картам или публикуют их в отдельном хранилище с контролем прав. Это уже часть delivery contract, а не косметика сборки.'),
h2('Действия по порядку'),
ol([
'Сохранить два metafile для одинакового input и убедиться, что output names и mode сопоставимы.',
'Посчитать delta по inputs и outputs, затем найти import path, chunk и owner изменившегося модуля.',
'Проверить lockfile, duplicate versions, tree-shaking, plugin transform и minify настройки.',
'Сверить source map с commit и отдельно проверить, не попала ли debug-карта в пользовательскую доставку.',
'Измерить transfer, decoded size, cache и время загрузки критического маршрута; только потом оценивать эффект оптимизации.',
]),
h2('Ограничения и следующий шаг'),
p('Metafile показывает структуру конкретного bundler и не знает о поведении браузера, compression, CDN и cache. Delta bytes не является p95 и не гарантирует изменение FCP. Source map может быть недоступна или намеренно скрыта, поэтому связь с исходником иногда требует другого артефакта. Статический diff также не видит работу runtime и dynamic import до его выполнения.'),
p('Следующий шаг — добавить автоматический budget по критическим output и список разрешённых изменений. Для каждого превышения pipeline должен печатать top inputs, commit и команду воспроизведения. Тогда фраза «bundle вырос» превращается в короткий проверяемый маршрут: какой файл добавился, в какой chunk попал и какой пользовательский ресурс изменился.'),
], [
{ key: 'esbuild', use: 'Metafile JSON и поля inputs/outputs/bytes используются для вычисления diff состава bundle.', boundary: 'Документация предупреждает, что текстовый analyze предназначен людям; JSON не измеряет браузерную доставку.' },
{ key: 'sourceMap', use: 'Правило SourceMap header и связь DevTools с исходным кодом используются для проверки карты.', boundary: 'MDN не подтверждает доступность карты и не измеряет размер или скорость конкретного ресурса.' },
{ key: 'webpackCache', use: 'Contenthash и стабильная структура output используются как контекст повторяемого артефакта.', boundary: 'Руководство webpack не описывает esbuild metafile и пользовательский performance budget.' },
]);
export const revisions = Object.freeze([practice, mechanism, field]);
export const revisions = deepFreeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() {
const fixture = runFixedBuildFixture();
const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|потрат|повторн)/i.test(text.slice(0, 1100)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /2027-04/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); });
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
const checks = revisions.map((item) => {
const body = bodyText(item.contentHtml);
return body.length >= 5000 && body.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body);
});
const compared = compareBuildRuns({ baseline: { inputFingerprint: 'a', durationMs: 10, outputBytes: 20 }, candidate: { inputFingerprint: 'a', durationMs: 9, outputBytes: 19 } });
const cacheChanged = makeDependencyCacheKey({ lockfile: 'a', config: 'b', runtime: 'c', sourceDigest: 'd' }) !== makeDependencyCacheKey({ lockfile: 'z', config: 'b', runtime: 'c', sourceDigest: 'd' });
const diffFound = summarizeBundleDiff({ inputs: { a: { bytes: 1 } } }, { inputs: { a: { bytes: 2 } } }).length === 1;
const fixtureOk = compared.comparable && compared.faster && cacheChanged && diffFound;
return Object.freeze({ passed: checks.filter(Boolean).length + (fixtureOk ? 1 : 0), total: checks.length + 1, accepted: checks.every(Boolean) && fixtureOk, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; }
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');