ConstructiCat Logo
CodeBust.
Browse section ▾

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 shared Result type).
  • A different library or API than the repo standard appears (axios in an app that standardized on a fetch wrapper; moment where the repo uses date-fns).
  • Naming, file layout, and error-handling style diverge — camelCase where the module is snake_case, raw throw new Error("...") where everything else returns a typed error, bespoke try/catch logging 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/date exists 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/apiClient and @/utils/date; do not add new HTTP libraries. Match the error-handling in services/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