One codebase, eleven defects, none of them tidied away
Everything I got wrong
building one small tool.
Every portfolio shows the finished thing. Almost nobody keeps the record of being wrong, so almost nobody can answer the question clients now ask most often, which is what broke and how you found out.
So here is the record. For each one: what it looked like from outside, the move I made first that was wrong, the actual cause, and the assertion that now fails if it comes back.
The third field is the one worth reading. Anyone can describe a fix after the fact. Almost nobody keeps a record of the fix they nearly shipped instead.
01
A single full stop walked through the address guard
What it looked like
Nothing. Nobody reported this and no request failed. I found it by attacking my own endpoint on purpose, because a public tool that fetches a stranger’s address is the one thing on this site worth attacking.
What I tried first, and why it was wrong
I added the string to the blocklist. That would have shipped, looked like a fix, and left every other spelling working.
The actual cause
The guard compared the hostname against a list of names. A trailing dot is a valid fully qualified way of writing the same host, so localhost. is not in any list of names. Neither is any domain that quietly points an A record at 127.0.0.1.
What it does now
Nothing compares hostnames any more. The name is resolved, and the address that comes back is what gets judged.
02
My test agreed with my code, so it found nothing
What it looked like
A reviewer walked from the internet to a loopback server through the guard I had just written 74 tests for.
What I tried first, and why it was wrong
There was no first move, because I did not believe there was a bug. The table asserted that ::ffff:127.0.0.1 was refused and I read that as covered.
The actual cause
::ffff:7f00:1 is the same address written in hex. My table contained the spelling I had thought of, and the check contained the same spelling, so the test could only ever confirm what the code already did.
What it does now
Eleven spellings of a v4-mapped address are asserted now, including ::ffff:a9fe:a9fe for the metadata service and ::7f00:1 for the v4-compatible form. One of the eleven is ::ffff:0808:0808, which is 8.8.8.8 and must still work, so the fix cannot pass by refusing everything.
03
It told two clients their Facebook link was dead
What it looked like
The audit reported the social links on two live client sites as broken. If that had gone out, somebody sends a client a report, the client clicks the link, it works, and every other line on the page stops being believed.
What I tried first, and why it was wrong
I nearly special-cased Facebook. That fixes the one report I was looking at and leaves the same wrong verdict waiting for every other site that guards itself.
The actual cause
Facebook answers 400 to anything that is not a logged-in browser. So does facebook.com/nasa, which I used as a control. The tool was treating any failed request as proof of a dead link.
What it does now
Only 404, 410 and 5xx count as gone. Everything else is "could not check from here" and is left out of the report entirely. Then four more of the same shape: HEAD lies, so ieca.org answers 404 to a HEAD and 200 to a GET, and any accusing verdict gets confirmed with a real GET. A timeout is usually a slow server, so it retries once at twenty seconds. A link to another website is not mixed content. A spam honeypot is meant to be hidden, so it is not an unlabelled form field.
What that was worth
Two real client sites scored 69 and 83 before those five fixes and 93 and 95 after them. Nothing about either website changed, so the whole of that gap was the tool being wrong.
04
A run that skipped half the checks scored higher than a complete one
What it looked like
An audit that ran out of time came back with a better number than the same audit finished properly.
What I tried first, and why it was wrong
I widened the deadline. Which makes it rarer and leaves the number wrong when it happens.
The actual cause
The score is 100 minus a weight for each finding. A check that never ran produces no finding, so cutting the run short quietly improves the result.
What it does now
A short run now returns partial, lists exactly what it skipped, and sets scoreTrustworthy to false. The readme tells anyone building on it not to display the number when that flag is false.
fails if it comes back: anything marked draft has to say what to check before using it
05
A 178 pixel icon was measured as 3 by 101, and the number shipped
What it looked like
Generated markup carried width="3" height="101" for an image that is 178 pixels square. It reached the output before anybody caught it.
What I tried first, and why it was wrong
The first fix read the opening tag properly and then read width="2em" as 2 pixels, which is a different wrong number arriving the same way.
The actual cause
The SVG reader searched from <svg to the end of the file, so it matched stroke-width="3" on a group and picked a height off a rectangle inside the drawing.
What it does now
It reads the opening tag only, accepts pixel values only, and falls back to the viewBox when the width is not in pixels.
fails if it comes back: 'svg ignores stroke-width and inner shapes'
06
The typecheck went green and the running app was broken
What it looked like
Everything compiled. The site threw at runtime.
What I tried first, and why it was wrong
The change that caused it was itself a fix, for a real problem: the built package could not be imported without .js on its relative imports. Adding them fixed the package and broke the bundler, and the type system had no opinion about either.
The actual cause
The two consumers of that code want opposite things from the same line, and neither of them is checked by tsc.
What it does now
The source stays as the bundler wants it, and a post-build step rewrites the extensions in the output. Nobody has to remember.
fails if it comes back: the built package is imported and run before it is published
07
A shipped feature that never ran once
What it looked like
The stylesheet check produced no findings, ever, on any site. It looked like sites were fine.
What I tried first, and why it was wrong
I read the check. The check was correct.
The actual cause
text/css was missing from the content-type list on the fetch, so the stylesheet body came back empty and the correct check ran on nothing.
What it does now
One regex, and a test that asserts a stylesheet is actually fetched rather than asserting the check works when handed a string.
fails if it comes back: the fixes suite parses every generated file rather than diffing text
08
It billed a site for a rate limit this tool caused itself
What it looked like
A menu page answering 429 was reported at the highest severity as a page that will not open. Also a members area answering 401, which is working exactly as designed, and anything behind a firewall answering 403.
What I tried first, and why it was wrong
Nothing, because the test suite looked complete. It asserted the timeout case and it asserted the 404 case, and both passed. No test asked about the space in between.
The actual cause
The rule that a failed request is not proof of a dead page was written down, agreed, and applied to outbound links only. The menu check still read status >= 400. The 429 is the one that stings: this crawler requests eight pages in a row, their server pushes back, and the tool then charges the owner for a status it produced itself.
What it does now
One classifier, named classifyLink, used by both paths. The same failure as fixing a bug where it was reported instead of where it lives.
What that was worth
Found by the golden set on its first run, in the split it had never been tuned on. Six more tests now cover the statuses nothing had asked about.
fails if it comes back: 'rate limiting we caused ourselves is not their broken page'
09
The tool told owners to make their pages worse for screen readers
What it looked like
A divider graphic carrying alt="" was listed as an image needing a description. An empty alt is the correct way to mark something decorative, because it tells a screen reader to skip it. Following that advice would have made every one of those pages worse for exactly the people the check exists to help.
What I tried first, and why it was wrong
None. Nobody reported it and nothing threw. A golden set caught it the first time it contained a decorative image.
The actual cause
The parser read the attribute as (attr || "").trim(), which turns "there is no alt attribute" and "the author deliberately wrote an empty one" into the same empty string.
What it does now
The image carries hasAlt now, so a decision and an omission stop being the same value. Whitespace-only alt still counts as missing. Nobody types alt=" " on purpose.
What that was worth
The generated alt-text.md had the same bug and was handing people a list of decorative images to go and write descriptions for.
fails if it comes back: 'alt="" is a decision, not a missing description'
10
A selector was widened and the code that reads it never was
What it looked like
Every responsive image pulling an insecure source passed the mixed content check silently. The element was visited. Nothing was found.
What I tried first, and why it was wrong
I read the selector, saw [srcset] listed in it, and moved on. It looked handled.
The actual cause
The selector matched <source srcset="http://...">, and then the body read a src attribute that a <source> does not have. So the value came back empty and the check said nothing, which looks exactly like a page with no problem.
What it does now
It reads srcset too, and splits the comma separated list, because each entry is a URL followed by a descriptor like 2x.
fails if it comes back: 'an insecure srcset is counted'
11
The rate limiter had a reset button anyone could press
What it looked like
None. This one came out of a review, not a failure.
What I tried first, and why it was wrong
I fixed it in the file the review pointed at. There were three near-identical copies, and the same line was sitting in the other two.
The actual cause
Two holes. The table was cleared entirely once it passed 500 entries, so cycling 500 invented addresses reset everybody’s counter. And the key came from the leftmost entry of x-forwarded-for, which is whatever the caller typed, so one caller could look like unlimited visitors.
What it does now
Eviction by age, never a wipe. The key comes from the header the platform sets and a caller cannot forge. One implementation, used by every public endpoint, because the real failure was fixing it where it was reported instead of where it lived.
fails if it comes back: there is one copy left to test
There are 250 tests across the tool, 74 of them on the address guard alone, and nearly every one of them is one of these. None of it uses a test framework.
Six of the eleven came from a reviewer, or from me attacking my own work on purpose. Three of those six came from a golden set of 34 pages scored on precision and recall, all three in the 19 cases it is never allowed to be tuned against. Four of the eleven had already passed a review, a typecheck or a green test suite while still being wrong.
That last number is the argument for scoring a population instead of asserting a rule. Number 08 sat behind a suite that tested the timeout case and the 404 case and passed both. Nothing had asked about any status in between, so nothing found it.
And one that was not mine
The same habit pointed at somebody else’s code found this in the official Anthropic SDK for TypeScript, version 0.90.0. When a rate-limited response carries a Retry-After header the client cannot parse, the delay comes out as NaN, the check that falls back to exponential backoff tests for undefined and does not catch it, and every retry fires immediately.
Measured against a local server answering 429 every time: a header it cannot read gave retry gaps of 11 and 17 milliseconds. No header at all gave 417 and 963. A server asking for more delay gets less than one that says nothing.
The half that needs no misbehaving proxy is worse. A valid HTTP-date, which the specification allows, is an absolute time, so it has passed by the second retry. The first retry waits and every one after it fires straight away.
The reproduction runs offline with no API key and no credit, because a bug report a maintainer cannot run is a bug report they have to take on trust. I looked in two other places first and found nothing, which is worth saying: the line decoder agreed with itself across 985 different ways of cutting up the same twenty inputs, and the event parser matches the specification on every rule I tested.