Я открыл свою панель помощи и прочитал в ней про эскалации:
Внутри лесенки каждая ступень уходит по своей задержке (0 = сразу); последующие ступени расширяют круг получателей.
Вторая половина фразы - неправда. Ступени не расширяют круг получателей. У каждой ступени свой набор каналов, никак не связанный с предыдущей. Ступень, на которой не отмечен ни один канал, не уведомит никого - даже если каналы отмечены на первой.
Разница не косметическая. Человек читает подсказку, отмечает Telegram на первой ступени, на второй ставит задержку 15 минут и не отмечает ничего, потому что “получатели и так расширяются”. Через 15 минут после инцидента ему не приходит ничего, и он про это не узнает, пока не случится авария, которую никто не заметил.
Фраза не устарела. Она была неправдой с того дня, как я её написал. Код при этом был верным всегда: тесты на эскалации есть, они проходят, поведение ровно то, что описано выше. Соврала проза.
Тесты-сторожа в проекте были и до этого: на неопределённые CSS-классы, на переменные окружения, на индексы под внешними ключами, на контрастность. Но они сторожили код. Эта фраза меня разозлила достаточно, чтобы за неделю добавить восемь новых - тех, которые сверяют с кодом документацию, - и заодно достроить до системы механизм, без которого все они через месяц стали бы зелёным шумом. Сейчас в пакете 54 файла и 110 тестов, и ни один из них не проверяет поведение приложения.
Почему проза гниёт иначе, чем код
С кодом всё понятно. Переименовал функцию - компилятор нашёл все вызовы. Поменял сигнатуру - упало то, что не подстроилось. Сломал поведение - упал тест. У кода есть механическая связь между частями, и эта связь чинит сама себя.
У прозы такой связи нет. Я переименовал таблицу в миграции - раздел документации про схему БД продолжает называть её по-старому и продолжает быть валидным markdown. Я поднял PostgreSQL с 16 на 17 в docker-compose - в инструкции по установке по-прежнему написано 16. Я добавил новый адрес приёма телеметрии - справочник адресов про него не знает. Ничего не падает. Всё зелёное.
Причём гниёт документация не равномерно. Есть два разных типа вранья:
Устаревшее: было правдой, перестало быть. Версии, имена таблиц, списки переменных окружения, перечни ручек. Гниёт тихо и постоянно.
Изначальное: не было правдой никогда. Та самая фраза про эскалации. Возникает, когда пишешь документацию по своему представлению о коде, а не по коду.
Первый тип ловится сверкой с кодом автоматически. Второй - только чтением кода глазами. Но когда начинаешь писать сверку, глазами приходится прочитать всё равно, и по дороге находится второй тип. Фраза про эскалации нашлась именно так: я писал сторожа для ключей перевода, дошёл до текстов панели помощи и прочитал их внимательно первый раз с момента, когда написал.
Форма сторожа
Все 54 файла устроены одинаково. Есть два источника истины об одном и том же. Один из них - код, другой - проза. Тест достаёт утверждение из каждого и сравнивает.
Самый простой пример - версии. В коде их три места: go.mod, Dockerfile, docker-compose.yml. В документации они упомянуты в инструкции по установке и в CONTRIBUTING.
var ( goModVersionRe = regexp.MustCompile(`(?m)^go (\d+\.\d+(?:\.\d+)?)`) dockerGoVersionRe = regexp.MustCompile(`golang:(\d+\.\d+(?:\.\d+)?)-alpine@sha256:`) composePGVersionRe = regexp.MustCompile(`postgres:(\d+)-alpine@sha256:`) composeCHVersionRe = regexp.MustCompile(`clickhouse-server:(\d+\.\d+)-alpine@sha256:`) )
Первое, что выяснилось при написании этого сторожа: сверять надо не только код с прозой, но и код с кодом.
goFromMod := extractVersion(t, goModVersionRe, goMod, "go.mod") goFromDocker := extractVersion(t, dockerGoVersionRe, dockerfile, "Dockerfile") if goFromMod != goFromDocker { t.Errorf("go.mod держит Go %s, Dockerfile — Go %s: истины разошлись между собой", goFromMod, goFromDocker) }
Второе: сравнивать версии нужно по-человечески. В документации написано “Go 1.26+”, в go.mod - go 1.26.0, в теге образа - 1.26-alpine. Это одна версия. Нормализация до двух компонентов, патч в сверке не участвует:
func normalizeVersion(v string) string { digits := versionDigitsRe.FindString(v) parts := strings.Split(digits, ".") if len(parts) > 2 { parts = parts[:2] } return strings.Join(parts, ".") }
Третье, и это важнее первых двух: у сторожа обязан быть сторож на самого себя. Регэксп, который ничего не нашёл, - это не “нарушений нет”, это “тест смотрит мимо файла”. Разница между Errorf и Fatalf тут принципиальная:
func extractVersion(t *testing.T, re *regexp.Regexp, body, what string) string { t.Helper() m := re.FindStringSubmatch(body) if m == nil { t.Fatalf("%s: версия не найдена — сторож смотрит мимо файла", what) } return normalizeVersion(m[1]) }
Без этой проверки любой сторож на регэкспах со временем превращается в зелёную заглушку. Поменяли формат тега образа - регэксп перестал матчиться - сторож молча перестал сторожить и продолжил радовать зелёным.
Где брать код как источник истины
С версиями просто: они лежат текстом в файлах. Дальше интереснее.
Мне нужно было сверить справочник адресов приёма телеметрии с адресами, которые приложение реально слушает. Парсить исходник регэкспом - плохо: получится сверка прозы с другой прозой. Правильный источник истины - то, что фактически зарегистрировано в мультиплексоре. Так что подсовываем регистрации поддельный мультиплексор, который просто записывает, что ему дали:
type ingestPatternRecorder struct{ patterns []string } func (r *ingestPatternRecorder) HandleFunc(pattern string, _ func(http.ResponseWriter, *http.Request)) { r.patterns = append(r.patterns, pattern) }
rec := &ingestPatternRecorder{} (&ingest.Handler{}).Register(rec) if len(rec.patterns) == 0 { t.Fatal("записано 0 паттернов приёма — сторож смотрит мимо регистрации") }
Теперь у теста на руках настоящий список адресов, а не его пересказ. Дальше две проверки в обе стороны: каждый задокументированный адрес зарегистрирован, и каждый зарегистрированный задокументирован. Вторая сторона нужнее первой - забыть дописать документацию к новой ручке гораздо легче, чем выдумать ручку, которой нет.
По дороге вылезла деталь, до которой я бы сам не додумался: в паттернах net/http конечный {$} значим для маршрутизации, но адресом не является. /api/{project}/store/{$} и документированный /api/7/store/ - это один адрес, и сторож обязан это понимать, иначе он падает на ровном месте:
// "{$}" в конце паттерна значим для ServeMux, но адресом не является: // /api/{project}/store/{$} и документируемый /api/7/store/ — один адрес. func normalizeRoutePath(p string) string { return strings.TrimSuffix(p, "{$}") }
Таких деталей в каждом стороже по две-три, и все они находятся только при написании. Это, кстати, ответ на вопрос “а зачем вообще, если можно просто внимательно читать”: внимательное чтение не заставляет тебя формализовать, что именно ты считаешь совпадением.
Дальше в том же духе:
имена таблиц из документации по схеме сверяются со списком таблиц из миграций;
роли в организации сверяются между Go-типом, схемой БД и таблицей в документации - три источника, все три обязаны совпадать;
имена собственных метрик сверяются с тем, что приложение реально пишет в
/metrics;переменные окружения сверяются между структурой конфига,
.env.example,docker-compose.ymlи таблицей в документации;время жизни кэша проб здоровья сверяется с константой в коде (это самый свежий, появился на этой неделе вместе с самим кэшем);
тела вебхуков сверяются с тем, что сериализуется на самом деле.
Ключи перевода: сторож, который ищет мёртвое
Отдельный случай - осиротевшие ключи перевода. Тут сверка не “проза против кода”, а “данные против кода”: в каталоге локализации лежит ключ, а в коде на него больше никто не ссылается. Такой ключ мёртв, и его перевод на второй язык - работа, сделанная впустую.
Проблема в том, что найти ссылку на ключ в Go-коде нельзя надёжно. Ключ бывает литералом, бывает склейкой с префиксом, бывает Sprintf. Сканер ищет все три формы:
var ( literalRe = regexp.MustCompile(`"([a-zA-Z0-9_.]+)"`) concatPrefixRe = regexp.MustCompile(`"([a-zA-Z0-9_.]*)"\s*\+`) sprintfPrefixRe = regexp.MustCompile(`Sprintf\(\s*"([a-zA-Z0-9_.]*)%`) )
И тут же первая ловушка. Если считать префиксом любой литерал слева от +, то строка "total" + x амнистирует каждый ключ, начинающийся на total. Префикс обязан быть многосегментным:
// Префикс обязан начинаться непустым сегментом до точки — иначе короткий литерал // слева от "+" ("total" + x) амнистирует всё, что с него начинается. func isKeyPrefix(p string) bool { dot := strings.IndexByte(p, '.') return dot > 0 }
Вторая ловушка тоньше. Сканер разбирает ключи по набору символов [a-zA-Z0-9_.]. Если в каталоге появится ключ с дефисом, сканер его просто не увидит и объявит осиротевшим - то есть соврёт. Значит, нужен сторож на набор символов:
func TestCatalogKeysMatchScannerCharset(t *testing.T) { tree := Load(t) var bad []string for k := range tree.Catalogs["ru"] { if !simpleKeyRe.MatchString(k) { bad = append(bad, k) } } // ... if len(bad) > 0 { t.Fatalf("ключи вне набора символов сканера (%d): %s\nсканер их не увидит — расширьте simpleKeyRe вместе с literalRe", len(bad), strings.Join(bad, " ")) } }
Третья: что считать признаком жизни. Сгенерированные файлы дублируют шаблоны, а ключ, на который ссылается только его собственный тест, для продукта мёртв:
// Признаком жизни ключа не считаем сгенерированные _templ.go (дублируют .templ) и тестовые файлы — // ключ, живой только в собственном тесте, для продукта мёртв.
Главная сложность - не тесты, а исключения
Вот тут начинается настоящая инженерия, и именно её я недооценил, когда начинал.
Любой сторож на живом проекте даёт ложные срабатывания. Есть строка, которая нарушает правило осознанно. Есть ключ, который выглядит осиротевшим, но собирается динамически способом, который сканер не разберёт. Есть таблица в документации, которой нет в миграциях, потому что её создаёт не миграция.
Очевидное решение - список исключений. И это же решение убивает всю затею, если сделать его наивно. Список исключений без дисциплины проходит три стадии: сначала туда складывают настоящие ложные срабатывания, потом - настоящие находки, которые некогда чинить, потом сторож становится документом о том, сколько нарушений мы решили не смотреть.
У меня исключение - не строка в массиве строк, а запись с обязательной причиной:
type Exemption struct { Value string Why string Finding string }
И три правила, которые проверяет один общий храповик на все списки:
// Три нарушения: запись без Why, список длиннее max, и запись на значение, // которого больше нет в seen (устаревшее исключение — храповик). func CheckExemptions(t testingT, name string, list []Exemption, max int, seen map[string]bool) { t.Helper() for _, e := range list { if e.Why == "" { t.Errorf("%s: исключение %q (%s) без причины — заполните Why", name, e.Value, e.Finding) } if !seen[e.Value] { t.Errorf("%s: исключение %q (%s) устарело — нарушение больше не найдено, строку надо удалить из списка исключений", name, e.Value, e.Finding) } } if len(list) > max { t.Errorf("%s: список исключений разросся до %d записей при потолке %d — почините находки или осознанно поднимите потолок", name, len(list), max) } }
Разберу по пунктам, потому что каждое правило закрывает свой способ убить сторожа.
Причина обязательна. Не для красоты. Через три месяца ты смотришь на исключение и не помнишь, это осознанное отклонение или “потом починю”. Без Why обе ситуации выглядят одинаково, и разбираться приходится заново по коду.
Устаревшее исключение - ошибка. Это и есть храповик, и это самое неочевидное правило из трёх. Если нарушение больше не находится, а исключение на него осталось - тест падает и требует удалить строку. Без этого правила список исключений только растёт: строку добавили, нарушение потом починили по другой причине, строка осталась и молча амнистирует всё, что когда-нибудь снова попадёт под это значение. Храповик крутится в одну сторону: исключение живёт ровно столько, сколько живёт нарушение.
У списка есть потолок. Причём поднять его можно - но это отдельная правка в коде, которую видно в ревью. Разница между “добавил строку в список из 40” и “поднял потолок с 40 до 41” - целиком психологическая, и именно поэтому она работает.
Сейчас в проекте 11 списков исключений и 249 записей в них, у каждой заполнена причина. Это много. Но это 249 явных, названных, отревьюренных отклонений, а не неизвестное число молчаливых.
Якорь: как назвать находку, чтобы имя не разъезжалось
Ещё одна неочевидная штука, на которой я потерял полдня.
Часть сторожей находит нарушения не в данных, а в коде: строка такая-то в файле таком-то нарушает правило. Чтобы такую находку можно было внести в исключения, ей нужно имя. Первое, что приходит в голову - путь:номер_строки. Это работает до первой правки: добавил три строки в начале файла - все исключения ниже разъехались и превратились в устаревшие.
Имя не должно содержать номер строки вообще:
// Ключ не включает номер строки — правка выше находки не сдвигает и не // портит его; переименование функции или правка самой строки якорь всё же меняют. func ContentAnchor(path, funcName, line string) string { if funcName == "" { funcName = "(top level)" } return path + " in " + funcName + ": " + line }
Путь, имя функции, сам текст строки. Правка выше находки не трогает якорь. Переименование функции или правка самой строки - трогает, и это правильно: изменилось то, на что мы давали исключение.
Отсюда подзадача: определить, в какой функции находится строка. Полноценный разбор через go/ast тут - из пушки по воробьям, но у регэкспа есть нюансы, которые надо назвать вслух:
// Регэксп, не go/ast: находит только НАЗВАННЫЕ func/templ с нулевой колонки — // вложенные замыкания и `var x = func()` не матчатся, генерики учтены `(?:\[[^\]]*\])?`. var funcDeclRe = regexp.MustCompile(`^(?:func|templ)\s+(?:\(\s*\S+\s+\*?(\w+)\s*\)\s+)?(\w+)(?:\[[^\]]*\])?\s*\(`)
И ресивер обязан входить в имя, иначе два одноимённых метода на разных типах схлопнутся в один якорь:
// Ресивер вплетается в имя как "Тип.Метод" — иначе два одноимённых метода на // разных ресиверах схлопнутся в одно имя.
А что если якорь всё-таки совпал у двух разных находок? Молча выбрать одну - худший вариант: исключение на одну строку тихо амнистирует вторую. Тест обязан упасть и сказать, что имя неоднозначно:
if prev, ok := seenLines[anchor]; ok { if prev != line { t.Errorf("%s: якорь %q неоднозначен — совпадает и со строкой %d, и со строкой %d одновременно; нужен более точный контекст (другое имя функции или различающийся фрагмент строки), а не молчаливый выбор одной из них", name, anchor, prev, line) } return }
Сторожа, которые сторожат сторожей
К этому моменту механизм стал достаточно сложным, чтобы у него самого завелись баги. Поэтому у него есть свои тесты - и это, пожалуй, главный вывод всей затеи. Среди 110 тестов есть такие:
TestScanFormatViolationsAnchorStableUnderInsertionAbove- вставка строк выше находки не меняет якорь;TestContentAnchorChangesWhenFindingLineItselfChanges- правка самой строки якорь меняет;TestScanFormatViolationsStaleExemptionCaught- храповик действительно ловит устаревшее исключение;TestConfigurationTableParityCatchesMissingRowиTestConfigurationTableParityCatchesGhostRow- сверка таблицы конфигурации ловит и пропавшую строку, и лишнюю;TestNormalizeVersion- нормализация версий на наборе входов;TestFormatCallPatternsRecognizeShapes- сканер узнаёт все формы вызова, которые обещает узнавать.
Именно ради такой проверки в храповике стоит узкий интерфейс вместо *testing.T:
// Узкий интерфейс вместо *testing.T — чтобы храповик можно было проверить самим тестом. type testingT interface { Helper() Errorf(format string, args ...any) }
Тест, который проверяет, что сторож падает, не может получить настоящий *testing.T - он бы завалил себя. Подсовываем фейк, который записывает вызовы Errorf, и проверяем, что их столько и с такими словами, сколько ожидаем.
Правило простое: сторож, который сам не покрыт тестом на своё падение, - это не сторож, а надежда. Зелёный тест не означает “нарушений нет”. Он означает “тест не нашёл нарушений”, а это совсем другое утверждение, и разница между ними целиком в том, умеет ли тест падать.
Что это стоит
Считаю честно.
54 файла, 110 тестов, 9657 строк тестового кода. Накопились с августа; восемь файлов - те самые, про документацию, - за последнюю неделю.
12,3 секунды на прогон всего пакета целиком. Это меньше, чем компиляция, и на порядок меньше остальных тестов.
11 списков исключений, 249 записей с причинами.
Времени на восемь новых сторожей ушло меньше, чем на механизм исключений и на разбирательства с якорями. Если повторять - закладывайтесь на то же соотношение.
Что получил:
Первое и главное - документация больше не может тихо разойтись с кодом в тех местах, которые сверяются. Не “мы стараемся синхронизировать”, а “не соберётся”. Разница как между code style в вики и линтером в CI.
Второе - нашлось то, что чтением не находится. Девять мёртвых ключей перевода, переведённых на два языка впустую: четыре фильтра по периоду на странице ошибок, два поля выгрузки по GDPR со всеми формами множественного числа, поле с id проекта и две подписи на странице статуса. Пять динамических префиксов ключей, которые сканер амнистировал по ширине префикса, - то есть амнистия была шире, чем кто-либо намеревался. Плюс дыра в собственном стороже адресов приёма, из-за которой он извлекал пути неверно и был зелёным по неправильной причине.
Третье, неожиданное - писать документацию стало быстрее. Когда знаешь, что список переменных окружения сверяется автоматически, не нужно каждый раз перечитывать конфиг и сверять глазами. Пишешь и запускаешь тест.
Чего это не даёт
Чтобы не создавать ложного впечатления.
Сторож проверяет совпадение фактов, а не смысл. Он поймает, что в документации написано “17”, а в compose стоит “18”. Он не поймает, что абзац объясняет механику неверно, если в абзаце нет ни одного сверяемого факта. Фразу про эскалации нашёл не тест - тест нашёл только то, что надо внимательно прочитать все тексты панели помощи, потому что я писал для них сторожа на ключи.
Сторож не заменяет обычные тесты. Он ничего не знает о поведении. Он знает только, что два описания одного и того же обязаны совпадать.
И сторож - это код, который надо поддерживать. Регэксп, смотрящий мимо файла, хуже отсутствующего теста, потому что создаёт ложное чувство покрытия. Отсюда Fatalf на нулевой результат поиска в каждом стороже без исключений.
Если захочется повторить
Порядок, который я бы посоветовал, зная, где терял время:
Начните с одного самого раздражающего расхождения. Не с механизма. Версии в инструкции по установке - хороший первый кандидат: источник истины однозначный, сверка тривиальная, польза сразу.
Сразу сделайте
Fatalfна “ничего не нашёл”. Это две строки, которые отделяют сторожа от заглушки.Механизм исключений вводите на третьем стороже, не на первом. На первом он не нужен, на пятом уже поздно - будет пять разных наивных списков.
Храповик на устаревшие исключения - самое ценное правило и самое неочевидное. Если сделать только его одно, польза уже будет.
Тест на то, что сторож падает, пишите сразу для каждого сторожа сложнее прямого сравнения строк.
Ошибки формулируйте как инструкцию. Не “несовпадение версий”, а “go.mod держит Go 1.26, Dockerfile - Go 1.25: истины разошлись между собой”. Читать это будете вы же через два месяца, в спешке, не помня контекста.
Код всего описанного лежит в internal/guards вот здесь: https://github.com/OtezVikentiy/gotcha - это self-hosted платформа наблюдаемости на Go и ClickHouse, но сторожа к предметной области не привязаны и переносятся в любой Go-проект почти без правок. Лицензия позволяет копировать.
А фразу про эскалации я исправил. Теперь там написано, как на самом деле: у каждой ступени свой набор каналов, не связанный с каналами предыдущей.

