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

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

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

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

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

public class CampaignManager {

  public String launchCampaign(String name) {
    if (FeatureFlags.isFlagEnabled("campaign-launch-enabled")) {
      logCampaignEvent("Launched: " + name);
      return "Campaign launched: " + name;
    } else {
      return "Campaign launch disabled";
    }
  }
}

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

public class CampaignManager {
  private final Unleash unleash;

  public CampaignManager(Unleash unleash) {
    this.unleash = unleash;
  }

  public String launchCampaign(String name) {
    if (unleash.isEnabled("campaign-launch-enabled")) {
      logCampaignEvent("Launched: " + name);
      return "Campaign launched: " + name;
    }
  }
}

Обновляя ожидаемую разницу по 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:

JAVAPARSER_VERSION="3.25.8"
JAVAPARSER_JAR="javaparser-core.jar"
wget -q "https://repo1.maven.org/maven2/com/github/javaparser/javaparser-core/${JAVAPARSER_VERSION}/javaparser-core-${JAVAPARSER_VERSION}.jar" -O "$JAVAPARSER_JAR"
wget -q "https://repo1.maven.org/maven2/com/github/javaparser/javaparser-symbol-solver-core/${JAVAPARSER_VERSION}/javaparser-symbol-solver-core-${JAVAPARSER_VERSION}.jar" -O javaparser-symbol-solver-core.jar

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

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

void processPrimaryClass(Path path) {
    System.err.println("Processing " + path.toString());
    try {
        CompilationUnit cu = StaticJavaParser.parse(path);
        LexicalPreservingPrinter.setup(cu);

        boolean usesFeatureFlags = cu.findAll(MethodCallExpr.class).stream()
            .anyMatch(mc -> mc.toString().startsWith("FeatureFlags.isFlagEnabled"));

        if (!usesFeatureFlags) return;

        AtomicBoolean changed = new AtomicBoolean(false);

        cu.addImport("io.getunleash.Unleash");

        // Заменить FeatureFlags на unleash
        for (MethodCallExpr mc : cu.findAll(MethodCallExpr.class)) {
            if (mc.toString().startsWith("FeatureFlags.isFlagEnabled")) {
                mc.setScope(new NameExpr("unleash"));
                mc.setName("isEnabled");
                changed.set(true);
            }
        }

        Optional<ClassOrInterfaceDeclaration> clazzOpt = cu.findFirst(ClassOrInterfaceDeclaration.class);
        if (!clazzOpt.isPresent()) return;
        ClassOrInterfaceDeclaration clazz = clazzOpt.get();

        // Добавить поле Unleash, если оно отсутствует
        if (clazz.getFieldByName("unleash").isEmpty()) {
            FieldDeclaration field = new FieldDeclaration()
                .addVariable(new VariableDeclarator(
                    StaticJavaParser.parseClassOrInterfaceType("Unleash"), "unleash"))
                .setModifiers(Modifier.Keyword.PRIVATE, Modifier.Keyword.FINAL);

            // Найти индекс последнего поля
            List<BodyDeclaration<?>> members = clazz.getMembers();
            int insertIndex = 0;
            for (int i = 0; i < members.size(); i++) {
                if (members.get(i).isFieldDeclaration()) {
                    insertIndex = i + 1;
                }
            }

            clazz.getMembers().add(insertIndex, field);
            changed.set(true);
        }

        // Убедиться, что в конструкторе есть параметр Unleash
        List<ConstructorDeclaration> ctors = clazz.getConstructors();
        if (ctors.isEmpty()) {
            ConstructorDeclaration ctor = new ConstructorDeclaration()
                .setName(clazz.getNameAsString())
                .addParameter("Unleash", "unleash")
                .setModifiers(Modifier.Keyword.PUBLIC);

            ctor.getBody().addStatement("this.unleash = unleash;");

            // Поставить конструктор после последнего поля
            List<BodyDeclaration<?>> members = clazz.getMembers();
            int insertIndex = 0;
            for (int i = 0; i < members.size(); i++) {
                if (members.get(i).isFieldDeclaration()) {
                    insertIndex = i + 1;
                }
            }
            clazz.getMembers().add(insertIndex, ctor);
            changed.set(true);
        } else {
            for (ConstructorDeclaration ctor : ctors) {
                boolean hasParam = ctor.getParameters().stream()
                    .anyMatch(p -> p.getTypeAsString().equals("Unleash"));
                if (!hasParam) {
                    ctor.addParameter("Unleash", "unleash");
                    ctor.getBody().addStatement("this.unleash = unleash;");
                    changed.set(true);
                }
            }
        }

        if (changed.get()) {
            modifiedClasses.add(clazz.getNameAsString());
            Files.write(path, LexicalPreservingPrinter.print(cu).getBytes());
        }

    } catch (Exception e) {
        System.err.println("Error in " + path + ": " + e.getMessage());
    }
}

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

