Detekt-набор

Правила MEL для detekt

Отдельный ruleset mel ловит нарушения архитектуры MEL прямо в сборке: машина без корутин, контракты-данные, чистый reduce, async только через команды и подписки.

Что это

Набор живёт в модуле :tools:detekt-rules и подключается как detekt-плагин. Это 26 правил с идентификатором ruleset mel. Все правила — PSI-only: они читают синтаксис исходника и не требуют type resolution. За счёт этого они работают одинаково и в CLI/Gradle, и под IDE-плагином detekt (который TR не поддерживает).

Те же 26 проверок есть в IDE-плагине MEL как инспекции редактора (уровень Warning, включены по умолчанию). PSI-хелперы (MelPsi.kt) у них общие с набором, но параметры зашиты дефолтами — detekt.yml на инспекции не действует. Quick-fix есть только у MelSubscribeOnError и MelDataContractLeaf.

  • Контракт (стейт / факты / интент / эффект) узнаётся по маркеру — простому имени MelState / MelFacts / MelIntent / MelEffect, достижимому по иерархии в пределах одного файла.
  • Машина и секция узнаются по прямому супертипу MelMachine / Section.
  • reduce-клетка узнаётся по лямбде вызовов on / onAny внутри машины.
Зачем отдельный набор Целевая модель проекта — MEL. Правила фиксируют её инварианты автоматически, чтобы экран не «съезжал» обратно к толстым моделям, мутабельному стейту и эффектам в reduce. Каноническое обоснование каждого правила — в спеке.

Подключение

Нужно четыре вещи: репозиторий артефактов MEL, версии в каталоге, зависимость detektPlugins на набор правил и секция mel в конфиге detekt. Ниже — по образцу боевого потребителя (Groovy DSL). Модуль при этом может быть любым — Android-библиотекой, приложением или kotlin-jvm.

1. Репозиторий артефактов

Группа ru.sberbank_sbbol.mel закрепляется за репозиторием артефактов MEL через exclusiveContent в settings.gradle: артефакты MEL Gradle ищет только там, а за остальными зависимостями туда не ходит.

// settings.gradle
dependencyResolutionManagement {
    repositories {
        // … остальные репозитории проекта
        exclusiveContent {
            forRepository {
                maven {
                    url "<репозиторий артефактов MEL>"
                    // креды — по месту
                }
            }
            filter { includeGroup "ru.sberbank_sbbol.mel" }
        }
    }
}
Откуда брать артефакт Набор публикуется артефактом ru.sberbank_sbbol.mel:detekt:0.2.0 (JAR + sources) в репозиторий артефактов MEL — git-трекаемый maven-репозиторий mel-maven/mel-repo в репозитории MEL, который раздаётся как raw. Адрес и креды — по месту.

2. Версии в каталоге

Набор собран против detekt-api 1.23.4 — на этой же версии detekt и потребитель. Версия набора — общая ссылка mel с остальными артефактами MEL:

# gradle/libs.versions.toml
[versions]
detekt = "1.23.4"
mel = "0.2.0"

[libraries]
mel-detekt = { module = "ru.sberbank_sbbol.mel:detekt", version.ref = "mel" }

3. Плагин detekt и набор правил

Gradle-плагин detekt лежит в classpath корневого build.gradle, а ко всем модулям его применяет общий скрипт (apply from: 'devops/detekt/detekt.gradle'). Набор mel добавляется в конфигурацию detektPlugins:

// devops/detekt/detekt.gradle
subprojects {
    apply plugin: "io.gitlab.arturbosch.detekt"

    detekt {
        config.setFrom("${rootProject.projectDir}/devops/detekt/detekt-config.yml")
        // … baseline, source и прочие проектные настройки
    }

    dependencies {
        detektPlugins libs.mel.detekt    // набор правил mel
    }
}

4. Конфиг — секция mel

В JAR набора нет дефолтного конфига, а detekt 1.23 считает правило без ключа active выключенным (сам ruleset mel по умолчанию включён). Поэтому каждое из 26 правил перечисляют явно с ключом active; параметры можно не писать — их дефолты зашиты в код правил:

# devops/detekt/detekt-config.yml
mel:
  active: true
  MelNoCoroutinesInMachine:
    active: true
  MelSubscribeOnError:
    active: true
  MelMigrationRatchet:
    active: false    # legacy-MVI в проекте ещё много — см. «Тонкая настройка»
  # … так же остальные 23 правила

Образец секции mel: со всеми параметрами и их дефолтами — config/detekt/detekt.yml в репозитории MEL. Параметры (списки маркеров, имён вызовов, allowlist) меняют только когда расширяют контракт или переименовывают базовые типы.

Запуск и отчёт

Правила — часть обычной задачи detekt:

# весь проект
./gradlew detekt

# один модуль
./gradlew :<модуль>:detekt

