ConstructiCat Logo
CodeBust.
Browse section ▾

Дрейф соглашений.

Сгенерированный ИИ код, который тихо игнорирует устоявшиеся соглашения репозитория — переизобретает вспомогательные функции, выбирает не ту библиотеку и использует не принятое в проекте именование и обработку ошибок — из-за чего кодовая база дрейфует к обобщённому, усреднённому по интернету стилю.

##Signs and Symptoms

Ревьюер распознаёт дрейф соглашений, когда изменение от ИИ работает, но не выглядит как часть кодовой базы. Оно тянется к глобально усреднённому паттерну вместо локального:

  • Новая вспомогательная функция пишется встроенно, хотя проверенная в бою утилита уже существует (utils/date.ts, lib/apiClient, общий тип Result).
  • Появляется библиотека или API, отличные от стандарта репозитория (axios в приложении, стандартизированном на обёртке над fetch; moment там, где репозиторий использует date-fns).
  • Именование, раскладка файлов и стиль обработки ошибок расходятся — camelCase там, где модуль на snake_case, голый throw new Error("...") там, где всё остальное возвращает типизированную ошибку, самодельное логирование в try/catch вместо общего логгера.
  • Каждое новое требование получает свой свежеслепленный, узкоспециализированный блок вместо переиспользования абстракции ("Сверхспецификация" от Ox Security, встречается в 80–90% ИИ-кода).
  • Симптоматическая сигнатура: дублированные блоки множатся. GitClear зафиксировал восьмикратный скачок числа блоков-клонов в 5+ строк в 2024 году, при этом скопированные строки впервые обогнали перемещённые (отрефакторенные).
// Внутренний стиль (уже в репозитории)
import { apiClient } from "@/lib/apiClient";   // оборачивает auth, ретраи, базовый URL
import { formatDate } from "@/utils/date";      // на всё приложение, с учётом локали

// Изменение от ИИ — отклоняется от обоих
import axios from "axios";                       // не паттерн зависимостей проекта
async function getUser(id: string) {
  try {
    const res = await axios.get(`https://api.example.com/users/${id}`); // захардкоженный базовый URL
    return { ...res.data, joined: new Date(res.data.joined).toLocaleDateString() }; // переизобретает formatDate
  } catch (e) {
    console.log("error", e);                     // не общий логгер; проглатывает ошибку
  }
}

Признак — согласованность, а не корректность: три ревьюера находят каждый свой "неправильный, но работающий" выбор, и ни один из них не совпадает с соседним файлом.

##Reasons for the Problem

Почему модели это порождают

  • Регрессия к среднему обучающей выборки. LLM обучены на огромном корпусе усреднённого интернет-кода, а не на вашем репозитории. Без подсказки они выдают статистически наиболее распространённую идиому (axios, moment, console.log), а не вашу внутреннюю — ваши соглашения это крошечный сигнал вне распределения на фоне глобального среднего.
  • Самосогласованность следующего токена важнее охвата репозитория. Локально проще дописать самодостаточный блок (встроить форматтер дат), чем "знать", что @/utils/date существует, и импортировать идентификатор, который модель никогда не видела. Переиспользование требует контекста репозитория, которого у модели нет; переизобретение требует лишь текущего буфера.
  • Ограниченный / теряющий детали контекст репозитория. Каноническая вспомогательная функция, конфиг линтера и ADR, гласящий "используйте обёртку над fetch", обычно находятся вне промпта. Модель не может следовать соглашению, которое ей никогда не показывали. Как выразился Eno Reyes из Factory, значительная часть соглашений неявна — это паттерны, которые люди впитывают, читая кодовую базу, и которые агенты попросту никогда не видят.
  • Подхалимство / буквальная фокусировка на задаче. На просьбу "добавь getUser" модель делает ровно это и не вызовётся сказать "вообще-то у нас уже есть клиент для этого". Она оптимизирует выполнение поставленной задачи, а не вписывание в систему. "Фиксация на учебнике" (By-The-Book Fixation) от Ox Security (80–90% образцов) — та же сила: модель следует обобщённому учебниковому соглашению, а не оценивает собственное соглашение проекта.
  • Устаревание из-за порога обучения. Если репозиторий мигрировал на более новую библиотеку или паттерн после порога обучения модели, модель уверенно возвращает ту версию, на которой обучалась.

