Convention Drift.
AI-generated code that quietly ignores a repo's established conventions — reinventing helpers, picking the wrong library, and using off-house naming and error-handling — so the codebase drifts toward generic, internet-average style.
##Signs and Symptoms
A reviewer recognizes Convention Drift when an AI change works but doesn't look like it belongs in the codebase. It reaches for the global average pattern instead of the local one:
- A new helper is written inline even though a battle-tested utility already exists (
utils/date.ts,lib/apiClient, a sharedResulttype). - A different library or API than the repo standard appears (
axiosin an app that standardized on afetchwrapper;momentwhere the repo usesdate-fns). - Naming, file layout, and error-handling style diverge —
camelCasewhere the module issnake_case, rawthrow new Error("...")where everything else returns a typed error, bespoketry/catchlogging instead of the shared logger. - Each new requirement gets its own freshly-minted, narrowly-tailored block rather than reusing an abstraction (Ox Security's "Over-Specification," seen in 80–90% of AI code).
- Symptom signature: duplicated blocks multiply. GitClear measured an 8× jump in 5+ line clone blocks in 2024, with copy/pasted lines overtaking moved (refactored) lines for the first time.
// House style (already in the repo)
import { apiClient } from "@/lib/apiClient"; // wraps auth, retries, base URL
import { formatDate } from "@/utils/date"; // app-wide, locale-aware
// AI-generated change — drifts away from both
import axios from "axios"; // not a project dependency pattern
async function getUser(id: string) {
try {
const res = await axios.get(`https://api.example.com/users/${id}`); // hardcoded base URL
return { ...res.data, joined: new Date(res.data.joined).toLocaleDateString() }; // reinvents formatDate
} catch (e) {
console.log("error", e); // not the shared logger; swallows the error
}
}
The tell is consistency, not correctness: three reviewers each find a different "wrong-but-working" choice, and none of them match the file next door.
##Reasons for the Problem
Why models produce it
- Regression to the training mean. LLMs are trained on a vast corpus of internet-average code, not your repository. When unprompted, they emit the statistically most common idiom (
axios,moment,console.log), not your house idiom — your conventions are a tiny, out-of-distribution signal next to the global average. - Next-token self-consistency over repo-reach. It is locally easier to complete a self-contained block (inline a date formatter) than to "know" that
@/utils/dateexists and import an identifier the model never saw. Reuse requires repo context the model doesn't hold; reinvention only requires the current buffer. - Limited / lossy repo context. The canonical helper, the lint config, and the ADR that says "use the fetch wrapper" are usually outside the prompt. The model can't follow a convention it was never shown. As Factory's Eno Reyes put it, much of a convention is tacit — patterns humans absorb by reading the codebase that agents simply never see.
- Sycophancy / literal task focus. Asked to "add
getUser," the model does exactly that and won't volunteer "actually we already have a client for this." It optimizes for completing the stated task, not for fitting the system. Ox Security's "By-The-Book Fixation" (80–90% of samples) is the same force: it follows a generic textbook convention rather than evaluating the project's own. - Training-cutoff staleness. If the repo migrated to a newer library or pattern after the model's cutoff, the model confidently reintroduces the version it was trained on.
Why it hurts
- Maintainability & cognitive load. Every drifted choice is one more way to do the same thing. Readers must hold N variants of "how we format dates" in their head; the codebase loses its single source of truth.
- Correctness on change. Duplicated/reinvented logic diverges silently — a bug fixed in the shared helper is not fixed in the AI's copy. GitClear ties the clone explosion directly to AI assistants making tab-to-insert cheaper than reuse, while refactoring's share of changes fell from 25% (2021) to under 10% (2024).
- Security. Reinvented validation, auth, or query-building bypasses the hardened shared path (hardcoded base URLs, swallowed errors, ad-hoc string SQL). Ox calls the aggregate effect an "Army of Juniors": fast, functional, no architectural judgment.
- Review load & tech-debt accrual. Drift isn't caught by tests (the code works), so it lands in review or not at all, compounding into the "Scattered Functionality / Modular Mirage" architectural smell where related behavior is fragmented across files with no real cohesion.
##Treatment
Prompting / workflow tactics
- Show the conventions. Put the rules where the agent reads them — a
CLAUDE.md/AGENTS.md/rules file listing the sanctioned client, logger, error type, naming, and "use X not Y." Conventions the model can't see, it can't follow. - Point at the canonical code. "Use
@/lib/apiClientand@/utils/date; do not add new HTTP libraries. Match the error-handling inservices/orders.ts." Few-shot the repo's idiom by pasting one exemplar file. - Ask before it writes. "What existing helpers/abstractions cover this? Reuse them; only add new code if none fit." This converts reinvention into reuse up front.
- Make the linter the gate. Require the agent to run the formatter, linter, and type-checker and fix all findings before returning. Factory's advice is to systematize quality signals (lint/format/type-check/test) so the agent optimizes toward them instead of drifting.
- Review for fit, not just function. Diff the imports for non-sanctioned libraries; grep for logic that should have called the shared helper; run a copy-paste detector on the PR.
The refactor — name the classic moves: Remove Duplication / Replace Inline Code with Function Call, Substitute Algorithm, and Extract Function if the right abstraction doesn't yet exist.
// BEFORE — drifted, reinvents client + date logic, hardcoded URL, swallowed error
import axios from "axios";
async function getUser(id: string) {
try {
const res = await axios.get(`https://api.example.com/users/${id}`);
return { ...res.data, joined: new Date(res.data.joined).toLocaleDateString() };
} catch (e) {
console.log("error", e);
}
}
// AFTER — reuses the established conventions
import { apiClient } from "@/lib/apiClient";
import { formatDate } from "@/utils/date";
async function getUser(id: string) {
const user = await apiClient.get<User>(`/users/${id}`); // base URL, auth, retries, typed errors handled here
return { ...user, joined: formatDate(user.joined) };
}
If the same drifted block appears in several PRs, that's a signal the convention is undiscoverable — fix the root cause by documenting it in the rules file and/or exposing it through a single obvious entry point, not by re-reviewing each instance.
##Detected by
- jscpd duplication threshold (min-lines / min-tokens) — Copy/paste detection
- PMD CPD CPD duplicated code blocks — Copy-Paste Detector
- ESLint no-restricted-imports — no-restricted-imports
- typescript-eslint @typescript-eslint/naming-convention — naming-convention
- SonarQube Source files should not have any duplicated blocks — Duplicated blocks