const escapeHtml = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); const p = (text) => `

${text}

`; const h2 = (text) => `

${text}

`; const code = (text) => `
${escapeHtml(text)}
`; const ol = (items) => `
    ${items.map((item) => `
  1. ${item}
  2. `).join('')}
`; const figure = (src, alt, caption) => `
${alt}
${caption}
`; const table = (caption, headers, rows) => `
${headers.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${caption}
${cell}
${cell}
`; 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>[\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 ``; } 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. Затем для каждого типа выберите минимальную директиву. default-src задаёт fallback, но не объясняет исключения; script-src управляет JavaScript, object-src none закрывает старый plugin-механизм, а base-uri self не даёт странице незаметно изменить базовый 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 с собственными требованиями и риском. includeSubDomains распространяет правило на поддомены, поэтому включать его можно только после проверки всех нужных имён.'), 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. Если приложение вставляет строку в innerHTML, разрешённый 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 полезен как дисциплина модальности: MUST можно применять к обязательной precondition, SHOULD — к шагу с допустимым исключением, а MAY — к необязательной диагностике. В русском тексте это можно перевести обычными словами, сохранив однозначность.'), 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 && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.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');