Эксперимент с Claude Code на заброшенном Java-проекте

от автора

Зачем это было нужно

Немного о себе — с начала 2000х писал на Java, но последние десять лет занимаюсь менеджментом. На данный момент работаю в банке, где ИИ использовать нельзя в виду закрытости контура. Параллельно веду блог, где документирую, как AI-агент справляется с реальной разработкой. В этой статье я хотел бы сделать отчёт по эксперименту: взять чужой заброшенный Java-проект и методично прогнать через Claude Code все стадии, которые проходит легаси-код в реальной команде — от первого знакомства до рефакторинга и аудита.

Проект — форк учебного e-commerce на Spring Boot 2.2 / Java 11, Spring Security, Spring Data JPA, JWT, PostgreSQL, Angular 7 на фронте. Не обновлялся около шести лет — типичный «замороженный» легаси, только меньшего масштаба, чем в реальной компании.

Методология

В ходе работы решил принять одно простое правило: один пункт задачи — одна остановка на подтверждение. Каждый значимый шаг фиксировался в журнал (Notion, база данных) с полями: что сделано, сколько было вмешательств человека, что агент сделал не так или хорошо, и отдельно — «что бы я сказал заказчику, если бы это была его команда». Инструкции агенту (CLAUDE.md) специально требовали не сглаживать неопределённость и явно помечать, где решение — предположение, а не факт.

Скажу заранее, что это правило пришлось немного починить по ходу: изначальная формулировка «пиши в журнал после моего подтверждения» была неявной и не сработала на шаге, где не было diff-кода для подтверждения — агент корректно не стал угадывать и просто ничего не записал. Пришлось заменить на явный триггер: агент сам спрашивает «записать это в журнал?» и ждёт прямого «да».

Этап 1. Карта модуля: с контекстом и без

Первая задача для агента — построить карту модуля: пакеты, точки входа, сущности, security, тесты. Без предварительного контекста агент справился в целом хорошо. С системным файлом CLAUDE.md — точнее и, что самое важное, честнее: там, где сборка держалась на snapshot-зависимости 2019 года, агент не стал утверждать это умозрительно, а сам проверил локальный ~/.m2. Там, где не был уверен в совместимости старой библиотеки jjwt с Java 11 — явно пометил как предположение.

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

Бонус: агент против агентов

Отдельно прогнал /gsd-map-codebase — фреймворк GSD запускает четыре параллельных саб-агента с независимым контекстом на анализ всего репозитория. Два минуты, семь документов. Сравнил с ручным разбором.

Пересечение — 4 из 6 моих находок агенты воспроизвели независимо, включая обе реальные security-дыры. Сверху нашли 8 пунктов, которых не было у меня: пароль логируется через toString(), нет rate limiting на логине, устаревшие версии Postgres и Angular, отсутствие CI. Всё — чек-листные категории, которые ловятся при чтении одного файла за раз.

А вот 7 находок, которые нашёл только я — типы ID в репозиториях, направление каскада, магические PK против авто-генератора — ни один агент не поймал ни одну. Общее у всех семи: требуют сопоставить два удалённых места в коде одновременно. Параллельные саб-агенты с раздельным контекстом по конструкции слабее именно здесь.

Отдельная находка: один из документов уверенно утверждал, что ShopApiApplicationTests — «smoke-тест на поднятие Spring-контекста». На деле это JUnit4-агрегатор шести сервисных тестов без @SpringBootTest — контекст вообще не поднимается. Вывод был сделан по имени файла и его размеру, а не по содержимому — и выглядел в документе так же уверенно, как проверенные факты. Забегая вперёд: этот же файл всплывёт ещё раз, в самом конце эксперимента, и совсем не так безобидно.

Этап 2. Тесты

Основная идея этапа — тесты фиксируют текущее поведение, включая баги, а не «как должно быть». Была проанализирована зона покрытия тестами, пришлось дописывать. Начальное количество в 73 теста было увеличено до 130 (позже выяснится, что это число тоже требует уточнения), продовый код не тронут, 12 дефектов помечены как известные проблемы.

На «обычной» логике — контроллеры, роли — тесты не нашли ничего сверх того, что уже дал разбор кода: 38 из 38 прошли с первого раза. А вот на слое ORM всё было иначе. DELETE /cart/{id} отвечает 200, корзина в ответе пустеет — выглядит так, будто удаление сработало. Три гипотезы теста подряд провалились, и вскрылось следующее: строка навсегда остаётся в дочерней таблице с обнулённой внешней связью. Ни ручной разбор, ни четыре агента GSD этот дефект не поймали — потому что он не написан в коде ни в каком виде, это поведение persistence context в момент flush.