Почему это вредит

  • Сопровождаемость и когнитивная нагрузка. Каждый отклонившийся выбор — ещё один способ делать одно и то же. Читателям приходится держать в голове N вариантов "как мы форматируем даты"; кодовая база теряет единый источник истины.
  • Корректность при изменениях. Дублированная/переизобретённая логика молча расходится — ошибка, исправленная в общей вспомогательной функции, не исправлена в копии, созданной ИИ. GitClear напрямую связывает взрыв клонов с тем, что ИИ-ассистенты делают вставку по Tab дешевле переиспользования, тогда как доля рефакторинга в изменениях упала с 25% (2021) до менее 10% (2024).
  • Безопасность. Переизобретённая валидация, аутентификация или построение запросов обходит укреплённый общий путь (захардкоженные базовые URL, проглоченные ошибки, самодельный строковый SQL). Совокупный эффект Ox называет "Армией джунов": быстро, функционально, без архитектурного суждения.
  • Нагрузка на ревью и накопление техдолга. Дрейф не ловится тестами (код работает), поэтому он попадает в ревью или вообще никуда, накапливаясь в архитектурный запах "Разбросанная функциональность / Модульный мираж", где связанное поведение раздроблено по файлам без реальной связности.

##Treatment

Тактики промптинга / рабочего процесса

  • Покажите соглашения. Разместите правила там, где агент их читает — файл CLAUDE.md/AGENTS.md/правил, перечисляющий санкционированный клиент, логгер, тип ошибки, именование и "используй X, а не Y". Соглашениям, которых модель не видит, она следовать не может.
  • Укажите на канонический код. "Используй @/lib/apiClient и @/utils/date; не добавляй новые HTTP-библиотеки. Повтори обработку ошибок из services/orders.ts". Покажите идиому репозитория по принципу few-shot, вставив один образцовый файл.
  • Спрашивайте до того, как она пишет. "Какие существующие вспомогательные функции/абстракции это покрывают? Переиспользуй их; добавляй новый код, только если ни одна не подходит". Это заранее превращает переизобретение в переиспользование.
  • Сделайте линтер вратарём. Требуйте, чтобы агент запускал форматтер, линтер и проверку типов и исправлял все находки до возврата результата. Совет Factory — систематизировать сигналы качества (lint/format/type-check/test), чтобы агент оптимизировал по ним, а не дрейфовал.
  • Проверяйте на соответствие, а не только на работоспособность. Сравнивайте импорты на наличие несанкционированных библиотек; ищите grep-ом логику, которая должна была вызвать общую вспомогательную функцию; запускайте детектор копипасты на PR.

Рефакторинг — назовите классические приёмы: Remove Duplication (Устранение дублирования) / Replace Inline Code with Function Call (Замена встроенного кода вызовом функции), Substitute Algorithm (Замена алгоритма) и Extract Function (Извлечение функции), если нужной абстракции ещё нет.

// ДО — дрейф, переизобретает клиент + логику дат, захардкоженный URL, проглоченная ошибка
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);
  }
}

// ПОСЛЕ — переиспользует устоявшиеся соглашения
import { apiClient } from "@/lib/apiClient";
import { formatDate } from "@/utils/date";

async function getUser(id: string) {
  const user = await apiClient.get<User>(`/users/${id}`); // базовый URL, auth, ретраи, типизированные ошибки обрабатываются здесь
  return { ...user, joined: formatDate(user.joined) };
}

Если один и тот же отклонившийся блок появляется в нескольких PR, это сигнал, что соглашение необнаружимо — устраните первопричину, задокументировав его в файле правил и/или сделав доступным через единую очевидную точку входа, а не перепроверяя каждый случай.

##Detected by