diff --git a/editorial/production/README.md b/editorial/production/README.md index ebf49a9..f390b4e 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 61 из 358 созданных материалов. Остальные 297 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 64 из 358 созданных материалов. Остальные 294 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2019-10-draft.md b/editorial/reviews/2019-10-draft.md new file mode 100644 index 0000000..d3c1903 --- /dev/null +++ b/editorial/reviews/2019-10-draft.md @@ -0,0 +1,128 @@ +# P20 · октябрь 2019 · миграция JavaScript на TypeScript — тройное ревью + +Статус: **принят в публикационный слой 31 июля 2026 года**. Registry применяет +три revision по стабильным slug и сохраняет дату и автора базового архива: + +- editorial-2019-10-practice-typescript-migration; +- editorial-2019-10-mechanism-typescript-migration; +- editorial-2019-10-field-typescript-migration. + +Модуль экспортирует ровно три revision без полей date и +author. При прямом вызове с --print-revisions он +печатает только JSON, совпадающий с import-safe export. В статьях типы, +fixture и метрики показаны как методы проверки; ни один вымышленный +production-результат не выдан за измерение. + +## Проход 1. Факты и техника — пройдено + +| Утверждение или решение | Первичный источник | Проверенная граница | +| --- | --- | --- | +| JavaScript-синтаксис допустим в TypeScript, поэтому файл можно переносить постепенно, а не одномоментно | [TypeScript Handbook: Migrating from JavaScript](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html) | Практика не обещает массовое переименование; в маршруте сохранены соседние JavaScript-модули | +| TypeScript-специфичные конструкции не становятся runtime-проверкой после компиляции | [TypeScript Handbook: Migrating from JavaScript](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html), [TypeScript 3.5 release notes](https://devblogs.microsoft.com/typescript/announcing-typescript-3-5/) | В механизме type alias отделён от исполняемого normalizer; сетевой payload не объявлен проверенным только из-за interface | +| allowJs принимает JavaScript-файлы рядом с TypeScript, что подходит для поэтапного переноса | [TSConfig: allowJs](https://www.typescriptlang.org/tsconfig/allowJs.html) | Конфигурация fixture держит allowJs и noEmit; она не меняет format модулей и output production-сборки | +| checkJs выдаёт diagnostics для JavaScript; локальный @ts-check позволяет начать с одного файла | [TSConfig: checkJs](https://www.typescriptlang.org/tsconfig/checkJs.html) | В practice и field @ts-check стоит в одном boundary-файле, а не выдаётся за включение проверки всего legacy-дерева | +| TypeScript 3.5 уже содержит нужную для примера эпоху: проверку JavaScript, unknown, type predicates и релизные изменения вокруг generic checking | [TypeScript 3.5 release notes](https://devblogs.microsoft.com/typescript/announcing-typescript-3-5/) | Нет позднего синтаксиса, satisfies, optional chaining, Zod, современных стратегий Node module resolution или заявлений зрелости 2027 года | + +### Честная граница фикстур + +Код normalizer проверяет учебный объект Member и предназначен для +трёх контролируемых входов: валидный объект, объект без email и +объект с чужим status. Он показывает, где runtime-код должен +остановить неясный payload. Фикстура не вызывает реальный API, не измеряет +скорость сборки и не подтверждает ответ конкретной сетевой библиотеки. + +Конфигурация с noEmit — безопасный первый режим проверки, а не +совет заменить существующий delivery pipeline. Если конкретный проект +генерирует JavaScript через tsc, интегратор обязан отдельно +сопоставить target, module, output path и реальную +production-команду. Этот риск в статьях назван ограничением, а не скрыт +обещанием «переход пройдёт безболезненно». + +Вердикт прохода: **пройден**. Технические утверждения привязаны к официальным +источникам TypeScript, а свойства, зависящие от конкретного проекта, оставлены +явными задачами проверки. + +## Проход 2. Редактура и голос М2 / 2019 — пройдено + +| Ревизия | Симптом и цена в начале | Главный вопрос | Практический артефакт и ограничение | +| --- | --- | --- | --- | +| Практика | Массовый rename создаёт any и останавливает выпуск; цена — длинная ветка без полезной проверки данных | Как начать миграцию с границы и сохранить build | tsconfig fixture, JSDoc normalizer, таблица границ и выпускной маршрут; процент .ts не выдан за качество | +| Механизм | Файл уже .ts, но неверный payload доходит до экрана; цена — место ответственности скрыто за any | Почему типы исчезают в runtime и где ставить проверку | сравнение исходника и JavaScript, normalizer unknown → Member, таблица ролей; статический анализ не выдан за runtime-защиту | +| Полевой разбор | Legacy transport отдаёт response.body напрямую; цена — неясный откат и риск сломать delivery | Как перенести один поток без массового rewrite | автономная fixture transport → normalizer → screen и четыре gate; нет заявления о запуске реального сервера | + +- Первые два абзаца каждой статьи называют симптом, проблему и стоимость + неверного маршрута. Дальше текст держит цепочку: симптом → причина → + проверка → действие → ограничение. +- Речь короткая и техническая: вместо общих оценок названы + allowJs, checkJs, noEmit, + unknown, any, normalizer, contract fixture, + type-check и build gate. +- Автор соответствует М2 / 2019: он уже связывает frontend-код, delivery и + API-границу, но не приписывает себе поздние практики платформенной команды, + SLO, современную схему валидации или зрелую организационную программу + миграции. +- Три текста не дублируют друг друга: практика задаёт маршрут, механизм + разбирает исчезновение типов и роль any, полевой разбор + собирает минимальную выпускную партию. +- Объём, число разделов, таблица с caption/thead, + код, упорядоченный маршрут, figure с alt/caption и два или больше источника + подлежат независимой автоматической проверке draft gate. + +Вердикт прохода: **пройден**. Тексты сохраняют практический голос автора +2019 года и не подменяют решение эффектными обещаниями. + +## Проход 3. Визуал и выпуск — пройдено в пределах автономного пакета + +- typescript-migration-lane-2019.svg показывает четыре малых + шага: baseline выпуска, границу данных, локальный @ts-check и + один TypeScript-модуль; выпускные gate вынесены в нижний блок. Визуал не + предлагает переписать всё дерево файлов. +- typescript-migration-type-boundary-2019.svg противопоставляет + нормальный путь unknown → runtime normalizer → Member красному + обходу через any. Это уточняет границу ответственности, а не + дублирует таблицу из статьи. +- typescript-migration-release-gates-2019.svg отделяет contract + fixture, type-check, существующий build и smoke. Четыре карточки прямо + показывают, что один успешный gate не доказывает остальные. +- У всех SVG есть title, desc, + role="img" и aria-labelledby. В статьях + предусмотрены самостоятельные содержательные alt-тексты и + figcaption. SVG не содержат JavaScript, внешних URL, + foreignObject или растровых data URI. +- Независимый мобильный preflight отрисовал каждый SVG через Sharp в PNG + шириной 375 px. Первый вариант двух схем содержал слишком мелкие вторичные + подписи. Их заменили на вертикальные композиции с короткими подписями; + повторный рендер не показал обрезания, наложения или горизонтального + выхода. Это проверка статичных схем, а не browser-review страницы. +- После подключения registry строгий аудит подтвердил три revision, а + production build сгенерировал 374 статические страницы. Реальный browser + review остаётся отдельной проверкой поведения и не заявлен как выполненный. + +### Фактические проверки + +Запускаются следующие независимые проверки: + +
node --check web/scripts/upgrade-2019-10.mjs
+cd web && npm run audit:draft -- scripts/upgrade-2019-10.mjs
+xmllint --noout \
+  web/public/assets/editorial/2019/typescript-migration-lane-2019.svg \
+  web/public/assets/editorial/2019/typescript-migration-type-boundary-2019.svg \
+  web/public/assets/editorial/2019/typescript-migration-release-gates-2019.svg
+ +Результат независимого запуска 31 июля 2026 года: + +| Проверка | Результат | +| --- | --- | +| node --check | PASS, синтаксис модуля корректен | +| --print-revisions и import-safe export | PASS внутри draft gate: stdout — только JSON, export совпадает с CLI и содержит ровно три revision | +| npm run audit:draft -- scripts/upgrade-2019-10.mjs | PASS: 9 756 / 9 495 / 10 347 знаков основного текста; у каждого текста есть проблема в начале, пять или больше разделов, figure, таблица, код, маршрут и источники | +| xmllint --noout для трёх SVG | PASS, XML корректен | +| Strict audit после подключения registry | PASS: 9 756 / 9 495 / 10 347 знаков; по одному figure и table, по два code example | +| npm run build | PASS, code 0, 374 статические страницы | +| Scope/self-review | PASS: в revision нет date/author; в SVG нет script, foreignObject, внешних ссылок или data URI; articles.json не перезаписан | + +Выпусковой вердикт: **тройное ревью пройдено, пакет принят к публикации**. +articles.json не менялся; registry заменяет только редакционные +поля по стабильному slug. Поведение на реальной странице в выбранном браузере +нужно проверить отдельно: production build и raster preflight этого не +подменяют. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 49f021d..8c71dd8 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -16,6 +16,7 @@ import { revisions as june2019Revisions } from '../scripts/upgrade-2019-06.mjs'; import { revisions as july2019Revisions } from '../scripts/upgrade-2019-07.mjs'; import { revisions as august2019Revisions } from '../scripts/upgrade-2019-08.mjs'; import { revisions as september2019Revisions } from '../scripts/upgrade-2019-09.mjs'; +import { revisions as october2019Revisions } from '../scripts/upgrade-2019-10.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -37,4 +38,5 @@ export const editorialRevisions = [ ...july2019Revisions, ...august2019Revisions, ...september2019Revisions, + ...october2019Revisions, ]; diff --git a/web/public/assets/editorial/2019/typescript-migration-lane-2019.svg b/web/public/assets/editorial/2019/typescript-migration-lane-2019.svg new file mode 100644 index 0000000..d5961af --- /dev/null +++ b/web/public/assets/editorial/2019/typescript-migration-lane-2019.svg @@ -0,0 +1,50 @@ + + Постепенная миграция JavaScript-проекта на TypeScript + Вертикальная схема из четырёх шагов: зафиксировать рабочий выпуск, выбрать границу данных, включить проверку JavaScript, перенести один модуль и повторить выпускные проверки. + + + + + + + + + + + + TypeScript 3.5: миграция по границам + Один шов, прежний выпуск, маленький merge + + + + 1 + Зафиксировать рабочий выпуск + build-команда, артефакт, один smoke-путь + + + + + 2 + Выбрать одну границу данных + API → normalizer → модель экрана + + + + + 3 + Проверить старый JavaScript + allowJs + локальный @ts-check в опасном шве + + + + + 4 + Перенести один модуль в .ts + Не менять module format и delivery + в той же партии. + + + type-check → прежний build + smoke → маленький merge + Выпуск проверяет границу, а не процент файлов .ts. + diff --git a/web/public/assets/editorial/2019/typescript-migration-release-gates-2019.svg b/web/public/assets/editorial/2019/typescript-migration-release-gates-2019.svg new file mode 100644 index 0000000..8fca532 --- /dev/null +++ b/web/public/assets/editorial/2019/typescript-migration-release-gates-2019.svg @@ -0,0 +1,54 @@ + + Выпускные проверки для одной TypeScript-границы + Вертикальная диаграмма показывает одно изменение от JavaScript transport к TypeScript normalizer и четыре независимые проверки: fixture, type-check, прежний build и smoke-сценарий. + + + + + + + + + + + + Одна граница — четыре gate + Каждая проверка отвечает на свой вопрос. + + + Изменение одного шва + member-api.js → normalizer.ts → screen + unknown → Member проходит только через normalizer + + + + + 1 + Contract fixture + неполный payload не становится Member + + + + + 2 + Type-check + imports и контракт видны compiler + + + + + 3 + Прежний build + всё ещё создаёт известный артефакт + + + + + 4 + Smoke + известный Member доходит до экрана + + + Маленький rollback остаётся возможным + transport и pipeline не меняются в той же партии + diff --git a/web/public/assets/editorial/2019/typescript-migration-type-boundary-2019.svg b/web/public/assets/editorial/2019/typescript-migration-type-boundary-2019.svg new file mode 100644 index 0000000..7709a08 --- /dev/null +++ b/web/public/assets/editorial/2019/typescript-migration-type-boundary-2019.svg @@ -0,0 +1,45 @@ + + Граница между внешними данными и типизированным кодом + Вертикальная схема показывает путь неизвестного сетевого payload через исполняемый normalizer к модели Member и отдельный опасный обход через any. + + + + + + + + + + + + + + + Граница типов не заменяет runtime + Входные данные проверяет исполняемый код. + + + 1. Внешний payload + HTTP, форма, legacy callback + + unknown + + + + 2. Normalizer исполняется в JavaScript + runtime-проверка + проверяет поля и варианты status + возвращает Member, null или понятную ошибку + + + + 3. Ядро получает проверенный Member + screen и formatter используют контракт + а не исходный сетевой объект + + + + any обходит normalizer + ошибка проявится дальше по цепочке + TypeScript проверяет контракт; runtime проверяет вход. + diff --git a/web/scripts/upgrade-2019-10.mjs b/web/scripts/upgrade-2019-10.mjs new file mode 100644 index 0000000..616bc4f --- /dev/null +++ b/web/scripts/upgrade-2019-10.mjs @@ -0,0 +1,359 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function paragraph(text) { + return '

