Перейти к содержимому

Легаси спорило само с собой. Как мы с AI собирали контракт поведения

· 15 мин
Содержание

TL;DR: старый код был одним из источников, но не готовой спецификацией нового бэкенда. Первую неделю мы с агентом собирали из клиентского интерфейса, PHP, Bitrix-админки, Confluence и новых требований контракт целевого поведения. Агент строил гипотезы, искал противоречия и придумывал комбинации, на которых они ломались. Я решал, какое поведение сохранить, изменить или удалить, а неизвестное останавливал до уточнения. Принятые решения агент превращал в состояния, переходы и исполняемые сценарии. Всё переписывание заняло 5 недель; это результат одного проекта, а не сравнение с ручной разработкой.


Названия, детали и отдельные последовательности сценариев изменены. Классы конфликтов, способ проверки и принятые инженерные решения сохранены.

Со стороны разработки на проекте был один человек – я. От сбора требований и разбора старой системы до проектирования, переписывания и ревью я работал с Claude как с AI-агентом.

Старый код рассказывал одну версию системы, bitrix-админка – вторую, в Confluence лежало ещё несколько версий, местами противоречащих обеим. Первую неделю мы с агентом не писали новый бэкенд, а выясняли, что именно он должен делать.

В клиентском контуре было два вида форм – анкета и доверенность. Анкета выглядела как пошаговая форма (типа stepper) для оформления автомобиля в лизинг: ранний ответ менял следующие экраны, состав участников, обязательные данные и право передать заявку дальше. Достижимых маршрутов набирались десятки. Какие-то данные проверялись внутри компании, какие-то – снаружи; устройство этих проверок для истории неважно. Важно другое: после изменения входных данных один результат терял актуальность, а соседний мог остаться пригодным. Основную сцену дальше я покажу именно на анкете, не перенося её внутреннюю модель на доверенность.

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

Если просто скормить агенту PHP и попросить перенести его на .NET, выбор целевой версии системы останется неявным. Последовательность одного источника ещё не делает его целевым поведением.

клиентский маршрут + PHP + админка + документы + новые требования
→ гипотезы и противоречия
→ решения автора
→ контракт целевого поведения

Почему нельзя было просто отдать агенту старый PHP

У такой системы нет одного рассказчика. Клиентский интерфейс хорошо показывает достижимый маршрут и момент валидации, но молчит о серверных последствиях. PHP исполняет реальные условия, сохраняет данные и вызывает интеграции, однако рядом с действующим правилом в нём может годами жить обход уже закрытого сценария. Админка показывает операции менеджеров и чтение истории, но не отвечает, какие ветки разрешены новым заявкам. Confluence иногда объясняет странное условие одной фразой, а иногда уверенно описывает версию процесса, которую код уже пережил.

На первой неделе агенту пришлось читать не только репозиторий старого приложения. В контекст попадали исходники Bitrix-админки, конфигурация, разбросанные по разделам статьи Confluence и новые требования. Каждый источник приносил значительный фрагмент, но почти ни один не закрывал правило целиком. По моей грубой ретроспективной оценке по фичам, примерно 30–40% поведения нельзя было однозначно восстановить только по коду. Это не доля строк, времени или сложности – просто масштаб зоны, где приходилось складывать пазл.

Я не просил агента сразу написать связный документ о системе. Такой документ слишком легко превращает пробелы в гладкий рассказ. Вместо этого он собирал кандидатные утверждения и рядом сохранял происхождение каждого фрагмента: где условие найдено, чем подтверждается, какой источник ему противоречит и что пока неизвестно. Если PHP говорил, что данные остаются, а интерфейс вёл себя так, будто ветки больше нет, это не усреднялось в формулировку "ветка частично активна". Конфликт оставался конфликтом.

AI сгладил противоречия между PHP, UI, Bitrix и документацией в одну спецификацию, а инженер просит вернуть трещины

Так становилось видно, где гипотеза додумывает связь. Одинаковое условие во фронтенде и PHP могло отвечать на разные вопросы: одно – за видимость шага, другое – за возможность продолжить уже начатый процесс. Похожий текст ещё не означал одинаковый контракт.

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

