T27.AI

Блог

Зелёный CI не значит, что библиотека соберётся у вас

2026-08-11 · 9 мин чтения

Пакет на Zig с проходящими тестами не мог собрать ни один потребитель. Ленивый анализ означает, что тесты доказывают лишь то, до чего дотянулись: одна строка вскрыла пять скрытых ошибок в одном пакете и шестнадцать в том, что под ним.

ZigCIVerificationStatic analysis

У меня был пакет с проходящим CI, которым не мог воспользоваться никто. Не «неудобно пользоваться» — потребитель не мог его скомпилировать вообще. Тесты были настоящие, они запускались и были зелёными, и то, о чём они были зелёными, не было тем, что кому-то нужно.

Причина — свойство языка, а не чья-то оплошность: Zig анализирует объявления верхнего уровня лениво. Объявление, на которое никто не ссылается, компилятору не передаётся никогда. Поэтому `zig build test` доказывает, что компилируются те объявления, до которых случайно дотянулись тесты, — и ровно ничего не говорит об остальной публичной поверхности.

Как это всплыло

Я чинил третий репозиторий, чья сборка падала четыре месяца, и частью починки было подключить этот пакет вместо файлов, которые рефакторинг вынес из-под сборки. Зависимость разрешилась. И компилятор сообщил об ошибке внутри самой зависимости:

no field named 'allocator' in struct 'ternary.hybrid.HybridBigInt'
  в .zig-cache/p/zig_hdc-.../src/vsa/core.zig:30:35

Этот файл принадлежит пакету с зелёным CI. Он ждёт поля у типа из той версии собственной зависимости, которую сам же и закрепил, и поля там нет. Его тесты никогда не ссылались на функцию, содержащую эту строку, поэтому строка не компилировалась, поэтому у CI не было о ней мнения.

Одна строка меняет то, что измеряет CI

test "every public declaration of this module is analysed" {
    @import("std").testing.refAllDeclsRecursive(@This());
}

С ней в корне модуля вся публичная поверхность проходит через компилятор. Пакет немедленно выдал пять разных ошибок, и все они были там всё это время:

Добавление теста ничего не починило. Изменился только прибор.

Пакет под ним не собирали ни разу

Две из этих ошибок указывали вниз, в пакет, от которого этот зависит. Я посмотрел туда и нашёл кое-что хуже слепого пятна: в том репозитории **нет ни одного воркфлоу, который запускает `zig build`**. Не слабый — ни одного. Его CI собирал документацию и языковые привязки.

Добавленный воркфлоу, закреплённый на той версии Zig, которой реально пользуются потребители, показал пакет, разъезжающийся сразу по трём несовместимым версиям языка:

Целится вСвидетельство
0.14Io.getStdOut, atomic.fence, fmt.fmtSliceHexLower, OpenFlags.read — всё убрано в 0.15
0.15то, что заявляет minimum_zig_version в манифесте
0.16tools/gen/* используют std.Io, std.Io.Dir и std.process.Init и прямо пишут об этом в своих комментариях

Заявление о версии в манифесте — не чья-то ложь. Это число, написанное однажды и больше не проверявшееся, потому что его никто не проверял.

Как выглядела починка

Шестнадцать дефектов, пятью волнами. Компилятор останавливается на первой ошибке в единице трансляции, поэтому каждый круг правок вскрывает следующий: 11 ошибок, потом 5, потом 3, потом 2, потом одна паника во время выполнения, потом ноль. Четыре — те самые убранные API. Остальные двенадцать — обычные дефекты, которые просто ни разу не компилировали:

На одном числе из этого списка стоит остановиться. Мест, где i8 присваивался в i2, было шесть. Компилятор назвал три. Починка названных оставила бы стоять остальные три, и следующий человек встретил бы их как свежую загадку, — поэтому правило простое: искать по образцу, а не работать по списку ошибок.

После последней волны: 267 тестов проходят, и у этого репозитория зелёная сборка впервые за его жизнь.

Находка под находкой

Перепривязка первого пакета к починенному второму не изменила ничего. И это не артефакт кэша: закрепление точного коммита слияния дало тот же хеш, что CDN уже отдавал. Причина скучнее и серьёзнее устаревшего архива:

Оба пакета несут собственные копии src/vsa/concurrency.zig, src/vsa/core.zig и src/vsa/common.zig, и копии разошлись.

Тот перенос, который сломал третий репозиторий, не собрал этот код в один дом. Он оставил второй экземпляр, и они разъехались независимо. Починка одного не может починить другой, потому что это разные файлы с одинаковыми именами.

Это превращает оставшийся ремонт в решение, а не в патч, и такое не принимают в четыре утра: либо потребитель отказывается от своих копий и берёт их из пакета ниже, либо дублирование объявляется намеренным и записывается как таковое. Применить те же пять правок ко второй копии значило бы сделать CI зелёным и закрепить две поддерживаемые копии одного кода — то есть ровно то, из-за чего расхождение и возникло.

Что отсюда забрать

Общая форма не про Zig. Набор тестов измеряет тот код, до которого он дотягивается. В языке с ленивым анализом недостигнутая часть не просто не протестирована — она не скомпилирована, и разница существенна: потребитель, дотянувшийся до неё, получает ошибку компиляции, а не неверный ответ. Зелёный значок на таком пакете — утверждение о тестах, а читатель принимает его за утверждение о библиотеке.

Дешёвая мера противодействия — одна строка на пакет. Дорогая часть — готовность прочитать то, что она скажет.

Чего это не решает

Пруфы

Каждая цифра выше измерена, и рядом с ней названы её пределы.