В консоли нарушение печатается строкой файл:строка:колонка: текст [ИмяПравила]. Сборку валит не severity, а порог build.maxIssues с учётом weights и baseline проекта. В detekt 1.23 дефолт maxIssues: 0, то есть сборку заваливает любое нарушение — и Defect, и Style.

Как читать правило

У каждого правила есть id (имя класса), severity и долг (debt). В таблицах ниже:

  • Defect — нарушение инварианта, ломающего рантайм/тесты (мутабельный стейт, эффект в reduce, корутина в машине). Чинить обязательно.
  • Style — отклонение от модели, которое стоит исправить, но рантайм оно не ломает сразу (нет onError, контракт не sealed, новый наследник legacy-MVI).

Колонка «срабатывает на» — то, что правило ищет в коде; «зачем» — какой инвариант оно бережёт.

Проверки 26 правил · 5 групп

Группировка повторяет порядок регистрации в MelRuleSetProvider.

Базовая восьмёрка фундамент модели

ПравилоСрабатывает наЗачем
MelNoCoroutinesInMachine
Defect
launch/async/runBlocking или поле CoroutineScope/GlobalScope в теле машины или секции Машина не запускает корутины сама — асинхронщина только через команды и подписки
MelSubscribeOnError
Style
subscribe в машине/секции без аргумента onError. Молчит, если onError не нужен: источник горячий, ошибку гасит .catch, поток из flowOf/emptyFlow, app-lifetime шина (.asFlow(), listen*/observe*), либо key в allowedKeysWithoutError Падающий источник должен маппить ошибку в интент, а не умирать молча
MelEffectsAreData
Defect
Поле или параметр функционального типа (лямбда-коллбэк) внутри эффекта Эффект — это данные; коллбэк протаскивает поведение мимо контракта
MelNoMutableEffectContext
Defect
var-свойства или MutableState/MutableStateFlow/MutableSharedFlow в наследнике MelEffectContext Контекст края экрана — иммутабельный набор инструментов
MelImmutableFacts
Defect
var или мутабельные коллекции / MutableState в стейте/фактах Стейт и факты иммутабельны — переход только через copy()
MelNoInstrumentsInState
Defect
Поведенческие инструменты (TextFieldState, Context, Modifier, NavController…) в стейте/фактах Compose/Android-инструментам место в UiHandles, не в стейте
MelNoTypeChecks
Defect
is/as по контракт-типам (суффиксы State/Intent/Effect) внутри машины/секции Связь с контрактом — типизированная регистрация on<>/view<>/subscribe<>, а не смарт-каст
MelMigrationRatchet
Style
Новый наследник SimpleMviViewModel/MviModel/MviViewModel вне allowlist Целевая модель — MEL; новые экраны не расширяют v3/SimpleMvi

Контракты-данные стейт / факты / интент / эффект

ПравилоСрабатывает наЗачем
MelDataContractLeaf
Defect
Конкретный лист стейта, фактов или эффекта объявлен не как data (интент не проверяется; обычный object — только при flagPlainObjects) Identity-равенство ломает дедуп StateFlow и сравнение эффектов в тестах
MelNoLambdaInContract
Defect
Поле/параметр функционального типа в стейте, фактах или интенте Лямбды ломают equality и протаскивают эффект в reduce
MelNoPlatformTypesInContract
Defect
Платформенные инструменты (Context, Modifier, SnackbarHostState…) в интенте или эффекте Интент уходит в чистый reduce, эффект — это данные; инструментам место в UiHandles
MelNoBehaviorInContract
Style
Член-функция с телом («толстая модель») в типе контракта; не трогаются override/operator и функции в object/companion object Решения принимает reduce клетки, а не методы контракта
MelSealedContractRoot
Style
Корень, напрямую реализующий маркер, объявлен открытым (interface, abstract или open), а не sealed Иначе листы расходятся по файлам, ломая исчерпываемость when и разрешение маркеров в пределах файла

Тело и поля машины stateless + DSL-граф

ПравилоСрабатывает наЗачем
MelStatelessMachine
Defect
Мутабельные поля экземпляра или компаньона машины/секции (var, MutableStateFlow, Atomic*, mutableListOf…) Машина stateless — всё состояние в MelState
MelNoSendInReduce
Defect
Вызов send(intent) без получателя где угодно внутри машины/секции, включая тела command (channel.send(…) не трогается) Ре-диспетчеризация минует переход клетки, гонится с петлёй и невидима перехватчикам — оформи цепочку командой
MelNoRawFrameworkConstructor
Defect
Сырой конструктор каркаса (Transition, Command, Graph, Cell…) вместо DSL; поле graph, собранное не билдером graph { } DSL держит инварианты и виден статическому walker'у; сырой конструктор их обходит
MelCommandOnErrorMustReturnIntent
Defect
onError команды/подписки перебрасывает ошибку вместо маппинга в интент (последний стейтмент лямбды — throw) Переброс гасит job и оставляет вечный спиннер

Чистота reduce чистый и синхронный шаг