Именно здесь AI давал основную экономию внимания. Вручную я тоже мог пройти по всем источникам, но агент хорошо удерживал параллельные цепочки, возвращался к связанным условиям в разных файлах и быстро пересобирал карту после нового свидетельства. При этом он не назначал источникам приоритет. Решение сохранить / изменить / удалить / не решено оставалось моим: агент мог показать, что две версии несовместимы, но не мог сам объявить одну из них требованием бизнеса.

Первая неделя: гипотеза, контрпример, решение

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

Для каждой гипотезы я просил держать один и тот же короткий каркас:

кандидатное правило
→ на каких наблюдениях оно стоит
→ что должно быть верно, если правило правильное
→ какой контрпример его опровергнет
→ какое решение ещё требуется от меня

Это не был промпт, который один раз решил проект. Каркас работал как ограничитель разговора. Агенту приходилось отделять найденное от выведенного, а мне – замечать момент, когда я сам начинаю принимать удобное объяснение за требование. После нового источника менялась не вся спецификация разом, а конкретная гипотеза и зависимые от неё строки.

Этот цикл быстро изменил качество вопросов. В начале можно было спросить: "Когда показывать блок поручителя?" Через несколько проходов вопрос уже звучал иначе: "Что означает наличие данных поручителя, когда активный маршрут его больше не включает?" Первый вопрос почти автоматически тянет к условию видимости на фронтенде. Второй заставляет отдельно решить судьбу данных, участие в комплектности, зависимые проверки и историческое чтение.

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

Полезнее всего были не правильные ответы агента, а быстрые ошибочные обобщения. Они показывали, где старые названия и флаги провоцируют слишком широкий вывод. Я не исправлял каждую формулировку вручную. Сначала добавлял контрпример или просил агента найти его самому, после чего он пересобирал правило и список последствий. Так спор шёл о поведении системы, а не о том, какой абзац звучит убедительнее.

У карты был нарочно бедный словарь решений:

  • сохранить – наблюдаемое поведение подтверждено и входит в новый контракт;
  • изменить – целевая система намеренно расходится со старой;
  • удалить – сценарий больше нельзя начать, хотя его история может остаться читаемой;
  • не решено – реализация останавливается до дополнительного решения.

Так наблюдение переставало незаметно превращаться в требование. Найденный if больше не отвечал на вопрос, нужно ли переносить правило. Статья в Confluence не получала приоритет только потому, что написана человеческим языком. Даже поведение работающего интерфейса могло оказаться ограничением старой реализации, а не обещанием новой системы.

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

Гладкая гипотеза ломается на поручителе

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

Одна из первых кандидатных гипотез агента звучала убедительно: данные есть → участник активен. В старом PHP наличие заполненной записи действительно участвовало в нескольких условиях. Гипотеза хорошо объясняла часть кода и позволяла быстро перейти к реализации. Именно поэтому она была опасна.

Я попросил агента прогнать противоположную комбинацию: запись поручителя существует, но текущий маршрут его не включает. Агент нашёл подтверждения сразу на нескольких поверхностях. Фронтенд скрывал ветку и переставал требовать её поля. PHP не удалял уже введённые значения. Админка продолжала показывать их сотруднику. При возврате к прежнему маршруту повторный ввод не требовался. Наличие данных оказалось историческим фактом, а участие – свойством текущего сценария.

После этого агент разложил плоское данные есть на отдельные вопросы. Существует ли запись? Активна ли ветка? Участвует ли человек в текущей комплектности? Можно ли использовать прежний результат проверки? Должны ли данные появиться снова при обратном переключении? В старой реализации ответы на эти вопросы местами выводились из одного признака. В целевой модели они перестали меняться одним пакетом.

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

Наличие данных не означает участие в активном сценарии.

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

На этом агент не остановился. Он применил новое различие к соседнему понятию – успешной проверке. Допустим, ParticipantCheck прошла для текущего состава участников, после чего поручителя отключили. Исторический факт успеха никуда не исчез, но вход проверки изменился. Значит, результат больше нельзя использовать для отправки. При этом независимая AssetCheck не должна становиться неактуальной просто за компанию.

Из контрпримера выросла компактная машина состояний:

Draft → ChecksPending → ReadyToSubmit → Submitted

                           └─ значимый вход или маршрут изменён
                              → ChecksStale → ChecksPending

независимое изменение: ReadyToSubmit → ReadyToSubmit

