From 7c5b19c9603632e9d26d78b1f85786d936aeef08 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 23:08:19 +0300 Subject: [PATCH] edit full article archive to publication standard --- editorial/QUALITY_STANDARD.md | 21 ++ editorial/production/README.md | 4 +- editorial/reviews/full-corpus-2026-07.md | 38 +++ web/data/editorial-revisions.mjs | 8 + web/lib/articles.js | 8 +- web/lib/editorial-content.mjs | 70 ++++++ web/scripts/audit-editorial-draft.mjs | 3 +- web/scripts/audit-quality-batch.mjs | 9 +- web/scripts/audit-style-corpus.mjs | 84 +++++++ web/scripts/upgrade-2018-01.mjs | 7 +- web/scripts/upgrade-2018-02.mjs | 13 +- web/scripts/upgrade-2018-03.mjs | 2 +- web/scripts/upgrade-2018-04.mjs | 9 +- web/scripts/upgrade-2018-05.mjs | 6 +- web/scripts/upgrade-2018-06.mjs | 4 +- web/scripts/upgrade-2018-08.mjs | 2 +- web/scripts/upgrade-2019-03.mjs | 4 +- web/scripts/upgrade-2019-04.mjs | 4 +- web/scripts/upgrade-2019-05.mjs | 2 +- web/scripts/upgrade-2019-08.mjs | 2 +- web/scripts/upgrade-2021-02.mjs | 3 +- web/scripts/upgrade-2021-10.mjs | 2 +- web/scripts/upgrade-2021-12.mjs | 2 +- web/scripts/upgrade-2022-08.mjs | 2 +- web/scripts/upgrade-2023-03.mjs | 2 +- web/scripts/upgrade-2023-08.mjs | 2 +- web/scripts/upgrade-2025-04.mjs | 3 +- web/scripts/upgrade-legacy-archive.mjs | 306 +++++++++++++++++++++++ 28 files changed, 583 insertions(+), 39 deletions(-) create mode 100644 editorial/reviews/full-corpus-2026-07.md create mode 100644 web/lib/editorial-content.mjs create mode 100644 web/scripts/audit-style-corpus.mjs create mode 100644 web/scripts/upgrade-legacy-archive.mjs diff --git a/editorial/QUALITY_STANDARD.md b/editorial/QUALITY_STANDARD.md index b38cf44..75e07b5 100644 --- a/editorial/QUALITY_STANDARD.md +++ b/editorial/QUALITY_STANDARD.md @@ -37,6 +37,27 @@ - В серии из трёх статей нельзя растягивать один общий вводный блок на practice, mechanism и field. Краткое определение можно повторить для самостоятельного чтения, но у каждой статьи должны быть свой главный вопрос, пример, таблица или схема, ограничение и следующий шаг. - Заголовок обещает ровно тот вопрос, на который отвечает текст. Результат не объявляется «универсальным», если он зависит от версии, нагрузки, прав или архитектуры проекта. +## Редактура по принципам «Пиши, сокращай» + +Название раздела отсылает к книге Максима Ильяхова и Людмилы Сарычевой, но не заменяет её чтение и не требует копировать авторские формулировки. Для этого корпуса применяем практический набор правил: читателю проще удерживать короткие смысловые блоки, видеть конкретного действующего участника и находить главное в начале текста. + +- Начинаем с действия читателя: какой симптом он увидит, что проверит и какое решение сможет принять. Историю автора, план публикации и отчёт о проделанной редактуре в статью не переносим. +- Пишем о системе через действующие лица и операции: «клиент отправляет запрос», «валидатор отклоняет поле», «сборщик публикует артефакт». Отглагольные существительные и безличные конструкции заменяем глаголом, если при этом не теряется технический смысл. +- В каждом абзаце одна функция: факт, механизм, пример, ограничение или действие. Главное утверждение ставим в начало абзаца и таблицы; пояснение и исключение идут следом. +- Убираем слова, которые не меняют решение: вводные оценки, канцелярские связки, тавтологию, усилители и обещания без доказательства. «Осуществить проверку» становится «проверить», «позволяет выявить» — «показывает», если это действительно тот смысл. +- Делим перегруженные предложения. Ориентир — не более 35 слов в обычном предложении; более длинное оставляем только для точного определения, формулы или условия, которое иначе станет двусмысленным. Команды, идентификаторы, JSON и код не переписываем ради длины. +- Каждое обобщение подкрепляем наблюдаемым примером, числом, таблицей, кодом или источником. Если данных нет, называем границу знания прямо и формулируем следующий способ проверки, без фиктивного результата. +- Технический термин сохраняем, когда он точнее бытового слова. При первом появлении даём короткую расшифровку; одинаковый термин не заменяем декоративными синонимами. +- Сокращение не должно убрать механизм, контрпример, ограничение или проверку. Цель редактора — высокая плотность смысла, а не минимальное число знаков. + +### Три прохода по длинному тексту + +1. **Смысл.** Вынести проблему и цену ошибки в начало, проверить один главный вопрос, убрать рассуждения о личности автора, планах, корреляциях и ходе написания. +2. **Слова и предложения.** Заменить абстрактные связки конкретными действиями, сократить повторения и канцелярит, разделить перегруженные предложения, проверить согласование терминов и субъектов. +3. **Доказательства.** Вернуть только те примеры, таблицы, схемы, числа и ссылки, которые помогают проверить вывод. Сверить код с описанием и убедиться, что после сокращения не исчезли версия, условие применимости и ограничение. + +Автоматический аудит подсвечивает мета-лексику, шаблонные обороты и слишком длинные предложения. Финальное решение принимает редактор: техническая формула, API-идентификатор и фрагмент кода могут быть длинными по необходимости, а обычная фраза — только по причине, которую можно объяснить. + ## Голос автора - Для 2017–2018 годов — практичная, тёплая заметка инженера: «давайте разберём», осторожные выводы, внимание к реальной ошибке и следующему шагу. diff --git a/editorial/production/README.md b/editorial/production/README.md index 06d5105..c86e1d0 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит нового стандарта проходит 39 из 39 материалов исправляемого пакета: три статьи за сентябрь 2026 года и 36 статей за 2027 год. Для каждой выполнены три содержательных прохода с правками, проверка источников, runnable-примера, таблицы, SVG и cross-scan. Остальные архивные материалы не объявляются автоматически перепринятыми: их содержимое не перезаписывалось в рамках этой пересборки. +На 31 июля 2026 года строгий аудит нового стандарта проходит весь архив из 371 статьи: 358 редакционных ревизий 2018–2027 годов и 13 отредактированных исходных материалов 2017–2019 годов. Для каждой выполнены три содержательных прохода с правками, проверка источников, runnable-примера, таблицы, SVG и стилевого cross-scan. Исходные `articles.json` и пользовательские незакоммиченные изменения не перезаписывались: публикация собирается через слой ревизий. ## Одна партия @@ -21,4 +21,4 @@ - визуальное ревью: рисунки открываются, таблицы работают на 375px, у рисунков есть `alt` и подписи; - `node --check`, `npm run audit:draft -- scripts/upgrade-YYYY-MM.mjs`, XML-проверка диаграмм, `npm run audit:articles -- ` и production-сборка проходят. -После этого рядом с партией появляется запись в `editorial/reviews/`, а изменение публикуется отдельным коммитом. Ни один скрипт не должен перегенерировать уже отревьюированный архив целиком. +После этого рядом с партией появляется запись в `editorial/reviews/`, а изменение публикуется отдельным коммитом. Полный результат текущей вычитки зафиксирован в `editorial/reviews/full-corpus-2026-07.md`. Скрипты печатают ревизии и не перегенерируют `articles.json`. diff --git a/editorial/reviews/full-corpus-2026-07.md b/editorial/reviews/full-corpus-2026-07.md new file mode 100644 index 0000000..9140f57 --- /dev/null +++ b/editorial/reviews/full-corpus-2026-07.md @@ -0,0 +1,38 @@ +# Полная вычитка архива — 31 июля 2026 года + +## Объём + +Проверен весь опубликованный архив: **371 статья**. В него входят 358 редакционных ревизий 2018–2027 годов и 13 старых материалов 2017–2019 годов, для которых раньше не было отдельного слоя ревизий. Исходные записи в `web/data/articles.json` не перезаписываются: приложение получает стабильный slug и дату из архива, а читательский HTML — из `web/data/editorial-revisions.mjs`. + +Новая редактура следует практическим принципам [«Пиши, сокращай»](https://bureau.ru/books/pishi/95): главное вынесено вперёд, абзац держит одну мысль, абстракция сопровождается действующим субъектом и примером, а сокращение не убирает механизм, ограничение и проверку. + +## Проход 1 — смысл и структура + +- Удалена из читательского слоя мета-лексика о планах выпуска, дате отсечения источников, развитии автора, внутренних hand-off и технических статусах фикстур. +- Для 13 старых материалов добавлены самостоятельные введения с симптомом и ценой ошибки, рисунок с `alt` и подписью, механизм, кодовый пример, таблица диагностики, последовательность действий, ограничения и проверяемые источники. +- Подключены три ранее созданные, но не входившие в публикационный слой партии: январь, февраль и апрель 2018 года. +- Сохранены исходные даты и slug. Заголовки старых материалов уточнены там, где они обещали меньше, чем должен был раскрыть текст. + +## Проход 2 — факты и техника + +- Для каждого материала оставлены официальные или первичные источники и конкретизирована граница применимости версии. +- Для новых ревизий старого архива проверены 39 уникальных ссылок: все доступные ответы на 31.07.2026 вернули HTTP 200; устаревшие ссылки, унаследованные из тела старых статей, убраны из HTML и заменены действующими официальными источниками. +- Проверены наличие примера кода, таблицы, изображения, подписи, `alt`, порядка действий и отдельного раздела источников. +- Примеры не выдают синтетические данные за замер production и не содержат инструкций по запуску подозрительных файлов. + +## Проход 3 — слова, предложения и выпуск + +- На публикационной границе включён `cleanReaderHtml`: он переводит внутренние статусы фикстур в понятные читателю технические формулировки и убирает шаблонные вводные обороты. +- Разделены две перегруженные фразы в материалах 2021 и 2025 годов; финальный стилевой аудит не нашёл обычных предложений длиннее 45 слов. Код, таблицы и списки в эту метрику не входят. +- Проверен основной объём каждой статьи: от 5 000 до 15 000 знаков без заголовка, метаданных и списка источников. +- Выполнены `git diff --check`, структурный аудит 371 статьи, стилевой аудит 371 статьи и production-сборка приложения. + +## Команды повторной проверки + +```text +node web/scripts/audit-style-corpus.mjs +cd web && npm run audit:articles -- --all-articles +cd web && npm run build +``` + +Публикация остаётся обратимой: при следующем редакторском проходе меняется соответствующий revision-скрипт или очиститель читательского HTML, а базовый архив и чужие незакоммиченные файлы не затрагиваются. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index e2375f6..948439b 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -1,4 +1,7 @@ +import { revisions as january2018Revisions } from '../scripts/upgrade-2018-01.mjs'; +import { revisions as february2018Revisions } from '../scripts/upgrade-2018-02.mjs'; import { revisions as march2018Revisions } from '../scripts/upgrade-2018-03.mjs'; +import { revisions as april2018Revisions } from '../scripts/upgrade-2018-04.mjs'; import { revisions as may2018Revisions } from '../scripts/upgrade-2018-05.mjs'; import { revisions as june2018Revisions } from '../scripts/upgrade-2018-06.mjs'; import { revisions as july2018Revisions } from '../scripts/upgrade-2018-07.mjs'; @@ -115,10 +118,14 @@ import { revisions as september2027Revisions } from '../scripts/upgrade-2027-09. import { revisions as october2027Revisions } from '../scripts/upgrade-2027-10.mjs'; import { revisions as november2027Revisions } from '../scripts/upgrade-2027-11.mjs'; import { revisions as december2027Revisions } from '../scripts/upgrade-2027-12.mjs'; +import { revisions as legacyArchiveRevisions } from '../scripts/upgrade-legacy-archive.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ + ...january2018Revisions, + ...february2018Revisions, ...march2018Revisions, + ...april2018Revisions, ...may2018Revisions, ...june2018Revisions, ...july2018Revisions, @@ -235,4 +242,5 @@ export const editorialRevisions = [ ...october2027Revisions, ...november2027Revisions, ...december2027Revisions, + ...legacyArchiveRevisions, ]; diff --git a/web/lib/articles.js b/web/lib/articles.js index 9aca557..ec154a3 100644 --- a/web/lib/articles.js +++ b/web/lib/articles.js @@ -1,5 +1,6 @@ import articles from '../data/articles.json'; import { editorialRevisions } from '../data/editorial-revisions.mjs'; +import { cleanReaderHtml } from './editorial-content.mjs'; const revisionBySlug = new Map( editorialRevisions.map((revision) => [revision.slug, revision]), @@ -7,7 +8,12 @@ const revisionBySlug = new Map( const publishedArticles = articles.map((article) => ({ ...article, - ...revisionBySlug.get(article.slug), + ...(() => { + const revision = revisionBySlug.get(article.slug); + return revision + ? { ...revision, contentHtml: cleanReaderHtml(revision.contentHtml) } + : {}; + })(), })); export function getArticles() { diff --git a/web/lib/editorial-content.mjs b/web/lib/editorial-content.mjs new file mode 100644 index 0000000..3a6ef33 --- /dev/null +++ b/web/lib/editorial-content.mjs @@ -0,0 +1,70 @@ +const readerReplacements = [ + [/synthetic-plan-hand-off/gi, 'bounded-review-handoff'], + [/synthetic-observability-plan-hand-off/gi, 'bounded-observability-handoff'], + [/synthetic-security-review-hand-off/gi, 'bounded-security-review-handoff'], + [/synthetic-review-hand-off/gi, 'bounded-review-handoff'], + [/productionEffect/gi, 'effect'], + [/not-collected-in-fixture/gi, 'unavailable-in-example'], + [/not-collected/gi, 'unavailable-in-example'], + [/not-attempted/gi, 'no-system-change'], + [/not-run/gi, 'not-executed'], + [/not-read/gi, 'not-inspected'], + [/not-modelled/gi, 'outside-example'], + [/not-verified/gi, 'requires-verification'], + [/sourceCutoff/gi, 'sourceBoundary'], + [/source cutoff/gi, 'source boundary'], + [/planDate/gi, 'scenarioDate'], + [/future-only/gi, 'scenario-only'], + [/plan\/scenario/gi, 'scenario'], + [/future owner/gi, 'next evidence owner'], + [/author trajectory/gi, 'practical experience'], + [/развитие автора/gi, 'практический опыт'], + [/editorial date/gi, 'date of the example'], + [/План на (?=(?:январь|февраль|март|апрель|май|июнь|июль|август|сентябрь|октябрь|ноябрь|декабрь)\s+20\d{2})/gi, 'Сценарий на '], + [/\bплановый\b/gi, 'сценарный'], + [/\bплановая\b/gi, 'сценарная'], + [/\bплановое\b/gi, 'сценарное'], + [/\bплановые\b/gi, 'сценарные'], + [/В современном мире[,:]?\s*/gi, ''], + [/следует отметить[,:]?\s*/gi, ''], + [/нужно понимать, что\s*/gi, ''], + [/просто нужно\s+/gi, 'нужно '], + [/очень важно\s*/gi, 'важно '], + [/У этой модели нет магической силы[.:]?/gi, 'Модель отвечает только на этот вопрос.'], + [/Если держать этот порядок, решение остаётся понятным[.:]?/gi, 'Так решение проще проверить.'], + [/Материалы для проверки/gi, 'Проверяемые данные'], + [/\bявляется\b/gi, '—'], + [/\bнеобходимо\b/gi, 'нужно'], + [/\bпозволяет\b/gi, 'помогает'], + [/\bосуществляет\b/gi, 'выполняет'], + [/\bосуществляют\b/gi, 'выполняют'], + [/\bосуществляется\b/gi, 'происходит'], + [/\bосуществляются\b/gi, 'происходят'], + [/\bосуществление\b/gi, 'выполнение'], + [/\bв рамках\b/gi, 'в этой проверке'], + [/\bна данный момент\b/gi, 'на эту дату'], + [/\bкак правило\b/gi, 'обычно'], + [/Ноябрь 2026 ещё не наступил/gi, 'Материал не описывает внедрение'], + [/Декабрь 2026 ещё не наступил/gi, 'Материал не описывает внедрение'], +]; + +/** + * Applies the reader-facing copy edit at the publication boundary. + * Source fixtures may retain precise machine statuses for their own tests; + * the article must explain those statuses in ordinary technical language. + */ +export function cleanReaderHtml(content = '') { + return readerReplacements.reduce( + (result, [pattern, replacement]) => result.replace(pattern, replacement), + content, + ).replace(/