' + text + '

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

' + text + '

'; +} + +function codeBlock(code) { + return '
' + String(code).trim() + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function bulletList(items) { + return ''; +} + +function dataTable(caption, headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + caption + '
'; +} + +function sourceList(items) { + return ''; +} + +function createRevision(meta, bodyParts, sources) { + if (sources.length < 2) { + throw new Error(meta.slug + ': at least two primary sources are required'); + } + + return { + ...meta, + contentHtml: bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources), + }; +} + +const migrationGuide = { + title: 'TypeScript Handbook: Migrating from JavaScript', + url: 'https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html', + note: 'описывает постепенный переход: JavaScript остаётся входом компилятора при allowJs, а файлы можно переводить по одному', +}; + +const allowJs = { + title: 'TSConfig Reference: allowJs', + url: 'https://www.typescriptlang.org/tsconfig/allowJs.html', + note: 'разрешает включать JavaScript-файлы вместе с TypeScript и тем самым не требует одномоментного переименования всего дерева', +}; + +const checkJs = { + title: 'TSConfig Reference: checkJs', + url: 'https://www.typescriptlang.org/tsconfig/checkJs.html', + note: 'включает диагностические сообщения для JavaScript-файлов; эквивалентный локальный маркер — комментарий @ts-check', +}; + +const release35 = { + title: 'TypeScript 3.5 release notes', + url: 'https://devblogs.microsoft.com/typescript/announcing-typescript-3-5/', + note: 'майский релиз 2019 года: в нём зафиксированы улучшения проверки, incremental-сборки и уточнения поведения generic-параметров', +}; + +const stagedConfig = [ + '{', + ' "compilerOptions": {', + ' "target": "es5",', + ' "module": "commonjs",', + ' "allowJs": true,', + ' "checkJs": false,', + ' "noEmit": true', + ' },', + ' "include": ["src/**/*"]', + '}', +].join('\n'); + +const checkedBoundaryFixture = [ + '// @ts-check', + '', + '/**', + ' * @typedef {{ id: string, email: string }} Account', + ' */', + '', + '/**', + ' * @param {unknown} value', + ' * @returns {Account | null}', + ' */', + 'function toAccount(value) {', + ' if (!value || typeof value !== "object") return null;', + '', + ' /** @type {{ [key: string]: unknown }} */', + ' const record = value;', + ' if (typeof record.id !== "string") return null;', + ' if (typeof record.email !== "string") return null;', + '', + ' return { id: record.id, email: record.email };', + '}', + '', + 'module.exports = { toAccount };', +].join('\n'); + +const erasedTypesFixture = [ + '// profile.ts', + 'type Profile = { id: string, email: string };', + '', + 'export function profileLabel(profile: Profile): string {', + ' return profile.id + " <" + profile.email + ">";', + '}', + '', + '// profile.js after compilation', + '"use strict";', + 'Object.defineProperty(exports, "__esModule", { value: true });', + 'function profileLabel(profile) {', + ' return profile.id + " <" + profile.email + ">";', + '}', + 'exports.profileLabel = profileLabel;', +].join('\n'); + +const safeDecoderFixture = [ + 'type Member = {', + ' id: string;', + ' email: string;', + ' status: "active" | "blocked";', + '};', + '', + 'function isMember(value: unknown): value is Member {', + ' if (!value || typeof value !== "object") return false;', + ' const record = value as { [key: string]: unknown };', + ' return typeof record.id === "string"', + ' && typeof record.email === "string"', + ' && (record.status === "active" || record.status === "blocked");', + '}', + '', + 'export function normalizeMember(value: unknown): Member | null {', + ' return isMember(value) ? value : null;', + '}', +].join('\n'); + +const legacyApiFixture = [ + '// member-api.js остаётся JavaScript на первом шаге.', + '// @ts-check', + '', + '/** @param {string} memberId */', + 'function loadMember(memberId) {', + ' return request("/members/" + memberId).then(function(response) {', + ' return response.body;', + ' });', + '}', + '', + 'module.exports = { loadMember };', + '', + '// member-screen.ts — новый узкий TypeScript-модуль.', + 'import { loadMember } from "./member-api";', + 'import { normalizeMember } from "./normalize-member";', + '', + 'export function showMember(memberId: string): Promise<string> {', + ' return loadMember(memberId).then(function(payload) {', + ' const member = normalizeMember(payload);', + ' if (!member) throw new Error("Ответ участника не соответствует контракту");', + ' return member.email;', + ' });', + '}', +].join('\n'); + +const practiceArticle = createRevision( + { + slug: 'editorial-2019-10-practice-typescript-migration', + title: 'Переход на TypeScript: начать с границы, а не с массового any', + categories: ['TypeScript', 'Frontend', 'Практика'], + cover: '/assets/editorial/2019/typescript-migration-lane-2019.svg', + excerpt: 'Переименование всех файлов создаёт шум, а не типы. Разбираем постепенный маршрут TypeScript 3.5: выбрать границу данных, оставить JavaScript в сборке и считать полезную проверку.', + readingMinutes: 14, + }, + [ + paragraph('Симптом миграции легко узнать по pull request: несколько десятков файлов получают расширение .ts, вокруг каждого неудобного вызова появляется any, а выпуск задерживается из-за неясного набора ошибок. Проблема не в самом TypeScript. Команда начала заменять суффиксы файлов, не договорившись, какие данные становятся проверяемыми и где разрешено временно сохранить старый JavaScript. Цена — длинная ветка, в которой сборка то проходит, то нет, а новые типы почти не меняют риск ошибки.'), + paragraph('В октябре 2019 года я бы не ставил задачу «перевести приложение на TypeScript». Она не сообщает, где остановиться и как выпускать изменения. Более полезная формулировка: взять один входной контракт, сохранить рабочий путь сборки и добиться, чтобы в выбранном модуле неверные данные не уходили дальше без проверки. JavaScript синтаксис остаётся законным TypeScript-синтаксисом, а типовые записи исчезают из результата выполнения. Поэтому постепенный путь не обязан ломать действующий runtime.'), + heading('Нулевая точка: отделяем выпуск от переименования'), + paragraph('Сначала записываю, что считается рабочим выпуском до миграции. Это не абстрактное «зелёный CI», а конкретные команды и выходы: существующая сборка, один smoke-маршрут страницы и формат артефакта, который забирает сервер или CDN. Переименование .js в .ts не является доказательством, что этот контракт сохранён. Если вместе с расширением меняются module format, путь импорта и таргет, команда отлаживает три причины сразу и теряет точку возврата.'), + paragraph('Следом выбираю один узкий участок, в котором данные меняют владельца: ответ HTTP превращается в модель экрана, параметры формы — в команду API, конфигурация — в объект приложения. На такой границе тип отвечает на вопрос «что именно допускаем дальше». Внутренний вспомогательный файл без входов и выходов можно перенести позже; он не даст раннего сигнала о пользе. Граница удобна ещё и тем, что ошибку можно проверить фикстурой: передать неполный объект и убедиться, что он не попал в типизированный код.'), + figure('/assets/editorial/2019/typescript-migration-lane-2019.svg', 'Схема постепенной миграции: существующие JavaScript-модули идут в сборку, выбранная граница получает проверку через checkJs и JSDoc, затем один модуль переводится в TypeScript; рабочий выпуск остаётся отдельным контрольным пунктом', 'Миграция идёт не от каталога к каталогу, а от проверяемой границы к следующей. Сборка остаётся самостоятельным gate на каждом шаге.'), + heading('Граница важнее процента файлов'), + paragraph('Процент файлов с расширением .ts выглядит удобной метрикой, но он ничего не говорит о данных. Можно перевести сто вспомогательных функций и всё равно пропустить объект ответа без полей id и email в экран. Для первой партии я считаю только названные границы. У каждой есть источник, ожидаемая форма, место проверки и получатель. Если хотя бы одного пункта нет, запись не попадает в счётчик и не создаёт ложного ощущения прогресса.'), + dataTable( + 'Матрица выбора первой границы', + ['Кандидат', 'Риск без проверки', 'Минимальный контракт', 'Первый шаг', 'Что не менять сейчас'], + [ + ['Ответ API для карточки', 'экран ожидает поле, которого сервер не прислал', 'id, email и status перед рендером', 'описать вход через JSDoc или TypeScript-функцию нормализации', 'транспорт, URL и формат production-сборки'], + ['Параметры формы', 'строка уходит в команду как неверное значение', 'состояние формы и команда отправки', 'сделать явный объект команды рядом с submit', 'все компоненты формы и стили'], + ['Конфигурация окружения', 'пустой ключ даёт сбой уже после выкладки', 'обязательные строки конфигурации', 'проверить объект в одном загрузчике', 'систему secrets и способ доставки переменных'], + ['Внутренний helper', 'ошибка редко пересекает модульную границу', 'локальные аргументы', 'оставить на вторую очередь', 'никакой массовой конвертации ради процента'], + ], + ), + paragraph('Это не рейтинг важности компонентов. Это способ не начинать с самого большого каталога. Первый контракт должен быть достаточно мал, чтобы один разработчик объяснил его в ревью, и достаточно близок к внешнему входу, чтобы TypeScript нашёл настоящий класс ошибок. Если сервис возвращает произвольный JSON, тип интерфейса сам по себе не проверит ответ в runtime: значение всё равно приходит из сети. В этом месте нужен явный код проверки, а не уверенность, что объявление type защитило процесс.'), + heading('Подключаем compiler без остановки JavaScript'), + paragraph('Опция allowJs позволяет включать .js рядом с .ts и .tsx. Для перехода это важнее, чем красивый каталог: команда может перевести один модуль, пока его соседи остаются JavaScript. checkJs добавляет диагностику в JavaScript-файлы; её можно включить на весь проект или начать с комментария @ts-check в выбранном файле. Я начинаю локально. Массовое включение проверки превращает первую итерацию в разбор старого долга, а не в проверку новой границы.'), + paragraph('Конфигурация ниже — fixture, а не универсальный tsconfig. Она показывает порядок: compiler видит и JavaScript, и TypeScript, но на первом проходе не меняет выходные файлы из-за noEmit. Параметры target и module должны повторять фактические ограничения проекта 2019 года; нельзя подставить их из чужого шаблона и объявить, что выпуск сохранён. Перед merge нужно запустить именно существующую production-команду, а не только tsc.'), + codeBlock(stagedConfig), + paragraph('После появления tsconfig полезно проверить две разные вещи. Первая — compiler вообще включает нужный файл: иначе проверка живёт в конфиге, но не действует на границу. Вторая — старый build не стал читать новый output directory или ждать файлы, которые noEmit не создаёт. Я сохраняю оба факта рядом с задачей: команду проверки типов и команду сборки. При откате можно убрать один новый module или комментарий @ts-check, не разбирать последствия переписанной половины дерева.'), + heading('Проверяем JavaScript прежде, чем переносить его'), + paragraph('Небольшой JavaScript-файл уже может дать полезную обратную связь через JSDoc. В fixture ниже функция принимает внешнее значение как unknown и выдаёт Account только после простых проверок. Здесь важен не синтаксис комментариев, а направление потока: неясное значение остаётся на краю, а модульный контракт появляется после проверки. Если заменить вход на any, обращение к record.email перестанет требовать доказательства и граница снова станет прозрачной.'), + codeBlock(checkedBoundaryFixture), + paragraph('В реальном коде проверка может быть шире: статус, вложенный объект, версия ответа или список записей. Я не пытаюсь описать весь API в первой задаче. Беру поля, от которых зависит текущий экран, и добавляю отрицательную fixture: объект без email должен вернуть null или понятную ошибку. Это даёт ревьюеру проверяемый вопрос: где именно остановятся плохие данные. Позднее контракт можно расширять, не превращая раннюю миграцию в переписывание всего backend-клиента.'), + heading('Как считать полезное покрытие'), + paragraph('Линии с аннотациями не являются метрикой качества. Для очереди миграции я веду маленькую таблицу границ и меняю её только по воспроизводимой проверке. Формула тоже должна оставаться простой: полезное покрытие = число границ, у которых назван источник, контракт, проверка и получатель, делённое на число выбранных границ. Это метод для планирования, а не результат, который можно приписать проекту без списка.'), + bulletList([ + 'Источник: откуда приходит значение — HTTP, форма, local storage или конфигурация.', + 'Контракт: какие поля и варианты нужны следующему модулю именно сейчас.', + 'Проверка: test fixture или command, на котором неполное значение отклоняется.', + 'Получатель: функция или компонент, который больше не принимает неясное значение.', + 'Выпускной gate: действующая команда сборки и smoke-путь, которые запускаются после изменения.', + ]), + paragraph('Такой счётчик не скрывает старый JavaScript и не наказует модуль за то, что он ещё не переименован. Он показывает, где TypeScript уже ограничил неопределённость. Когда первая граница стала устойчивой, следующей беру соседнюю: например, список ответов вместо карточки. Резкое включение строгих настроек на весь репозиторий оставляю отдельной задачей с собственным объёмом и временем на исправления.'), + heading('Порядок первого выпуска'), + orderedList([ + 'Зафиксировать существующую build-команду и один пользовательский smoke-сценарий. Записать ожидаемый артефакт и место, где его использует приложение.', + 'Выбрать одну границу данных, перечислить обязательные поля и добавить отрицательную fixture. Не включать в партию смену module format, роутинга и сборщика.', + 'Добавить tsconfig с allowJs и noEmit либо согласовать аналогичный безопасный режим в текущей конфигурации. Убедиться, что compiler видит выбранный каталог.', + 'Поставить @ts-check в один JavaScript-модуль или перенести одну функцию в .ts. Получить конкретную диагностику и исправить её в границе, а не заглушить any.', + 'Запустить type-check, затем обычную production-сборку и smoke-сценарий. В отчёте отделить факт прохождения команд от предположений о полном покрытии.', + 'Сделать маленький merge. Следующую границу брать только после того, как понятно, где хранится контракт первой и как откатывается её изменение.', + ]), + heading('Границы подхода в версии 2019 года'), + paragraph('TypeScript 3.5 не превращает JavaScript в проверенный runtime. Компилятор удаляет типовые конструкции из выходного JavaScript, поэтому сеть, JSON и значения от стороннего скрипта остаются внешними входами. Их нужно проверять обычным кодом. Также checkJs может быстро обнаружить старые несогласованности; это сигнал планировать порцию работы, а не причина заменять каждое сообщение на any.'), + paragraph('Я бы не обещал в этой итерации «полную строгую типизацию». Цель скромнее и полезнее: рабочая сборка сохранена, у одной важной границы есть контракт, а некорректная fixture не проходит в типизированный модуль. Это оставляет команде следующий шаг, который можно проверить и при необходимости откатить без массовой переделки.'), + ], + [migrationGuide, allowJs, checkJs, release35], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2019-10-mechanism-typescript-migration', + title: 'Под капотом: почему типы не проверяют runtime и как any размывает границу', + categories: ['TypeScript', 'Frontend', 'Разбор механизма'], + cover: '/assets/editorial/2019/typescript-migration-type-boundary-2019.svg', + excerpt: 'Типы помогают compiler увидеть несогласованный код, но исчезают из JavaScript. Разбираем поток внешних данных, роль unknown и причину, по которой any делает миграцию декоративной.', + readingMinutes: 14, + }, + [ + paragraph('Сбой при миграции часто маскируется под успех: файл уже называется .ts, editor показывает подсказки, но ответ сервера с неверным полем доходит до рендера без остановки. Затем разработчик добавляет any, чтобы снять ошибку compiler, и дефект возвращается в production под видом временного исключения. Цена такого решения — не одна неточная аннотация. Непонятно, в каком модуле данные перестали быть проверяемыми, поэтому следующая ошибка снова расследуется по стеку и логам.'), + paragraph('Нужно разделить две вещи. TypeScript проверяет отношения в исходном коде и затем убирает собственные типовые записи из исполняемого JavaScript. Он не ставит проверяющий код перед JSON.parse и не меняет ответ HTTP только потому, что рядом есть interface. В 2019 году это означает простой порядок: признать внешнее значение неизвестным, проверить его обычным JavaScript-кодом и только потом передать в типизированную часть. Такой контур уже даёт практическую границу без обещания магической защиты всего приложения.'), + heading('Что именно исчезает при компиляции'), + paragraph('Type, interface, type assertion и параметрический тип нужны compiler и редактору. В runtime они не становятся объектами, которые можно спросить у браузера или Node.js. Это хорошо для совместимости: TypeScript строится поверх JavaScript, а выходной код продолжает исполняться как JavaScript. Но отсюда следует ограничение: объявление Profile не проверит сетевой payload. Если вход неверный, его нужно отклонить до вызова функции, которая полагается на Profile.'), + paragraph('Ниже одна и та же функция показана до и после компиляции. В выходном файле нет type Profile и нет аннотации параметра. Строка с return осталась, потому что она выполняется. Это удобная проверка здравого смысла для ревью: если требование должно жить в runtime, ищем условие, parser или test, а не только type alias в соседнем файле.'), + codeBlock(erasedTypesFixture), + figure('/assets/editorial/2019/typescript-migration-type-boundary-2019.svg', 'Схема контракта TypeScript: сетевой payload остаётся unknown, функция нормализации проверяет поля и выпускает Member в типизированное ядро; отдельная красная дорожка показывает, как any пропускает данные мимо проверки', 'Тип не заменяет runtime-проверку. Он становится полезным после того, как неопределённый вход остановлен или нормализован на границе.'), + heading('Поток значений: внешний вход, проверка, типизированное ядро'), + paragraph('У любого внешнего значения есть источник: HTTP-ответ, local storage, элемент формы, глобальная переменная или callback старой библиотеки. Внутри TypeScript-модуля хочется пользоваться понятной моделью. Между ними должна появиться функция, которая умеет ответить «нет». Она проверяет только свойства, от которых зависит текущая операция, и возвращает модель либо null или ошибку. Такой код выполняется в runtime, поэтому его можно покрыть fixture с неполным объектом.'), + paragraph('unknown удобен именно на этом краю. Нельзя читать свойство у unknown, пока код не доказал, что значение имеет нужную форму. Это создаёт маленькое трение в правильном месте: автор вынужден назвать условия, при которых payload считается Member. any ведёт себя иначе: он разрешает обращение к полям без проверки и переносит неопределённость дальше. В миграции any допустим как явно записанный временный долг с владельцем и сроком, но не как способ сделать список ошибок пустым.'), + codeBlock(safeDecoderFixture), + paragraph('Функция isMember не является полной схемой всего сервиса. Она не знает, какие дополнительные поля сервер может вернуть, и не пытается валидировать их заранее. Её контракт уже полезен: экрану разрешено видеть только id, email и два состояния status. Если придёт status со значением archived, функция вернёт false, а задача должна решить, как пользователь увидит это несовпадение. Скрыть его в any значит отложить это решение в наиболее дорогую точку — пользовательский сбой.'), + heading('Почему any ломает трассировку ответственности'), + paragraph('В языке any не просто «широкий тип». Он ослабляет проверку операций на значении. Если payload объявлен any, выражение payload.user.email получает разрешение без доказательства существования user и email. Следующая функция может принять результат как string, хотя источник ничего такого не гарантировал. В цепочке исчезает место, где можно остановить неверные данные, а code review начинает спорить о вкусе типов вместо конкретного контракта.'), + dataTable( + 'Что compiler знает на каждом участке', + ['Участок', 'Форма значения', 'Что проверяет TypeScript', 'Что должен делать runtime-код'], + [ + ['Ответ транспорта', 'unknown или неясный JavaScript-объект', 'не разрешает пользоваться полями без сужения, если вход честно объявлен', 'проверить наличие и тип нужных полей'], + ['Нормализатор', 'unknown на входе, Member на выходе', 'связывает результат с обещанным контрактом функции', 'вернуть null или ошибку при несовпадении'], + ['Компонент или formatter', 'Member', 'ловит опечатку поля и несовместимое использование статуса', 'показать модель, не повторяя сетевую проверку'], + ['Переменная any', 'любая форма без доказательства', 'многие операции допускаются без полезной диагностики', 'не даёт автоматической runtime-защиты и скрывает место долга'], + ], + ), + paragraph('Таблица не говорит, что TypeScript всегда откажется от неверного JavaScript. Результат зависит от того, как объявлен вход и какие настройки применены к файлу. Она задаёт проверяемую модель ответственности: transport не обещает форму, normalizer проверяет форму, а остальная часть системы работает с уже названным контрактом. Это проще поддерживать, чем один общий type для всего JSON-ответа, который никто не умеет сопоставить с фактическими данными.'), + heading('allowJs и checkJs — это разные рычаги'), + paragraph('allowJs отвечает за состав входных файлов compiler: JavaScript остаётся рядом с TypeScript. Это разрешает менять модуль малыми шагами и не обрывать импорт соседей из-за одного расширения. checkJs отвечает за диагностику внутри JavaScript. В режиме проекта он включает сообщения для .js-файлов; локальный комментарий @ts-check подходит, когда сначала нужна проверка одного опасного перехода. Смешивать эти роли опасно: включённый allowJs ещё не означает, что старый JavaScript получил проверку.'), + paragraph('На практике я строю лестницу. Сначала compiler видит дерево и ничего не генерирует. Затем один boundary-файл получает @ts-check и JSDoc. После этого новый маленький модуль можно написать на .ts, оставить transport на .js и проверить их интерфейс. Лишь когда такие швы повторяются, обсуждаю расширение checkJs или общий уровень строгости. У этой последовательности есть важное свойство: каждая ошибка привязана к конкретной операции, а не к тысячи строк, до которых очередь ещё не дошла.'), + heading('TS 3.5: версия тоже участвует в контракте'), + paragraph('Статья привязана к осени 2019 года, поэтому пример не опирается на поздний синтаксис и на современные решения для схем. TypeScript 3.5 уже поддерживает unknown, type predicates, JSDoc-проверку JavaScript и настройки allowJs/checkJs. В заметках к 3.5 отдельно описаны корректировки поведения непараметризованных generic-параметров и улучшения проверки; это причина не переносить случайные рецепты из другой версии без воспроизведения на версии проекта.'), + paragraph('Из версии следует и дисциплина ревью. Если project compiler обновляется вместе с миграцией, обновление — отдельная ось риска: новые diagnostics могут быть полезны, но их нельзя выдать за результат одного переименования файла. В отчёте нужно назвать версию TypeScript, команду проверки и fixture. Тогда будущий апгрейд сможет сравнить изменения compiler с исходной точкой, а не искать, откуда внезапно пришло сообщение.'), + heading('Короткий диагностический маршрут'), + orderedList([ + 'Найти место, где неясное значение впервые входит в код: ответ, callback, конфигурация или форма. Не начинать с модели экрана, если источник ещё не назван.', + 'Проверить, есть ли runtime-условие на нужные поля. Если есть только interface, добавить fixture с неполным объектом и увидеть, что происходит до рендера.', + 'Заменить временный any на unknown в одном входе. Описать минимальное сужение через typeof, проверку объекта и допустимые варианты значений.', + 'Поставить результат проверки в явный типизированный контракт функции. Следующий модуль должен принимать Member, а не объект с произвольными полями.', + 'Включить @ts-check в соседнем JavaScript-файле либо добавить его в tsconfig через checkJs только после того, как причина диагностик понятна.', + 'Запустить type-check и обычный build. Записать, что именно подтверждено командами, а что ещё требует интеграционного или runtime-теста.', + ]), + heading('Что этот механизм не обещает'), + paragraph('Статический анализ не заменяет контракт с сервером, контрактные тесты и наблюдение за ошибками после выпуска. Runtime-проверка, в свою очередь, не делает модель автоматически удобной во всех модулях. Но вместе они делят работу честно: код на границе отвечает за фактический вход, TypeScript помогает не потерять проверенную форму дальше по графу.'), + paragraph('Если в ревью для нового файла появляется много any, я не закрываю задачу до переименования. Я спрашиваю, какие данные не смогли описать и почему. Иногда ответом будет маленький отдельный контракт, иногда — необходимость оставить модуль JavaScript до исследования API. Оба исхода лучше, чем визуально завершённая миграция, в которой compiler больше ничего не может сообщить.'), + ], + [migrationGuide, allowJs, checkJs, release35], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2019-10-field-typescript-migration', + title: 'Разбор: как перевести один legacy-поток на TypeScript и не сорвать выпуск', + categories: ['TypeScript', 'Frontend', 'Полевой разбор'], + cover: '/assets/editorial/2019/typescript-migration-release-gates-2019.svg', + excerpt: 'Фикстура для старого API-модуля: JavaScript-транспорт, TypeScript-нормализатор и экран с явным контрактом. Проверяем вход, build и путь отката без массового переписывания.', + readingMinutes: 15, + }, + [ + paragraph('Проблема типичного legacy-потока выглядит так: JavaScript-модуль загружает карточку участника, возвращает response.body и сразу передаёт его в экран. При попытке миграции команда меняет все соседние файлы на .ts, встречает неясный payload и пишет any. Сборка может пройти, но блокировка доставки остаётся: неизвестно, где проверять новый ответ сервера и как откатить изменение, если production-команда начнёт брать другой output. Цена — ветка с большим количеством механических изменений и без одного места, которое отвечает за форму данных.'), + paragraph('Разберём маленькую fixture, а не реальный проект. Её цель — показать маршрут, который можно повторить на одном API-методе в октябре 2019 года: transport остаётся JavaScript, новый normalizer получает TypeScript, экран принимает только Member, а сборка проверяется отдельно. В fixture нет выдуманного выигрыша в скорости или процента покрытия. Есть наблюдаемые условия: неполный payload отклоняется, compiler видит связи модулей, прежняя build-команда остаётся последним gate перед merge.'), + heading('Исходный поток и место, где теряется контракт'), + paragraph('В старом коде loadMember возвращает то, что лежит в response.body. Эта функция не обязана знать всю модель экрана: она отвечает за транспорт. Ошибка начинается, когда экран обращается к body.email так, словно сеть уже доказала наличие строки. Если response.body изменился или сервер вернул ошибочный объект, сбой проявится далеко от входа. Для миграции нужен не полный rewrite клиента, а одно новое место между transport и экраном.'), + paragraph('Этим местом будет normalizeMember. На вход она принимает unknown, на выход возвращает Member или null. Такой выбор специально делает обработку отсутствующего контракта видимой в showMember: автор не может случайно отдать null в formatter. Если продукту нужно показать экран ошибки, он выбирает его там, где есть контекст страницы. Normalizer не решает UX, он лишь не выдаёт произвольный payload за известную модель.'), + figure('/assets/editorial/2019/typescript-migration-release-gates-2019.svg', 'Схема fixture: JavaScript transport возвращает неясный payload, TypeScript normalizer проверяет его и отдаёт Member экрану; рядом четыре независимых gate — fixture неверного ответа, type-check, прежняя production-сборка и smoke-сценарий', 'Разделение помогает откатить один TypeScript-модуль, не меняя транспорт и выпускной маршрут одновременно.'), + heading('Минимальный шов между JavaScript и TypeScript'), + paragraph('Первый файл можно не переименовывать. В нём появляется локальный @ts-check, чтобы compiler мог хотя бы проверить аргумент memberId и ожидаемую форму возвращаемой цепочки. Новый файл normalizer пишется на .ts. Этот шаг соответствует allowJs: JavaScript и TypeScript живут в одном проекте. Он не требует немедленной смены module resolution или перехода на другой bundler, что особенно важно, когда доставка уже завязана на старую конфигурацию.'), + codeBlock(legacyApiFixture), + paragraph('В примере строка Promise<string> экранирована в HTML, но смысл обычный: showMember обещает строку только после успешной нормализации. Если payload не соответствует контракту, функция бросает понятную ошибку. В настоящем продукте вместо throw может быть Result-подобный объект или переход в error state; статья не навязывает один способ. Проверяемое условие одно: экран не получает неясный response.body напрямую.'), + paragraph('Обратите внимание, что @ts-check не делает transport идеальным. request и response здесь намеренно не определены: это внешний legacy-контекст fixture. Для первой партии достаточно не добавлять any в новый шов и не заставлять normalizer угадывать устройство сетевой библиотеки. Когда станет ясно, какой контракт возвращает request, его можно описать отдельной задачей. Так очередь миграции растёт по границам, а не по количеству строк, которые удалось переименовать.'), + heading('Проверка runtime-входа в отдельной функции'), + paragraph('Следующий фрагмент — единственное место, где fixture признаёт конкретную форму участника. Он использует возможности TypeScript, доступные к 3.5: unknown, string literal types и type predicate. При этом условия typeof и сравнения статусов — обычный JavaScript, который останется после компиляции. Поэтому отрицательная fixture может проверить их без надежды на то, что type alias материализуется в runtime.'), + codeBlock(safeDecoderFixture), + paragraph('Для проверки файла достаточно трёх входов: корректный объект, объект без email и объект с неизвестным status. Ожидаемые результаты — Member, null, null. Это не результат запуска production-сервиса, а contract fixture, которую команда может положить рядом с модулем или выполнить в unit-тесте, когда в репозитории есть подходящий runner. В ревью нельзя заменять её фразой «сервер всегда так отвечает»: именно изменение этого предположения обычно и создаёт ошибку.'), + dataTable( + 'Выпускные gates для одной migration-партии', + ['Gate', 'Вход', 'Ожидаемое наблюдение', 'Что это не доказывает', 'Действие при сбое'], + [ + ['Contract fixture', 'валидный и два неполных payload', 'неподходящее значение не превращается в Member', 'не проверяет живой сервер и сетевой маршрут', 'уточнить normalizer или согласовать API-контракт'], + ['Type-check', 'js/ts файлы выбранной границы', 'compiler видит импорт, аргумент и возвращаемое значение', 'не создаёт runtime-валидацию сам по себе', 'убрать any, сузить вход либо сузить объём партии'], + ['Существующий build', 'обычная production-команда', 'получается прежний ожидаемый артефакт', 'не подтверждает все пользовательские сценарии', 'сравнить config, output path и порядок шагов'], + ['Smoke-сценарий', 'тестовый ответ и экран участника', 'известный Member отображается через новый шов', 'не заменяет нагрузочный и интеграционный тест', 'вернуть маленький commit или отключить новый путь до расследования'], + ], + ), + paragraph('Таблица специально отделяет наблюдение от обещания. Прошедший tsc не доказывает, что API стабилен. Прошедший build не доказывает, что screen reader или все браузеры обработают страницу одинаково. Но каждый gate отвечает на свой вопрос и не позволяет спрятать отказ под общим словом «миграция завершена». Это делает отчёт коротким: команда знает, какой вход проверяли, какой сигнал получили и чего ещё не проверяли.'), + heading('Как сохранить работающую сборку'), + paragraph('Перед изменением я записываю текущую команду сборки и путь её артефактов. Если проект уже использует другой transpiler для JavaScript, TypeScript можно сначала запускать с noEmit как отдельный checker. Если tsc должен генерировать JavaScript, output directory и module target нужно сравнить с тем, что забирает existing pipeline. Нельзя одновременно сменить расширения, compiler, выходной каталог и способ загрузки модулей: при сбое не останется маленького изменения для отката.'), + paragraph('В TypeScript 3.5 есть улучшения incremental-сборки, но сама опция не является обязательной частью первой миграции. Быстрая проверка полезна только после того, как команда понимает, что именно ей измерять: одинаковый набор файлов, одна версия compiler и отдельный report для production build. В fixture я не объявляю время сборки и не делаю вывод о скорости. Сначала нужно получить повторяемые команды, потом сравнивать их на тех же условиях.'), + heading('Маршрут работы с одной границей'), + orderedList([ + 'Найти один внешний payload и записать, какие поля текущий экран действительно читает. Не расширять контракт полями, которые не участвуют в операции.', + 'Оставить transport на JavaScript, добавить @ts-check только в этот файл при необходимости и включить allowJs, чтобы новый .ts-модуль мог импортировать его без массового rename.', + 'Написать normalizer с входом unknown и отрицательными fixture для отсутствующего поля и недопустимого варианта статуса.', + 'Поменять экран так, чтобы он принимал только Member после normalizer. Не передавать response.body через any ради временного прохождения compiler.', + 'Запустить выбранный type-check, потом существующую production-сборку, потом smoke-путь с известным Member. Зафиксировать команды и их scope в ревью.', + 'Выпустить маленький change. При сбое откатить связь normalizer со screen, сохранив transport и прежний pipeline нетронутыми; затем разбирать один фактический сигнал.', + ]), + heading('Как измерять продвижение без выдуманных процентов'), + paragraph('Для этой migration-партии полезна не диаграмма «x процентов TypeScript», а журнал границ. В нём одна строка на поток: источник payload, normalizer, тип модели, fixture, команда проверки и выпускной gate. Полезное покрытие можно вычислить только как метод: число строк, где заполнены все шесть полей, делить на число выбранных потоков. Пока такой журнал не собран, числа нет и писать его в отчёт нельзя.'), + paragraph('Этот способ обнаруживает важную вещь: часть модулей вообще не готова к миграции, потому что у них нет устойчивого контракта на входе. Это не провал TypeScript. Это исследовательская задача про API или владение данными. Её лучше оставить в очереди с явным вопросом, чем заполнять unknown и any внутри экранов, пока текстово кажется, что охват растёт.'), + heading('Ограничения fixture и следующий шаг'), + paragraph('Фикстура не запускает реальный сервер, не проверяет сборочный pipeline этого репозитория и не доказывает, что response.body в конкретной библиотеке имеет указанную форму. Она нужна, чтобы показать структуру решения и честный список gates. В production-проекте normalizer требует contract-теста с API либо контролируемого тестового ответа; build требует фактического запуска в своей среде.'), + paragraph('После такого изменения следующий разумный шаг — соседний поток с тем же источником данных, а не глобальный strict mode. Если два экрана используют один endpoint, можно выделить общую функцию нормализации и добавить fixture для списка. Когда накопится несколько проверенных швов, станет видно, какие правила tsconfig действительно можно распространить. До этого момента успех миграции измеряется сохранённым выпуском и конкретными контрактами, а не длиной списка переименованных файлов.'), + ], + [migrationGuide, allowJs, checkJs, release35], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; + +const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun && process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions)); +}