
Photo by Arawark chen on Unsplash
This is the story of an afternoon that had no business case whatsoever, and that I would spend again tomorrow without hesitation.
I keep a copy of an old PHP library around — Minify, a venerable thing I forked and then froze back in 2014 and have barely touched since. One afternoon, in the spirit of “for a bit of fun, let’s go back a decade,” I opened its issue tracker. Eighty-five open issues. The newest from 2015. The oldest going back to the Google Code era, when bug reports still arrived with the faint smell of <table>-based layouts. Nobody had expected an answer in years. That was rather the point.
Several hours later the tracker read 0 open. I had read all eighty-five, fixed the two that were genuinely still broken, and replied — individually, by hand — to every single person, including the ones who’d filed in 2010 and have long since moved on with their lives.
Nothing depended on any of it. Nobody asked. By any reasonable measure of how a working day ought to be spent, it was indefensible. Let me try to defend it anyway.
An abandoned issue tracker is a peculiar kind of debt. It isn’t code debt — the code works, or it doesn’t, regardless of what the tracker says about it. It’s social debt. Eighty-five times, somebody took the trouble to write something down and hand it to me, and eighty-five times nothing came back. The tracker just sits there, quietly recording the gap between someone asked and someone answered.
You can clear that debt two dishonest ways. You can ignore it forever — the default, and the one most dead repos take. Every new visitor sees a wall of open issues and concludes, reasonably, that nobody’s home. Or you can declare issue bankruptcy: select all, close all, perhaps a mass comment murmuring “closing stale issues, reopen if still relevant.” This feels like resolution, but it’s a delete key wearing a hat. The person who filed gets a notification that says, in effect, “we never read this, and now it’s gone.”
I wanted the honest third option — the one that costs far more time and is worth every minute of it. The whole method reduces to a single rule: closing an issue is a reply, not a delete. Here is what that looks like in practice.
Reproduce before you believe. The temptation is to triage from the title, and you must resist it, because some of the most confident-sounding reports don’t reproduce and some throwaway ones turn out to be real. In my batch, the headline bug — a minifier mangling perfectly good CSS — did not reproduce as literally described. But chasing it down anyway uncovered a genuine, adjacent defect that was real and fixable. Had I triaged from the title, I’d have closed the wrong thing and missed the right one. Twenty minutes in a REPL is cheaper than a wrong disposition that then stands for another decade.
Bucket honestly. Read the whole set first, then sort each issue into a bucket that describes what it actually is — not how you’d prefer to dispose of it:
The buckets force honesty. “Stale” is not a bucket; it’s an excuse. “Obsolete because it targets PHP 5.2” is a bucket — it’s a reason, and a reason is something you can put in a comment without embarrassment. Read end to end, the eighty-five sorted themselves cleanly. The real and reproducible pile had exactly two issues in it. Two, out of eighty-five. But two real bugs is two real bugs.
One comment per issue, written for that issue. This is the rule that makes the whole thing humane, and the one that costs the time. Every issue got a comment written for it: what I found, which bucket it landed in, and where to go next if it still mattered to the reporter. No canned paragraph copy-pasted eighty times. People can smell a form letter, and a form letter on a decade-old bug report reads as contempt. It needn’t be long — three honest sentences beat three boilerplate ones every time. “This targets a PHP version that’s been EOL since 2018, and the caching layer it describes was rewritten upstream — here’s the current code” is a complete, respectful answer. It tells the reporter you read their words.
Use the close reason, and use it correctly. GitHub lets you close as completed or not planned, and the distinction matters: it’s the difference between “we did this” and “we consciously decided not to.” It shows up in the UI and it tells the truth at a glance. (A small, real trap, since it cost me a pass: the CLI’s --reason flag wants not planned with a space, not not_planned. The underscore version is silently rejected. Tools have feelings about whitespace.)
Minify_Cache_File has two nearly-identical methods: fetch(), which reads a cache entry, and display(), which streams one straight to output. When file locking is on, both call fopen(). But only fetch() checked whether the open had actually succeeded:
$fp = fopen($this->_path . '/' . $id, 'rb');
if (!$fp) {
return false; // fetch() had this guard
}
display() didn’t. So a missing or unreadable cache file sent false straight into flock(), fpassthru() and fclose(), producing a cascade of “expects parameter 1 to be resource, boolean given” warnings. The fix is the most boring three lines you will ever see — copy the guard the sibling method already had:
$fp = fopen($this->_path . '/' . $id, 'rb');
if (!$fp) {
return;
}
The other one is juicier, and it is the bug I actually chased the headline report into. It is a CSS minifier optimisation that was completely correct in 2009 and quietly wrong by about 2012 — and then sat in the code for another decade because nobody noticed the world had changed underneath it.
The rule is seductive in its simplicity: a zero with a unit is just a zero. margin: 0px means exactly margin: 0. So does 0em, 0pt, 0in. Strip the unit, save the bytes, ship a smaller stylesheet. Every minifier does it, and it’s correct — for lengths.
The YUI CSS compressor (the PHP port living inside Minify) did it like this:
// Replace 0(px,em,%) with 0.
$css = preg_replace("@([\\s:])(0)(px|em|%|in|cm|mm|pc|pt|ex)@", "$1$2", $css);
Read the unit list carefully. px, em, in, cm, mm, pc, pt, ex — all lengths, all safe. And then, tucked in among them: %.
A percentage is not a length. And that one character is the whole bug.
For most properties, width: 0% and width: 0 do behave identically, so the error stays invisible. It only bites in the two places where the percentage carries meaning that the bare zero throws away.
The IE10/11 flexbug. Flexbox sizing leans on flex-basis, and flex-basis distinguishes between a length of zero and a percentage of zero. The relevant member of the famous Flexbugs list: IE 10 and 11 mishandle a unitless flex-basis of 0. The accepted workaround is to always write the unit —
flex: 1 1 0%; /* works in IE10/11 */
flex: 1 1 0; /* IE10/11 collapses or mis-sizes the item */
So an author does the right thing, writes 0% precisely to dodge the bug… and the minifier helpfully rewrites it to 0, reintroducing the exact defect the author was avoiding. The build step creates the bug.
Gradient colour-stops. A colour-stop in a gradient needs a position, and a position is a length or a percentage — never a bare number. So:
background: linear-gradient(to right, red 0%, blue 100%); /* valid */
background: linear-gradient(to right, red 0, blue 100%); /* invalid */
red 0% is a colour and a position. red 0 is a colour followed by a syntax error, and the browser drops the whole declaration. The minifier turns working CSS into discarded CSS.
There is no clever fix. The % simply does not belong in a list of length units. Delete it:
// Replace 0<length-unit> with 0. The percentage unit is deliberately
// NOT stripped: "0%" carries meaning that bare "0" loses in a few
// contexts (flex-basis:0% triggers the IE10/11 flexbug, and gradient
// colour-stops such as "red 0%" become the invalid "red 0"). Keeping
// the "%" costs one byte and is always safe.
$css = preg_replace("@([\\s:])(0)(px|em|in|cm|mm|pc|pt|ex)@", "$1$2", $css);
Lengths still shrink — margin:0px becomes margin:0 as before. 0% now survives. The comment is longer than the change, which is exactly right: the why is the expensive part, and the next person to look at this list deserves to know why % is conspicuously absent before they “helpfully” add it back. (I checked the obvious neighbours while I was in there. hsl(120, 0%, 0%) is untouched — those percentages are preceded by commas, not the whitespace-or-colon the regex anchors on, so they were never at risk. The fix is as narrow as the bug.)
Both fixes were verified on PHP 8.4, committed together as ca7fcdf, and pushed. The parent commit on master? A pull-request merge from October 2014. The tree had not moved in nearly twelve years.
Here is the part I actually care about. Every one of the other eighty-three issues got closed too — but not silently, and not with a canned line. Each got its own comment: what it was, why it was being closed, and where to go if it still mattered to someone. “Not planned” with a reason is a world apart from “not planned” with a shrug.
It took far longer than the code did. It was also the better half of the day’s work, because closing an issue is a reply, not a delete, and somebody is on the other end of every one of those, even if they stopped waiting years ago. The backlog ends at zero either way. Only one of the two ways leaves behind a record you’d be happy for the reporter to read.
For a frozen library nobody is installing fresh in 2026, strictly nothing depended on any of this. So was it worth it? A few things crystallised that I half-knew already.
There’s a quiet kind of satisfication that has nothing to do with applause. Woodworkers know it; gardeners know it. The feeling of leaving a thing tidier than you found it when nobody is watching and nobody will check. Not the loud satisfaction of shipping a feature to a cheering crowd — the quiet kind. The drawer that finally closes properly. The fence post you reset straight even though it was only slightly leaning. An issue tracker reading 0 open where it read 85 that morning is a fence post reset straight. No user will ever notice. I notice.
Most “bugs” aren’t, and that’s fine. Two of eighty-five were real. The rest were questions, obsolete reports, things that belonged elsewhere. Reading them all didn’t feel like waste — it felt like finally listening to people who’d been talking into a void. The ratio isn’t the point. The reply is.
Old code is a letter from a past author. That minifier optimisation that broke modern CSS was correct when it was written — flexbox wasn’t shipping yet, CSS gradients were vendor-prefixed experiments. The author wasn’t careless; the language moved. Then it grew new corners where the assumption no longer held, and the optimisation kept right on applying it, because the code still ran and the failure was silent. That’s the trap with byte-shaving cleverness: it encodes an assumption about the language, and the language doesn’t hold still. You read old code most generously when you assume it was right once and ask what changed — not when you assume the person before you was a fool. When in doubt, keep the byte.
Closing the gap is the work. Every unanswered issue is a small space between someone asked and someone answered. Software maintenance, when you strip away the tooling, is mostly the unglamorous business of closing those gaps — in the code, in the docs, in the tracker — and being honest about the ones you’re choosing to leave open.
I make small macOS utilities under the Jorvik name, and the through-line from “tidy a dead repo for fun” to “ship menu-bar apps people trust” is not a coincidence. The same instinct that won’t let an eighty-five-issue tracker sit unanswered is the one that won’t ship an app with a rough edge I know about. It isn’t discipline, exactly. It’s that the rough edge bothers me, and fixing it feels good, and that turns out to be a perfectly good engine to run a craft on.
“Better late than never” started that afternoon as a joke. It ended it as a small, sincere statement of principle. Some things are worth doing simply because they should have been done — and the fact that you’re a decade late is a reason to start now, not a reason to never bother.
The tracker reads zero. Onward.