Навигация

Рефакторинг легаси через AI

Перенести миллион строк с одного фреймворка на другой руками — это годы, поэтому соблазн отдать миграцию агенту огромен. Но у легаси нет спецификации: единственный надёжный её источник — сам код, полный недокументированных контрактов и неявных инвариантов, которые не знает ни один файл и помнят только разработчики.

Эта статья — о практике brownfield-рефакторинга с AI: как страховаться characterization-тестами, комбинировать codemod и агента, изолировать сессии и мигрировать по частям, не разбивая прод.

Содержание

  1. Чем brownfield отличается от greenfield
  2. Characterization-тесты первыми
  3. Гибрид: codemod плюс агент
  4. Изоляция и защитный контур
  5. Где агент надёжен, а где ломает инварианты
  6. Инкрементальная миграция
  7. Ревью агентских диффов
  8. Кейсы
  9. Ключевые тезисы для интервью
  10. Заключение
  11. Полезные ссылки

Чем brownfield отличается от greenfield

В greenfield-промпте «сделай приложение» агент сам придумывает поведение — но и ломать ему нечего. В brownfield всё наоборот: спецификация существует, но она записана в самом коде, запутанно и противоречиво.

GreenfieldBrownfield
Что даёт агентуЧистый лист и вашу спецификациюТолько код с его конвенциями и легаси-паттернами
Где спецификацияВ голове и артефактахВ самом коде, неявно и противоречиво
Цена ошибкиЛокальная: видно сразуНелокальная: проявляется через модули позже
Типичный отказНе компилируется, тесты падаютПроходит типы и тесты, ломает поведение в проде
Что критичноСпецификация до кодаТесты фиксации поведения, границы и защита

Большее контекстное окно эту проблему не решает. Вопрос не в объёме, а в том, какой код важен, почему и как он связан с правкой. Отсюда правило: скорость без корректности — это не выигрыш, а ускоренное производство техдолга.


Characterization-тесты первыми

Characterization-тест не проверяет, что код должен делать; он фиксирует, что код делает сейчас — вместе с багами, странными краевыми случаями и давно забытыми особенностями.

Почему именно до изменений

Порядок здесь решает всё. Агент, который сначала рефакторит, а потом пишет тесты, пишет ассерты под уже изменённый вывод: случайно потерянный сбор или поменявшееся округление становится «ожидаемым». Агент, который сначала снимает тесты на нетронутом коммите, создаёт goldens, которые рефакторинг обязан воспроизвести.

Рабочий цикл

1. Снять goldens на нетронутом коммите (default branch).
2. Прочитать их один раз — на предмет «странностей», но не чинить.
3. Доказать, что goldens умеют падать: mutation-тестирование модуля.
4. Запереть goldens: CODEOWNERS, deny-правило, CI-гейт.
5. Отдать рефакторинг агенту. Definition of done: «набор зелёный, goldens без изменений».

Пример: от функции к тесту

// src/legacy/statement.js
export function calculateDiscount(amount, customer) {
  if (amount > 1000) return amount * 0.1;
  if (customer.vip) return amount * 0.05;
  return 0;
}
// tests/characterization/statement.test.ts
import { calculateDiscount } from "../../src/legacy/statement";

describe("calculateDiscount (characterization)", () => {
  it("returns 0 for amount 1000 and non-vip", () => {
    expect(calculateDiscount(1000, { vip: false })).toBe(0);
  });

  it("returns 0 for amount 999 and non-vip", () => {
    expect(calculateDiscount(999, { vip: false })).toBe(0);
  });

  it("returns 200 for amount 2000 and non-vip", () => {
    expect(calculateDiscount(2000, { vip: false })).toBe(200);
  });

  it("returns 50 for amount 1000 and vip", () => {
    expect(calculateDiscount(1000, { vip: true })).toBe(50);
  });
});

Важно: amount > 1000 даёт 10%, а amount === 1000 даёт 5% для VIP. Это может быть багом, но сначала мы фиксируем поведение как есть и выносим в QUIRKS.md.

Mutation testing: почему coverage врёт

Line coverage врёт: тест может выполнить все строки и ничего не проверить. Честная метрика — мутационное тестирование: инструмент намеренно ломает код (меняет > на <, убирает return, инвертирует флаг) и смотрит, упал ли хотя бы один тест. Выживший мутант — дыра в проверках.

npx stryker run

Для модуля под рефакторинг ориентир — mutation score выше ~80%.

Где брать эталон

