Как доверить ИИ рефакторинг кода: простой пример на Java

Как доверить ИИ рефакторинг кода: простой пример на Java

В этой статье мы рассмотрим, как при помощи искусственного интеллекта отрефакторить множественные файлы на Java. Действуем по такому сценарию:

Есть компания, которая при работе с микросервисами на Java использует собственную библиотеку, управляющую флагами для переключения фич. Теперь решено мигрировать наUnleash, где работа с флагами переключения фич организована удобнее, а также предусмотрено поэтапное включение фич.

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

Пример рефакторинга

Допустим, сейчас у нас во вспомогательном классе FeatureFlags используется такой статический метод:

После перехода к работе с Unleash код будет отрефакторен примерно так (упрощено для ясности):

Обновляя ожидаемую разницу по Git, вводим новое поле unleash и обновляем  вызов метода так, чтобы метод вызывал unleash.isEnabled(…), а не вспомогательную процедуру FeatureFlags.

Также требуется обновить модульные тесты. Всякий раз, когда создаётся экземпляр класса, использующего FeatureFlags, требуется предоставлять сымитированный клиент Unleash.

Задача по рефакторингу — резюме

  • Выявить все классы, использующие вспомогательную процедуру FeatureFlags
  • Добавить поле экземпляра Unleash и внедрить его через конструктор
  • Заменить вызовы к вспомогательной процедуре FeatureFlags на вызовы экземпляра Unleash
  • Обновить соответствующие модульные тесты, так, чтобы они проходили с сымитированным экземпляром Unleash

В реальности здесь могут быть пограничные случаи, но, в сущности, это простой сценарий с обычной миграцией.

Тестовая конфигурация

Мы приготовили на Githubрепозиторий с бенчмарками, в которых содержатся:

  • 20 классов Java, использующих утилиту FeatureFlags
  • Соответствующий «ожидаемый» класс в ожидаемом пакете — для справки
  • По модульному тесту, соответствующему каждому классу

Успешность миграции мы будем оценивать по следующим критериям:

  • Сборка и выполнение тестов— код должен компилироваться, и все тесты должны успешно проходить
  • Стиль и форматирование— изменения сравниваются на соответствие с ожидаемым пакетом как с эталоном

Детерминированный подход

Можно воспользоваться готовыми инструментами для анализа абстрактных синтаксических деревьев в Java, например,JavaParserилиOpenRewrite. С их помощью можно обходить имеющийся код на Java и программно менять его детерминированным образом.

В данном случае для оценки используется JavaParser, поскольку настраивать OpenRewrite сложнее. Можно создать скрипт jshell и запустить его в Morph, указав собственный образ docker: eclipse-temurin:21-alpine

Ниже я объясню некоторые фрагменты скриптаentrypoint.sh. Сначала нужно скачать jar-библиотеки javaparser:

Далее можем создать наш jshell-скрипт: cat << ‘EOF’ > switch-to-unleash.jsh

Вот функция, которая должна исправлять классы, используя устаревающую утилиту FeatureFlags:

А вот функция, при помощи которой мы обновляем тесты:

Далее обходим файлы, и в изменённом виде код будет выглядеть так:

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

LLM-подход

Давайте напишем для LLM промпт, предназначенный для решения той же задачи:

В Morph можно предпросмотреть изменения, внесённые в выбранные файлы, убедиться, что изменения корректны, а затем применить их.

Сложности при работе с LLM

Сегментирование работы

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

В Morph есть базовая функциональность, позволяющая обращаться с выбранными файлами как с такими сегментами, а также расширять каждый сегмент, пользуясь регулярными выражениями. В данном случае мы извлекаем из java-файла имя объявленного класса, а также ищем все остальные файлы, в которых создавался экземпляр этого класса.

Несогласованности при форматировании

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

Пусть это и не влияет на логику кода, важно, тем не менее, поддерживать код разных репозиториев в единообразном виде.

Сочетание изменений, сделанных при помощи LLM и при помощи других изменений

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

Например, можете подключить к вашему проекту плагинSpotless, сконфигурированный в варианте google-java-format. Он автоматически переформатирует код после того, как применит изменения, внесённые LLM. Так обеспечивается согласованный стиль кода, а также снижается количество шума при сравнении версий.

Результаты тестирования и заключение

Результаты тестирования представлены в следующей таблице. Длительность указана с учётом последовательного выполнения.

Все свежие LLM за исключением DeepSeek V3 справились с этим рефакторингом хорошо. DeepSeek V3 стабильно не справлялся с обновлением или добавлением конструкторов при обработке множества классов — вероятно, потому, что эта задача была не полностью прописана в промпте. Возможно, эта проблема решается путём более тщательного промпт-инжиниринга.

Некоторые тесты форматирования в разных моделях не были пройдены из-за разницы в пустых строках. Часть таких строк spotless/google-java-format не удалил. Вероятно, такие дефекты приемлемы, либо их можно избежать, воспользовавшись более разборчивыми инструментами форматирования.

В принципе, кажется, что LLM — жизнеспособный вариант, чтобы автоматизировать миграцию такого рода, особенно с учётом того, как долго требуется готовить кастомизированные детерминированные приёмы (например, с использованием JavaParser). Исключение составляют стандартные обновления, когда можно опираться на готовые рецепты миграции. Их часто можно применять после минимальной подготовки, пользуясь опенсорсными или проприетарными инструментами.

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

Читать оригинал