Blog
A Zig package with passing CI could not be compiled by any consumer. Lazy analysis means a test run proves only what the tests happen to touch — one line exposed five hidden errors in one package, and sixteen in the package under it.
A package of mine had passing CI and could not be used by anybody. Not "was awkward to use" — could not be compiled by a consumer at all. The tests were real, they ran, and they were green, and the thing they were green about was not the thing anybody needed.
The cause is a language feature, not a mistake: Zig analyses top-level declarations lazily. A declaration nothing references is never handed to the compiler. So `zig build test` proves that the declarations the tests happen to reference compile — and says nothing whatsoever about the rest of the public surface.
I was repairing a third repository whose build had failed for four months, and part of the repair was to depend on this package instead of on files that a refactor had moved out from under it. The dependency resolved. Then the compiler reported an error inside the dependency itself:
no field named 'allocator' in struct 'ternary.hybrid.HybridBigInt'
at .zig-cache/p/zig_hdc-.../src/vsa/core.zig:30:35
That file belongs to the package with the green CI. It expects a field on a type from the version of its own dependency that it pins, and that field is not there. Its tests never referenced the function containing the line, so the line was never compiled, so the CI never had an opinion about it.
test "every public declaration of this module is analysed" {
@import("std").testing.refAllDeclsRecursive(@This());
}
With that in the module root, the whole public surface goes through the compiler. The package immediately reported five distinct errors, all of which had been there the entire time:
Nothing was fixed by adding the test. Only the instrument changed.
Two of those errors pointed downwards, into the package that this one depends on. So I looked there, and found something worse than a blind spot: that repository had no workflow that runs `zig build`. Not a weak one — none. Its CI built documentation and language bindings.
Adding one, pinned to the Zig version its consumers actually use, produced a package that spans three incompatible versions of the language at once:
| Targets | Evidence |
|---|---|
| 0.14 | Io.getStdOut, atomic.fence, fmt.fmtSliceHexLower, OpenFlags.read — all removed in 0.15 |
| 0.15 | what minimum_zig_version in the manifest claims |
| 0.16 | tools/gen/* use std.Io, std.Io.Dir and std.process.Init, and say so in their own comments |
The version claim in the manifest was not a lie anybody told. It was a number written once and never checked again, because nothing checked it.
Sixteen defects, in five waves. A compiler stops at the first failure in a unit, so each round of fixes exposes the next round: 11 errors, then 5, then 3, then 2, then one runtime panic, then zero. Four were the removed APIs above. The other twelve were ordinary defects that had simply never been compiled:
One number in that list is worth pausing on. Six sites assigned an i8 into an i2. The compiler named three. Fixing the three it named would have left the other three standing, and the next person would have met them as a fresh mystery — so the rule is to grep for the pattern rather than to work the error list.
After the last wave: 267 tests pass and that repository has a green build for the first time in its life.
Re-pinning the first package against the repaired second one changed nothing. Not a caching artefact — pinning the exact merge commit produced the same hash the CDN had already served. The reason is duller and more serious than a stale tarball:
Both packages carry their own copy of src/vsa/concurrency.zig, src/vsa/core.zig and src/vsa/common.zig, and the copies have diverged.
The migration that broke the third repository did not move that code into one home. It left a second copy behind, and the two drifted independently. Fixing one cannot fix the other, because they are different files with the same names.
That turns the remaining repair into a decision rather than a patch, and it is not one to take at four in the morning: either the consumer drops its copies and takes them from the package below, or the duplication is deliberate and gets written down as such. Applying the same five fixes to the second copy would turn the CI green and entrench two maintained copies of the same code — which is exactly how the divergence happened.
The general shape is not about Zig. A test suite measures the code it reaches. In a language with lazy analysis the unreached part is not merely untested — it is uncompiled, and the difference matters because a consumer reaching it gets a compile error rather than a wrong answer. A green badge on such a package is a statement about the tests, and a reader takes it as a statement about the library.
The cheap countermeasure is one line per package. The expensive part is being willing to look at what it says.
Every figure above is measured, and the limits are named with it.