Снижать объём работы нужно через «шов» (seam) — место, где поведение видно снаружи, не редактируя код: возвращаемое значение функции, HTTP-ответ, выходной файл джобы. Выбирайте самый высокий шов, который остаётся детерминированным и достаточно быстрым для PR.


Гибрид: codemod плюс агент

Чистый агент на больших миграциях проигрывает не потому, что «глупый», а потому что у него другая природа. Он редактирует исходники как текст, каждый проход — новая догадка, он не видит типы между файлами и легко пропускает редкие вхождения. Детерминированный codemod — наоборот: одно и то же изменение применяется одинаково при каждом запуске, без галлюцинаций, по всем вхождениям.

Анализ (grep/glob/LST)  →  Deterministic codemod  →  AI-блок (смысл)  →  Тесты + сборка  →  Ревью
   что менять              переименования,            переписать          компиляция +       человек
                           импорты, API, синтаксис     логику              unit-тесты
Что делатьИнструментПримеры
Механические заменыjscodeshift, ast-grep, eslint --fixпереименование функций, замена импортов, обновление API
Конфиги и шаблоныshell-скрипт, sed, jqфлаги сборки, переменные окружения, YAML
Смысловые решенияАгентпереписать логику, выбрать идиому, разобрать неоднозначный случай

Пример: codemod на jscodeshift

// codemods/replace-old-api.js
module.exports = function (fileInfo, api) {
  const j = api.jscodeshift;
  return j(fileInfo.source)
    .find(j.CallExpression, { callee: { name: "oldFetchData" } })
    .replaceWith((path) =>
      j.callExpression(
        j.memberExpression(
          j.identifier("apiClient"),
          j.identifier("fetch")
        ),
        path.value.arguments
      )
    )
    .toSource();
};
npx jscodeshift -t codemods/replace-old-api.js src --parser tsx

Когда звать агента

  • переписать компонент с Options API на Composition API;
  • заменить кастомный стейт-менеджер на Pinia или TanStack Query;
  • разобрать, почему функция ведёт себя странно на определённом входе;
  • дописать characterization-тесты для непонятного модуля.

Агент может помогать писать и улучшать codemod: находит краевые случаи, а вы добавляете их в рецепт.


Изоляция и защитный контур

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

Worktree на сессию

Каждая агентская сессия работает в отдельном git worktree на своей ветке: агент свободно экспериментирует, не задевая чужой рабочий каталог.

# создать изолированную ветку
git worktree add ../legacy-refactor-login -b refactor/login-form

# перейти и подготовить окружение
cd ../legacy-refactor-login
pnpm install

# после завершения — удалить
git worktree remove ../legacy-refactor-login
git branch -d refactor/login-form

Единица изоляции = единица миграции: один компонент, один модуль, одна ветка.

Хуки и deny-правила

  • На агенте (PreToolUse). Хук видит предлагаемое действие до выполнения и может заблокировать правки в опасных зонах: криптография, платежи, скрипты миграции данных.
  • На репозитории (git pre-commit/pre-push). Он срабатывает для любого автора — человека, тиммейта, агента.
{
  "permissions": {
    "deny": [
      "Edit(./src/legacy/billing/**)",
      "Edit(./tests/characterization/**)",
      "Edit(./.env*)",
      "Edit(./pnpm-lock.yaml)"
    ]
  }
}

deny-правило — это «хук наоборот»: пока модуль не покрыт characterization-тестами и они не заперты, агенту физически запрещено его править.

PR-контур

Перед старшим ревью дифф проходит набор проверяющих агентов: охотник за «тихими» отказами, анализатор покрытия, security-сканер, ревьюер документации. Это снимает рутину и оставляет человеку архитектуру и границы.


Где агент надёжен, а где ломает инварианты

Тип рефакторингаНадёжность агентаЧто важно
Механический (переименования, импорты, замена API)ВысокаяЛучше отдать codemod; агент уместен как автор рецепта
Структурный (извлечь утилиту/класс в пределах файла)Средне-высокаяРаботает, пока «радиус поражения» целиком в контексте
Семантический (смена поведения, новая идиома)Низкая без тестовНужны characterization-тесты и человек в цикле
Кросс-модульный / граф зависимостейНизкаяАгент видит не всех вызывающих — обязателен обзор архитектором
ПроизводительностьНенадёжнаНет объективного измерения — легко получить «правдоподобно, но неверно»

Три типичных отказа

  • Кросс-модульные изменения. Агент починит тех вызывающих, которых увидел, и тихо сломает остальных.
  • Неявные контракты. «Этот метод вызывается только из джобы», «поле 0 — особый случай», «исключение здесь ожидаемо».
  • Экстраполяция паттернов. Агент видит устаревшие паттерны и продолжает их, вместо того чтобы модернизировать.