Агент предложил несколько вариантов структуры, но схема стала контрактом только после того, как я подтвердил последствия каждого перехода. В Draft отключение поручителя сохраняет черновик и данные. В ReadyToSubmit то же действие делает ParticipantCheck неактуальной, оставляет AssetCheck актуальной и запрещает отправку до повторной проверки. Один маршрут даёт разные последствия в зависимости от истории процесса – это уже невозможно надёжно выразить условием "поручитель заполнен".

Так AI помог не просто распутать старые ветвления, а найти более сильную модель. Его вклад был не в магическом чтении PHP. Он быстро предложил правдоподобное правило, помог построить контрпример, протащил принятое различие через соседние состояния и показал места, где новая семантика ещё не сходится. Моя работа была выбрать, какой из найденных миров должен стать целевым.

Легаси уже недостаточно: добавляем аудит

До этого момента источники помогали решить, что делать со старым поведением. Но переписывание не было реставрацией. В целевой системе появилось новое требование: все действия с анкетой и доверенностью должны оставлять восстановимый след.

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

Для агента это оказался другой тип задачи. При восстановлении легаси он искал, какое правило уже существует. Здесь правило было задано, а я просил разложить его на кандидатные действия анкеты и доверенности, чтобы каждое можно было связать с отдельным сценарием.

Фраза "аудит всех действий" сама по себе ещё не была исполняемым требованием. Сначала нужно было перечислить действия каждого вида формы и для каждого связать изменение с ожидаемым следом. Конкретный состав этого списка остаётся за публичной границей; для статьи он намеренно схлопнут до одного целевого инварианта.

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

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

Новый аудит усилил центральную мысль кейса. Контракт целевого поведения собирался не только из того, что удалось доказать по легаси:

целевое поведение
= подтверждённое поведение легаси
+ согласованные новые требования
− явно удалённые сценарии

Агент помогал на всех трёх участках, но по-разному. Для сохранения искал подтверждения и контрпримеры. Для изменения протаскивал новое правило через известные пути. Для удаления проверял, не отрезана ли вместе с новым входом нужная история. Решение о границе в каждом случае принимал я.

От принятого решения к исполняемому контракту

К концу первой недели карта стала достаточно компактной, чтобы её можно было читать целиком, и достаточно строгой, чтобы агент не додумывал смысл во время реализации:

СценарийСостояние доДействиеСостояние послеРешениеПодтверждениеИсполняемая проверка
Поручитель отключён после проверокReadyToSubmit, WithGuarantor, обе проверки ActualПерейти на SelfВетка неактивна, данные сохранены, заявка ChecksStaleСохранить данные, изменить участиефронтенд + PHP + админка + моё решениеParticipantCheck = Stale, AssetCheck = Actual, отправка запрещена
Действие с анкетой или доверенностьюФорма в разрешённом состоянииВыполнить разрешённое изменениеНовое состояние; действие отражено в аудитеИзменить: новое требование аудитановое требованиеКаждое требуемое действие связано с отдельным проверяемым сценарием
Закрытая историческая веткаСтарая запись существуетОткрыть существующую / создать новуюЧтение разрешено / новый вход запрещёнУдалить новый маршрут, сохранить историюкод + админка + документы + моё решениеИстория открывается; создание и переход в ветку отклоняются

Строка со статусом не решено до этой таблицы не доходила. Она блокировала вертикальный сценарий раньше: агент мог продолжать собирать свидетельства и контрпримеры, но не получал права зафиксировать удобное поведение в коде.

Для принятой строки агент готовил сразу три представления одного решения: короткое правило человеческим языком, изменение модели и проверяемый исход. Я сверял их между собой. Если в тексте говорилось "данные сохраняются", а предложенная модель удаляла объект при деактивации, конфликт был виден до ревью большого PR. Если диаграмма допускала отправку из ChecksStale, тест уже не мог честно подтвердить описанный процесс. Эта тройная запись была избыточной на простых сценариях, зато на спорных хорошо ловила смысловые потери между обсуждением и кодом.

Агент также пытался обобщать. После сцены поручителя он предложил единое правило инвалидации всех проверок при любом изменении маршрута. Формально оно выглядело безопасно, но контрпример с независимой AssetCheck показал цену такой осторожности: пользователь проходил бы лишний круг без изменения её входа. Я сузил правило до зависимых результатов, а агент обновил состояния и исполняемый сценарий. Получился характерный для всей первой недели ритм: обобщение ускоряет модель, контрпример возвращает ей точность.

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

