Files
progcode/web/scripts/upgrade-2027-12.mjs
T
2026-07-31 22:26:56 +03:00

302 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const escapeHtml = (value) => 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 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 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]*$/, ''));
}
const REFERENCES = deepFreeze({
csp: { title: 'W3C Content Security Policy Level 3', url: 'https://www.w3.org/TR/CSP3/', version: 'W3C, CSP Level 3 Working Draft, опубликованная редакция спецификации' },
hsts: { title: 'RFC 6797 — HTTP Strict Transport Security', url: 'https://www.rfc-editor.org/rfc/rfc6797.html', version: 'IETF, ноябрь 2012 года, RFC 6797, Standards Track' },
lcp: { title: 'W3C Largest Contentful Paint', url: 'https://www.w3.org/TR/largest-contentful-paint/', version: 'W3C Web Performance Working Group, Working Draft, страница проверена 31 июля 2026 года' },
performance: { title: 'W3C Performance Timeline', url: 'https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/', version: 'W3C Candidate Recommendation Draft, 21 мая 2025 года' },
webVitals: { title: 'Web Vitals — web.dev', url: 'https://web.dev/articles/vitals', version: 'Google web.dev, опубликовано 4 мая 2020 года, обновлено 31 октября 2024 года' },
nistIncident: { title: 'NIST SP 800-61 Revision 2 — Computer Security Incident Handling Guide', url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf', version: 'NIST, revision 2, май 2012 года, Special Publication 800-61' },
rfc2119: { title: 'RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels', url: 'https://www.rfc-editor.org/rfc/rfc2119.html', version: 'IETF, март 1997 года, RFC 2119' },
});
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>`;
}
export function buildSecurityHeaders({ nonce, hstsMaxAge = 31536000 } = {}) {
if (typeof nonce !== 'string' || nonce.length < 16 || !Number.isInteger(hstsMaxAge) || hstsMaxAge < 0) return { ok: false, reason: 'security-header-input-invalid' };
return {
ok: true,
headers: {
'Content-Security-Policy': `default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'self'`,
'Strict-Transport-Security': `max-age=${hstsMaxAge}; includeSubDomains`,
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
};
}
export function classifyWebVitals({ lcpMs, inpMs, cls }) {
if (![lcpMs, inpMs, cls].every((value) => Number.isFinite(value) && value >= 0)) return { ok: false, reason: 'vital-input-invalid' };
const lcp = lcpMs <= 2500 ? 'good' : lcpMs <= 4000 ? 'needs-improvement' : 'poor';
const inp = inpMs <= 200 ? 'good' : inpMs <= 500 ? 'needs-improvement' : 'poor';
const layout = cls <= 0.1 ? 'good' : cls <= 0.25 ? 'needs-improvement' : 'poor';
const overall = [lcp, inp, layout].includes('poor') ? 'poor' : [lcp, inp, layout].includes('needs-improvement') ? 'needs-improvement' : 'good';
return { ok: true, lcp, inp, layout, overall };
}
export function validateRunbookCard(card) {
const required = ['symptom', 'scope', 'precondition', 'action', 'rollback', 'verification'];
if (!card || typeof card !== 'object') return { ok: false, reason: 'runbook-must-be-object' };
const missing = required.filter((key) => typeof card[key] !== 'string' || card[key].trim().length < 10);
if (missing.length > 0) return { ok: false, reason: 'runbook-fields-missing', missing };
if (!/rollback|откат|вернуть/i.test(card.rollback)) return { ok: false, reason: 'rollback-must-be-explicit' };
if (!/провер|verify|метрик|threshold/i.test(card.verification)) return { ok: false, reason: 'verification-must-be-observable' };
return { ok: true, order: required };
}
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 securityRefs = [
{ key: 'csp', use: 'Описывает Content-Security-Policy, директивы источников, nonce и режим Report-Only/Enforce.', boundary: 'Working Draft может изменяться; конкретную поддержку браузеров и собственные inline-скрипты нужно проверить отдельно.' },
{ key: 'hsts', use: 'Определяет Strict-Transport-Security и поведение браузера после получения политики по HTTPS.', boundary: 'Не исправляет mixed content, сертификат, redirect до первого безопасного ответа и настройки API-клиента.' },
];
const practice = revision({
slug: 'editorial-2027-12-practice-author-manifesto',
title: 'Security headers: CSP и HSTS без иллюзии защиты',
categories: ['Security', 'Web'],
cover: '/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg',
excerpt: 'Практический разбор Content-Security-Policy и HSTS: что именно ограничивают заголовки, как вводить nonce и где нужна отдельная проверка.',
readingMinutes: 15,
}, [
p('Проблема проявляется после XSS или downgrade-атаки: приложение отдаёт страницу по HTTPS, но разрешает любой inline script или продолжает открываться по HTTP. Цена ошибки — выполнение чужого кода в контексте origin, утечка токена и ложное ощущение, что один security header закрыл весь риск.'),
p('Причина — копировать длинную строку заголовка без модели ресурсов. CSP ограничивает, откуда браузер может загружать или выполнять ресурсы; HSTS заставляет браузер обращаться к домену по HTTPS после получения политики. Ни один из заголовков не исправляет серверный XSS, плохой сертификат или секрет, уже попавший в JavaScript.'),
h2('CSP начинается с карты ресурсов'),
p('Сначала перечислите, что странице действительно нужно: собственные scripts, стили, изображения, API и frame. Затем для каждого типа выберите минимальную директиву. <code>default-src</code> задаёт fallback, но не объясняет исключения; <code>script-src</code> управляет JavaScript, <code>object-src none</code> закрывает старый plugin-механизм, а <code>base-uri self</code> не даёт странице незаметно изменить базовый URL.'),
p('Nonce применяют к конкретному inline script, когда убрать inline-код сразу нельзя. Значение должно быть непредсказуемым и новым для ответа; статическая строка превращается в разрешение для любого, кто её узнал. Шаблон должен вставить nonce и в CSP, и в атрибут script, а логирование полного значения создаёт лишний риск.'),
table('Директива и её граница', ['Директива', 'Что ограничивает', 'Частая ошибка', 'Проверка'], [
['default-src', 'fallback для типов ресурсов', 'считать её полной политикой', 'проверить исключения по типам'],
['script-src', 'источники JavaScript и nonce', 'добавить unsafe-inline навсегда', 'найти все inline и third-party scripts'],
['object-src', 'plugin/object загрузку', 'оставить широкое значение', 'поставить none, если object не нужен'],
['base-uri', 'изменение базового URL', 'забыть директиву', 'ограничить self или отключить'],
['report-only', 'наблюдение нарушений', 'принять отчёт за блокировку', 'после анализа перейти к enforce'],
]),
h2('HSTS имеет момент включения'),
p('HSTS действует после того, как браузер получил заголовок через доверенный HTTPS-ответ. Он не защищает самый первый HTTP-переход, если домен ещё не известен браузеру; для этого существует отдельная политика preload с собственными требованиями и риском. <code>includeSubDomains</code> распространяет правило на поддомены, поэтому включать его можно только после проверки всех нужных имён.'),
p('Большой max-age нельзя трактовать как кнопку «попробовать». Если поддомен ещё не умеет HTTPS, браузер перестанет подключаться к нему по HTTP на весь период. Перед расширением политики проверьте redirect, сертификаты, mixed content и административные endpoint. Безопасность заголовка включает и возможность восстановить ошибочную конфигурацию.'),
figure('/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg', 'Граф security headers: карта ресурсов формирует CSP, HTTPS-ответ включает HSTS, а неизвестный script или неподготовленный поддомен останавливает расширение политики.', 'Схема показывает два независимых слоя. CSP управляет ресурсами страницы, HSTS — схемой соединения; один заголовок не заменяет другой.'),
h2('Runnable-пример: собрать минимальные headers'),
p('Функция принимает nonce длиной не менее 16 символов и max-age HSTS. Она возвращает набор заголовков или явную ошибку входа. Это учебный генератор: он не устанавливает response headers и не проверяет ваш шаблонизатор. Ожидаемый результат показывает, что CSP содержит nonce, а HSTS — числовой срок и includeSubDomains.'),
code(`import { buildSecurityHeaders } from './upgrade-2027-12.mjs';
const result = buildSecurityHeaders({
nonce: '7c2f1b8e9a4d6f0c',
hstsMaxAge: 31536000,
});
console.log(result.ok);
console.log(result.headers['Content-Security-Policy']);
console.log(result.headers['Strict-Transport-Security']);
// true
// default-src 'self'; script-src 'self' 'nonce-7c2f1b8e9a4d6f0c'; object-src 'none'; base-uri 'self'
// max-age=31536000; includeSubDomains`),
h2('Порядок внедрения'),
ol([
'Соберите список ресурсов страницы и найдите inline scripts, eval, object, iframe, внешние CDN и API. Не начинайте с копирования чужой политики.',
'Включите CSP в Report-Only и соберите нарушения по URL, директиве и типу ресурса. Отчёт не блокирует выполнение, поэтому не называйте его исправлением.',
'Уберите лишние источники, замените inline-код на файл или nonce и добавьте тест на отсутствие unsafe-inline и unsafe-eval без обоснования.',
'Переведите CSP в enforce на одной проверяемой странице и сравните ошибки загрузки с разрешённым списком.',
'Включите HSTS только после проверки HTTPS для основного домена и поддоменов. Начните с контролируемого max-age, затем расширяйте.',
'Проверьте rollback конфигурации: изменение header должно быть версионируемым, а не ручной правкой в одном proxy.',
]),
h2('Почему nonce не лечит XSS'),
p('Nonce разрешает конкретные скрипты, но не санитизирует пользовательский HTML и не исправляет небезопасный sink. Если приложение вставляет строку в <code>innerHTML</code>, разрешённый bootstrap может помочь атакующему выполнить уже загруженный код. CSP снижает последствия и ловит часть нарушений, но контекстное экранирование и безопасные API остаются обязательными.'),
p('Диагностические отчёты CSP тоже требуют осторожности: URL может содержать чувствительные параметры, а third-party ресурс может присылать много шума. В отчёте храните только нужные поля, ограничивайте доступ и отделяйте нарушение политики от подтверждённой уязвимости. Заголовок — контроль браузера, не verdict о безопасности приложения.'),
h2('Ограничения и следующий шаг'),
p('Генератор не проверяет браузерную поддержку, CDN, service worker, iframe-политику, сертификаты и preload. CSP Level 3 — рабочая редакция W3C, поэтому конкретную совместимость и статус директивы нужно сверять с целевыми браузерами. RFC 6797 не защищает первый небезопасный переход и не заменяет TLS.'),
p('Следующий шаг — взять одну страницу, собрать Report-Only нарушения, закрыть источники по одному и добавить автоматический тест на заголовки. После enforce отдельно проверьте HSTS на каждом поддомене и храните процедуру возврата конфигурации рядом с кодом, чтобы ошибка не требовала ручной импровизации.'),
], securityRefs);
const performanceRefs = [
{ key: 'lcp', use: 'Определяет Largest Contentful Paint и API наблюдения за крупнейшей отрисовкой, чтобы измерение имело точный объект.', boundary: 'Working Draft может изменяться; LCP не измеряет всю скорость страницы, отзывчивость или стабильность layout.' },
{ key: 'performance', use: 'Задаёт Performance Timeline и доступ к измеряемым entry, на которых строятся браузерные наблюдения.', boundary: 'Не задаёт ваши пороги, backend-агрегацию, sampling и причинность медленной страницы.' },
{ key: 'webVitals', use: 'Фиксирует рекомендованные пороги LCP, INP и CLS и правило p75 по сегментам для практического triage.', boundary: 'Это guidance, а не гарантия UX и не доказательство причины конкретной регрессии.' },
];
const mechanism = revision({
slug: 'editorial-2027-12-mechanism-author-manifesto',
title: 'Web performance budget: LCP, INP и CLS без одной магической метрики',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg',
excerpt: 'Как читать пользовательские web-метрики: разделить LCP, INP и CLS, выбрать пороги и не менять код по одному красивому числу.',
readingMinutes: 16,
}, [
p('Проблема начинается с отчёта «страница медленная». Цена такого диагноза — оптимизировать не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить первый paint, оставив клик заблокированным длинной задачей. Одно среднее число скрывает разные виды задержки.'),
p('Причина — смешать LCP, INP и CLS в общий score без определения окна и percentile. LCP отвечает за крупнейший видимый элемент, INP — за отзывчивость взаимодействий, CLS — за неожиданные сдвиги. У каждой метрики свой источник, порог и способ исправления. Сначала нужно понять измерение, затем выбирать действие.'),
h2('Три метрики — три пользовательских вопроса'),
p('LCP показывает, когда крупнейший контентный элемент стал видимым в пределах загрузки. Большой LCP часто связан с TTFB, критическим CSS, размером изображения или шрифтом. INP оценивает задержку взаимодействий и указывает на работу main thread после ввода. CLS суммирует неожиданные сдвиги layout, например из-за изображения без размеров или поздней рекламы.'),
p('Метрика не говорит, где находится причина. Плохой LCP может быть следствием сервера, сети или браузера; плохой INP — длинной задачи, стороннего скрипта или тяжёлого обработчика; плохой CLS — отсутствующего места под контент. Поэтому budget должен включать и измерение, и диагностический разрез: URL, устройство, connection, release и элемент.'),
table('Как читать Core Web Vitals', ['Метрика', 'Вопрос пользователя', 'Хорошая граница', 'Первый разрез'], [
['LCP', 'крупнейший контент появился?', '≤ 2500 ms', 'TTFB, resource, element'],
['INP', 'интерфейс ответил после ввода?', '≤ 200 ms', 'long task, handler, device'],
['CLS', 'страница не сдвинулась?', '≤ 0.1', 'element, font, reserved space'],
['Percentile', 'у какой доли пользователей проблема?', 'p75 по сегменту', 'country, device, release'],
['Budget', 'какой порог блокирует выпуск?', 'явно в CI/monitoring', 'threshold + owner action'],
]),
h2('Budget не равен среднему'),
p('Среднее значение сглаживает хвост и может выглядеть здоровым при плохом опыте части пользователей. Для пользовательских web-метрик часто нужен p75 в определённом сегменте, но даже percentile не спасает от смешения мобильных и десктопных данных. Порог должен быть привязан к одинаковому URL, устройству, версии и периоду наблюдения.'),
p('Лабораторный Lighthouse и field data отвечают на разные вопросы. Лаборатория воспроизводима и удобна для CI, но не содержит реального разнообразия сети. Field data показывает пользователей, но зависит от sampling, трафика и состава сегмента. Решение об оптимизации подтверждайте обоими видами данных или честно называйте, какой слой измерен.'),
figure('/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg', 'Матрица web-performance: LCP, INP и CLS имеют разные объекты и пороги; lab и field measurement нельзя складывать в один безымянный score.', 'Схема связывает метрику с вопросом и первым диагностическим разрезом. Улучшение одного показателя не доказывает исправление остальных.'),
h2('Runnable-пример: классифицировать три числа'),
p('Функция принимает миллисекунды LCP и INP, а также значение CLS. Она возвращает статус каждой метрики и общий худший статус. Это учебный классификатор, не реализация браузерного PerformanceObserver: реальные значения нужно собирать из API и агрегировать по сегментам. Входы ниже показывают пороги без округления.'),
code(`import { classifyWebVitals } from './upgrade-2027-12.mjs';
const release = classifyWebVitals({
lcpMs: 2180,
inpMs: 240,
cls: 0.08,
});
const invalid = classifyWebVitals({
lcpMs: 1200,
inpMs: -1,
cls: 0.02,
});
console.log(release.overall, release.lcp, release.inp, release.layout);
console.log(invalid.ok, invalid.reason);
// needs-improvement good needs-improvement good
// false vital-input-invalid`),
h2('Порядок поиска причины'),
ol([
'Определите URL, сегмент, percentile и окно измерения. Не сравнивайте p75 мобильного трафика со средним для всех устройств.',
'Для плохого LCP найдите element и разделите TTFB, загрузку ресурса и отрисовку. Для INP найдите interaction и long task; для CLS — shifted element.',
'Сформулируйте один budget на релиз и один diagnostic signal. Не блокируйте сборку по метрике, которую CI не может воспроизвести.',
'Проверьте lab fixture и field distribution отдельно. Разница между ними — информация о среде, а не повод выбрать удобный источник.',
'Измените один тяжёлый участок: critical resource, handler, image dimensions или layout reservation.',
'Повторите измерение тем же сегментом и окном. Снижение LCP не закрывает INP/CLS автоматически.',
]),
h2('Почему порог не является причинностью'),
p('Пересечение границы 2500 мс сообщает о классификации, но не объясняет, что исправить. Порог полезен для triage и разговора о риске, а не для выбора виновника. Если после preload LCP улучшился, это ещё не доказывает, что preload был единственной причиной: изменились сервер, кэш или состав трафика.'),
p('У performance есть побочный эффект оптимизации. Сжатие изображения уменьшает LCP, но может увеличить CPU-декодирование или ухудшить качество. Разделение JavaScript может помочь INP, но добавить запросы и повлиять на LCP. В budget следует держать соседние ограничения — error rate, size, long tasks — и проверять, что выигрыш одной метрики не создаёт новый долг.'),
h2('Ограничения и следующий шаг'),
p('Классификатор использует учебные пороги и не считает реальные percentile. W3C LCP и Performance Timeline описывают API и объект измерения, но не обещают, что конкретный dashboard правильно собрал данные. INP и CLS требуют своих источников и сегментации; один локальный запуск не является field evidence.'),
p('Следующий шаг — выбрать один URL и сделать таблицу p75 для LCP, INP и CLS по двум сегментам. Для каждой плохой строки добавьте element/interaction и один проверяемый сигнал причины. После оптимизации повторите ту же выборку и проверьте соседние метрики, а не только тот показатель, который был в заголовке задачи.'),
], performanceRefs);
const writingRefs = [
{ key: 'nistIncident', use: 'Даёт дисциплину подготовки, обнаружения, анализа, containment, восстановления и работы после инцидента для структуры эксплуатационной инструкции.', boundary: 'Не знает ваших команд, прав доступа, сервисных зависимостей и порогов остановки.' },
{ key: 'rfc2119', use: 'Фиксирует различие между обязательным, рекомендуемым и необязательным действием, чтобы инструкция не прятала приоритет в тоне.', boundary: 'Не является руководством по эксплуатации, не проверяет команду и не даёт разрешение менять production.' },
];
const field = revision({
slug: 'editorial-2027-12-field-author-manifesto',
title: 'Эксплуатационная инструкция: симптом, действие, откат и проверка',
categories: ['Техническая документация', 'Надёжность'],
cover: '/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg',
excerpt: 'Как написать короткую инструкцию для опасной операции, чтобы читатель видел вход, ограничения, обратимое действие и критерий завершения.',
readingMinutes: 15,
}, [
p('Проблема инструкции обнаруживается в первый же сбой: читатель знает, что сервис нездоров, но не понимает, какой командой начать и как не усугубить ситуацию. Цена расплывчатого текста — параллельные ручные действия, потеря исходных метрик и откат без проверки данных.'),
p('Причина — писать статью как последовательность уверенных советов. В эксплуатации важнее не количество команд, а граница каждой команды: какое условие должно быть истинным, какой результат ожидается и когда нужно остановиться. Reader-facing текст должен позволить сверить вход, выполнить один шаг и увидеть измеримый выход.'),
h2('Карточка операции — минимальная единица'),
p('Полезная инструкция начинается с симптома и scope: например, «5xx выше 5% на POST /payments в одном регионе». Затем идут precondition, действие, rollback и verification. Эти поля не формальность. Без scope оператор может отключить здоровый трафик; без precondition — выполнить команду на неправильной версии; без verification — принять завершение команды за восстановление.'),
p('Порядок должен отражать риск, а не удобство автора документа. Сначала сохранить наблюдаемый факт, потом ограничить влияние, затем изменить один рычаг. После действия нужен интервал наблюдения и критерий возврата. Если операция необратима, инструкция должна прямо сказать, что её нельзя запускать без отдельного разрешения и резервного пути.'),
table('Структура проверяемой инструкции', ['Поле', 'Что написать', 'Проверяемый вопрос', 'Нельзя заменять'], [
['Symptom', 'метрика, endpoint, время', 'что именно нарушено?', '«сервис плохой»'],
['Scope', 'регион, версия, процент', 'кого затрагивает?', '«все пользователи»'],
['Precondition', 'доступ, версия, backup', 'можно ли выполнять шаг?', '«должно работать»'],
['Action', 'одна команда/изменение', 'что изменится?', 'список несвязанных команд'],
['Rollback', 'обратное действие и условие', 'как вернуть состояние?', '«откатить при проблеме»'],
['Verification', 'метрика и окно', 'что считать восстановлением?', '«проверить вручную»'],
]),
h2('Глаголы задают риск'),
p('Рекомендации вроде «проверьте», «убедитесь» и «при необходимости» слишком широки, если рядом нет объекта. RFC 2119 полезен как дисциплина модальности: <code>MUST</code> можно применять к обязательной precondition, <code>SHOULD</code> — к шагу с допустимым исключением, а <code>MAY</code> — к необязательной диагностике. В русском тексте это можно перевести обычными словами, сохранив однозначность.'),
p('Каждый command block должен иметь входы и ожидаемый результат. Если команда меняет состояние, рядом укажите право, namespace и способ увидеть diff. Не вставляйте секрет в пример и не предполагайте, что читатель знает локальные alias. Хорошая краткость убирает лишние слова, но не убирает условия безопасности.'),
figure('/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg', 'Петля эксплуатационной инструкции: симптом и precondition ведут к одному действию, затем к проверке метрики и условному откату.', 'Диаграмма показывает reader-facing последовательность. Текст считается завершённым только после наблюдаемой проверки, а не после окончания команды.'),
h2('Runnable-пример: проверить карточку runbook'),
p('Функция принимает объект с шестью полями и проверяет, что каждое достаточно содержательно, rollback назван явно, а verification ссылается на наблюдаемый сигнал. Это учебная проверка структуры документа, не оценка литературного стиля и не разрешение выполнить команду. Вход ниже показывает минимальный принятый набор и отказ без измеримой проверки.'),
code(`import { validateRunbookCard } from './upgrade-2027-12.mjs';
const card = validateRunbookCard({
symptom: '5xx выше 5 процентов на POST /payments',
scope: 'region eu-west, release 42, 10 percent traffic',
precondition: 'есть доступ к flag и сохранён dashboard за 15 минут',
action: 'отключить flag payments-v2 для 10 процентов трафика',
rollback: 'вернуть flag payments-v2 после проверки результата',
verification: 'проверить error rate и p95 в течение 10 минут',
});
console.log(card.ok, card.order.join(' -> '));
// true symptom -> scope -> precondition -> action -> rollback -> verification`),
h2('Порядок редакторской проверки инструкции'),
ol([
'В первых двух абзацах назовите симптом и цену ошибки. Reader должен понять, для какой ситуации текст предназначен.',
'Сделайте scope измеримым: endpoint, регион, версия, доля трафика и временное окно.',
'Перед каждой опасной командой поставьте precondition и ожидаемый output. Если output не наблюдаем, шаг нельзя считать проверенным.',
'Разделите один шаг изменения и rollback. Для rollback укажите условие, а не только команду возврата.',
'Добавьте таблицу решений для соседних симптомов, чтобы reader не применил одинаковое действие к 500, timeout и 409.',
'Запустите учебный пример с валидной и неполной карточкой, затем перечитайте текст на мобильной ширине и уберите длинные строки.',
]),
h2('Технический текст не заменяет разрешение'),
p('Даже подробная инструкция не даёт права менять production. Доступ, approval и окно операции должны жить в локальном процессе, а статья должна честно указать, какие precondition ей неизвестны. Если шаг может удалить данные или нарушить доступность, reader должен увидеть остановку до команды, а не бодрый призыв продолжать.'),
p('Не стоит добавлять в runbook вымышленные метрики и имена сервисов только для гладкого чтения. Лучше оставить placeholder с точным описанием входа, чем заставить оператора повторить чужой пример. Учебный пример должен быть маркирован как учебный и не содержать секретов, настоящих hostnames или команд с необратимым эффектом.'),
h2('Ограничения и следующий шаг'),
p('Проверка карточки не знает прав, shell, облака, backup, lock и реальных порогов. NIST SP 800-61 задаёт общий цикл обращения с инцидентом, но ваша инструкция всё равно должна назвать локальные сигналы и способ остановки. RFC 2119 помогает выбрать модальность, но не тестирует исполнимость команды.'),
p('Следующий шаг — взять один существующий alert и переписать его в шесть полей, затем прогнать на staging с безопасным флагом и настоящей проверкой метрики. Если reader не может назвать ожидаемый output любого шага, вернитесь к precondition и добавьте наблюдаемый критерий.'),
], writingRefs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function runTechnicalWritingFixture() {
const validCard = { symptom: '5xx выше 5 процентов на endpoint', scope: 'region eu-west release 42', precondition: 'есть доступ и сохранён dashboard', action: 'отключить flag на десяти процентах', rollback: 'вернуть flag после проверки', verification: 'проверить метрику error rate 10 минут' };
const cases = [
['security-headers-accept', buildSecurityHeaders({ nonce: '7c2f1b8e9a4d6f0c' }).ok, true],
['security-headers-reject', buildSecurityHeaders({ nonce: 'short' }).reason, 'security-header-input-invalid'],
['vitals-classify', classifyWebVitals({ lcpMs: 2180, inpMs: 240, cls: 0.08 }).overall, 'needs-improvement'],
['vitals-reject', classifyWebVitals({ lcpMs: -1, inpMs: 200, cls: 0.1 }).reason, 'vital-input-invalid'],
['runbook-accept', validateRunbookCard(validCard).ok, true],
['runbook-reject', validateRunbookCard({ ...validCard, verification: 'посмотреть' }).reason, 'verification-must-be-observable'],
];
const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected }));
return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks });
}
export function verifyRevisionsAgainstFixture() {
const fixture = runTechnicalWritingFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900));
});
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])) });
}
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');