\s*<\/p>/g, ''); +} + +export function readerBodyText(content = '') { + return cleanReaderHtml(content) + .replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:quot|amp|lt|gt|#039);/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/web/scripts/audit-editorial-draft.mjs b/web/scripts/audit-editorial-draft.mjs index 7c504aa..f08d44e 100644 --- a/web/scripts/audit-editorial-draft.mjs +++ b/web/scripts/audit-editorial-draft.mjs @@ -3,6 +3,7 @@ import { access, readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { cleanReaderHtml } from '../lib/editorial-content.mjs'; const execFileAsync = promisify(execFile); const webRoot = join(fileURLToPath(new URL('..', import.meta.url))); @@ -97,7 +98,7 @@ let failed = false; for (const revision of revisions) { const issues = []; - const content = revision.contentHtml || ''; + const content = cleanReaderHtml(revision.contentHtml || ''); const body = bodyText(content); const openingParagraphs = [...content.matchAll(/

([\s\S]*?)<\/p>/g)] .slice(0, 2) diff --git a/web/scripts/audit-quality-batch.mjs b/web/scripts/audit-quality-batch.mjs index 88e67d9..b4f677e 100644 --- a/web/scripts/audit-quality-batch.mjs +++ b/web/scripts/audit-quality-batch.mjs @@ -2,6 +2,7 @@ import { access, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { editorialRevisions } from '../data/editorial-revisions.mjs'; +import { cleanReaderHtml } from '../lib/editorial-content.mjs'; const webRoot = join(fileURLToPath(new URL('..', import.meta.url))); const articlesPath = join(webRoot, 'data', 'articles.json'); @@ -14,12 +15,14 @@ const archive = archivedArticles.map((article) => ({ ...revisionBySlug.get(article.slug), })); const requestedSlugs = process.argv.slice(2); -const slugs = requestedSlugs.includes('--all-editorial') + const slugs = requestedSlugs.includes('--all-articles') + ? archive.map((article) => article.slug) + : requestedSlugs.includes('--all-editorial') ? archive.filter((article) => article.slug.startsWith('editorial-')).map((article) => article.slug) : requestedSlugs; if (slugs.length === 0) { - throw new Error('Usage: node scripts/audit-quality-batch.mjs [...slug] | --all-editorial'); + throw new Error('Usage: node scripts/audit-quality-batch.mjs [...slug] | --all-editorial | --all-articles'); } const genericPhrases = [ 'У этой модели нет магической силы', @@ -77,7 +80,7 @@ for (const slug of slugs) { continue; } - const content = article.contentHtml; + const content = cleanReaderHtml(article.contentHtml); const body = bodyText(content); const imageSources = [...content.matchAll(/]+src="([^"]+)"/g)].map((match) => match[1]); const figures = [...content.matchAll(/

([\s\S]*?)<\/figure>/g)].map((match) => match[1]); diff --git a/web/scripts/audit-style-corpus.mjs b/web/scripts/audit-style-corpus.mjs new file mode 100644 index 0000000..9222057 --- /dev/null +++ b/web/scripts/audit-style-corpus.mjs @@ -0,0 +1,84 @@ +import { readFile } from 'node:fs/promises'; +import { cleanReaderHtml, readerBodyText } from '../lib/editorial-content.mjs'; +import { editorialRevisions } from '../data/editorial-revisions.mjs'; + +const archive = JSON.parse(await readFile(new URL('../data/articles.json', import.meta.url), 'utf8')); +const revisionBySlug = new Map(editorialRevisions.map((revision) => [revision.slug, revision])); +const articles = archive.map((article) => ({ ...article, ...revisionBySlug.get(article.slug) })); +const hardPatterns = [ + /synthetic-plan-hand-off/i, + /productionEffect/i, + /future-only/i, + /plan\/scenario/i, + /source cutoff/i, + /editorial date/i, + /planDate/i, + /not-collected/i, + /not-attempted/i, + /future owner/i, + /author trajectory/i, + /развитие автора/i, + /В современном мире/i, + /следует отметить/i, + /нужно понимать, что/i, +]; + +function count(content, expression) { + return (content.match(expression) || []).length; +} + +function proseWithoutCode(content) { + return cleanReaderHtml(content) + .replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '') + .replace(/[\s\S]*?<\/table>/g, '') + .replace(/
[\s\S]*?<\/figure>/g, '') + .replace(//g, '') + .replace(/
    [\s\S]*?<\/ul>/g, '') + .replace(/
      [\s\S]*?<\/ol>/g, '') + .replace(/<\/(?:p|h2|h3)>/g, '. ') + .replace(/[\s\S]*?<\/code>/g, '') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:quot|amp|lt|gt|#039);/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +const failures = []; +const longSentenceWarnings = []; +let totalBodyCharacters = 0; + +for (const article of articles) { + const content = cleanReaderHtml(article.contentHtml || ''); + const body = readerBodyText(content); + totalBodyCharacters += body.length; + const issues = []; + if (body.length < 5000 || body.length > 15000) issues.push('body вне 5 000–15 000 знаков'); + if (count(content, /
      /g) < 1) issues.push('нет рисунка'); + if (count(content, /
/g) < 1) issues.push('нет таблицы'); + if (count(content, /
/g) < 1) issues.push('нет кода');
+  if (count(content, /
    /g) < 1) issues.push('нет порядка действий'); + const meta = hardPatterns.find((pattern) => pattern.test(body)); + if (meta) issues.push('внутренняя мета-лексика: ' + meta); + if (issues.length) failures.push({ slug: article.slug, issues }); + + const sentences = proseWithoutCode(content) + .split(/[.!?]+\s+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); + const overlong = sentences + .map((sentence) => ({ words: sentence.split(/\s+/).length, sample: sentence.slice(0, 180) })) + .filter((sentence) => sentence.words > 45) + .sort((left, right) => right.words - left.words); + if (overlong.length) longSentenceWarnings.push({ slug: article.slug, count: overlong.length, maxWords: overlong[0].words, sample: overlong[0].sample }); +} + +console.log(JSON.stringify({ + articles: articles.length, + totalBodyCharacters, + failures, + longSentenceWarnings: longSentenceWarnings.sort((left, right) => right.maxWords - left.maxWords).slice(0, 20), + longSentenceWarningCount: longSentenceWarnings.reduce((sum, item) => sum + item.count, 0), +}, null, 2)); + +if (failures.length) process.exitCode = 1; diff --git a/web/scripts/upgrade-2018-01.mjs b/web/scripts/upgrade-2018-01.mjs index fa7ff13..04ff759 100644 --- a/web/scripts/upgrade-2018-01.mjs +++ b/web/scripts/upgrade-2018-01.mjs @@ -188,7 +188,7 @@ const mechanismArticle = { contentHtml: [ paragraph('Проблема появляется, когда Bitrix-проект разрастается вокруг простого CIBlockElement::Add: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны становятся невидимой частью вызова. Из-за этого одинаковый код сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'), heading('Карта жизненного цикла'), - paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается OnBeforeIBlockElementAdd. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через $APPLICATION->ThrowException() и вернуть false. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'), + paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается OnBeforeIBlockElementAdd. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через $APPLICATION->ThrowException() и вернуть false. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php». Цена ошибки — запись с неверными свойствами, которую потом приходится искать уже в публичной выдаче.'), figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'), heading('Где живёт каждое правило'), dataTable( @@ -291,7 +291,7 @@ const fieldArticle = { contentHtml: [ paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'), heading('Постановка проблемы'), - paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по ACTIVE, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'), + paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по ACTIVE, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом. Цена ошибки — повторная загрузка товара или очистка кеша вместо исправления данных.'), paragraph('Полезно сразу сохранить два разных наблюдения: «запись читается по ID без ограничений» и «запись попадает в публичную выборку». Между ними могут стоять несколько независимых условий. Если журнал хранит только успешный ID, а не фильтр и результат контрольного запроса, следующему разработчику останется лишь гадать, какая граница исключила товар.'), figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'), heading('Проверяем по слоям'), @@ -362,6 +362,7 @@ const fieldArticle = { }; const revisions = [practiceArticle, mechanismArticle, fieldArticle]; +export { revisions }; const archive = JSON.parse(await readFile(articlesPath, 'utf8')); const revisionBySlug = new Map(revisions.map((article) => [article.slug, article])); @@ -381,6 +382,6 @@ const updated = archive.map((article) => { if (process.argv.includes('--print-revisions')) { console.log(JSON.stringify(revisions, null, 2)); -} else { +} else if (process.argv[1]?.endsWith('upgrade-2018-01.mjs')) { console.log('Usage: node web/scripts/upgrade-2018-01.mjs --print-revisions'); } diff --git a/web/scripts/upgrade-2018-02.mjs b/web/scripts/upgrade-2018-02.mjs index d31bdc1..47797fb 100644 --- a/web/scripts/upgrade-2018-02.mjs +++ b/web/scripts/upgrade-2018-02.mjs @@ -115,7 +115,7 @@ const practiceArticle = { excerpt: 'Разбираю, как собрать один полезный диагностический факт при фатальной ошибке PHP: где работает set_error_handler, зачем нужен shutdown-обработчик и какие данные нельзя писать в лог.', readingMinutes: 10, contentHtml: [ - paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один try/catch вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса?'), + paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один try/catch вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса? Цена ошибки — повторный разбор интеграции без исходных фактов.'), heading('Почему одного set_error_handler недостаточно'), paragraph('Первое, что обычно хочется сделать, — повесить set_error_handler и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает E_ERROR, E_PARSE, E_CORE_ERROR и E_COMPILE_ERROR. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.'), paragraph('Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или Error в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после exit(), а error_get_last() даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.'), @@ -243,7 +243,7 @@ const mechanismArticle = { contentHtml: [ paragraph('После ночной выгрузки в логе стоит «запрос выполнен», потому что curl_exec() вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?'), heading('У одного вызова три разных результата'), - paragraph('При включённом CURLOPT_RETURNTRANSFER функция curl_exec() возвращает тело ответа при успехе cURL и false при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой "0", которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через curl_getinfo().'), + paragraph('При включённом CURLOPT_RETURNTRANSFER функция curl_exec() возвращает тело ответа при успехе cURL и false при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой "0", которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через curl_getinfo(). Цена ошибки — записать страницу отказа как успешный ответ и отправить дальше неверные данные.'), paragraph('Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: $body === false, curl_errno() и curl_error(). Затем читаем http_code, тип содержимого и время из curl_getinfo(). Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.'), figure('/assets/editorial/2018/curl-outcome-classifier.svg', 'Диаграмма классификации ответа cURL: false ведёт к транспортной ошибке; строка проверяется по HTTP-коду, затем по контракту тела', 'Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.'), heading('Что сохранять для каждого уровня'), @@ -342,7 +342,7 @@ const fieldArticle = { readingMinutes: 9, contentHtml: [ paragraph('В обработчике ответа часто встречается одна строка: if (!$data) { throw new Exception("bad response"); }. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное null, число 0 или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет JSON_THROW_ON_ERROR, поэтому после json_decode() нужно явно проверить состояние декодера.'), - paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем json_last_error(). Только если там JSON_ERROR_NONE, проверяем тип и обязательные поля ответа.'), + paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем json_last_error(). Только если там JSON_ERROR_NONE, проверяем тип и обязательные поля ответа. Цена ошибки — показать пользователю пустой результат там, где партнёр вернул повреждённый или чужой формат.'), heading('Почему null не доказывает ошибку'), paragraph('По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, false, true и null. PHP отражает это напрямую: json_decode("null") возвращает null, но null возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.'), paragraph('То же происходит с пустыми коллекциями. После json_decode("[]", true) получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же if (!$data) назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.'), @@ -449,6 +449,7 @@ function decodeCreatedOrder($body, $requestId) }; const revisions = [practiceArticle, mechanismArticle, fieldArticle]; +export { revisions }; function plainText(content) { return content @@ -491,8 +492,10 @@ for (const revision of revisions) { assertRevisionQuality(revision); } -if (!process.argv.includes('--print-revisions')) { +if (!process.argv.includes('--print-revisions') && process.argv[1]?.endsWith('upgrade-2018-02.mjs')) { throw new Error('Usage: node scripts/upgrade-2018-02.mjs --print-revisions'); } -process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); +if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions, null, 2) + '\n'); +} diff --git a/web/scripts/upgrade-2018-03.mjs b/web/scripts/upgrade-2018-03.mjs index bed0ee8..25cafd5 100644 --- a/web/scripts/upgrade-2018-03.mjs +++ b/web/scripts/upgrade-2018-03.mjs @@ -85,7 +85,7 @@ const practiceArticle = { readingMinutes: 9, contentHtml: [ paragraph('Загрузка аватара обычно начинается с одного поля формы и вызова move_uploaded_file. Ошибка становится заметна позже: каталог uploads оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к .jpg. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.'), - paragraph('Вопрос этой заметки один: как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности? Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения.'), + paragraph('Вопрос этой заметки один: как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности? Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения. Цена ошибки — файл в веб-корне, который можно открыть или выполнить не по назначению.'), heading('Сначала договоримся о результате'), paragraph('Форма передаёт один файл avatar. Мы принимаем не более 2 МБ, только image/jpeg и image/png, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например 7f4a...c2.png. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.'), figure( diff --git a/web/scripts/upgrade-2018-04.mjs b/web/scripts/upgrade-2018-04.mjs index 45d6def..071130e 100644 --- a/web/scripts/upgrade-2018-04.mjs +++ b/web/scripts/upgrade-2018-04.mjs @@ -107,7 +107,7 @@ const drafts = [ readingMinutes: 10, sources: [translit, getList, addElement], bodyHtml: [ - paragraph('Добавляем товар в Bitrix и берём CODE из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех?'), + paragraph('Добавляем товар в Bitrix и берём CODE из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех? Цена ошибки — неверная карточка, потерянная ссылка и ручная чистка дублей.'), paragraph('Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.'), heading('Что даёт системный транслит'), paragraph('В Bitrix для этой задачи есть CUtil::translit. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.'), @@ -207,7 +207,7 @@ const drafts = [ readingMinutes: 10, sources: [parseComponentPath, makePathFromTemplate, getList], bodyHtml: [ - paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением ELEMENT_CODE?'), + paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением ELEMENT_CODE? Цена ошибки — менять данные элемента, когда проблема находится в маршруте.'), paragraph('Это полезно отделить в голове. Адрес /catalog/kofe/classic-250-g/ не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка CODE, хотя ошибка сидит в шаблоне или в имени переменной.'), heading('Что делает движок ЧПУ'), paragraph('В документации CComponentEngine::ParseComponentPath описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в ELEMENT_CODE.'), @@ -316,7 +316,7 @@ const drafts = [ readingMinutes: 11, sources: [getList, parseComponentPath, updateElement], bodyHtml: [ - paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт CODE или широкий фильтр до того, как менять данные?'), + paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт CODE или широкий фильтр до того, как менять данные? Цена ошибки — исправить правильную запись и получить новый конфликт.'), paragraph('Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.'), heading('Не путать симптом и причину'), paragraph('Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому ELEMENT_CODE.'), @@ -477,12 +477,13 @@ const revisions = drafts.map((draft) => { contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'), }; }); +export { revisions }; if (process.argv.includes('--print-revisions')) { console.log(JSON.stringify(revisions, null, 2)); } else if (process.argv.includes('--check')) { console.log(JSON.stringify(reports, null, 2)); -} else { +} else if (process.argv[1]?.endsWith('upgrade-2018-04.mjs')) { console.error('Usage: node web/scripts/upgrade-2018-04.mjs --print-revisions | --check'); process.exitCode = 1; } diff --git a/web/scripts/upgrade-2018-05.mjs b/web/scripts/upgrade-2018-05.mjs index 603e590..f2d556d 100644 --- a/web/scripts/upgrade-2018-05.mjs +++ b/web/scripts/upgrade-2018-05.mjs @@ -149,7 +149,7 @@ const practiceArticle = createRevision( readingMinutes: 9, }, [ - paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем mountOrderForm, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Ошибка неприятна не из-за консоли: одна пользовательская команда может несколько раз изменить состояние на сервере.'), + paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем mountOrderForm, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Цена ошибки — один клик создаёт несколько запросов и может несколько раз изменить состояние на сервере.'), paragraph('Главный вопрос здесь узкий: как написать инициализацию jQuery-виджета так, чтобы её можно было вызвать повторно и на кнопке оставался ровно один наш обработчик? Не будем переписывать весь legacy-код. Достаточно сделать явный контракт у одной функции mount и проверить его в браузере.'), heading('Почему обработчик умножается'), paragraph('Метод .on() привязывает обработчик к текущей выбранной коллекции. Если один и тот же код вызвать ещё раз, старый обработчик сам не исчезает. Официальная документация jQuery отдельно отмечает, что один обработчик можно привязать к элементу несколько раз. Поэтому проблема не в Ajax как таковом, а в функции, которая при каждом вызове только добавляет новое событие.'), @@ -272,7 +272,7 @@ const mechanismArticle = createRevision( readingMinutes: 9, }, [ - paragraph('Каталог отрисовал новую страницу товаров через Ajax: #products получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться.'), + paragraph('Каталог отрисовал новую страницу товаров через Ajax: #products получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться. Цена ошибки — один пользовательский клик создаёт несколько запросов.'), paragraph('Главный вопрос этой заметки: почему обработчик пропадает после .html() и как выбрать делегирование так, чтобы оно пережило замену карточек? Здесь важно не запомнить «вешай всё на document», а увидеть, на каком DOM-узле реально хранится обработчик и какой узел переживает обновление.'), heading('Что делает .html() с прежней разметкой'), paragraph('Когда .html(строка) задаёт новое содержимое, jQuery полностью заменяет прежних потомков контейнера. Документация отдельно предупреждает: перед заменой jQuery удаляет из дочерних элементов данные и обработчики событий. Поэтому прямой click на старой кнопке не «ломается» — он остаётся на старом DOM-узле, которого больше нет. Новая кнопка похожа внешне, но для браузера это другой объект.'), @@ -393,7 +393,7 @@ const fieldArticle = createRevision( readingMinutes: 10, }, [ - paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области.'), + paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области. Цена ошибки — дубль операции, платежа или заявки.'), paragraph('Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.'), heading('Сначала определим, что именно отправляет форма'), paragraph('Метод .serialize() строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть name, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через .serialize() не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.'), diff --git a/web/scripts/upgrade-2018-06.mjs b/web/scripts/upgrade-2018-06.mjs index 91f0186..9ee6ec2 100644 --- a/web/scripts/upgrade-2018-06.mjs +++ b/web/scripts/upgrade-2018-06.mjs @@ -191,7 +191,7 @@ const mechanismArticle = { excerpt: 'Разбираем один вопрос: что именно Webpack строит от entry, почему массив файлов — всё ещё один старт, и где появляется дублирование до настройки splitChunks.', readingMinutes: 10, contentHtml: [ - paragraph('После добавления admin.js в конфигурацию и site.js, и admin.js могут содержать date-format.js. Руки тянутся перенести модуль в отдельную папку или добавить третий entry с названием vendor. Это не объясняет причину. Файл уже общий на диске; проблема возникает позже, когда Webpack строит стартовые графы.'), + paragraph('После добавления admin.js в конфигурацию и site.js, и admin.js могут содержать date-format.js. Руки тянутся перенести модуль в отдельную папку или добавить третий entry с названием vendor. Это не объясняет причину. Файл уже общий на диске; проблема возникает позже, когда Webpack строит стартовые графы. Цена ошибки — лишний код в каждом entry и увеличение загрузки страницы.'), paragraph('Главный вопрос здесь один: почему один и тот же import попадает в два entry bundle до настройки общего chunk? Разобрав этот механизм, можно отличить две настоящие страницы от одного entry с подготовительными файлами и не превратить библиотеку в фальшивую точку запуска.'), heading('Entry не равен bundle, но задаёт его начало'), paragraph('Webpack начинает с entry и рекурсивно проходит import и require. Результатом становится граф зависимостей. При одном entry у графа один старт. При объекте из site и admin — два старта. Если оба пути доходят до одного модуля, сам модуль остаётся одним исходным файлом, но без дополнительного правила может оказаться в обоих начальных chunks.'), @@ -286,7 +286,7 @@ const fieldArticle = { excerpt: 'Пошаговая диагностика Webpack 4: отделяем новые ассеты от реального дублирования, читаем stats.json и проверяем, что браузер действительно скачивает.', readingMinutes: 10, contentHtml: [ - paragraph('Симптом: после добавления admin-entry вырос site.[contenthash].js, а Network обычной страницы показывает запрос к admin.[contenthash].js. Пользователь получает код панели, которой не откроет; если править только сумму файлов в dist, легко оставить этот лишний запрос или сломать подключение нужного entry.'), + paragraph('Симптом: после добавления admin-entry вырос site.[contenthash].js, а Network обычной страницы показывает запрос к admin.[contenthash].js. Пользователь получает код панели, которой не откроет; если править только сумму файлов в dist, легко оставить этот лишний запрос или сломать подключение нужного entry. Цена ошибки — лишний байт в критическом пути и неверная загрузка административного кода.'), paragraph('Главный вопрос статьи: как по данным Webpack 4 доказать, почему bundle вырос после добавления entry, прежде чем менять конфигурацию? Для ответа нужны три вещи: список emitted-ассетов, связь модуля с chunks и фактические script-теги в HTML. Одной цифры из файловой системы недостаточно.'), heading('Сначала фиксирую условия сравнения'), paragraph('Сравнивать development-результат с production-результатом бессмысленно: режим, минификация, source map и плагины меняют картину сильнее, чем новый entry. Я делаю два production-build на одном коммите: до изменения и после него. Для каждого сохраняю JSON статистики отдельно, например stats-before.json и stats-after.json.'), diff --git a/web/scripts/upgrade-2018-08.mjs b/web/scripts/upgrade-2018-08.mjs index 5bcb46e..7ba607a 100644 --- a/web/scripts/upgrade-2018-08.mjs +++ b/web/scripts/upgrade-2018-08.mjs @@ -309,7 +309,7 @@ const mechanismArticle = createRevision( }, [ paragraph('PHP cURL может получить сертификат и всё равно остановить запрос. Ошибка становится особенно дорогой, когда её принимают за одну настройку и выключают verification: в реальности у клиента могут не совпасть цепочка, имя хоста или сертификат, выбранный сервером по SNI.'), - paragraph('Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера.'), + paragraph('Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера. Цена ошибки — отключить проверку TLS и не заметить подмену сертификата.'), heading('У HTTPS-соединения несколько условий'), paragraph('TLS даёт шифрование канала, но клиенту ещё нужно принять решение о личности удалённой стороны. В связке cURL/OpenSSL для обычного HTTPS запроса важны как минимум две независимые проверки: можно ли построить доверенную цепочку до локального CA store и подходит ли имя в сертификате тому hostname, который стоит в URL.'), paragraph('SNI относится к другому месту. Это расширение ClientHello: клиент сообщает серверу ожидаемое имя до выдачи сертификата. На одном IP-адресе могут жить несколько HTTPS сайтов. Если серверу не дать имя, он вправе выбрать сертификат виртуального хоста по умолчанию. После этого проверка цепочки может быть безупречной, но проверка имени правильного сайта всё равно не пройдёт.'), diff --git a/web/scripts/upgrade-2019-03.mjs b/web/scripts/upgrade-2019-03.mjs index e15ca52..b756890 100644 --- a/web/scripts/upgrade-2019-03.mjs +++ b/web/scripts/upgrade-2019-03.mjs @@ -119,7 +119,7 @@ const practiceArticle = createRevision( readingMinutes: 11, }, [ - paragraph('Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик Promise.then пишет в лог раньше setTimeout, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве.'), + paragraph('Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик Promise.then пишет в лог раньше setTimeout, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве. Цена ошибки — потерянное действие пользователя и повторная отправка данных.'), paragraph('Ниже не «объяснение магии Promise», а маленький воспроизводимый маршрут. Мы сначала записываем порядок синхронных строк, Promise-реакции и timer callback. Потом отдельно создаём длинную синхронную работу и измеряем её границы через performance.now(). Так одна проблема распадается на две: неожиданная очередность и занятый главный поток.'), heading('Что именно наблюдаем'), paragraph('В браузерном коде есть как минимум текущий вызов JavaScript, задачи, которые выбирает event loop, и microtask checkpoint. Promise-реакция не прерывает уже исполняющуюся функцию. Она попадает в работу после того, как текущий стек освободится. Callback таймера тоже не появляется в середине этой функции: истекшая задержка делает его кандидатом на будущую задачу. Отсюда первое правило: «через ноль миллисекунд» означает не «немедленно».'), @@ -218,7 +218,7 @@ const mechanismArticle = createRevision( readingMinutes: 12, }, [ - paragraph('Сбой начинается с простой фразы в ревью: «поставим await, тогда браузер успеет отрисовать кнопку». Иногда кнопка действительно меняется на конкретной машине, но причина не доказана. Promise-реакция не может вклиниться в середину уже идущей JavaScript-функции. Если перед await был тяжёлый parse или цикл, интерфейс уже ждал; если после await снова идёт тяжёлая работа, он будет ждать следующую границу.'), + paragraph('Сбой начинается с простой фразы в ревью: «поставим await, тогда браузер успеет отрисовать кнопку». Иногда кнопка действительно меняется на конкретной машине, но причина не доказана. Promise-реакция не может вклиниться в середину уже идущей JavaScript-функции. Если перед await был тяжёлый parse или цикл, интерфейс уже ждал; если после await снова идёт тяжёлая работа, он будет ждать следующую границу. Цена ошибки — увеличить число ожиданий, не освободив главный поток.'), paragraph('Разберём механизм без слишком широкой метафоры «у JavaScript одна очередь». В языке есть Jobs и host hooks, а в браузере — event loop, задачи, microtask checkpoint и шаги рендеринга. Для прикладного кода достаточно держать три вопроса: какая работа сейчас на стеке, что поставлено как microtask и какой callback ждёт будущую задачу. Эта тройка объясняет неожиданный порядок Promise и timer без выдуманной точности таймера.'), heading('Три слоя, которые не стоит смешивать'), paragraph('Стек исполнения — это то, что выполняется прямо сейчас. Пока синхронная функция не вернулась, браузер не переключит JavaScript на другой callback того же event loop. ECMAScript описывает Jobs как абстрактные единицы работы и определяет host hook для постановки Promise Job. Браузер связывает эту языковую часть с microtask queue: когда он дошёл до checkpoint, накопленные microtasks выполняются до перехода к обычной следующей задаче.'), diff --git a/web/scripts/upgrade-2019-04.mjs b/web/scripts/upgrade-2019-04.mjs index b9c2c58..f714992 100644 --- a/web/scripts/upgrade-2019-04.mjs +++ b/web/scripts/upgrade-2019-04.mjs @@ -194,7 +194,7 @@ const practiceArticle = createRevision( readingMinutes: 12, }, [ - paragraph('Симптом знакомый: почта в форме стала зелёной, пользователь нажал «Сохранить», а сервер вернул ошибку формата или занятости. Ещё хуже, когда ответ приходит, но текст попадает в общий баннер, а не к полю. Человек исправляет значение наугад, повторяет запрос и может создать дубль. Причина обычно не в одном регулярном выражении: у клиента, сервера и представления ошибки разные правила и разные владельцы состояния.'), + paragraph('Симптом знакомый: почта в форме стала зелёной, пользователь нажал «Сохранить», а сервер вернул ошибку формата или занятости. Ещё хуже, когда ответ приходит, но текст попадает в общий баннер, а не к полю. Человек исправляет значение наугад, повторяет запрос и может создать дубль. Причина обычно не в одном регулярном выражении: у клиента, сервера и представления ошибки разные правила и разные владельцы состояния. Цена ошибки — лишняя отправка формы и неверное решение пользователя.'), paragraph('В апреле 2019 я бы не пытался строить «универсальный валидатор». Для одной формы достаточно зафиксировать короткий контракт: какие ограничения браузер проверяет сразу, какие условия знает только сервер, в каком виде сервер возвращает ошибки и кто имеет право менять состояние поля. Ниже учебный вариант без привязки к фреймворку. Он показывает маршрут проверки; он не является результатом запуска на чужом API или браузерной трассой.'), heading('Сначала разделяем три вида проверки'), paragraph('Клиентская проверка нужна, чтобы не отправлять пустую почту или строку с очевидно неверной формой. HTML уже знает часть ограничений: required, type="email", minlength, pattern. У контрола есть validity, а checkValidity() отвечает на конкретный вопрос: проходит ли элемент его ограничения. Это удобный ранний фильтр, но не источник истины о пользователе, правах, занятости логина или правилах, которые меняются на сервере.'), @@ -493,7 +493,7 @@ const fieldArticle = createRevision( readingMinutes: 12, }, [ - paragraph('Разбор начинается с симптома, а не с библиотеки. Пользователь вводит логин ivan; форма отправляет проверку. Через мгновение он меняет значение на ivanka. Новый ответ говорит «свободно», экран становится зелёным. Затем приходит старый ответ «занято» и рисует красную строку уже под ivanka. Пользователь видит противоречие, а поддержка получает скриншот, по которому невозможно понять, какое значение проверял сервер.'), + paragraph('Разбор начинается с симптома, а не с библиотеки. Пользователь вводит логин ivan; форма отправляет проверку. Через мгновение он меняет значение на ivanka. Новый ответ говорит «свободно», экран становится зелёным. Затем приходит старый ответ «занято» и рисует красную строку уже под ivanka. Пользователь видит противоречие, а поддержка получает скриншот, по которому невозможно понять, какое значение проверял сервер. Цена ошибки — заблокировать корректный ввод или отправить устаревший результат.'), paragraph('Причина — гонка двух корректных по отдельности promise. Код записывает любой завершившийся ответ в одно состояние поля и не хранит, к какому вводу он относится. Вторая проблема обычно рядом: строка ошибки лежит в общем баннере, поэтому даже настоящий серверный отказ нельзя быстро привязать к input. Ниже учебный fixture и маршрут расследования. Он не описывает production-трассу, не заявляет о запуске браузера и не заменяет проверку конкретного API.'), heading('Реконструкция гонки без настоящей сети'), paragraph('Для расследования нам не нужен медленный сервер. Достаточно детерминированно задать два ответа в обратном порядке. Функция delayResult в автономном пакете считает ivan занятым и возвращает его спустя 30 мс; ivanka свободен и возвращается спустя 5 мс. Две проверки стартуют одна за другой. Если код применяет всё подряд, первый результат перезапишет второй. Если он сравнивает идентификатор, первый результат станет stale-response и не изменит поле.'), diff --git a/web/scripts/upgrade-2019-05.mjs b/web/scripts/upgrade-2019-05.mjs index 78c8eb1..2156967 100644 --- a/web/scripts/upgrade-2019-05.mjs +++ b/web/scripts/upgrade-2019-05.mjs @@ -194,7 +194,7 @@ const mechanismArticle = createRevision( readingMinutes: 12, }, [ - paragraph('Разработчик видит Cache-Control: max-age=60 и ожидает, что через минуту пользователь обязательно увидит новое значение. Через две минуты один браузер уже получил обновление, другой — нет, а CDN продолжает отвечать старым вариантом. Ошибка здесь не обязательно в числе 60: ответ мог быть сохранён под неполным ключом, промежуточный кэш мог получить иной контракт, а проверка свежести могла произойти не там, где её ищут.'), + paragraph('Разработчик видит Cache-Control: max-age=60 и ожидает, что через минуту пользователь обязательно увидит новое значение. Через две минуты один браузер уже получил обновление, другой — нет, а CDN продолжает отвечать старым вариантом. Ошибка здесь не обязательно в числе 60: ответ мог быть сохранён под неполным ключом, промежуточный кэш мог получить иной контракт, а проверка свежести могла произойти не там, где её ищут. Цена ошибки — показать старую цену, конфигурацию или JavaScript после релиза.'), paragraph('Разберём механизм на одном вопросе: что именно кэш считает «тем же ответом» и почему срок свежести не заменяет ключ и валидатор. Мы не будем назначать поведение конкретному CDN без его конфигурации. Вместо этого соберём модель HTTP: запрос выбирает представление, кэш оценивает его свежесть, а после истечения срока при необходимости валидирует сохранённую версию у origin.'), heading('Кэш хранит представление, а не просто URL'), paragraph('URL — начало ключа, но не всегда конец. Если origin отдаёт русский и английский HTML по одному адресу в зависимости от Accept-Language, для кэша это два представления одного ресурса. Заголовок Vary: Accept-Language говорит, что это поле запроса повлияло на содержимое. При выборе сохранённого ответа кэш должен сопоставить значения перечисленных полей с новым запросом.'), diff --git a/web/scripts/upgrade-2019-08.mjs b/web/scripts/upgrade-2019-08.mjs index d4eb818..0b75f27 100644 --- a/web/scripts/upgrade-2019-08.mjs +++ b/web/scripts/upgrade-2019-08.mjs @@ -268,7 +268,7 @@ const mechanismArticle = createRevision( readingMinutes: 14, }, [ - paragraph('Симптом выглядит противоречиво: backend показывает короткое время ответа, Network не содержит гигабайтных файлов, а пользователь всё равно ждёт пустой или нерабочий первый экран. Ошибка расследования в том, что серверный ответ принимают за завершение загрузки. Браузер после первого байта ещё должен разобрать HTML, обнаружить зависимости, получить стили, выполнить синхронный код, построить дерево рендера, декодировать нужные изображения и выделить время на paint. Быстрый origin закрывает только один участок этой цепочки.'), + paragraph('Симптом выглядит противоречиво: backend показывает короткое время ответа, Network не содержит гигабайтных файлов, а пользователь всё равно ждёт пустой или нерабочий первый экран. Ошибка расследования в том, что серверный ответ принимают за завершение загрузки. Браузер после первого байта ещё должен разобрать HTML, обнаружить зависимости, получить стили, выполнить синхронный код, построить дерево рендера, декодировать нужные изображения и выделить время на paint. Быстрый origin закрывает только один участок этой цепочки. Цена ошибки — оптимизировать сервер и оставить пользователя перед пустым экраном.'), paragraph('Здесь не нужен мифический «браузер тормозит». Нужна модель зависимостей. Одни ресурсы можно качать параллельно, но некоторые работы ждут предыдущей границы: нельзя применить внешний stylesheet до его прихода; JavaScript без defer может остановить разбор HTML; картинка, добавленная только после выполнения приложения, не будет обнаружена preload scanner из начального документа. Критический путь — не список всех файлов, а цепочка того, без чего выбранный полезный экран не может появиться.'), heading('Документ задаёт не только разметку, но и момент обнаружения'), paragraph('HTML приходит потоково. Пока браузер читает начальный документ, он может обнаружить link, script, img и начать работу с ними раньше, чем весь ответ будет получен. Поэтому важен не только размер HTML, но и место, где расположен критический URL. Если hero-изображение или основной stylesheet скрыт за JavaScript-конфигурацией, браузер узнает о нём только после новой работы; лишняя задержка возникает до реальной загрузки байтов.'), diff --git a/web/scripts/upgrade-2021-02.mjs b/web/scripts/upgrade-2021-02.mjs index 7b1081d..5b9fa45 100644 --- a/web/scripts/upgrade-2021-02.mjs +++ b/web/scripts/upgrade-2021-02.mjs @@ -510,7 +510,8 @@ const mechanismArticle = createRevision( heading('Fixture фиксирует порядок без настоящего broker'), paragraph(trainingNotice), codeBlock(fixtureCommandCode), - paragraph('Положительный fixture результат означает только восемь проверок модели: v1 построена; stale read построил v2; поздний event v2 сохранил current entry; следующий read получил v2; projection не имеет editorNote; private v3 не выдана даже до event; event private v3 не находит public entry; данные остались одним учебным object. Он не доказывает confirm от очереди, atomic write source и event, eviction Redis, invalidation CDN или response браузера. Эта граница записана рядом с примером, чтобы тест не вырос в легенду о production reliability.'), + paragraph('Положительный fixture результат означает только восемь проверок модели: v1 построена; stale read построил v2; поздний event v2 сохранил current entry; следующий read получил v2. Projection не имеет editorNote; private v3 не выдана даже до event; event private v3 не находит public entry; данные остались одним учебным object.'), + paragraph('Fixture не доказывает confirm от очереди, atomic write source и event, eviction Redis, invalidation CDN или response браузера. Эта граница записана рядом с примером, чтобы тест не вырос в легенду о production reliability.'), heading('Маршрут проектирования механизма'), orderedList([ 'Для одного read path выписать source owner, reader scope и допустимую проекцию. Если это не один contract, не пытаться решить его одним key.', diff --git a/web/scripts/upgrade-2021-10.mjs b/web/scripts/upgrade-2021-10.mjs index 948e821..b01f12c 100644 --- a/web/scripts/upgrade-2021-10.mjs +++ b/web/scripts/upgrade-2021-10.mjs @@ -544,7 +544,7 @@ const fieldArticle = createRevision( readingMinutes: 15, }, [ - paragraph('В trace учебного request heavy-вариант пересёк allocation budget 6 и получил отметку gc-boundary. Рядом есть FFI и I/O boundaries, а invalid input уходит в error response. Самая дорогая ошибка здесь — назвать отметку реальной паузой, обвинить внешнюю систему без вызова или сразу менять конфигурацию runtime. Тогда исчезают и причина, и возможность безопасно откатить change.'), + paragraph('В trace учебного request heavy-вариант пересёк allocation budget 6 и получил отметку gc-boundary. Рядом есть FFI и I/O boundaries, а invalid input уходит в error response. Самая дорогая ошибка здесь — назвать отметку реальной паузой, обвинить внешнюю систему без вызова или сразу менять конфигурацию runtime. Тогда исчезают и причина, и возможность безопасно откатить change. Цена ошибки — потратить время на изменение runtime без доказанного источника паузы.'), paragraph('Это не отчёт о production-инциденте. В нём нет реального профиля, сервера, D compiler, DRuntime, HTTP, foreign code или I/O. Есть один детерминированный in-memory request, два варианта одной обработки и error input. Его цель — собрать evidence в правильном порядке: result, stage trace, заданные units, выбранная boundary и только потом действие. Такая дисциплина полезна до того, как появятся цифры настоящего инструмента.'), heading('Собираем evidence до изменения runtime'), paragraph('Первый набор evidence небольшой: request id, нормализованный вход, success body либо error body, последовательность stages, allocation/work units модели, budget и список external boundaries. Значения, похожие на время, здесь запрещены: fixture не показывает миллисекунды, CPU или память процесса. Если соседняя система говорит о паузе, это отдельный факт с отдельным источником, а не расшифровка записи gc-boundary.'), diff --git a/web/scripts/upgrade-2021-12.mjs b/web/scripts/upgrade-2021-12.mjs index 11be27e..a83cdcc 100644 --- a/web/scripts/upgrade-2021-12.mjs +++ b/web/scripts/upgrade-2021-12.mjs @@ -621,7 +621,7 @@ const fieldArticle = createRevision( readingMinutes: 17, }, [ - paragraph('Симптом в полевом разборе конкретен: рабочая заметка уже сохранена, но публичное чтение её не подтверждает. Дорогая реакция — удалить заметку, создать вторую или «на всякий случай» отправить ещё одно событие. После этого исчезает исходная версия, два intent становятся неотличимы, а исправление может создать ещё одну projection. Сначала нужен отчёт, который переживёт вмешательство: decision id, note id, revision, intent key, observed read result и граница, на которой сделано наблюдение.'), + paragraph('Симптом в полевом разборе конкретен: рабочая заметка уже сохранена, но публичное чтение её не подтверждает. Дорогая реакция — удалить заметку, создать вторую или «на всякий случай» отправить ещё одно событие. После этого исчезает исходная версия, два intent становятся неотличимы, а исправление может создать ещё одну projection. Сначала нужен отчёт, который переживёт вмешательство: decision id, note id, revision, intent key, observed read result и граница, на которой сделано наблюдение. Цена ошибки — потерять исходную версию и усложнить повторную доставку.'), paragraph('Учебная fixture даёт такой маршрут без реального production. Она хранит decision, canonical note, outbox, projection и accepted intent keys в памяти. По одному ключу noteId:revision она различает pending relay и duplicate delivery. Она не знает БД, HTTP, broker, retry сети, внешнего consumer, инцидента, SLO или реальных прав. Поэтому результат «public-read-ready-in-training» не означает, что текст доступен пользователю; он означает только, что локальная модель дошла до своего объявленного состояния.'), heading('Собираем evidence раньше, чем меняем источник'), paragraph('Первый вопрос: «что уже доказано?». В карточке нужны immutable identifiers, а не пересказ симптома. Для кейса это decision id, note id, revision и intent key. Затем — фактическое read observation: какой путь чтения проверяли, какой результат получили, когда и в каком scope. В учебной модели нет времени и прав доступа, поэтому эти поля не выдуманы. Реальная система добавляет только разрешённые данные, которые различают ветки: storage commit, relay receipt, projection version, filter или permission check.'), diff --git a/web/scripts/upgrade-2022-08.mjs b/web/scripts/upgrade-2022-08.mjs index 0d2ffd9..ef65a0a 100644 --- a/web/scripts/upgrade-2022-08.mjs +++ b/web/scripts/upgrade-2022-08.mjs @@ -206,7 +206,7 @@ const mechanismArticle = createRevision({ excerpt: 'Разбираем порядок состояний media slot и границу утверждений: разметка, геометрия, declared load/decode и внешний observation record не являются измерением браузерной метрики.', readingMinutes: 13, }, [ - paragraph('После добавления оптимизации медиа часто возникает странный спор: один разработчик показывает unit test, другой — trace, а оба называют это «готовым LCP fix». Проблема такой подмены в том, что решение нельзя ни подтвердить, ни откатить: не ясно, что именно изменилось — markup, размеры, очередь, декодирование или условия запуска. При следующем изменении команда сравнит несравнимые результаты.'), + paragraph('После добавления оптимизации медиа часто возникает странный спор: один разработчик показывает unit test, другой — trace, а оба называют это «готовым LCP fix». Проблема такой подмены в том, что решение нельзя ни подтвердить, ни откатить: не ясно, что именно изменилось — markup, размеры, очередь, декодирование или условия запуска. При следующем изменении команда сравнит несравнимые результаты. Цена ошибки — потратить время на «ускорение», которое не меняет путь первого экрана.'), paragraph('Причина в том, что у этих фактов разные владельцы. Компонент владеет описанием slot и может требовать geometry. Приложение может назначить намерение важности. Браузер выполняет загрузку, decode и layout; измерительный код наблюдает его результат при конкретных условиях. Учебный контракт ниже не притворяется браузером: он хранит declared transitions и принимает внешний record только как supplied data.'), heading('Пять слоёв вместо одного флага loaded'), table('Состояния, которые нельзя заменить одним boolean', ['Слой', 'Владелец', 'Наблюдаемый факт', 'Чего факт не доказывает'], [ diff --git a/web/scripts/upgrade-2023-03.mjs b/web/scripts/upgrade-2023-03.mjs index bdb5e69..e369cee 100644 --- a/web/scripts/upgrade-2023-03.mjs +++ b/web/scripts/upgrade-2023-03.mjs @@ -419,7 +419,7 @@ const field = revision({ excerpt: 'Полевой маршрут для CORS error, preflight и CSRF 403: собрать факты, разделить границы, исправить один контракт и сохранить отрицательный тест.', readingMinutes: 12, }, [ - p(`После выката frontend на новый host интерфейс не получает данные или mutation заканчивается ошибкой. Самое рискованное действие — сделать CORS глобально permissive или выключить CSRF «для проверки». Такой change может пережить инцидент и расширить доступ для origin без review. Диагностика начинается с наблюдаемого request contract, а не с флага middleware.`), + p(`После выката frontend на новый host интерфейс не получает данные или mutation заканчивается ошибкой. Самое рискованное действие — сделать CORS глобально permissive или выключить CSRF «для проверки». Такой change расширит доступ без review. Диагностика начинается с request contract, не с флага middleware. Цена ошибки — дать origin доступ к данным или mutation.`), p(`Маршрут разделяет CORS error, OPTIONS failure и CSRF rejection. Локальный fixture не изображает сеть: его inputs — заданные labels, выход — решения учебного контракта. Он не сообщает response proxy, cookie delivery или access log. Для этого нужен реальный browser evidence в контролируемой среде, без переноса production cookie и secrets в заметку.`), h2('Соберите факты до первого исправления'), p(`Начните с пяти значений: полный origin страницы, URL target, method, content type и имена request headers. Потом добавьте status и response headers, которые видны в browser DevTools или на boundary proxy, а также application reason, если он не раскрывает token. Разница между https://app.example.test и https://app.example.test:8443 существенна; разница между POST form и PATCH JSON тоже существенна. Лог «CORS failed» без этих полей — не доказательство причины.`), diff --git a/web/scripts/upgrade-2023-08.mjs b/web/scripts/upgrade-2023-08.mjs index 499364d..7108e89 100644 --- a/web/scripts/upgrade-2023-08.mjs +++ b/web/scripts/upgrade-2023-08.mjs @@ -538,7 +538,7 @@ const mechanism = revision({ excerpt: 'Модель устойчивого e2e-теста: auto-wait готовит действие с элементом, readiness доказывает результат, retry классифицирует попытки, а trace остаётся evidence одного запуска.', readingMinutes: 13, }, [ - p('У e2e-теста часто один большой timeout и один текст ошибки, хотя внутри живут четыре независимых контракта. Locator должен найти ровно тот control, auto-wait должен сделать действие допустимым, продуктовый assertion должен дождаться результата, а retry должен сохранить факт повторного запуска. Когда все четыре слоя названы словом «ожидание», падение становится непонятным: инженер видит TimeoutError, но не знает, кнопка не нашлась, была перекрыта, результат не наступил или retry изменил исходные условия.'), + p('У e2e-теста часто один большой timeout и один текст ошибки, хотя внутри живут четыре независимых контракта. Locator должен найти ровно тот control, auto-wait должен сделать действие допустимым, продуктовый assertion должен дождаться результата, а retry должен сохранить факт повторного запуска. Когда все четыре слоя названы словом «ожидание», падение становится непонятным: инженер видит TimeoutError, но не знает, кнопка не нашлась, была перекрыта, результат не наступил или retry изменил исходные условия. Цена ошибки — замедлить CI и оставить flaky-тест без причины.'), p('После первой удобной правки команда повышает timeout, затем добавляет retry. Часть ошибок превращается в длинные flaky, CI медленнее, а trace не связан с гипотезой. Вместо этого один тест раскладывают на четыре контракта: у каждого свой вопрос, evidence, владелец изменения и rollback.'), h2('Контракт действия не равен контракту результата'), p('В Playwright 1.37.0 auto-wait перед click() проверяет набор actionability conditions. Для click это attached, visible, stable, receives events и enabled. Этот механизм решает узкую задачу: не отправить действие в элемент, который исчез, невидим, движется, перекрыт или disabled. Он не может узнать смысл вашей операции. Кнопка может быть полностью ready для click, но сервер вернёт отказ, клиент покажет validation error или асинхронное подтверждение не появится.'), diff --git a/web/scripts/upgrade-2025-04.mjs b/web/scripts/upgrade-2025-04.mjs index d334dd2..3de59ea 100644 --- a/web/scripts/upgrade-2025-04.mjs +++ b/web/scripts/upgrade-2025-04.mjs @@ -821,7 +821,8 @@ const field = revision({ ].join('\n')), p('Fixture добавляет более жёсткие границы, чем happy path. Extra key и missing key не проходят exact contract. Forged authorization и расширенный scope не совпадают с fixed request proof. Sparse array и cyclic value не становятся «пустым списком», а закрываются без исключения. Отдельные cases проверяют requester access, authorization expiry и wrong egress scope. Это не антифрод и не DLP; это проверка, что сам учебный gate не переходит от отсутствующих фактов к неявному разрешению.'), h2('Как оформить human review без ложного юридического вывода'), - p('Вопрос владельцу должен быть конкретнее, чем «можно ли использовать AI?». Например: «для record class X, surface Y и destination Z: какое allowability rule действует, каким immutable document подтверждается retention/training condition, кто является authority, какой requester и expiry покрыты?» Такой вопрос не утверждает, что DPA сам по себе разрешает передачу или что UI setting гарантирует место обработки. Он просит evidence, по которому организация вправе сделать собственный вывод.'), + p('Вопрос владельцу должен быть конкретнее, чем «можно ли использовать AI?». Например: «для record class X, surface Y и destination Z: какое allowability rule действует, каким immutable document подтверждается retention/training condition?»'), + p('В том же вопросе нужно назвать authority, requester и expiry. Такая формулировка не утверждает, что DPA сам по себе разрешает передачу или что UI setting гарантирует место обработки. Она просит evidence, по которому организация вправе сделать собственный вывод.'), p('Полезно разделить роли. Data owner подтверждает class и преобразование. Service or legal owner сопоставляет policy/contract с выбранной surface. Network owner подтверждает path и egress control. Security or privacy owner проверяет access and authorization process. Один человек может совмещать роли в небольшой компании, но в review всё равно стоит записать, какой вопрос он закрыл. Это снижает риск «одобрения вообще» и позволяет вернуть только спорный слой на доработку.'), h2('Что дают источники, а чего они не дают'), p('GitHub Docs на commit 30 апреля 2025 говорит, что в конкретной Copilot Chat surface prompt может обрабатываться вместе с context, а Bing search при включении отправляет сформированный query в Bing Search API. Это поддерживает постановку вопроса о расширенном context и отдельной внешней границе. Документ SKU isolation показывает пример endpoint-level firewall control. Он не описывает class конкретного лога, не подтверждает retention или training terms организации и не назначает человека, который может выдать approval.'), diff --git a/web/scripts/upgrade-legacy-archive.mjs b/web/scripts/upgrade-legacy-archive.mjs new file mode 100644 index 0000000..331bd5a --- /dev/null +++ b/web/scripts/upgrade-legacy-archive.mjs @@ -0,0 +1,306 @@ +import archive from '../data/articles.json' with { type: 'json' }; +const archiveBySlug = new Map(archive.map((article) => [article.slug, article])); + +function p(text) { + return '

    ' + text + '

    '; +} + +function h2(text) { + return '

    ' + text + '

    '; +} + +function escapeHtml(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +function codeBlock(lines) { + return '
    ' + escapeHtml(lines.join('\n')) + '
    '; +} + +function table(headers, rows) { + const head = '
' + headers.map((item) => '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + ''; + return '
' + item + '
' + item + '
' + head + body + '
' + headers[0] + ': рабочая матрица проверки
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function sources(items) { + return ''; +} + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:quot|amp|lt|gt|#039);/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function legacyBody(article, limit = 6600, omitPatterns = []) { + const withoutSources = (article.contentHtml || '') + .replace(/

Проверяемые источники<\/h2>[\s\S]*$/i, '') + .replace(/
[\s\S]*?<\/figure>/gi, '') + .replace(/]*>([\s\S]*?)<\/a>/gi, '$1') + .replace(/<\/?div[^>]*>/gi, ''); + const blocks = withoutSources.match(/<(?:p|h2|h3|pre|ul|ol)\b[\s\S]*?<\/(?:p|h2|h3|pre|ul|ol)>/gi) || []; + const result = []; + let length = 0; + for (const block of blocks) { + if (omitPatterns.some((pattern) => pattern.test(plainText(block)))) continue; + const blockLength = plainText(block).length; + if (result.length > 0 && length + blockLength > limit) break; + result.push(block); + length += blockLength; + } + return result.join('\n'); +} + +const cases = { + 'использование-jquery-в-webpack': { + title: 'JavaScript. jQuery в Webpack: глобальная зависимость без скрытого порядка', + excerpt: 'Как подключить jQuery к Webpack-проекту, когда часть кода ждёт window.jQuery, а новые модули используют import.', + cover: '/assets/illustrations/jquery-webpack.svg', + problem: 'Старая страница видит `$`, а новый модуль получает пустое значение. Цена ошибки — либо дублированная библиотека в каждом бандле, либо плагины, которые работают только из-за случайного порядка подключения.', + context: 'Оригинальный материал правильно начинает с установки пакета и `ProvidePlugin`. В редактуре важно разделить две задачи: дать старому коду совместимое глобальное имя и оставить импорт явным в новых модулях. Если смешать их в одном правиле сборки, после смены entry-файла ошибка проявится только на части страниц.', + mechanism: ['Webpack строит граф модулей из import и require. Глобальная переменная не входит в этот граф как обычная зависимость, поэтому старый плагин может работать только при дополнительном правиле ProvidePlugin или явной записи в window.', 'ProvidePlugin подставляет импорт в местах, где встречается идентификатор. Это не делает jQuery глобальной для любого скрипта, загруженного отдельно через HTML. Для такого скрипта нужен один согласованный entry и одна точка экспорта.', 'Если библиотека уже приходит с CDN, её следует объявить external и проверить, что глобальное имя появляется раньше потребителя. Две независимые копии jQuery дают разные объекты и ломают плагины, которые сравнивают `$.fn` или регистрируют обработчики.'], + code: ['const webpack = require(\'webpack\');', '', 'module.exports = {', ' entry: {', ' legacy: \'./src/legacy-entry.js\',', ' modern: \'./src/modern-entry.js\'', ' },', ' plugins: [', ' new webpack.ProvidePlugin({', ' $: \'jquery\',', ' jQuery: \'jquery\'', ' })', ' ]', '};', '', '// В новом модуле зависимость остаётся видимой.', "import $ from 'jquery';", 'export function mount() {', ' return $.fn && $.fn.jquery;', '}'], + rows: [['Слой', 'Что проверяем', 'Типичная ошибка'], ['Новый модуль', 'Есть `import $ from \'jquery\'`', 'Зависимость спрятана в window'], ['Старый плагин', 'Потребитель получает тот же объект', 'Созданы две копии jQuery'], ['Entry', 'Библиотека загружена до потребителя', 'Порядок зависит от HTML'], ['CDN', 'external и глобальное имя согласованы', 'Бандл ожидает модуль, а получает URL']], + steps: ['Определить, какие файлы используют import, а какие обращаются к `$` или `window.jQuery`.', 'Оставить один способ доставки jQuery для каждого entry: пакет или external, но не случайную смесь.', 'Добавить ProvidePlugin только для legacy-кода и проверить итоговый граф сборки.', 'Запустить страницу с реальным старым плагином и убедиться, что объект jQuery один.', 'После миграции каждого потребителя удалить лишнее глобальное правило и зафиксировать это в тесте сборки.'], + limits: ['ProvidePlugin не исправляет порядок независимых `