302 lines
42 KiB
JavaScript
302 lines
42 KiB
JavaScript
const escapeHtml = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||
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');
|