ПравилоСрабатывает наЗачем
MelNoThrowInReduce
Defect
throw или error/require/check/TODO в теле клетки on/onAny reduce завершается переходом — верни стейт/эффект ошибки вместо исключения
MelNoMutationInReduce
Defect
Присваивание захваченному полю (=, +=…) или ++/-- в reduce Единственный выход reduce — переход через copy() над иммутабельным стеком
MelNoSideEffectsInReduce
Defect
Навигация, логирование, аналитика (navigate, log, track…) в reduce Это возвращённые данные-эффекты, а reduce чист
MelNoNonDeterminismInReduce
Defect
System.currentTimeMillis, UUID.randomUUID, Random и пр. в reduce Недетерминизм ломает реплей и golden-тесты — время и случайность приходят фактом на интенте
MelNoBlockingInReduce
Defect
Блокирующий дренаж будущего (blockingGet, getCompleted…) или Thread.sleep в reduce reduce синхронен — асинхронщину оформляй командой/подпиской

Async и эффект-слой команды, подписки, эффекты

ПравилоСрабатывает наЗачем
MelBlockingInCommandOrSubscription
Defect
Блокирующий вызов (blockingGet, blockingFirst…, Thread.sleep) в теле command/subscribe Тело async-блока должно приостанавливаться, а не блокировать общий loop-диспетчер
MelHotFlowSubscriptionSource
Style
Горячий/неконечный поток как source подписки (MutableStateFlow, stateIn, shareIn…) Подписка не завершится, onError мёртв — нужен холодный завершающийся-или-отменяемый Flow
MelNoManualEffectCollection
Defect
Ручной collect/onEach/launchIn/collectAsState… по model.effects; в файле, где упомянут MelModel/MelScreen, — по любому получателю Канал одного потребителя — второй коллектор крадёт навигацию/снэкбары из effects {}
MelNoRouterCloseEffect
Style
Прямой router.close() в эффект-слое (наследник MelEffectContext или блок effects { }) Закрытие — это данные контракта (MelCloseEffect/MelExhausted), а не сырой вызов роутера

Тонкая настройка

У каждого параметра есть дефолт в коде правила; в detekt.yml пишут только то, что отличается. buildUponDefaultConfig на секцию mel не влияет: дефолтный конфиг detekt её не содержит.

Параметры правил, которые правят чаще всего

  • Маркеры контракта (contractMarkers) — простые имена базовых типов (MelState, MelFacts, MelIntent, MelEffect). Меняют при переименовании базовых интерфейсов контракта.
  • Базы машины (machineBases: ['MelMachine', 'Section']) — якорь для всех «внутри машины»-правил.
  • Allowlist у MelMigrationRatchet — FQN экранов, которым временно разрешено наследовать legacy-MVI (пилоты переноса). Уменьшается по мере миграции. Дефолт в коде — два демо-экрана ru.sberbank_sbbol.mel.sample.migration.*, так что в проекте с legacy-MVI список заполняют своими FQN либо выключают правило (боевой потребитель держит MelMigrationRatchet: active: false). У IDE-инспекции allowlist пуст.
  • Списки запрещённых инструментов / вызовов (bannedInstrumentTypes, bannedCalls, blockingCallees…) — расширяют под новые API.
Флаги-расширения по умолчанию выключены Часть правил умеет ловить «мягкие» случаи, но они выключены, чтобы не давать ложных срабатываний: flagNow (часы вроде Instant.now() в reduce), enableGet/enableRunBlocking, checkMutatingCalls, flagInteractorVerbs, flagPlainObjects, flagAmbiguousGetExecute, flagDataObjectsAndEnums. Включают точечно, когда хотят строже.

Границы и оговорки

  • PSI без type resolution. Контракт определяется по маркеру/суффиксу имени, а не по реальному типу. Это даёт работу под IDE-плагином, но значит: класс с «контрактным» именем без маркера в файле правило не увидит, а маркер должен быть достижим в пределах того же файла.
  • Якорь машины — прямой супертип. Правила «внутри машины» смотрят на непосредственный : MelMachine/: Section. Глубокие цепочки наследования не отслеживаются — это намеренно, чтобы не ловить legacy SimpleMvi when-is вне машины.
  • Известные пропуски (из KDoc правил). Не ловятся: лист контракта в другом файле, чем маркированный корень; throw за if/when или в хелпере (MelCommandOnErrorMustReturnIntent); typealias на конструктор каркаса (MelNoRawFrameworkConstructor); мутабельная фабрика под алиасом (MelStatelessMachine); now() без приёмника (MelNoNonDeterminismInReduce); горячий поток за вызовом метода (MelHotFlowSubscriptionSource); router.close() вне эффект-контекста и effects { }; каст контракта вне машины/секции.
  • Источник истины — код и спека. Если поведение правила разошлось с этой страницей — прав код в :tools:detekt-rules и спека.

Набор: :tools:detekt-rules · ruleset mel · 26 правил · detekt 1.23.4 · артефакт ru.sberbank_sbbol.mel:detekt:0.2.0.