Второй кейс интереснее не сам по себе, а тем, как теряется риск при списке находок по отдельности: GET /profile отдавал клиенту его собственный BCrypt-хэш, а update() безусловно перехэшировал пришедшее значение. По отдельности — замечания среднего приоритета. Тест, воспроизводящий обычный сценарий «прочитал профиль → поменял имя → отправил обратно», показал: пользователь молча теряет доступ к своему аккаунту.

Отдельный маленький бонус, до прогона интеграционных тестов, агент сам проверил docker ps и обнаружил на локальном порту 5432 базу данных совсем другого моего проекта, которую снёс бы ddl-auto: create при наивном запуске. Предотвращённый инцидент, а не везение — агент проверил окружение, а не понадеялся на него.

Этап 2.5. Миграция на Spring Boot 3 и Java 21

Здесь план дал первую серьёзную трещину. Изначальная идея была в том, чтобы изолировать шаги миграции: сначала JDK 11 → 21 на старом фреймворке, потом уже Spring Boot 3, чтобы понимать, что именно вызвало эффект. На практике не сработало: ASM в Spring 5.2 не умеет читать class file от JDK 21, и 57 тестов с @SpringBootTest просто не стартуют — защитная сетка из тестов пропадает ровно там, где нужнее всего. Пришлось вставить промежуточный хоп на Spring Boot 2.7.18, чтобы получить честную матрицу 2×2, а не иллюзию изоляции.

После обновления упало 44 теста. 41 из них — инфраструктурный шум: другой порядок колонок у Hibernate 6, изменившаяся легальность паттернов путей, пропавшая транзитивная зависимость у старой JWT-библиотеки.

А один — не шум, и это главная находка всего эксперимента. Три репозитория с самого начала были объявлены с неверным generic-типом ID. На Spring Boot 2 это било громко и всегда: исключение при любом вызове. После миграции тест на этот баг… прошёл. Не потому что баг исчез — Hibernate 6 стал сговорчивее и начал молча приводить типы там, где раньше кидал исключение. findById с одними значениями стал реально возвращать результат, с другими — тихо отдавать пустой Optional, и падает только на откровенном мусоре. Дефект не исчез, он сменил режим отказа: с громкого и предсказуемого на тихий и зависящий от конкретных данных. Разработчик, который смотрит на зелёные тесты после такой миграции, видит подтверждение, что всё в порядке — а по факту получил менее предсказуемую версию того же самого бага. Приоритет исправления после этого пришлось не понижать, а повышать.

Этап 3. Рефакторинг под защитой тестов

Пять шагов, правило одно: одно исправление красит ровно один известный дефект из красного в зелёный.

Шаг 1. Прежде чем чинить типы ID, агент прогнал grep по всему src/main — оказалось, что унаследованные CRUD-методы этих трёх репозиториев вызываются во всём проекте ровно один раз, и не у них. Риск правки — доказанный ноль, а не предположенный. Побочный эффект интереснее самого фикса: тест на баг пришлось не переписать, а удалить — вызов с неверным типом после исправления просто перестаёт компилироваться. Формально покрытие уменьшилось. Фактически ошибка переехала из рантайма в компилятор, что строго лучше.

Шаг 2. Дефект с осиротевшей строкой в корзине чинился двумя строками — маппинг уже семь лет как объявлял каскадное удаление, просто сервис в обход этого дёргал не ту сторону связи. Правка покрасила оригинальный тест 2019 года, который проверял факт вызова метода через мок, а не результат — он был зелёным все годы, пока жил баг, и покраснел именно в момент, когда баг исправили. Такой тест вреден вдвойне: молчит, пока проблема есть, и кричит, когда её решают.

Шаги 3a/3b. Утечку хэша и безусловный перехэш чинили по отдельности и в контринтуитивном порядке — сначала перехэш, хотя утечка выглядит как более очевидный «security»-приоритет. Если бы починили в обратном порядке, тест на потерю доступа сломался бы по предусловиям раньше, чем успел бы показать причину, и осталось бы неясно, какой из двух багов на самом деле нёс риск. Починили перехэш — проблема с доступом исчезла, утечка осталась задокументированной отдельно. Несущий дефект — перехэш, утечка лишь делала ловушку удобной для попадания.

Шаг 4. Недостижимая проверка роли в одном из контроллеров — мёртвый код в цепочке проверок доступа: до него просто никогда не доходило исполнение из-за порядка условий выше. Здесь агент отказался натягивать правило «один дефект → зелёный тест»: прямо сказал, что мёртвый код не наблюдаем снаружи и физически не может быть проверен тестом на своё исправление — и предложил три варианта вместо того, чтобы молча выбрать один. Я выбрал вариант с полным удалением недостижимой ветки, а не её «оживлением» — разбор агента показал, что оживлять там нечего: удалить было правильнее, чем чинить, потому что ветка была лишней по замыслу, не по недосмотру.

