Что удерживает 315 markdown-файлов от вранья
Где-то в августе я заметил, что один из моих репозиториев врёт.
В его README было написано, что приложение предоставляет 56 MCP-инструментов. Код предоставлял 59. Там же значилась версия v0.9.0 — при том что проект был на v0.26.0. В списке таблиц SQLite стояло 8 вместо 12, в списке секретов — 7 ключей вместо 12, а модуль прогнозов описывался как умеющий только направление, хотя это перестало быть правдой месяцами раньше.
Никто не писал ничего ложного. Каждое из этих предложений было истинным в тот момент, когда его написали. Сломалось другое: ничто их не перезапускало. В коммите, где я всё это чинил, диагноз сформулирован одной строкой:
Число, зашитое в манифест, не имеет проверки и устаревает молча — именно так README пришёл к утверждению про 56 инструментов на v0.9.0.
Этот пост — про другой репозиторий, где я уже успел отнестись к этому всерьёз, и про то, чего такое отношение стоит.
Сразу оговорюсь: ни одна из восьми проверок ниже не была спроектирована заранее. Каждая написана после той порчи, которую теперь предотвращает, — тем, кому надоело находить одну и ту же беду дважды. Это стоит знать, прежде чем читать их как систему: это не фреймворк, а следы от уже набитых шишек, и нужны вам будут те, которые набил ваш собственный репозиторий.
Почему это не просто «документация гниёт»
Документация гнила всегда, и это все знают. Думать об этом отдельно, а не просто стараться, пришлось потому, что две привычные линии защиты здесь недоступны.
Первая — автор. В обычной кодовой базе кто-то есть достаточно давно, чтобы почувствовать, что страница неверна: он помнит, как её писал, помнит изменение, которое её обесценило, и связь срабатывает. Здесь сессия пишет план, реализует, закрывает и заканчивается. Следующая читает оставленное. Непрерывного автора, держащего корпус в голове, нет, поэтому обычный механизм, которым замечают протухание, попросту отсутствует. Ничто не ощущается неверным, потому что ничто не ощущает.
Вторая — дефицит. Гниение документации обычно ограничено тем, сколько документации кто-то был готов написать, а готов он был немного. Снимите это ограничение — сессии охотно напишут четырёхстраничную запись о решении в три часа ночи — и корпус дорастёт до размера, при котором никто не читал его целиком, а значит, никто и не заметит противоречия между двумя его частями.
Выходит, что объём, делающий документацию ценной, — это тот же объём, который делает её неподдерживаемой вниманием. Вот настоящая задача, и поэтому ответ обязан быть механическим.
Изменился объём
Документация гнила всегда. В репозитории, который пишут агенты, отличаются скорость и форма.
Корпус документации Ritmolux — это 6 714 932 байта в 315 markdown-файлах. Около 5,5 МБ из них — рабочая запись: ADR, планы, бэклог проектирования. Она пишется с расчётом на то, что её будут грепать свежие сессии. Ещё примерно 1 МБ обращён к читателю.
Эти документы пишут сессии, которые не возвращаются. Сессия пишет план, реализует его, закрывает и заканчивается; следующая читает оставленное и пишет дальше. Нет автора, который держал бы весь корпус в голове и заметил, что предложение на двухсотой странице перестало быть истинным. Нет никого, кто помнит соглашение. Есть только то, что записано, и то, что проверяется.
В обращённом к читателю подмножестве 1059 относительных markdown-ссылок, и 926 из них — 87 % — ведут на документы за пределами этого подмножества. Такова форма явления: корпус с такой плотностью перекрёстных ссылок нельзя удержать аккуратностью. Девятьсот двадцать шесть ссылок — это больше, чем кто-либо когда-нибудь проверит руками единожды, не говоря уже о каждом изменении, и каждая из них — маленькое обещание, что документ по пути, записанному кем-то месяцы назад, всё ещё там.
Два документа в одной шляпе
Корпус — не одно целое, и именно это разделение делает задачу решаемой.
Около 5,5 МБ — рабочая запись: ADR, планы, бэклог проектирования и его архив. Этот материал существует, чтобы его грепала свежая сессия, которой нужно узнать, почему порог именно такой. Ему хорошо быть простыми файлами в дереве — без рендеринга, без навигации, без поиска сложнее rg. Линейно его никто не читает, и не должен.
Оставшийся мегабайт обращён к читателю: руководство по пресетам, справочник по выражениям, палитры, операторская документация, CLI съёмки. Другая аудитория, другой режим отказа. Слегка устаревший документ рабочей записи стоит сессии пяти минут замешательства. Устаревший читательский документ стоит незнакомому человеку его впечатления о том, поддерживается ли проект вообще.
Этим двум нужно разное обращение, и разделение их — половина пользы от того, что об этом вообще подумали. Оно же сделало возможной публикацию: сайт публикует читательское подмножество и оставляет рабочую запись файлами, — единственная причина, по которой сайт документации проекта со 183 записями о решениях не превращается в непроходимую стену.
Соглашение, которое не сработало, — и это измерено
Сначала я попробовал очевидное, и у меня есть цифры, чем это кончилось.
Три файла в корпусе — реестры: индекс ADR, индекс планов и журнал закрытых записей. Каждый существует затем, чтобы сессия могла найти нужный документ, не открывая сотню. Все три обросли строками, которые пересказывали документ вместо того, чтобы на него указывать. Индекс ADR дорос до 188 820 байт — 16 % всего корпуса, который он индексирует. Строки 0101–0115 весили в среднем 3302 байта против 152 байт у строк 0001–0020.
Чинить это сначала попробовали соглашением. План убрал пересказы и написал «По одной строке на план.» — тремя строками выше самих строк.
Через восемь дней раздел вырос обратно в 7,1 раза — под собственным правилом и на виду у него.
Это и есть весь аргумент, и конструировать его мне не пришлось: репозиторий поставил эксперимент на самом себе.
Правило, которое ничто не перезапускает, — это правило, которому никто не следует, поэтому здесь проверка, а не абзац.
Что именно измеряли
Ещё одна деталь про то, откуда взялись числа в этом посте, потому что «индекс разросся» и «индекс дорос до 16 % корпуса, который индексирует» — утверждения разного веса, и второе требует, чтобы кто-то посчитал оба размера.
Так и было со всеми цифрами ниже. 74 битые ссылки в 23 файлах посчитаны прогоном будущей проверки по истории. Рост в 7,1 раза за восемь дней — это два измерения одного раздела с датами. 235 голых номеров, одиннадцать сломанных ссылок в комментариях, четыре опровергнутые записи бэклога — всё это подсчёты, сделанные до того, как была написана проверка, а не оценки после.
Порядок здесь не случаен, и он же объясняет, почему проверок восемь, а не тридцать. Каждая начиналась с раздражения, продолжалась подсчётом — насколько это вообще распространено? — и только потом становилась кодом. Раздражений, не переживших подсчёта, оказалось больше, чем переживших: то, что кажется системной порчей, часто оказывается двумя случаями, которые проще починить руками, чем стеречь вечно.
Восемь проверок
Сейчас есть шесть проверок, которые идут в pre-push-хуке и повторно в CI, и ещё две в workflow публикации сайта — этим нужен уже собранный сайт. Ни одна из них не проверяет код. Каждая написана против конкретной порчи — и написана после того, как та уже случилась.
| Проверка | Что утверждает |
|---|---|
check-doc-links | каждая относительная markdown-ссылка разрешается на диске |
check-comment-hygiene | в комментариях .rs / .cpp нет относительных ссылок и рассказа изнутри плана |
check-index-rows | каждая строка реестра — указатель, а не пересказ |
check-backlog-claims | у каждого живого утверждения в бэклоге есть проба, и проба всё ещё проходит |
check-reader-prose | в читательских документах ссылка на запись — это ссылка, а не голый номер |
check-filter-figures | числа стоимости одной подсистемы встречаются ровно в одном документе |
check-site-links | каждая переписанная ссылка разрешается на собранном сайте |
check-site-routes | каждый опубликованный маршрут достижим из меню, а не только через поиск |
Дальше — о том, против чего написана каждая.
check-doc-links.mjs. Церемония закрытия делает git mv завершённого плана в plans/done/, и это ломает ссылки в обе стороны: и в каждом документе, который называл план по старому пути, и внутри самого плана, где всякая ссылка ../adrs/... теперь разрешается на каталог выше. Ссылки протухают молча, и заметно это только в браузере, поэтому их ничто не обнаруживало. К закрытию одного из планов накопилось 74 битых ссылки в 23 файлах за шесть закрытий подряд.
check-comment-hygiene.mjs. Относительная ссылка в комментарии .rs ломается молча сразу тремя способами: она ломается, когда план переезжает в done/; проверка markdown-ссылок её не видит, потому что тот скрипт обходит только .md; и в отрендеренном rustdoc она не разрешается вообще. Одиннадцать таких были сломаны в main, когда это писалось. Второй вид порчи — рассказ изнутри плана: this plan, used to, no longer. Он пишется из середины сессии и перестаёт читаться на её закрытии, когда никакого «этого плана» уже нет, а есть только код.
Тонкость здесь оправдывает всю проверку. Наивный список слов ловит this plan. Он не ловит before Plan 0038 Phase 2 bound it — а это читается как ссылка на запись, проходит список слов и всё равно остаётся рассказом: фраза датирует код относительно события, так что читателю приходится восстанавливать историю, чтобы разобрать предложение о настоящем. Поэтому в отчёт попадает предлог протяжённости перед номером записи — before / since / until / pre- / after, — а сам номер, который он украшает, не попадает.
check-index-rows.mjs — та самая проверка про 7,1 раза, и арифметике её предела ниже отведён отдельный раздел.
check-backlog-claims.mjs перезапускает машинно-исполнимые пробы, которые живые записи бэклога несут рядом со своими утверждениями. Четыре записи были опровергнуты, и все четыре одинаково: каждая утверждала что-то о репозитории — что в нём есть, что в нём задокументировано, что собирается, — и каждая была неверна либо сразу, либо вскоре после написания. Три вообще не несли отметки о проверке. Четвёртая несла отметку — датированную, свежую и правдивую, — вот только проверяла она ту половину записи, которая уцелела, а не ту, что стояла в её собственном заголовке.
Именно четвёртый случай объясняет, почему текстовой отметки мало. Она фиксирует, что кто-то посмотрел, а не то, на что именно он смотрел, и её нельзя перезапустить, когда предмет сдвинулся.
check-reader-prose.mjs. В тех пяти документах, которые читает автор пресетов, ссылка на запись должна быть внутри markdown-ссылки, а не голым номером. В остальном репозитории соглашение прямо противоположное, и оно остаётся: комментарий в коде обязан называть запись, которая обосновала утверждение, чтобы порог можно было проследить до измерения за ним. Но 235 голых номеров в трёх читательских документах были обращены к сессии, восстанавливающей, почему число вообще существует, — а читал их тот, кто хотел узнать, что делает параметр. Правило для этих пяти умещается в строку: сохраните факт, понизьте происхождение до ссылки. Ссылка инертна, пока по ней не кликнули; голый номер разрывает предложение.
check-filter-figures.mjs требует, чтобы числа стоимости одной подсистемы жили ровно в одном документе. И устройство этой проверки — самая переносимая идея здесь.
Публикация и две проверки, которых она потребовала
У публикации читательского подмножества обнаружилось ограничение, которого никто не предвидел, и оно не про рендеринг.
Читательские документы содержат 1059 относительных markdown-ссылок, и 926 из них — 87 % — ведут наружу опубликованного набора: в записи о решениях, в планы, в бэклог проектирования. Любой сайт, публикующий подмножество, обязан куда-то их разрешить.
И разрешать их правкой исходников нельзя: проверка ссылок утверждает, что каждая относительная ссылка разрешается на диске, а сама относительная форма — это то, что делает документы навигируемыми в редакторе и на GitHub. Переписать их в абсолютные URL значило бы обменять работающую проверку и работающую локальную навигацию на работающий сайт.
Поэтому ссылки переписываются во время сборки, исходник читается на месте и никуда не копируется, а для того, что видно только на собранном сайте, существуют ещё две проверки: одна утверждает, что каждая переписанная ссылка разрешается на выходе сборки, другая — что каждый опубликованный маршрут достижим из меню, а не только через поиск. Вторая необычна и стоит заимствования: страница, которая существует, но до которой не ведёт ни один путь навигации, фактически отсутствует, и ничто другое в сборке вам об этом не скажет.
Сам workflow публикации разделён по тому же принципу, что и всё здесь: job сборки идёт на каждый push и каждый pull request и является настоящей защитой, тогда как job деплоя идёт только с основной ветки. Правка документации, которую нельзя отрендерить, падает до публикации, а не порождает сайт, уверенно говорящий неправду.
Требовать отсутствия копий, а не согласия копий
Эта проверка существует потому, что один план выпустил инструмент и задокументировал его сразу в трёх файлах: профили, флаги, порядок проверки и таблица стоимости были выписаны целиком, разными словами, в трёх местах.
Копии, написанные разными словами, нельзя сравнить через diff, поэтому они расходятся молча — по построению. И это не гипотетика: на ревью закрытия того плана правка чисел стоимости прошла по двум копиям из трёх и пропустила третью. Нашли её грепом по числам уже после того, как список файлов был составлен и закоммичен.
Очевидная проверка сравнивала бы три копии между собой. Она воспроизвела бы тот же пропуск в точности, потому что копия, которая всё сломала, была как раз вне списка. Проверка на равенство значений по известному списку не может быть лучше самого списка, а список пишет тот же человек, который забыл про третий файл.
Поэтому проверка требует отсутствия копий. Есть одна канонная страница, и любое такое число где-то ещё — это провал проверки. Забыть добавить новую копию в список невозможно, потому что списка нет.
Ещё два генерируемых блока и общее у них правило
Справочник параметров — самый крупный генерируемый артефакт, но не единственный, и узор достаточно устойчив, чтобы сформулировать его правилом.
Длинный документ несёт генерируемый блок содержания — его перегенерирует скрипт, проверка держит его в соответствии, руками его не правят. Опубликованный документ, перешедший порог размера, разбивается на маршруты по размеру, а не по чьему-то суждению о естественном месте разрыва. Числа стоимости диффузионного фильтра живут ровно на одной странице, удерживаемые описанной выше проверкой.
Общее правило такое: если блок документа выводим из чего-то ещё в репозитории — выводите его, а ручную правку заваливайте. Маркеры важны не меньше генерации. Генерируемая область, ограниченная явными начальным и конечным маркерами, с тестом, утверждающим, что закоммиченное содержимое совпадает с тем, что написал бы генератор, безопасна для правки вокруг: очерки выше и ниже остаются рукописными, заморожен только выводимый блок.
Так обходится тот режим отказа, при котором файл либо целиком генерируется — и тогда человеку некуда добавить глубину, — либо целиком рукописный, и тогда он расходится с реальностью. Маркеры позволяют одному файлу быть и тем и другим.
Более сильный ход — генерировать
Проверять текст — это сдерживание, а не решение. Конечное состояние — текст, который не может быть неверным, потому что его никто не пишет.
Самая большая публичная поверхность Ritmolux — формат пресетов, и справочник по нему занимает 268 КБ. Он и раньше был защищён: тест утверждал, что каждый объявленный движком параметр в нём назван. Только защита эта доказывала не то свойство. Она утверждала, что каждое объявленное имя встречается в обратных кавычках где-нибудь, — а этому удовлетворяла и единственная таблица с одной строкой на систему. Поэтому она не могла заметить ни того, что значение по умолчанию изменилось, ни того, что задокументированный диапазон больше не читается, ни того, что параметр назван и нигде не объяснён.
Если сформулировать точно: три копии каждого имени — список объявлений, ветка сеттера и справочник — держались друг за друга. Смысл не держался ни за что.
ADR-0170 заменил голый список имён в движке на ParamSpec — имя, значение по умолчанию, читающийся диапазон и однострочное описание — и генерирует из этих объявлений таблицы справочника, помещая их в блок между маркерами. Ручная правка внутри маркеров валит тест. Очерки ниже маркеров остаются написанными от руки, и глубина живёт в них.
Старый тест упраздняется по построению: объявленный параметр не может отсутствовать в блоке, сгенерированном из объявлений. На его место встаёт утверждение, которое старый тест сформулировать не мог, — что каждая спецификация несёт непустую строку описания.
Делать вид, что это досталось бесплатно, я не стану. Самая честная часть того решения — его собственный список минусов:
- Примерно двести однострочных описаний пришлось написать на Rust, один раз. Материал лежал в очерках; работа состояла в том, чтобы его сжать, а не в том, чтобы его найти.
- Строка описания в Rust — это второе место, где сказано, что делает параметр, рядом с очерком. Правило такое: строка спецификации — определение, очерк — обсуждение, и ревью плана проверяет, что ни один очерк не противоречит своей таблице.
- Значение по умолчанию, объявленное в спецификации, и значение, применяемое при сбросе, — это две копии одного числа до тех пор, пока сцена не начнёт читать своё умолчание из спецификации. План потребовал этого чтения, чтобы таблица не могла заявить умолчание, которого движок не применяет.
Последний пункт — весь узор в миниатюре. Генерация документа из объявления помогает только тогда, когда объявление и есть то, чем программа реально пользуется. Иначе вы просто сдвинули ложь на один файл влево.
Арифметика за пределом
Одна деталь про проверку строк реестра, потому что «держите строки короткими» — это правило, а 320 байт — это проверка, и разница между ними в том, что кто-то посчитал.
Индекс существует, чтобы сессия нашла нужный документ, не открывая сотню. Это вся его работа, и из неё следует размер: индекс обязан быть достаточно дешёвым, чтобы прочитать его целиком. При 188 820 байтах он составлял 16 % корпуса, который индексировал, — то есть сессия, читающая индекс, чтобы не читать документы, всё равно прочитывала шестую их часть.
Строка-указатель — номер, заголовок, однострочная зацепка — свободно умещается в пару сотен байт. Строки 0001–0020 весили в среднем 152. Строки 0101–0115 — 3302, и это не длинный указатель, а пересказ документа, написанный тем, кто только что его закончил и держал резюме в голове. Каждая из них по отдельности была разумной.
Предел поставлен там, где указатель помещается, а пересказ нет, — так что проверке не нужно распознавать пересказывание, механически непроверяемое. Она проверяет длину, и длина оказывается тем показателем, который эти два случая чисто разделяет. Выбрать проверяемый показатель, коррелирующий с нужным свойством, вместо попытки проверить само свойство — в этом и состоит бо́льшая часть ремесла написания проверок.
Чего это не даёт
Хорошей документацию это не делает. Каждая из проверок выше проверяет механическое свойство: ссылка разрешается, строка коротка, число встречается один раз, ссылка на запись обёрнута, утверждение несёт запускаемую пробу. Ни одна из них не скажет, ясно ли объяснение, удачно ли выбран пример и отвечает ли документ на тот вопрос, с которым читатель на самом деле пришёл. Эта работа по-прежнему делается чтением, и она по-прежнему более трудная половина.
И есть цена, о которой я бы предупредил всякого, кто соберётся это повторять. Каждая проверка выше написана после той порчи, которую она предотвращает, а значит, каждая стоила фазы плана и каждая может упасть в пятницу по причинам, никак не связанным с тем, чем вы занимались. Именно поэтому pre-push-хук намеренно держат в границах быстрого подмножества: проверка, которая мешает, будет отключена, и вот тогда она стоит меньше нуля — ведь само её существование было причиной, по которой никто не проверял руками.
С чего начинать, если это стоит повторять
Если что-то из этого стоит перенимать, порядок важен: проверки не равноценны, и самая дешёвая при этом лучшая.
Начните с проверки ссылок. Пятьдесят строк, ловит целый класс порчи, деградирующей незаметно, и окупается в первый же переезд файла. Она должна быть у любого проекта с более чем тридцатью markdown-файлами, независимо от того, пишут его агенты или нет.
Дальше — то, что у вас играет роль индекса. Любой файл, чья работа — помогать находить другие файлы, будет расти в сторону их пересказа, потому что пересказ полезен в моменте и дорог только в сумме. Это самая надёжная порча из набора.
Дальше — генерируемые блоки, везде, где документ повторяет то, что объявляет код. Это единственная проверка, которая устраняет проблему, а не патрулирует её, и она же самая дорогая: справочник параметров стоил примерно двухсот однострочных описаний, написанных на Rust.
Проверки текста — в последнюю очередь, если вообще. Форма ссылок на записи и гигиена комментариев сильнее всего завязаны на то, как устроен именно этот проект; они написаны против измеренной здесь порчи и в репозитории с другими привычками могут не поймать ничего. Скопировать проверку, которая ни разу ничего не нашла у вас, — верный способ получить медленную сборку без выгоды.
Даёт всё это вещь узкую и, по-моему, стоящую того: документации позволено быть большой. 315 файлов, 6,7 МБ, 1059 перекрёстных ссылок, написанных сессиями, которые друг с другом не встречались. Ориентироваться в таком корпусе получается только потому, что фиксированный набор механических свойств верен для всего корпуса и верен всегда — и верен потому, что машина перепроверяет их на каждый push, а не потому, что кто-то вспомнил.
Результат — igorkonovalov.github.io/Ritmolux: те же документы, что лежат в репозитории, опубликованные на месте и никуда не скопированные, с поиском по ним.