«Думай сильнее» не помогает

Длинная цепочка рассуждений не вскрывает неявные контракты — она достраивает связную историю. Помогает не больше размышлений, а больше опоры на инструменты: сначала агент собирает факты (grep, логи, трассировка), потом планирует.

EVIDENCE   — факт, взятый прямо из репозитория/логов/вывода тестов.
INFERENCE  — вывод из EVIDENCE, который можно перепроверить.
ASSUMPTION — утверждение без доказательства.

Правило: ни одна ASSUMPTION не ведёт к изменению кода, пока не превращена
в EVIDENCE через запрос к инструменту или пробный тест.

Меньше — значит быстрее откатывать и легче локализовать ошибку.


Инкрементальная миграция

Big-bang-переписывание в brownfield — худший вариант: ошибка не локализуется, а откат означает потерю всей работы. Миграцию ведут по единицам: компонент, модуль, эндпоинт.

Green рядом с legacy

Надёжный паттерн — собирать новое рядом со старым и переключать feature-flag’ом: новый Vue/React-компонент монтируется в легаси-приложение, пока не будет подтверждён поведенческий паритет.

// featureFlags.ts
export const useNewCheckout = () =>
  import.meta.env.VITE_ENABLE_NEW_CHECKOUT === "true";

// CheckoutPage.vue
<template>
  <NewCheckout v-if="useNewCheckout()" />
  <LegacyCheckout v-else />
</template>

Порядок и метрики

  • Порядок — от фундамента к потребителям. Сначала общие утилиты и фундаментальные слои, затем те, кто от них зависит.
  • Оценка «ценность против сложности». Не начинайте с самого страшного модуля. Берите то, что часто меняется и приносит боль.
  • Метрики: доля мигрированных LoC в неделю, зелёные characterization-тесты с неизменными goldens, mutation score, дельта по смеллам, размер диффа на PR.

Ревью агентских диффов

Ревью человеческого кода ищет «что не так». Ревью агентского диффа должно искать «чего не хватает»: потерянный вызов, забытую ветку, молча изменённое поведение.

Чек-лист:

  • Размер патча позволяет прочитать его целиком.
  • Есть результат прогона тестов, линтера и тайпчека.
  • Characterization-тесты проходят и goldens не менялись.
  • Есть mutation score или другая проверка, что тесты реально ловят изменения.
  • Радиус правки понятен: не затронуты ли другие модули?
  • Неявные контракты записаны в тестах или комментариях.
  • Видно, что сгенерировано AI: промпт, состояние репозитория, комментарий в PR.

Помните: зелёный набор, написанный агентом, — это свидетельство, а не доказательство.


Кейсы

Кейс 1: Vue Options API → Composition API

Команда мигрирует старый Vue-компонент с Options API на Composition API, чтобы отдать его другой команде как самодостаточный модуль.

План:

  1. Options API → Composition API. Структурная задача с примерами в репозитории. Агент справляется уверенно; человек быстро ревьюит.
  2. Смена управления состоянием. В плане нужно было явно сказать, что компонент должен стать stateless и работать только через props/emits. Без этого агент сделал бы «разумный» выбор, не подходящий под цель.
  3. Экспорт компонента. Перенос в каталог экспортируемых, правка импортов, настройка экспорта в сборке. В проекте уже есть паттерн — агент повторяет его.

Вывод: агент хорош, когда задача чётко определена и есть образец. Там, где нужно дизайн-решение или неявные контракты, человеческое ревью критично.

Кейс 2: Замена axios на fetch-обёртку

В приложении десятки вызовов axios.get('/api/...') разбросаны по компонентам и хукам. Задача — уйти на централизованный apiClient.

Разделение работы:

  • Codemod ищет все вызовы axios.*, заменяет на apiClient.fetch, добавляет импорт.
  • Агент переписывает хуки, где логика завязана на интерцепторы axios (обработка 401, ретраи), потому что там нужно понимание контекста.
  • Characterization-тесты фиксируют поведение загрузки данных до правок.

Ловушка: агент заменил axios.get везде, но пропустил место, где код ожидал, что axios бросает ошибку с полем response.status. Новый apiClient оборачивал ошибку иначе, и обработчик catch перестал ловить статус. Поймали на characterization-тесте эндпоинта.

Кейс 3: React class → function + hooks

