The Dangers of the AI Refactor
Turning to an LLM to fix your problems is becoming a much more common aspect of modern life. Hit a snag, articulate it to the LLM du jour, and 🤯, your problem is solved like clicking your fingers 🫰! As engineers, this loop takes a lot of pain out of our work: Obscure compilation error? Throw it to the LLM with the logs and it will often suggest a good fix. We do, however, need to check what is being output, as there can be subtle issues with the solutions. Some will be quite benign, some will cause runtime errors on certain edge cases, and some will expose all of your customer’s data to the world 😬.
The following example falls into the former category: Between now and 7 months ago, Gleam removed the range interface from the List module in standard lib (I know… Unbelieveable that a 0.x library changed its interface), after I’d written code that used it as part of the AOC 2025 solutions.
list.range was pretty simple (though the inclusivity of the last element is galling)
pub fn range(from start: Int, to stop: Int) -> List(Int)
assert list.range(0, 5) == [0, 1, 2, 3, 4, 5]
Now we have int.range instead:
pub fn range(
from start: Int,
to stop: Int,
with acc: acc,
run reducer: fn(acc, Int) -> acc,
) -> acc
assert range(from: 1, to: -2, with: [], run: list.prepend) == [-1, 0, 1]
The new interface is more complex. It accepts that most of the time, the raw list of ints is not really useful, and is immediately mapped or folded. As such, it is trying to shortcut code that would look like this:
list.range(0, 5)
|> list.reduce(with: int.add)
into this
int.range(from: 0, to: 5 + 1, with: 0, run: int.add) // Notice the plus 1
So after asking the LLM to update the code to fix usages of the old list.range, it generated these diffs
pub fn prefixes_til_half(input: String) -> List(String) {
- list.range(1, string.length(input) / 2)
+ int.range(from: 1, to: string.length(input) / 2 + 1, with: [], run: list.prepend)
+ |> list.reverse
|> list.map(fn(to_take) { string.slice(input, at_index: 0, length: to_take) })
}
Here, the LLM has correctly replaced the code in such a way that it will work (literally generating the same list as before, the tests all pass), but it is not really in the spirit of the API change, adding extra steps to replicate the old functionality, rather than leaning into the new paradigm:
pub fn prefixes_til_half(input: String) -> List(String) {
int.range(from: 1,
to: string.length(input) / 2 + 1,
with: [],
run: fn (acc, to_take) {
[string.slice(input, at_index: 0, length: to_take), ..acc]
})
}
Here we are doing the prefix in the fold, rather than keeping some intermediate step. We also didn’t need to reverse the list at all as the order does not matter. This meant we could make things simpler again, rather than adding more clutter. It seems we should also go back and sort out the indexing paradigm, to remove the little + 1 gremlin.
This is an example of the LLM going for a quick fix rather than thinking through changes and coming to a better solution. It is impressive it made the correct changes in the first place, but it just wasn’t ideal. Small things like this can really add up in a codebase to making things quite incomprehensible, particularly when we now have AI agents making changes at 10x the speed of human engineers. We should be reviewing carefully what they are doing and always asking the question “Is this actually what we want? Or is it just superficially correct?”