So you're a code copier? What else do you do.

Copy-paste is the one form of technical debt that feels like productivity while you're creating it.
You found a block that already does the thing. You pasted it, changed two variable names, and moved on. No new logic to reason about, no design to get wrong, nothing for review to flag. The diff even looks clean, because pasted code is by definition code that already passed once. The most efficient-feeling five seconds in the whole job, and it's how one function quietly becomes four functions that nobody knows are the same function.
The copies don't look like copies anymore
Here's the part that makes duplication worse than every other kind of debt: you can't find it later.
The day you paste, the two blocks are identical. Then time happens. Someone renames userData to payload in one copy. Someone reformats another to satisfy the new Prettier config. A third gets a variable inlined and a comment added. Six months on, the four copies share their entire structure and none of their surface text.
Here's what that looks like in practice. You wrote this once:
async function validateUser(userData: Record<string, unknown>) {
if (!userData.email || !userData.name) {
throw new Error('Missing required fields')
}
const existing = await db.users.findOne({email: userData.email})
if (existing) {
throw new Error('User already exists')
}
return true
}Six months and three pasting incidents later, your codebase also contains these:
// Copy #2 - renamed to "payload", reformatted, logic identical
async function validatePayload(payload: Record<string, any>) {
if (!payload.email || !payload.name) throw new Error('Missing required fields')
const found = await db.users.findOne({email: payload.email})
if (found) throw new Error('User already exists')
return true
}
// Copy #3 - variable inlined, comment added, still the same function
async function checkUserInput(input: Record<string, unknown>) {
// Ensure we have what we need before hitting the database
if (!input.email || !input.name) {
throw new Error('Missing required fields')
}
if (await db.users.findOne({email: input.email})) {
throw new Error('User already exists')
}
return true
}Three names, three formatting styles, same logic. Now try to find the relationship. grep matches text, and the text no longer matches. Your linter checks each file in isolation and sees three perfectly valid functions. Search-and-replace can't fix a bug across copies it can't locate. The duplication is still fully present - every maintenance cost intact - but it has become invisible to every tool you'd reach for to catch it.
That's the trap. Duplication isn't dangerous because it exists; it's dangerous because it stops looking like duplication the moment anyone touches it.
In 2026, you're not the only one copying
Everything above assumed a human did the pasting, and increasingly nobody did.
An LLM doesn't have a shared helper it reaches for. It generates the block it needs, where it needs it, every time you ask. Ask it to add validation to three endpoints across three sessions and you get three validation functions - structurally identical, independently generated, none of them aware the other two exist. The model isn't copying from your codebase. It's copying from itself, one prompt at a time, and the result lands in your repo as fresh original code that happens to be the fourth instance of a pattern nobody asked it to check for.
// src/routes/users.ts - generated Monday
function validateRequest(body: Record<string, unknown>) {
if (!body.email || !body.name) throw new Error('Missing fields')
// ...identical logic
}
// src/routes/teams.ts - generated Wednesday, different session
function validateTeamInput(data: Record<string, unknown>) {
if (!data.email || !data.name) throw new Error('Missing fields')
// ...identical logic, different variable names
}
// src/routes/invites.ts - generated Friday
function checkInvitePayload(payload: Record<string, unknown>) {
if (!payload.email || !payload.name) throw new Error('Missing fields')
// ...you get the idea
}We wrote about the volume problem - fourteen files arriving faster than anyone can read them. Duplication is what that volume compounds into. When plausible-looking code gets waved through review because it reads fine in isolation, the thing sliding past is very often a copy of something that already lives three directories over. The copier used to be a tired developer under deadline pressure, and now it's also the tool you adopted to go faster - except this copier never gets tired of pasting and never thinks to check whether the function already exists.
The bill arrives as a bug you fix in the wrong number of places
Duplication doesn't cost you anything the day you create it, which is the whole problem - same as technical debt in general, the cost is deferred to someone who didn't make the call and doesn't know they're inheriting it.
Then a bug turns up in one of the copies. You find it, you fix it, you ship. Except the same bug is still sitting in three other blocks you never knew were related, because they don't look related anymore. You didn't fix a bug - you fixed a quarter of a bug and closed the ticket. The other three surface one at a time over the next year, each reported as a brand-new issue, each investigated from scratch by someone who has no idea it was already solved once.
That's the real interest rate on copy-paste. Not the extra lines, because lines are cheap. The cost is that every fix, every audit, every "where else does this happen" now has to run N times, and nobody knows what N is. A logic change that should touch one place touches four, and you only find out about the fourth in production.
// You fixed the validation bug here:
if (!body.email || !isValidEmail(body.email) || !body.name) { ... }
// But these three still have the old version:
if (!payload.email || !payload.name) { ... } // src/routes/teams.ts
if (!data.email || !data.name) { ... } // src/routes/invites.ts
if (!input.email || !input.name) { ... } // src/lib/validators.tsMultiply a wrong N across a codebase and "we fixed that already" stops being something anyone can say with confidence.
"Just be DRY" is advice, not a mechanism
The standard answer is a principle: Don't Repeat Yourself. Every developer has heard it, most believe it, and it changes almost nothing for a simple reason.
DRY only works if you know the repetition is there. You can't refactor a duplicate you can't see, and we just established that duplicates make themselves unseeable within a sprint or two of being created. Telling a team to be DRY is telling them to avoid something their tools won't show them, and the missing piece was never the discipline - it was the visibility.
The other answer is a code review that catches the copy, but review reads the diff in front of it, and the diff in front of it looks like new code. A reviewer would need the other three files open, memorised, and mentally normalised for renames and reformatting to notice the match. Nobody reviews like that, because review is the wrong instrument for a problem that spans files the reviewer isn't looking at.
What's missing is something that compares code the way the copies actually relate - by structure rather than by text - and does it automatically, so "where else does this exist" stops depending on whether anyone happens to remember.
Catching copies that stopped looking alike
This is what duplicate detection in Iris Code does, and the design is built entirely around the invisibility problem.
Instead of matching text, Iris Code normalises source into a token stream before comparing. Identifier names, string and number literals, whitespace, and comments are all stripped out, while keywords and structure are kept verbatim. Here's a simplified version of what that means:
// What your editor sees (two "different" functions):
async function validateUser(userData) { async function checkPayload(input) {
if (!userData.email || !userData.name) if (!input.email || !input.name)
throw new Error("Missing fields"); throw new Error("Required");
const existing = await db.find(...) if (await db.find(...))
if (existing) throw new Error("Exists"); throw new Error("Exists");
return true; return true;
} }
// What Iris Code sees after normalisation (same token stream):
async function _(x) {
if (!x._ || !x._) throw new Error(_);
const _ = await _._(_);
if (_) throw new Error(_);
return true;
}The renamed copy, the reformatted copy, the copy with an inlined variable - same structure, same match. Text-level grep sees different blocks. Structure-level comparison sees one block repeated, which is what it actually is.
The hard part of a tool like this isn't finding matches but not drowning you in them, so the results stay worth acting on:
- Import headers are blanked before comparison. Two files importing the same components isn't duplication.
- Uniform data literals are excluded. Country lists and enum tables are structurally repetitive by nature, not by copy-paste.
- No pairwise explosion. A block repeated five times reports as a minimal covering set rather than all twenty-something pair combinations, and matches contained inside larger matches are dropped.
- Markup needs more evidence. JSX is repetitive by design, so the match threshold doubles when both sides are markup.
A match has to span at least 40 normalised tokens before it counts, and consecutive matching windows merge so a long copy reports once instead of as a smear of shifted fragments. When Iris Code tells you two blocks are the same, it should be worth your attention rather than something you learn to dismiss.
Then it puts the finding where the decision gets made. Within-file copies show up as a smell on the file itself. The workspace view gives you a duplication percentage and the largest blocks, with each click-through jumping to both locations. A full duplicates table lets you filter cross-file versus within-file and updates live as you edit - resolve a pair and it drops out without a rescan. On Pro, an Open diff action puts both copies side by side, titled GitHub-style (scanner.ts:10-24 ↔ parser.ts:88-102), editable in place, and Iris Code toasts the moment you've actually collapsed the duplication.
And because detection alone has a shelf life when someone can always see the warning and merge anyway, gateMaxDuplicateBlocks turns it into an enforceable limit. Set the cap and the git hook, the build hook, and iris gate fail when new duplication pushes you over it. A block spanning two files counts once. Existing copies don't block you, but new ones don't get to compound quietly the way the last four did.
| Layer | Tool | What it does |
|---|---|---|
| Editor | Iris Code sidebar | Flags duplicate blocks by structure, catching copies that were renamed or reformatted |
| Review | Duplicates table + diff view | Shows both locations side by side, updating live as you resolve them |
| Enforcement | gateMaxDuplicateBlocks | Caps total duplicate blocks in the git hook, build hook, and iris gate |
Copy-paste was never a moral failing, and DRY was never going to fix it by force of principle alone. Duplication survives because it goes invisible faster than anyone can act on it, and you can't be disciplined about a thing you can't find.
So stop trying to remember where the copies are. Let something that compares structure do the remembering, and the next time you fix that bug, fix it the right number of times.
Originally published on the Iris Code blog.
Iris Code finds copy-pasted blocks within and across files - even when the copies were renamed or reformatted - right in your editor, fully offline. Try it free.
Iris Code scores every file on save.
Get per-file health scores, security smell detection, and enforcement rules - directly in VS Code. Free to start, no account required.
Install the extension →