Refactoring Avoidance.
AI assistants pile new, near-duplicate code next to what already exists instead of restructuring it, so duplication climbs and refactoring — the rework that keeps a codebase healthy — quietly disappears.
##Signs and Symptoms
A reviewer sees a diff that is almost entirely additions. The model solved the prompt by appending a fresh function, branch, or file rather than editing the abstraction that should have absorbed the change. Tell-tale signs:
- Copy-paste-with-tweaks: a new function is 90% identical to an existing one, differing by a literal, a field name, or one extra
if. - Parallel near-duplicates:
formatUserCsv,formatAdminCsv,formatGuestCsvall hand-rolling the same loop instead of one parameterized function. - Branch sprawl instead of extraction: a long method grows another
else ifrather than the model pulling a strategy/lookup table out. - Reinvention: a hand-written
deepClone/debounce/date parser when the repo already imports lodash, date-fns, etc. (OX Security calls this "Vanilla Style".) - Over-specification: a hyper-specific single-use helper where a generic one already exists two files over.
- "Bugs déjà-vu": the same fix has to be applied in three copies because the duplicate was never DRYed up.
// Existing in the repo:
function priceWithTax(items: Item[]) {
const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
return subtotal * 1.2; // 20% VAT
}
// What the model adds for the new "discounted" case —
// a whole second copy instead of a parameter:
function priceWithTaxDiscounted(items: Item[], discount: number) {
const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0); // duplicated
return subtotal * (1 - discount) * 1.2; // duplicated VAT logic
}
The smell is structural, so it is most visible across the whole change, not one hunk: lots of green, little code moved, and a duplication scanner lighting up on the new lines.
##Reasons for the Problem
Why models produce it
- Additive next-token bias. An LLM completes the prompt in front of it. Emitting a self-contained new block is the locally highest-probability, lowest-risk continuation; editing a distant abstraction requires holding the whole module in working context and predicting a globally-consistent edit, which is harder and not what the immediate prompt rewards.
- Thin repo context. Coding assistants rarely load the entire codebase. If the model can't see the existing helper, library, or base class, it can't reuse it — so it rebuilds. OX Security's report frames this as the "Army of Juniors" effect: lots of locally-functional code, no architectural memory.
- Refactoring is risky and unrewarded. Restructuring touches code the model wasn't asked to change and can break callers and tests. A sycophantic, "just make it work" assistant minimizes blast radius by not touching working code — exactly the behavior OX measured as "Avoidance of Refactors" in 80–90% of AI-generated code, and "Over-Specification" (single-use over reusable) in another 80–90%.
- Training-data staleness. The model may not know the repo adopted a util module or upgraded a library after its cutoff, so it hand-rolls what already exists.
- Generation is cheap, deletion is scary. Producing 40 new lines costs the model nothing; convincing it to delete and consolidate 40 existing lines fights its instinct to preserve.
Why it hurts
- Duplication compounds. GitClear's 2025 analysis of 211M changed lines found copy/pasted lines rose from ~8.3% (2021) to 12.3% (2024) — the first year duplication exceeded "moved" (refactored) code — while refactored lines fell from ~25% to under 10%, roughly a 60% drop. Blocks of 5+ duplicated lines jumped ~8x in 2024.
- Maintainability and correctness. Every clone is a place a future fix can be forgotten — "Bugs déjà-vu," where the same defect recurs and must be patched N times (OX: 70–80% of AI code violates reuse principles this way).
- Tech-debt accrual. Refactoring is the rework that keeps entropy down; suppressing it means debt is created but never repaid. The codebase grows faster than it improves.
- Review load. Reviewers must now diff near-identical blocks by eye to confirm they're intentionally the same, the cognitive-load shift Stack Overflow's "code smells for AI agents" discussion describes — the work moves from writing to reviewing and consolidating.
##Treatment
Prompting / review tactics
- Point the model at what exists. "Before adding code, search the repo for an existing helper/util/base class and reuse it; if none fits, generalize the closest one." Paste the relevant module into context so it can actually see the abstraction.
- Constrain the diff shape. "Prefer editing existing functions over adding new ones. If two code paths share logic, extract a shared function (Extract Function) rather than duplicating."
- Make it run the tools. Require the assistant to run the duplication scanner (jscpd / PMD CPD) and the linter, and to resolve any new duplicated-block findings before declaring done — this turns an invisible smell into a failing gate.
- Ask for the refactor explicitly as a second step. Generation and consolidation are different tasks; do "make it work," then a separate "now DRY this up and remove duplication," which models handle far better when asked directly.
- Add a quality gate in CI so duplication can't ratchet up unnoticed (Sonar duplicated-lines threshold, or
jscpd --threshold).
The actual refactor — name the classic moves: Extract Function, Parameterize Function, and Pull Up / Replace Conditional with Polymorphism or a lookup table to kill Duplicate Code.
// Before: two near-identical functions (refactoring avoided)
function priceWithTax(items: Item[]) {
const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
return subtotal * 1.2;
}
function priceWithTaxDiscounted(items: Item[], discount: number) {
const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
return subtotal * (1 - discount) * 1.2;
}
// After: Extract Function + Parameterize Function
const VAT = 1.2;
const subtotalOf = (items: Item[]) =>
items.reduce((s, i) => s + i.price * i.qty, 0);
function priceWithTax(items: Item[], discount = 0) {
return subtotalOf(items) * (1 - discount) * VAT;
}
One source of truth for subtotal and VAT: a future change to the tax rule is now a one-line edit instead of an N-place hunt.
##Detected by
- jscpd duplication above --threshold / --min-tokens — Copy/paste detection
- PMD Copy/Paste Detector (minimum-tokens duplication) — CPD
- SonarQube duplicated_lines_density / quality-gate duplicated-blocks condition — Duplications
- ESLint (eslint-plugin-sonarjs) sonarjs/no-identical-functions — no-identical-functions
- ESLint (eslint-plugin-sonarjs) sonarjs/no-duplicate-string — no-duplicate-string