void updateTests(Path path) {
    try {
        CompilationUnit cu = StaticJavaParser.parse(path);
        LexicalPreservingPrinter.setup(cu);
        AtomicBoolean changed = new AtomicBoolean(false);

        for (String modified : modifiedClasses) {
            cu.findAll(ObjectCreationExpr.class).forEach(newExpr -> {
                if (newExpr.getTypeAsString().equals(modified)) {
                    cu.addImport("io.getunleash.Unleash");
                    cu.addImport("org.mockito.Mockito", true, true); // static import

                    BlockStmt parentBlock = newExpr.findAncestor(BlockStmt.class).orElse(null);
                    if (parentBlock != null) {
                        if (cu.findAll(VariableDeclarator.class).stream()
                            .noneMatch(v -> v.getNameAsString().equals("unleash"))) {
                            parentBlock.addStatement(0, StaticJavaParser.parseStatement(
                                "Unleash unleash = mock(Unleash.class);"));
                            parentBlock.addStatement(1, StaticJavaParser.parseStatement(
                                "when(unleash.isEnabled(anyString())).thenReturn(true);"));
                        }

                        newExpr.addArgument("unleash");
                        changed.set(true);
                    }
                }
            });
        }

        if (changed.get()) {
            Files.write(path, LexicalPreservingPrinter.print(cu).getBytes());
        }

    } catch (Exception e) {
        System.err.println("Error in test " + path + ": " + e.getMessage());
    }
}

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

void run() throws IOException {
        Files.walk(Paths.get("/morph/project/src/main/java/dev/codemorph/benchmark/unleash"))
        .filter(p -> p.toString().endsWith(".java"))
        .filter(p -> !p.toString().contains("/expected/") && !p.toString().endsWith("FeatureFlags.java"))
        .forEach(p -> {
            processPrimaryClass(p);
            removeBlankLineBeforeUnleash(p);
        });

    Files.walk(Paths.get("/morph/project/src/test/java/dev/codemorph/benchmark/unleash"))
        .filter(p -> p.toString().endsWith(".java"))
        .forEach(p -> updateTests(p));

}

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

LLM-подход

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

We are switching to unleash for checking feature flags. 
If FeatureFlags util is used make sure unleash instance is added
to the constructor (it will be injected automatically) 
and that unleash instance used instead
to check whether feature flag is enabled. Also fix tests accordingly.
Assume all feature flags are enabled in tests,
so you can mock unleash response to return true.

Unleash is a client in io.getunleash.Unleash package
Make sure necessary imports are present
New class fields should be added to the end of existing fields. 

Do not introduce new lines or remove existing code and empty lines
Use google java format.
Do not create any new files.

В 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 является способность предсказуемо сегментировать работу в соответствии с определённым паттерном. Все сегменты должны быть структурированы одинаково, чтобы для их обработки было удобно составлять точные и при этом не слишком многословные промпты.