Именно работа над документацией вскрыла обе ошибки. Тут есть мета-урок: документация — самое дешёвое дизайн-ревью, которое можно у себя устроить, но это уже тема для другого поста.

Это и есть тот самый «пост», другая статья — и первое, что ей нужно сделать, это поправить тизер. Тизер привирает. Документация поймала только одну из двух ошибок. Вторую поймал первый настоящий потребитель API, которого я собирал параллельно. Эти два ревью сработали в паре: документация смотрит на форму API, потребитель — на использование. Вместе они ловят то, чего не видят тесты.

Если вы выпускаете что угодно за интерфейсом — библиотеку, CLI, любую сущность за контрактом, — это два ревью, которые вы, скорее всего, не оставляете без внимания.

Скриншот demo.machines.mellonis.ru с запущенным Brainfuck UTM: многоленточное представление слева, исходник движка справа
Скриншот demo.machines.mellonis.ru с запущенным Brainfuck UTM: многоленточное представление слева, исходник движка справа

Напоминание

«Три мажора, две ошибки» разбирает движок и v4-API паузы — onStep, onDebugBreak, флаги debug на состояниях. Здесь я опираюсь на неё, не повторяясь слишком. Траектория v4 → v6 включает три брейкинг-мажора: переименование хука, ужесточение семантики halt и схлопывание lifecycle-тиков. Первые два всплыли в демо. Третий — в документации.

Демо: v4 → v5

Пока v4 уезжала в релиз, я начал собирать machines-demo — интерактивный отладчик Turing-машины и первый не-тестовый клиент движка.

Демо — естественный первый потребитель: у него двойная цель. Продуктовая — публичное распространение и демонстрация движка в действии. Техническая — обкатка изменений и проверка концепций на живой API-поверхности. Обе цели делают разработку демо неотъемлемой частью релизного цикла, а не опциональной добавкой к нему.

Демо задействовало оба хука одновременно: onStep заполнял буфер команд для UI-трейса на каждой итерации; onDebugBreak управлял циклом «пауза / продолжение».

Демо собиралось. Тесты проходили. Но писать его было неудобно, по двум причинам.

Во-первых, after-сработка onDebugBreak приходила с данными предыдущего yield’а — теми же, что предыдущий onStep уже показал. Демо обрабатывало одно и то же дважды, и вопрос «почему два хука для одного события?» задавал не я, а как будто сам код. Я открыл turing-machine-js#109 как RFC об отношениях этих двух хуков, перечислил четыре эскиза; резолюция сузилась до именования. onDebugBreak ставил целью использования «отладку», глагол же потребителя — «пауза». Переименование без зазрения совести и без алиаса. v5.

Во-вторых, в UI демо появился сценарий «приостановиться перед halt» — дать пользователю заглянуть в финальное состояние машины до того, как она завершится. Естественная реализация — флажок debug на самом haltState. Первый тест-кейс установил haltState.debug = { before: true, after: true }, потому что симметрия выглядела правильной. Срабатывал же только before. Хуже того: after-сработка на самой итерации, которая привела к halt, не доходила до обработки — цикл выходил, как только state.isHalt становилось true. turing-machine-js#108 разбил это на две части: восстановить потерянную after-сработку (баг); бросать исключение при записи в haltState.debug.after (API).

Обе претензии пришли со стороны потребителя. Демо не показал баг в коде — он показал формат взаимодействия с API. Имена не подходили под применение. Снисходительность к входным данным не соответствовала тому, что пользователь пытался сделать. Тесты сверяли поведение с собственной внутренней моделью движка — ок, зе́лено. Демо сверило поведение с ментальной моделью потребителя — и выдало две конкретные претензии (не ок).

Документация: v5 → v6

v5 уехала. README требовал обновления. Новый раздел про порядок диспатча должен был объяснить — словами, — когда срабатывают onPause(before), onStep и onPause(after) относительно той итерации, которую они описывают.