[Fact]
public void Disabling_guarantor_preserves_data_stales_only_dependent_check_and_is_audited()
{
    var application = Application
        .In(ApplicationState.ReadyToSubmit)
        .For(ApplicationRoute.WithGuarantor)
        .WithSavedGuarantorData()
        .WithActual(CheckKind.ParticipantCheck)
        .WithActual(CheckKind.AssetCheck);

    application.ChangeRoute(ApplicationRoute.Self);

    Assert.Equal(ApplicationState.ChecksStale, application.State);
    Assert.False(application.Guarantor.IsActive);
    Assert.True(application.Guarantor.HasSavedData);
    Assert.Equal(CheckStatus.Stale, application.Checks[CheckKind.ParticipantCheck]);
    Assert.Equal(CheckStatus.Actual, application.Checks[CheckKind.AssetCheck]);
    Assert.False(application.CanSubmit);
    Assert.True(application.HasAuditTrailFor(QuestionnaireAction.RouteChanged));
}

Здесь не показано устройство реального приложения. Имена и API синтетические; тест удерживает только публичную семантику. Данные поручителя пережили переключение, его участие исчезло, зависимая проверка устарела, независимая сохранилась, отправка закрылась, а новое требование добавило след изменения. Это один представительский сценарий анкеты, а не доказательство полного аудита всех действий. Для полного требования нужны отдельные сценарии остальных действий анкеты и доверенности, без предположения, что внутренние состояния двух форм одинаковы.

Мне стало проще ревьюить работу агента. Вместо чтения очередного выразительного класса с вопросом "похоже ли это на правду?" я возвращался к решению и наблюдаемому исходу. Если реализация стирала данные, инвалидировала всё подряд или меняла состояние без аудита, она расходилась с контрактом. Если новый бэкенд отличался от PHP в строке изменить или удалить, само расхождение не было дефектом.

Каждое расхождение нового бэкенда со старым должно быть либо согласованной целью, либо обнаруженной ошибкой.

Всё переписывание заняло 5 недель. Первая ушла на сбор требований и исполняемую спецификацию. Во второй половине проекта более 90% PR после моего ревью доходили до merge без правок кода с моей стороны. Формулировка узкая: она не означает "без замечаний", не измеряет автономию агента, ничего не говорит о частоте дефектов и не доказывает, что именно первая неделя обеспечила результат. Изменения читал я, решение о merge принимал тоже я.

Для этой истории важнее другой результат. Код остался ценным свидетелем, но перестал быть готовой спецификацией. Агент перестал получать поручение "перепиши PHP" и начал получать ограниченную задачу с подтверждённым смыслом. Скорость генерации кода здесь вторична: сначала нужно было решить, какой код вообще будет правильным.

Следующий вопрос – как воспроизводимо провести агента от такого контракта до проверенного PR. Это уже тема статьи #5.

Что почитать

Ниже четыре относительно свежих материала, которые продолжают разные линии этого кейса. Они не подтверждают факты проекта и не принимают решения за инженера.

  • Alessio Ferri, Tom Coggrave, Shodhan Sheth. Legacy Modernization meets GenAI, MartinFowler.com, 2024 – свежий практический текст на площадке Мартина Фаулера об AI и легаси, где работа начинается с восстановления требований, бизнес-правил и модели существующей системы, а не с генерации нового кода.

  • Carlos E. Jimenez et al. SWE-bench: Can Language Models Resolve Real-world GitHub Issues?, ICLR 2024 – канонический бенчмарк задач на уровне репозитория. Хорошо отрезвляет после примеров с одной функцией: реальная задача часто требует понять и изменить связанное поведение в нескольких участках кодовой базы.

  • Siru Ouyang et al. RepoGraph: Enhancing AI Software Engineering with Repository-level Code Graph, ICLR 2025 – работа о структурной карте репозитория. Полезна как техническое продолжение мысли, почему агенту мало большого куска кода в контексте: связи между сущностями и файлами помогают не потерять систему за отдельным фрагментом.

  • Zoe Hitzig et al. Agentic coding and persistent returns to expertise, 2026 – исследование реального использования кодингового агента и разделения труда между человеком и AI. Люди чаще принимали решения о направлении работы, а более высокая предметная экспертиза коррелировала с успешностью сессии и восстановлением после проблем.

Поделиться: Telegram X