Blog
A gate written to stop elaboration errors creeping back reported 186 of them; 25 were the compiler's own summary line saying how many errors it had found. The number had already reached commit messages and a status page. This is the proof that could not have come out any other way, the live emitter defect that was hiding inside the count, why fixing it made the number worse, and one fix that was built, measured, and thrown away.
A gate we wrote to stop elaboration errors from creeping back reported 186 of them. Twenty-five were not errors. They were the line iverilog prints at the end of a failing file to say how many errors it found.
The counter matched any line of stderr containing the substring ' error'. That is the obvious way to count compiler errors and it is wrong here, because the compiler closes each failing file with a summary: 'N error(s) during elaboration.' The summary contains the substring. One phantom per failing module, every time.
What makes this worth writing down is not the bug — it is a two-line bug — but that the number had already travelled. It was in commit messages, in an issue comment, and on a status page, in the form '573 down to 186, a 68% reduction'. Both ends of that were inflated by the same mechanism. The direction and the proportion survive; the absolutes did not.
A diagnosis of a counting bug is easy to believe and easy to get wrong, so it is worth insisting on a shape that could not have come out any other way. After excluding the summary line, every single module drops by exactly one. Twenty-five modules do so. And 186 minus 161 is 25, which is also the number of modules that fail to elaborate at all. Three independent quantities agreeing on the same 25 is not a story about the fix; it is the fix.
$ tools/check_elab_ratchet.py
elaboration errors: 161 (baseline 186)
BETTER apb_bridge: 5 -> 4
BETTER assembler: 5 -> 4
BETTER axi4: 3 -> 2
... 22 more, every one of them exactly -1
# 25 modules x 1 phantom = 25; 186 - 161 = 25; failing modules = 25
Its docstring said the remaining errors are 'two named design decisions'. That was measured — over one class of error, the unbound identifiers, where it is exact: 56 string-field reads and 12 unsized-array reads, nothing else. It was then written as though it described all of them. It described 68 of 161.
The other 93 had never been classified at all. Printing the distribution by message shape rather than counting lines took one command and produced this:
A parameter named 'cross' — a SystemVerilog keyword — was escaped where it is declared and printed bare where it is used, so the generated part-select read as a keyword and the compiler answered with a plain 'syntax error'. The root is one variable doing two jobs: the same string is both the key that looks a type up in a table, where it must stay raw, and the text that gets printed, where it must be escaped. Five places printed the key.
This exact shape had been repaired in this codebase once before, for a keyword-named local array, with a note recording that the expression paths were already correct. Nobody swept the part-select paths. When you fix 'escaped here but not there', the useful next move is to grep every other place that value is printed before closing it.
Removing four syntax errors raised the module's count from four to five, and the ratchet correctly refused the change until it was explained. A syntax error truncates the file. Behind those four were five real elaboration errors the parser had never reached, so they had never been counted. A syntax error is worth more than its count, and a ratchet is a guard against silent slippage, not a quality score.
The explanation went into the baseline file next to the number, not only into the pull request. The next person to look will open the file.
The remaining errors in one module traced back to an earlier change of our own: giving string fields zero width, so that a single string would stop blocking a struct's numeric fields. Correct when numeric fields exist. When every field is a string — fourteen such structs in this corpus — the struct packs to zero bits, and a part-select of width zero is not legal.
The obvious guard is to refuse zero-width structs. It removes both illegal part-selects, and it cascades: the enclosing struct then has an inadmissible field type, stops lowering, and the numeric reads that work today become unbound names. Measured, not predicted: that module went from 16 errors to 33, and the corpus from 162 to 179. A strict regression traded for two illegal lines. The branch was deleted and the question was filed as an input to the decision it actually depends on.
A claim in the first draft of that write-up also had to go: that the zero-width field corrupted the offsets of everything after it. It does not. The layout stays self-consistent; the field simply has no storage, and both reading and writing it fail loudly. That difference decides which fix is right, which is why it was worth measuring on a four-line example instead of asserting.
A gate is an instrument, and instruments fail the same way the thing they measure does. If a gate reports a number, classify its raw output by message shape once — print the distribution, read every row, and confirm each row is a thing you meant to count. Counting lines is not classifying. And when the output of a gate becomes a sentence you say about the project, the gate has been promoted to a publication, whether or not anyone decided that.
Every figure above is measured, and the limits are named with it.