The Ghost Function: Duplicate Code Left Behind by an AI Refactor

The Ghost Function: Duplicate Code Left Behind by an AI Refactor
I asked an AI assistant to tidy up a helper function that had grown messy over several edits.
It produced a cleaner version, moved it into a shared module, and reported the refactor as done.
The code ran. The tests passed. And for two days, a bug I was sure I had fixed kept coming back.
The new function was correct. The application was still calling the old one.
Two functions with the same job
The helper formatted a dice modifier for display. It turned 3 into +3 and -1 into -1. Small, dull, and used in a few different places.
I wanted it out of the main script and into a shared utils.js, with a clearer name. The assistant obliged. It wrote a neat formatModifier, updated an import, and gave me the summary I was hoping to see.
What it did not do was remove the original.
The old copy still sat near the bottom of the main file. Same name, almost the same behaviour, one forgotten edge case different. Two functions now existed for one job. Only one of them was the one I happened to be reading.
Why nothing complained
This is what makes a ghost function dangerous: nothing was broken enough to notice.
The project still built. There was no error, because the two copies lived in different files and neither the language nor the tooling objected. The tests still passed, because they exercised the new, tidy version, the one the assistant had just written and was proud of.
The running application, though, had not fully moved. Some screens used the new shared helper. One older screen still called the local original. From the outside, everything looked consistent. Inside, the work was half done.
A partial refactor is worse than no refactor. Before, there was one messy function that was at least the truth. Now there were two, and they disagreed.
The refactor that never finished
When I traced the bug, the shape of the problem was clear.
The assistant had done the interesting part of the task and skipped the boring part. Writing a clean replacement is satisfying work. Hunting down every call site and deleting the old definition is not. The model optimised for the visible result, a better function, and left the cleanup as an exercise for nobody.
The steps it missed are the ones that make a refactor real:
- update every call site, not just the convenient ones
- delete the original definition
- confirm nothing else still imports the old name
- run the case that actually exercised the bug
Each of those is dull. Each of them was the difference between a finished change and a ghost.
I fixed the wrong copy
Here is the part I am least proud of.
When the bug first appeared, I opened the shared utils.js, found formatModifier, and corrected the edge case. The fix was right. I tested the screen in front of me, saw the correct output, and moved on.
The screen that showed the bug to my users was the one still calling the old copy. I had been editing a function that particular path never ran.
Two functions with the same name will do that to you. You fix one, you verify one, and it never occurs to you to ask whether the code you are reading is the code being executed.
How I catch ghosts now
After losing two days to a duplicate, I changed how I review AI refactors.
I no longer trust "done." When an assistant tells me it has moved or renamed something, I search the whole project for the old name before I believe the work is finished.
Search the entire project for
formatModifier. Show me every place it is defined and every place it is called.
If more than one definition comes back, the refactor is not complete, no matter how good the new version looks.
I also ask for the removal explicitly, because models treat deletion as optional:
Move this function to the shared module, update all call sites, and delete the original. List the call sites you changed.
And when I fix a bug, I confirm the failing path actually reaches the code I am about to edit, rather than assuming a matching name means a matching execution.
Deleting is part of the job
The lesson underneath this one is about what the word "refactor" means.
Refactoring is not writing a better version next to the old version. It is replacing the old version. The new code is only half of it. The removal is the other half, and it is the half an AI is most likely to skip, because it produces nothing you can see.
A model is rewarded, in the moment, for showing you something new. Deleting code shows you less. So it writes the replacement, reports success, and leaves the original haunting the file.
A refactor is not finished when the new code works. It is finished when the old code is gone.
Search for the duplicate. Delete the original. Then believe the change is done.
An AI will happily write you a better function. It will just as happily leave the worse one running.