Самое ценное — не сам фикс, а разбор того, что удаляли. Мёртвая проверка была deny-list («если роль не X — запретить»), а не allow-list («разрешить только Y и Z»). Работай она вообще, при любой новой роли, добавленной в систему позже, она пропускала бы её по умолчанию — резервная защита, которая в теории защищала бы только от одной конкретной роли и молча открывала бы все остальные. Мёртвый код в security-слое опаснее живого, потому что создаёт иллюзию второго эшелона обороны, которого по факту никогда не было. Перед сдачей шага агент сам засомневался в новом тесте на удалённую ветку и проверил через временный пробой, что тест зелёный по правильной причине, а не случайно.

Шаг 5, последний. Единый обработчик ошибок (@ControllerAdvice) вместо трёх разных контрактов на «не найдено», разбросанных по контроллерам — специально оставлен напоследок, потому что меняет форму HTTP-ответа сразу у многих эндпоинтов. Оценка «это сломает половину тестов», данная ещё на этапе аудита и не подвергнутая сомнению вплоть до этого шага, держала задачу в конце очереди все пять шагов подряд. Оказалась завышена примерно втрое: один grep по кодовой базе, сделанный только сейчас, сразу показал, что все 14 тестов, завязанных на старое кастомное исключение, — сервисного уровня и проверяют не HTTP-контракт, а внутреннюю логику, то есть форма ответа контроллера их вообще не касается.

Когда правка прошла все тесты зелёным с первого раза, агент не отчитался об успехе, а сам насторожился: «слишком гладко для правки такого масштаба» — и нашёл непокрытую тестами ветку в своём же только что написанном коде, закрыв её отдельным тестом уже сверх исходного плана, явно об этом сообщив. По ходу же агент сам поправил собственное более раннее утверждение из архитектурного аудита (Этап 4) про обработку кодов 401/403 — не потому что я указал на ошибку, а перепроверив его в процессе работы над смежным кодом — и отказался ставить пометку «исправлено» там, где дефект был закрыт не полностью, а только частично. Отдельная деталь, которая говорит об истории проекта больше, чем любой отчёт: у поля с кодом ошибки семь лет не было геттера — механизм обработки ошибок кто-то начал строить и бросил на полпути, не убрав и не закончив.

Этап 4. Архитектурный аудит

Задача агенту требовала маркировать каждое утверждение: «подтверждено файлом и строкой» или «вывод без прогона». Это заставило агента лезть в кэш зависимостей и XML-отчёты тестов вместо того, чтобы опираться на память о прошлых этапах — и именно там нашлись две вещи важнее самого архитектурного разбора.

Первая: число «130 тестов», которое я использовал во всех предыдущих отчётах, оказалось неверным. Реальных уникальных тестов — 94. 38% прогонов были дублями: тот самый JUnit4-агрегатор, который GSD ещё на бонусном этапе ошибочно описал как smoke-тест, всё это время тихо запускал шесть сервисных тестов повторно, раздувая счётчик, которым я сам оперировал во всех предыдущих разборах.

Вторая: миграция на Spring Boot 3 оставила четыре поломки в прод-конфигурации, не замеченные до сих пор. Причина не в недосмотре, а в самой методологии: тесты сознательно изолированы от боевой БД — решение верное само по себе. Но именно поэтому конфигурация, которую тесты подменяют, оказалась не покрыта вообще ничем. Сто процентов зелёных тестов на миграции спокойно сосуществовали с четырьмя поломками ровно там, куда тесты просто не смотрят.

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

Что из этого следует для команды, которая думает о похожем

Ни контекст-файл, ни мультиагентный скан, ни тесты по отдельности не заменяют друг друга — каждый метод ловит свой класс дефектов и слеп к остальным. Чтение (человеком или агентом) находит то, что видно в коде статически. Тесты находят то, что определяется состоянием во время выполнения. Аудит после факта находит то, что вы сами перестали проверять, приняв это на веру раньше.

Зелёный CI — это не доказательство, что продукт работает, это доказательство, что работает тот срез, который вы решили проверять. Всё, что тесты сознательно обходят — даже по правильным причинам — становится невидимым не из-за недосмотра, а по конструкции.

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

И последнее, самое неудобное: даже в эксперименте, специально построенном вокруг проверяемости и честного протокола, headline-цифра («130 тестов»), которую я сам использовал во всех предыдущих разборах, целый месяц была неверной — просто никто, включая меня, не удосужился её перепроверить, пока правило «подтверждай или помечай как вывод» не заставило это сделать явно.

ссылка на оригинал статьи https://habr.com/ru/articles/1071690/