Первый честный абзац выглядел примерно так:

onPause(after, K) срабатывает на yield’е итерации K+1, с payload’ом, подставленным из снэпшота итерации K, до того как сработают onPause(before, K+1) или onStep(K+1).

Я какое-то время смотрел на это предложение. Короче не получалось. Предложение о подстановке — это не то, что читатель должен был встретить.

Код работал. Тесты проходили. Демо корректно потребляло хуки. Ошибка была не в этом — она была в форме диспатча, и эта форма становилась видна только с той стороны, где её приходилось формулировать словами.

Фикс схлопнул lifecycle: before(K) → step(K) → after(K) на одном и том же yield’е. Без подстановок. Без межитерационного планирования. Без финального добора для останавливающейся итерации (в «Трёх мажорах, двух ошибках» я перевёл это как «пост-цикловой добор»; сейчас, пожалуй, обошёлся бы покороче). Абзац README теперь звучит так:

На yield’е итерации K хуки срабатывают в lifecycle-порядке.

А такое предложение читатель проходит не задумываясь. turing-machine-js#119 уехал как v6.

Прямая цитата из предыдущей статьи:

Код работал, тесты проходили, документация была корректной. Просто дизайн был неправильным.

К этому хочется добавить: документация была корректной только при условии, что читатель смирится с тремя объясняющими предложениями, которые ему читать не следовало. Это не «корректная документация» — это документация, извиняющаяся за форму.

Ревью документацией поймало форму, не использование. Демо работало. Тесты проходили. Только давление прозы в этот раз вскрыло внутренние проблемы реализации.

Три ревью, три слоя

Опишем, что проверяет каждое:

  • Тесты сверяют код с самим собой. Внутренняя консистентность. Движок выдаёт то, что движок должен выдавать. Зелёные тесты — это база.
  • Первый настоящий потребитель сверяет код с ментальной моделью потребителя. Совпадает ли формат API с тем, что пользователь пытается сделать? Ревью реального взаимодействия с API привело к его изменению: переименованию и запрету halt-after.
  • Документация в виде прозы (не JSDoc, а связный README-нарратив) сверяет код с авторским объяснением. Есть ли у дизайна честное однопараграфное описание? Отсутствие такового заставило внимательно посмотреть на танец с подстановкой и отказаться от него.

Каждое ревью ловит то, что не ловит слой ниже. Тесты не ловят форму; потребитель не ловит проблемы взаимодействия, которые он умело может обойти; документация не ловит UX-неровности, которые ей упоминать не пришлось.

Стоимость асимметричная. Разработка настоящего потребителя — самое дорогое из трёх ревью; написание документации — самое дешёвое. И всё-таки даже самое дорогое из них обходится дешевле, чем переделывать API после того, как проблемы вскроются у пользователей: отсюда и «почти бесплатные» в заголовке. Настоящего потребителя нужно разработать до мажорного релиза — только это ревью делает API комфортным в использовании. Документация после этого ограждает то, что осталось.

Эвристики на следующий мажор

  1. Соберите первого настоящего потребителя до мажора. Не тест-фикстуру — а потребителя со своей ментальной моделью. Трудности, с которыми он сталкивается, — это те же трудности, с которыми потом столкнутся все ваши пользователи.
  2. Напишите параграф про порядок диспатча до того, как зафиксируете порядок диспатча. Если не получается одной фразой описать, что и когда срабатывает, — диспатч неправильный. Решения такого рода должны приниматься на этапе проектирования, а не документации, тогда когда не написано даже и строчки кода.
  3. Перечитайте документацию глазами незнакомца. Если параграф читается как извинение за форму — документация работает, она явила ошибку формы. Чинить надо форму, а не параграф.

Три мажора. Один README. Одно демо. Тестам сказать было нечего.


Код: turing-machine-js (движок) и machines-demo (первый не-тестовый потребитель, на котором всплыли v4 → v5).