Старый классовый компонент фильтра таблицы нужно перевести на функцию с useState/useEffect, чтобы использовать новые shared hooks.

Что пошло хорошо:

  • Агент переписал componentDidMount + componentDidUpdate на useEffect.
  • Перевёл this.state/this.setState на useState.

Что сломалось:

  • В componentDidUpdate была проверка prevProps.query !== this.props.query, которая запускала поиск. Агент заменил её на useEffect(() => {...}, [query]), но не учёл, что query — объект, и сравнение по ссылке стало лишний раз триггерить эффект.

Как поймали: characterization-тест зафиксировал количество вызовов поиска при одинаковом query. Тест упал, потому что эффект стал срабатывать чаще.

Кейс 4: Утилита с неявным контрактом

// src/legacy/parseReportDate.ts
export function parseReportDate(raw: string): Date {
  if (raw === "TODAY") return new Date();
  if (raw === "YESTERDAY") {
    const d = new Date();
    d.setDate(d.getDate() - 1);
    return d;
  }
  return new Date(raw);
}

Агент предложил заменить “магические строки” на enum и parseISO из date-fns. Локально — красиво. Но в ночной джобе в raw иногда приходила пустая строка, и new Date("") возвращала Invalid Date, которую дальнейший код использовал как сигнал «нет данных». parseISO("") бросал ошибку, и джоба упала.

Вывод: characterization-тест, снятый на реальных данных джобы, поймал разницу. Без него правка выглядела бы очевидным улучшением.

Кейс 5: Миграция настроек под feature-flag

Команда переписывает экран настроек Cloud Agents. Вместо big-bang они:

  1. Построили новый Vue-компонент рядом со старым.
  2. Обернули выбор в feature-flag enableNewCloudAgentsSettings.
  3. Мигрировали таблицу → форму → валидацию по очереди, на каждом шаге проверяя паритет через characterization-тесты.
  4. После недели наблюдений в проде включили флаг по умолчанию и удалили старый компонент.

Вывод: feature-flag дал возможность откатить одним переключателем, а маленькие шаги локализовали каждую ошибку.


Ключевые тезисы для интервью

  • Brownfield не даёт агенту ни спецификации, ни тестов: спецификация — сам код, а неявные инварианты живут в головах, поэтому локально корректная правка может ломать потребителя через несколько модулей.
  • Characterization-тесты фиксируют фактическое поведение (включая баги) до изменений, снимаются на нетронутом коммите, проверяются мутационным тестированием и запираются от правок агента.
  • Гибрид codemod + AI: механическое отдаётся детерминированному AST-инструменту, смысловое — агенту.
  • Агент склонен к локализованным и консервативным правкам: доминируют переименования и смена типов, тогда как высокоуровневый дизайн он трогает редко.
  • Опаснее всего кросс-модульные изменения, неявные контракты, обновление графа зависимостей и повторение легаси-антипаттернов; «больше рассуждений» без опоры на инструменты лишь усиливает уверенные, но неверные изменения.
  • Защитный контур brownfield: worktree на единицу миграции, PreToolUse-хуки на опасные каталоги, git-гейт на commit/push, deny-правила на goldens и набор review-агентов перед старшим ревью.
  • Миграцию ведут инкрементально: новое строится рядом с легаси и включается feature-flag’ом, поведенческий паритет подтверждается тестами, а порядок идёт от фундамента к потребителям.
  • Ревью агентского диффа — это «чего не хватает» плюс доказательства: маленькие патчи, неизменные goldens, mutation score и явная трассируемость сгенерированного AI.

Заключение

Рефакторинг легаси с AI — это в первую очередь управление риском, а не генерация кода.

  • Сначала страховка, потом агент. Characterization-тесты на нетронутом коммите — самый дешёвый способ сделать рефакторинг скучным.
  • Не давайте модели то, что решается детерминированно. Механическое — codemod, смысловое — агенту, а модели позвольте писать сами рецепты.
  • Ограничьте радиус поражения. Маленькие единицы миграции, ветка на единицу, работа рядом с легаси и переключение feature-flag’ом.
  • Перенесите повторяющиеся нарушения из прозы в механику. deny-правила, хуки и CI-гейты надёжнее любых инструкций в промпте.
  • Помните про неявные инварианты. Самая опасная ошибка агента — не сломанный синтаксис, а тихо изменённое поведение, которое не ловят существующие тесты.

Попробуйте начать со следующего небольшого рефакторинга иначе: сначала зафиксируйте поведение модуля тестами и прогоните мутации, и только потом запускайте агента в изолированном worktree.


Полезные ссылки