The duplicate-code problem nobody warns you about
Three copies of the same helper is not untidiness, it is a bug you fix once and ship twice. How to find them and stop making more.
You go looking for the date formatter because a customer in Lisbon says her invoice timestamps are an hour out. rg 'function formatDate' src comes back with three definitions in three files, none of which import each other. While you are in there you notice apiClient.ts and api-client.ts sitting in the same folder, both exporting get and post, both pointing at the same base URL, one retrying on a 429 and the other not.
Nobody decided to do this. It accumulated over about eleven sessions, and every one of those additions looked completely reasonable on the day it was written.
Creating is cheaper than finding
There is no mystery in the mechanism, and it helps to describe it without pretending the agent has intentions.
An agent cannot use what it has not read. Its picture of your repository is whatever files it has opened in this session plus whatever a search happened to surface. A perfectly good helper in src/lib/date.ts that was never opened does not exist, functionally, at the moment the next file is written.
Creating is also cheaper than finding. Writing a fifteen-line formatter is one tool call and it is guaranteed to compile. Finding yours means guessing at names — formatDate, toDisplayDate, humaniseDate, fmt — opening two or three files, and working out what the options already do. The first route terminates faster and produces working code. So it wins, repeatedly.
Then every fresh session starts blind again. Agents do not remember previous sessions by default, so the helper written on Tuesday is invisible on Wednesday unless something puts it back in view. That is the same root cause behind a lot of other odd behaviour, covered in why your agent forgets everything.
And, honestly, the prompt asks for it. "Add a function that formats the invoice date as DD MMM YYYY" is a request for a new function, and you will get exactly that: correct, tested, in the wrong place. Nothing in that sentence asked whether the thing already existed.
The bug you fix once and ship twice
The cost is not that the repo looks scruffy. The cost is that copies drift.
// src/lib/date.ts — written in session 2
export function formatDate(d: Date): string {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeZone: "UTC",
}).format(d);
}
// src/components/invoice/format-date.ts — written in session 9
export function formatDate(d: Date): string {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
}).format(d); // no timeZone: renders in the server's local zone
}
Both are fine in isolation. Both pass their tests, because each copy arrived with its own test file asserting its own behaviour. The invoice page imports the second one, your server runs in a zone that is not UTC for part of the year, and a customer sees the wrong day.
Now scale that to validation. When two schemas validate the same upload, the one you find is the one that gets the size limit and the MIME check. The other keeps working, keeps passing, and stays wrong. The caller you had forgotten about is precisely the one that is exposed, because forgotten and unpatched are the same property.
The everyday version is duller and still expensive: you fix a rounding bug in one currency helper, the tests go green, and the same bug ships from the other two.
Finding what you already have
Do this before you believe anything about your own repo. It takes about two minutes.
# copy-paste clones, not just identical files
npx jscpd src --min-lines 8
# exports nobody imports — dead twins usually show up here first
npx knip
# import cycles, which duplication tends to create
npx madge --circular src
jscpd catches the structural copies. knip (or ts-prune if you want something smaller) is often the faster signal, because when a function has a twin, one of the two usually ends up with no importers at all.
Then a census of exported names. Duplicates rarely share a name exactly, but they cluster:
# how many times is each exported function name defined?
rg -o --no-filename 'export (async )?function (\w+)' -r '$2' src \
| sort | uniq -c | sort -rn | head -20
# same intent, different words
rg -n 'function (format|to|humanise|render)\w*(Date|Time|Currency|Price)' src
And the one that catches the apiClient.ts / api-client.ts pair, which no clone detector will flag because the contents genuinely differ:
# filenames that collide once you ignore case, dashes and underscores
rg --files src \
| sed 's#.*/##' \
| tr 'A-Z' 'a-z' | tr -d '-_' \
| sort | uniq -d
On a case-insensitive filesystem that pair can also cost you an afternoon in CI, where the two files stop being two files.
Search first, then write
Prevention is mostly one instruction, stated so that it produces visible output rather than a claim.
Put a standing rule in whichever file your tool reads at session start — CLAUDE.md, AGENTS.md, a Cursor rules file, .github/copilot-instructions.md, GEMINI.md. The wording matters less than the demand for evidence:
## Before writing a new function
1. Search first. Run ripgrep for the behaviour and for at least three
plausible names before creating anything.
2. Report what you found, with file paths, before writing any code.
3. Shared helpers live in `src/lib/`. Do not create a second home for
dates, currency, validation or HTTP clients.
4. If an existing helper nearly does the job, extend it, or state why it
cannot be extended. Do not fork it.
5. Never add a file whose name differs from an existing one only by
case, hyphen or underscore.
The part that does the work is point two. An agent that has to print search results before writing has actually read the neighbourhood, and you get a checkpoint: if the report says "no existing date formatting found" and you know there is some, you stop it there rather than reviewing the duplicate later.
Naming one home for shared code matters more than it sounds. Without it, "put it somewhere sensible" resolves differently every session, and sensible-but-different is how you end up with four utils directories. Repo layout does a lot of the heavy lifting here, which is the subject of structuring a repo for agents.
It also helps to ask for the search separately from the code. "Where in this codebase is invoice date formatting handled? List every file, do not write anything yet." Read the answer. Then ask for the change, naming the file it belongs in. Two turns, and the second one has no room to invent.
The end-of-feature dedupe pass
Run this before the pull request, not before the release. It is five minutes and it is always faster than the alternative.
git diff --stat main...HEADand read the list of added files. For each one, say out loud where its nearest neighbour lives.npx jscpd src --min-lines 8, looking only at clones the branch introduced.npx knip— a newly unused export is usually the older twin of something you just added.- Grep each new exported name against the rest of the repo. One match means you are fine. Three means pick a winner.
- For every duplicate you keep on purpose, write the reason in a comment. Unexplained duplication gets copied again.
- Delete the loser in the same commit. A follow-up ticket for this never gets done.
Rule of thumb: if you cannot name the single file where a kind of logic lives, your agent cannot either, and it will make you another one tomorrow.
Keep reading
Debugging an AI-generated codebase you have never read
A repeatable order of operations for fixing code you never wrote, from reproducing on demand to finding the seams where generated code goes quiet.
How to review code you did not write
An ordered pass for large agent-written diffs, so your attention lands on the parts that can actually hurt you.
Security mistakes agents make repeatedly
The same short list turns up in agent-written code every time, and all of it passes your tests. Here is the list, with the fixes.