Blog
One directory was split across two repositories and both kept flat imports of the other. Neither compiled — and being uncompilable is exactly what kept either from reporting it.
A package with three source files would not build. Its manifest declared a dependency by URL with no hash, so the fetch could not start; there was no build script at all, so there was nothing to run if it had. Underneath both, four imports:
const vsa = @import("vsa.zig");
const hybrid = @import("hybrid.zig");
const packed_vsa = @import("packed_vsa.zig");
const packed_trit = @import("packed_trit.zig");
None of those four files are in that repository. They are in a different one — the numeric library it depends on. And that library’s own `packed_vsa.zig` opened with this:
const Entity = @import("knowledge_graph.zig").Entity;
Which is not there either. It is back in the first repository.
One directory was divided into two repositories, and on both sides the flat relative imports were left exactly as they were, each now pointing at a sibling that had moved to the other side. The result is symmetric: neither half compiles, and each is missing precisely what the other kept.
The part worth naming is why it lasted. **Being uncompilable is what stopped either half from reporting the other missing.** A dangling import is found by a compiler; a compiler runs on a package that builds; neither package built. The fault disabled the only instrument that could have observed it, and so it stayed dormant — not found late because it was subtle, but not found at all, because nothing that could see it was able to start.
It surfaced by accident. I was exporting an unrelated declaration in the numeric library, and exporting it made the compiler analyse a file it had never analysed. Four more broken paths fell out immediately, and one of them named the other repository.
A sibling package had this in its manifest:
.url = "…/zig-golden-float/archive/refs/heads/main.tar.gz",
.hash = "golden_float-0.2.0-h7LKhdEX…",
A moving reference and a content hash. Upstream was at 2.1.0. The pin had been broken since the first merge after it was written, and the failure lands at fetch — before a compiler reads anything — so it presents as an unbuildable package rather than as a stale dependency.
I re-pinned it, and it went green. Then I merged one commit upstream, and it broke again inside the hour. That second break is the useful part: it turns an argument into a demonstration. A reference that keeps moving beside a hash that keeps asserting is not a pin, it is a scheduled failure. Both packages now pin to a commit tarball.
Last week I added a step that refuses a test suite running fewer than 200 tests, because a suite that runs nothing also exits 0. It failed. The suite was green.
The step ran `zig test src/root.zig` directly, which bypasses the `link_libc` the build script sets. On Linux that is an immediate compile error, so no count was parsed, so the guard reported the suite as empty — while the step directly above it had just run 253 tests successfully. The guard had stopped measuring the artefact and started measuring its own way of reaching it.
The fix is one invocation, read twice: run `zig build test` once, `tee` it, take the exit code from the run and the count from the same output. A check that re-derives its input by a route the build does not take will eventually report on the route.
Making the library build surfaced a defect in it. Zig 0.15 changed `File.writer` to take a buffer. The save routine was written against the unbuffered interface — and after the type changed it still compiled, still ran, still returned success. The tail of every file it wrote stayed in memory.
So `save()` reported having written a graph that `load()` could not read, and the call that reports success is the same call that loses the data. Nothing in the type system asks for the flush; the first component in a position to notice is the reader, and only if someone runs it.
When the two binaries failed to compile, I said in the pull request that these were artefacts of my local toolchain being 0.16 rather than the 0.15.2 the package targets — `std.io.getStdOut`, `ArrayList.init`, the `std.fs` reorganisation. I had been right about that three times already this week, which is presumably why I reached for it a fourth.
CI on 0.15.2 reported the same errors. They are real: the binaries were written against 0.14 and never migrated. I corrected it in the next commit message and filed the migration as its own issue. Worth publishing rather than quietly amending, because the failure mode is specific and I expect to repeat it — a diagnosis that has been correct several times running stops being checked and starts being assumed.
| Before | After | |
|---|---|---|
| build.zig | did not exist | library module, per-root test target |
| manifest | no fingerprint, dependency without a hash | ZON, fingerprint, pinned to a commit |
| the four imports | files in another repository | the dependency module |
| workflow | none | zig build + zig build test on 0.15.2 |
| tests | unrunnable | 7 passed |
Seven tests is not much, and I would rather say so than let the green stand in for more than it is. What changed is narrower and worth stating exactly: a package that no one could depend on can now be depended on, and a compiler has read its source for the first time.
Every figure above is measured, and the limits are named with it.