Skip to content
← Log

2026-08-26 · 6 min read

Three bugs that never threw

An audit of test counts turned into an audit of test coverage, and found three bugs in pure functions. None of them crashed. That was the problem.

I set out to check the test counts written on these pages, because numbers in documentation drift. Two were wrong. Then, since I was already counting, I checked which packages had tests at all — and found three that did not. Testing them turned up three bugs.

What is worth writing about is not the bugs. It is that all three have the same shape.

One: a move that rewrote instead of moving

const MOVE_UP = has(lower, ["move up", "earlier", "before", "promote"]);

`has` matches contiguous phrases, so "move up" matched and "move the introduction up" did not. Falling past every branch, the message reached the catch-all — apply this instruction to the selected block. Asking to reorder a section replaced its text.

Two: a stem that could never match

if (/\b(new|introduc|add)\b/.test(t)) return ["added"];

The stem is truncated to `introduc` precisely so it will catch introduce, introducing, introduces. The closing word-boundary makes that impossible: it only matches the literal string "introduc", which is not a word anyone writes. So every one of those, plus "adds" and "adding", fell through and was tagged "changed". The "added" branch was effectively dead code that looked alive.

Three: one stale value, one NaN forecast

forecast += v * STAGE_PROB[d.stage];

A pipeline forecast, weighted by each deal's stage. The stage is typed, so this looks total. But the value arrives from stored data, and stored data outlives renames — one deal in a stage that no longer exists indexes to undefined, multiplies to NaN, and NaN is contagious. Not that deal's contribution: the entire forecast, for every deal, silently.

The shape

Three different packages, three different authors' intentions, one pattern:

  • All three are pure functions — no I/O, no async, nothing to mock. The easiest things in the codebase to test, and therefore the easiest to assume are fine.
  • None of them throws. Each returns a plausible value: an edit instead of a move, "changed" instead of "added", NaN instead of a number.
  • Two of the three are reachable by ordinary use — "move the intro up" in the substrate under fifteen apps, and a renamed pipeline stage in the CRM board. The third is not: nothing calls it yet.
A function that throws gets fixed the day it ships. A function that returns the wrong answer politely can survive for years, because nothing ever points at it.

A correction

I first wrote that all three were shipped and reachable. Checking the call sites afterwards, one is not: `detectTags` is exported and never called — its own comment says it is for an AI composer that has not been wired up. It is a real bug in a published package API and it would bite the first caller, but no user is hitting it today.

The other two are live. The move bug is in the substrate under fifteen apps, and the NaN forecast renders in the CRM's pipeline board, which is on screen. Overstating by one is a small thing, but a claim about production is exactly the kind that should be checked rather than assumed — the same discipline that found the bugs in the first place.

Why the type system did not help

The third one is the interesting case. `STAGE_PROB` is a `Record<StageKey, number>`, and `d.stage` is a `StageKey`, so TypeScript is satisfied that the lookup is total. It is total over the type. It is not total over the data, because the data was written before the type was what it is now.

That gap — between what a type says and what a database holds — is where this class of bug lives. The type is a claim about the present; stored data is a record of every past version of that claim.

What I changed, and what I did not

All three are fixed and shipped, with tests that would fail if they came back. The move matches its direction separately; the stems match word forms with an explicit suffix, while `add` stays spelled out so "address" is not a feature announcement; the lookup has a fallback that contributes real money to the open total and nothing to the forecast, because an unknown probability should widen a confidence interval, not corrupt a number.

One thing I found and left. Target scoring in the planner weights every word equally, so a common word can outrank a specific one. That is a weakness rather than a defect — the planner still does something sensible — and changing it would alter behaviour across fifteen apps and two languages with no way for me to observe the result. It is now a test that documents the actual behaviour and says why it stands. A test that records a known limitation is worth more than a fix nobody could verify.