Привет, Хабр! На связи Алина, старший Android-разработчик в команде Инвестиций «Совкомбанк Технологии». Сегодня поговорим о том, как заставить Android Studio самостоятельно следить за порядком в коде – без ручных проверок и без вечных напоминаний в командном чате.

Представьте, в вашей команде договорились, что все реализации репозитория должны находиться в пакете .data.repository. Но каждую неделю кто-то создает UserRepositoryImpl не в том месте. Или команда решила мигрировать с RxJava на Coroutines – и теперь нужно найти все Single и Observable в 200+ файлах.

Пользовательские правила статического анализа автоматизируют проверки:

  • Контролируют соблюдение архитектуры проекта

  • Находят устаревший API при миграциях

  • Обеспечивают соблюдение внутренних конвенций команды

  • Экономят время на проверку кода

Android Lint и Detekt – это инструменты статического анализа кода. Lint от Google анализирует код на языках программирования Java/Kotlin, XML-ресурсы и манифесты. Detekt специализируется на Kotlin и предоставляет более современный API для написания правил. Оба инструмента поставляются со стандартным набором правил, но у каждого проекта есть свои специфические требования.

В этой статье мы создадим практические правила для обоих инструментов и разберем, как внедрить их в проект. На примере lint рассмотрим контроль архитектуры пакетов и обязательную документацию с QuickFix, а на примере detekt – проверку неизменяемого состояния представления без Android-зависимостей и миграцию с RxJava на Coroutines.

Lint vs Detekt: выбор инструмента

Критерий

Android Lint

Detekt

Языки

Java + Kotlin, через UAST

Только Kotlin

Анализирует

Код, XML, манифесты, ресурсы

Только Kotlin код

QuickFix

Есть, автоматическое исправление в IDE

Нет

API

UAST, унифицированный

PSI, нативный Kotlin

Поддержка

Google, интеграция с AGP

Community, JetBrains ecosystem

Сложность API

Средняя

Низкая

Скорость работы

Медленнее, анализирует больше

Быстрее, только Kotlin

Когда использовать Lint:

  • Если нужны QuickFix для автоматического исправления проблем

  • Требуется анализ XML-ресурсов и манифестов

  • Проект использует Java и Kotlin одновременно

Когда использовать Detekt:

  • Проект полностью на Kotlin

  • Нужен простой и современный API для написания правил

  • Требуется быстрое создание прототипов новых правил

Рекомендация: используйте оба инструмента вместе – они решают разные задачи и дополняют друг друга. Lint незаменим для проверки ресурсов, XML-файлов и манифестов с возможностью автоматических исправлений. Detekt работает быстрее за счет фокуса исключительно на Kotlin-коде и обеспечивает эффективные проверки стиля и качества.

Android Lint

Основные концепции

Issue – описание проблемы с уникальным идентификатором, уровнем серьезности и сообщением об ошибке.

Detector – класс с логикой поиска проблем. Обходит код и создает отчеты о найденных нарушениях.

Scanner – интерфейс-маркер, определяющий тип анализируемого кода (SourceCodeScanner, XmlScanner, GradleScanner).

UAST или Unified Abstract Syntax Tree – унифицированное представление Java и Kotlin кода. Позволяет одному правилу работать с обоими языками.

QuickFix – автоматическое исправление проблем прямо в IDE. Уникальная возможность Lint, которой нет в Detekt.

Настройка проекта

Скрытый текст
// settings.gradle.kts
include(":app")
include(":lint-checks")


// lint-checks/build.gradle.kts
plugins {
    id("java-library")
    id("kotlin")
}

dependencies {
    compileOnly("com.android.tools.lint:lint-api:31.13.0")
    compileOnly("com.android.tools.lint:lint-checks:31.13.0")
    
    testImplementation("com.android.tools.lint:lint:31.13.0")
    testImplementation("com.android.tools.lint:lint-tests:31.13.0")
    testImplementation("junit:junit:4.13.2")
}

tasks.jar {
    manifest {
        attributes("Lint-Registry-v2" to "com.example.lint_checks.CustomIssueRegistry")
    }
}


// app/build.gradle.kts
dependencies {
    lintChecks(project(":lint-checks"))
}

Пример 1: контроль архитектуры пакетов

Задача: *RepositoryImpl должны находиться только в пакете .data.repository.

Зачем это нужно: в Clean Architecture Repository – это часть слоя хранилища данных. Размещение реализаций в едином пакете помогает соблюдать разделение по слоям и упрощает навигацию в больших проектах.

Скрытый текст
package com.example.lint_checks

import com.android.tools.lint.detector.api.*
import org.jetbrains.uast.UClass

// Наследуется от Detector и реализует SourceCodeScanner для анализа исходного кода
class RepositoryPackageDetector : Detector(), SourceCodeScanner {
    
	// Указываем, какие типы элементов AST (Abstract Syntax Tree) нас интересуют
	// В данном случае - только классы (UClass)
    override fun getApplicableUastTypes(): List<Class<out UElement>> =  
    listOf(UClass::class.java)
    
    // Создаем обработчик для элементов кода
    override fun createUastHandler(context: JavaContext): UElementHandler {  
	    return object : UElementHandler() { 
		    // Метод вызывается для каждого найденного класса в проекте
	        override fun visitClass(node: UClass) { 
		        // Получаем имя класса, если null - выходим 
	            val className = node.name ?: return  
  
				// Проверяем только классы-реализации Repository по соглашению об именовании
	            if (!className.endsWith("RepositoryImpl")) return  
  
				// Получаем полное имя класса с пакетом (например, com.example.app.data.repository.UserRepositoryImpl)
	            val fullQualifiedName = node.qualifiedName ?: return  
	            
	            // Извлекаем название пакета, удаляя из полного имени название класса 
	            // Например, из "com.example.app.data.repository.UserRepositoryImpl" получаем "com.example.app.data.repository"
	            val packageName = fullQualifiedName.substringBeforeLast(".$className", "")  
  
				// Проверяем что пакет содержит .data.repository
	            if (!packageName.contains(".data.repository")) {
		            // Создаем отчет о проблеме
		            // location - где подсветить проблему (имя класса)
		            // message - сообщение разработчику с объяснением проблемы  
	                context.report(  
	                    issue = ISSUE,  
	                    location = context.getNameLocation(node),  
	                    message = "Реализация Repository `$className` должна быть в пакете `*.data.repository`. " +  
                            "Текущий пакет: `$packageName`"  
		            )  
		        }  
		    }  
		}  
	}
    
    companion object {
        // Issue - описание проблемы, которую ищет детектор
        val ISSUE = Issue.create(
            id = "RepositoryPackageViolation", // Уникальный ID для конфигурации и suppress
            briefDescription = "Реализация Repository в неправильном пакете", // Короткое описание
            explanation = """
                Реализации Repository должны находиться в пакете `.data.repository` 
                для соблюдения Clean Architecture.
            """.trimIndent(), // Подробное объяснение проблемы
            category = Category.CORRECTNESS, // Категория - корректность кода
            priority = 6, // Приоритет от 1 (низкий) до 10 (высокий)
            severity = Severity.WARNING, // WARNING не блокирует сборку
            implementation = Implementation(
                RepositoryPackageDetector::class.java, // Класс детектора
                Scope.JAVA_FILE_SCOPE // Область проверки - Java/Kotlin файлы
            )
        )
    }
}

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

Скрытый текст
package com.example.lint_checks

import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
import org.junit.Test

class RepositoryPackageDetectorTest {
    
    @Test
    fun `RepositoryImpl в правильном пакете - не срабатывает`() {
        lint()
            .files(
                kotlin("""
                    package com.example.app.data.repository
                    
                    class UserRepositoryImpl {
                        fun getUser(id: String) = "User"
                    }
                """).indented()
            )
            .issues(RepositoryPackageDetector.ISSUE) // Указываем какое правило тестируем
            .allowMissingSdk() // Разрешаем запуск без полного Android SDK
            .run()
            .expectClean() // Ожидаем что проблем не найдено
    }
    
    @Test
    fun `RepositoryImpl в неправильном пакете - срабатывает`() {
        lint()
            .files(
                kotlin("""
                    package com.example.app.domain
                    
                    class UserRepositoryImpl {
                        fun getUser(id: String) = "User"
                    }
                """).indented()
            )
            .issues(RepositoryPackageDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .expect("""
                src/com/example/app/domain/UserRepositoryImpl.kt:3: Warning: Реализация Repository UserRepositoryImpl должна быть в пакете *.data.repository. Текущий пакет: com.example.app.domain [RepositoryPackageViolation]
                class UserRepositoryImpl {
                      ~~~~~~~~~~~~~~~~~~
                0 errors, 1 warnings
            """.trimIndent()) // Проверяем что получили именно это предупреждение
    }
    
    @Test
    fun `Не RepositoryImpl класс - не срабатывает`() {
        lint()
            .files(
                kotlin("""
                    package com.example.app.domain
                    
                    class UserService {
                        fun getUser(id: String) = "User"
                    }
                """).indented()
            )
            .issues(RepositoryPackageDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .expectClean()
    }
}

Запуск тестов: ./gradlew :lint-checks:test

Пример 2: обязательная документация с QuickFix

Задача: все методы в интерфейсах Repository/Interactor/DataSource должны иметь документацию в формате KDoc.

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

Скрытый текст
package com.example.lint_checks

import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.*
import org.jetbrains.kotlin.kdoc.lexer.KDocTokens
import org.jetbrains.uast.UClass
import org.jetbrains.uast.UMethod

class KDocRequiredDetector : Detector(), SourceCodeScanner {
    
    // Суффиксы интерфейсов, которые требуют документации
    private val REQUIRED_SUFFIXES = listOf("Repository", "Interactor", "DataSource")

    // Указываем что хотим проверять UClass элементы
    override fun getApplicableUastTypes() = listOf(UClass::class.java)

    // Вызывается для каждого класса в проекте
    override fun createUastHandler(context: JavaContext) = object : UElementHandler() {
        override fun visitClass(node: UClass) {
            // Проверяем только интерфейсы
            if (!node.isInterface) return

            val className = node.name ?: return

            // Проверяем что имя интерфейса заканчивается на нужный суффикс
            if (!REQUIRED_SUFFIXES.any { className.endsWith(it) }) return

            // Проверяем все методы интерфейса
            node.methods.forEach { method ->
                checkMethodDocumentation(context, method, className)
            }
        }
    }
    
    // Проверяет наличие KDoc у метода
    private fun checkMethodDocumentation(
        context: JavaContext,
        method: UMethod,
        interfaceName: String
    ) {
        // Ищем KDoc комментарий (/** ... */)
        // sourcePsi - это PSI элемент (Program Structure Interface) из компилятора
        // Через него получаем реальный текст комментария
        val hasKDoc = method.comments.any { comment ->
            comment.sourcePsi?.node?.elementType == KDocTokens.KDOC
        }
        
        // Если KDoc отсутствует - создаем отчет с QuickFix
        if (!hasKDoc) {
            val quickFix = createKDocQuickFix(context, method)
            
            context.report(
                issue = ISSUE,
                location = context.getNameLocation(method), // Подсвечиваем имя метода
                message = "Метод `${method.name}` в `$interfaceName` должен иметь KDoc",
                quickfixData = quickFix // Передаем QuickFix для автоисправления
            )
        }
    }
    
    // Создает QuickFix с шаблоном KDoc документации
    private fun createKDocQuickFix(context: JavaContext, method: UMethod): LintFix {
        val methodName = method.name
        // Фильтруем синтетические параметры (например $completion для suspend функций)
        val parameters = method.uastParameters.filter { param ->
            val paramName = param.name
            !paramName.startsWith("$")
        }
        
        // Проверяем возвращает ли метод значение (не void и не Unit)
        val hasReturnValue = method.returnType?.canonicalText?.let {
            it != "void" && it != "kotlin.Unit"
        } ?: false
        
        // Генерируем шаблон KDoc с TODO для заполнения разработчиком
        val kdocTemplate = buildString {
            append("/**\n")
            append("     * TODO: описать назначение метода $methodName\n")
            
            // Добавляем @param для каждого параметра
            parameters.forEach { param ->
                append("     * @param ${param.name} TODO: описать параметр\n")
            }
            
            // Добавляем @return если метод возвращает значение
            if (hasReturnValue) {
                append("     * @return TODO: описать возвращаемое значение\n")
            }
            
            append("     */\n    ")
        }
        
        // Создаем QuickFix который добавит шаблон перед методом
        return fix()
            .name("Добавить KDoc шаблон") // Название действия в меню IDE
            .replace() // Тип действия - замена текста
            .range(context.getLocation(method)) // Где применять
            .beginning() // В начале метода
            .with(kdocTemplate) // Что добавить
            .autoFix() // Пометка что это автоматическое исправление
            .build()
    }
    
    companion object {
        val ISSUE = Issue.create(
            id = "MissingKDocInDomainInterface",
            briefDescription = "Отсутствует KDoc",
            explanation = "Методы в Repository/Interactor/DataSource должны иметь KDoc",
            category = Category.USABILITY, // Категория - удобство использования
            priority = 5,
            severity = Severity.WARNING,
            implementation = Implementation(
                KDocRequiredDetector::class.java,
                Scope.JAVA_FILE_SCOPE
            )
        )
    }
}

Тесты:

Скрытый текст
package com.example.lint_checks

import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
import org.junit.Test

class KDocRequiredDetectorTest {

    @Test
    fun `Repository без KDoc - срабатывает`() {
        lint()
            .files(
                kotlin(
                    """
                    package com.example.app.domain.repository
                    
                    interface UserRepository {
                        suspend fun getUser(userId: String): User?
                    }
                    
                    data class User(val id: String)
                """
                ).indented()
            )
            .issues(KDocRequiredDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .expect(
                """
                src/com/example/app/domain/repository/UserRepository.kt:4: Warning: Метод getUser в UserRepository должен иметь KDoc [MissingKDocInDomainInterface]
                    suspend fun getUser(userId: String): User?
                                ~~~~~~~
                0 errors, 1 warnings
            """.trimIndent()
            )
    }

    @Test
    fun `Repository с KDoc - не срабатывает`() {
        lint()
            .files(
                kotlin(
                    """
                    package com.example.app.domain.repository
                    
                    interface UserRepository {
                        /**
                         * Получает пользователя по ID
                         * @param userId идентификатор пользователя
                         * @return данные пользователя или null
                         */
                        suspend fun getUser(userId: String): User?
                    }
                    
                    data class User(val id: String)
                """
                ).indented()
            )
            .issues(KDocRequiredDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .expectClean()
    }

    @Test
    fun `Обычный интерфейс без KDoc - не срабатывает`() {
        lint()
            .files(
                kotlin(
                    """
                    interface UserView {
                        fun showUser(name: String)
                    }
                """
                ).indented()
            )
            .issues(KDocRequiredDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .expectClean()
    }

    @Test
    fun `QuickFix добавляет корректный KDoc шаблон`() {
        lint()
            .files(kotlin("""
            package com.example.app.domain.repository
            
            interface UserRepository {
                suspend fun getUser(userId: String): User?
            }
            
            data class User(val id: String)
        """.trimIndent()))
            .issues(KDocRequiredDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .checkFix(null, kotlin("""
            package com.example.app.domain.repository
            
            interface UserRepository {
                /**
                 * TODO: описать назначение метода getUser
                 * @param userId TODO: описать параметр
                 * @return TODO: описать возвращаемое значение
                 */
                suspend fun getUser(userId: String): User?
            }
            
            data class User(val id: String)
        """.trimIndent()))
    }
}

Регистрация правил Lint

Скрытый текст
package com.example.lint_checks

import com.android.tools.lint.client.api.IssueRegistry
import com.android.tools.lint.detector.api.CURRENT_API

class CustomIssueRegistry : IssueRegistry() {
	// Список всех кастомных правил
    override val issues = listOf(
        RepositoryPackageDetector.ISSUE,
        KDocRequiredDetector.ISSUE
    )
    
    // Версия API Lint (должна соответствовать используемой версии)
    override val api: Int = CURRENT_API
    
    // Информация о вендоре (требуется линтером)
    override val vendor: Vendor = Vendor( 
        vendorName = "Your Company Name",
        feedbackUrl = "https://github.com/yourcompany/lint-rules/issues",
        contact = "team@yourcompany.com"
    )
}

Запуск: ./gradlew :app:lint

Detekt

Основные концепции

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

Интерфейс RuleSetProvider – группирует правила и регистрируется через ServiceLoader – механизм Java для автоматического обнаружения реализаций.

Интерфейс PSI (Program Structure Interface) – дерево элементов Kotlin-кода от JetBrains, используемое в IntelliJ IDEA для анализа структуры программы.

Настройка проекта

Скрытый текст
// build.gradle.kts (project)
plugins {
    id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false
}

// app/build.gradle.kts
plugins {
    id("io.gitlab.arturbosch.detekt")
}

dependencies {
    detektPlugins(project(":detekt-rules"))
}

// detekt-rules/build.gradle.kts
plugins {
    id("kotlin")
}

dependencies {
    compileOnly("io.gitlab.arturbosch.detekt:detekt-api:1.23.8")
    testImplementation("io.gitlab.arturbosch.detekt:detekt-test:1.23.8")
    testImplementation("io.kotest:kotest-assertions-core:6.0.3")
    testImplementation("junit:junit:4.13.2")
}

Пример 1: ViewState без Android зависимостей

Задача: состояния ViewState/UiState не должны импортировать android.* пакеты.

Зачем это нужно: ViewState без Android-зависимостей можно переиспользовать в Kotlin Multiplatform проектах и легко тестировать без техники мокирования фреймворка.

Скрытый текст
package com.example.detekt_rules

import io.gitlab.arturbosch.detekt.api.*
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtImportDirective

class NoAndroidInViewStateRule(config: Config = Config.empty) : Rule(config) {
    
    // Issue описывает проблему которую находит правило
    override val issue = Issue(
        id = "NoAndroidInViewState",
        severity = Severity.Defect, // Defect = проблема в коде
        description = "ViewState не должен содержать Android зависимости",
        debt = Debt.FIVE_MINS // Примерное время на исправление
    )

    // Флаг что в файле есть State класс
    private var hasStateClass = false
    // Список найденных android импортов
    private val androidImports = mutableListOf<KtImportDirective>()

    // Вызывается для всего файла в начале
    override fun visitKtFile(file: KtFile) {
        // Сбрасываем состояние для нового файла
        hasStateClass = false
        androidImports.clear()

        // Продолжаем обход дерева - вызовутся visitClass и visitImportDirective
        super.visitKtFile(file)

        // После обхода файла - если есть State класс, проверяем импорты
        if (hasStateClass) {
            androidImports.forEach { importDirective ->
                val importPath = importDirective.importPath?.pathStr ?: return@forEach
                // Создаем отчет об ошибке для каждого android импорта
                // Entity.from создает сущность с информацией о местоположении
                report(
                    CodeSmell(
                        issue = issue,
                        entity = Entity.from(importDirective), // Указываем где нашли проблему
                        message = "ViewState/UiState не должен импортировать " +
                                "Android зависимости: `$importPath`. Это нарушает переиспользование " +
                                "и усложняет тестирование."
                    )
                )
            }
        }
    }

    // Вызывается для каждого класса в файле
    override fun visitClass(klass: KtClass) {
        val className = klass.name ?: ""

        // Проверяем по соглашению об именовании - это State класс или нет
        if (className.endsWith("ViewState") || className.endsWith("UiState")) {
            hasStateClass = true
        }

        // Продолжаем обход - вдруг внутри класса есть еще вложенные классы
        super.visitClass(klass)
    }

    // Вызывается для каждого import в файле
    override fun visitImportDirective(importDirective: KtImportDirective) {
        super.visitImportDirective(importDirective)

        // Получаем полный путь импорта (например android.os.Parcelable)
        val importPath = importDirective.importPath?.pathStr ?: return

        // Если импорт начинается с android.* - сохраняем его
        if (importPath.startsWith("android.")) {
            androidImports.add(importDirective)
        }
    }
}

Тесты:

Скрытый текст
package com.example.detekt_rules

import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.test.compileAndLint
import io.kotest.matchers.collections.shouldHaveSize
import org.junit.Test

class NoAndroidInViewStateRuleTest {

    @Test
    fun `ViewState с android импортом - срабатывает`() {
        val code = """
            import android.os.Parcelable
            
            data class UserViewState(val name: String)
        """.trimIndent()

        val findings = NoAndroidInViewStateRule(Config.empty)
            .compileAndLint(code)

        findings shouldHaveSize 1
    }

    @Test
    fun `ViewState без android импорта - не срабатывает`() {
        val code = """
            data class UserViewState(val name: String, val age: Int)
        """.trimIndent()

        val findings = NoAndroidInViewStateRule(Config.empty)
            .compileAndLint(code)

        findings shouldHaveSize 0
    }

    @Test
    fun `Обычный класс с android импортом - не срабатывает`() {
        val code = """
            import android.os.Bundle
            
            class MainActivity {
                fun onCreate(savedInstanceState: Bundle?) {}
            }
        """.trimIndent()

        val findings = NoAndroidInViewStateRule(Config.empty)
            .compileAndLint(code)

        findings shouldHaveSize 0
    }
}

Пример 2: immutable ViewState

Задача: состояния ViewState/UiState должны содержать только переменные val, не var.

Зачем это нужно: неизменяемое состояние предсказуемо, избегает побочных эффектов и безопасно в многопоточной среде.

Скрытый текст
package com.example.detekt_rules

import io.gitlab.arturbosch.detekt.api.*
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtProperty

class ImmutableViewStateRule(config: Config = Config.empty) : Rule(config) {
    
    override val issue = Issue(
        id = "ImmutableViewState",
        severity = Severity.Defect,
        description = "ViewState должен быть immutable (только val)",
        debt = Debt.FIVE_MINS
    )
    
    // Флаг показывает что мы внутри State класса
    private var isStateClass = false
    
    // Вызывается при входе в класс
    override fun visitClass(klass: KtClass) {
        val className = klass.name ?: ""
        
        // Определяем ViewState/UiState по соглашению об именовании
        isStateClass = className.endsWith("ViewState") || className.endsWith("UiState")
        
        if (isStateClass) {
            // Получаем первичный конструктор (primary constructor)
            val primaryConstructor = klass.primaryConstructor

            // Проходим по всем параметрам конструктора
            primaryConstructor?.valueParameters?.forEach { parameter ->
                // Проверяем тип ключевого слова перед параметром
                val isVar = parameter.valOrVarKeyword?.node?.elementType == KtTokens.VAR_KEYWORD

                // Если параметр объявлен как var - это ошибка
                if (isVar) {
                    report(
                        CodeSmell(
                            issue = issue,
                            entity = Entity.from(parameter),
                            message = "Свойство `${parameter.name}` должно быть val. " +
                                    "Используйте copy() для изменения состояния."
                        )
                    )
                }
            }
        }

        // Продолжаем обход для проверки вложенных классов
        super.visitClass(klass)
        // Сбрасываем флаг после обработки класса
        isStateClass = false
    }

    // Проверяем свойства внутри тела класса
    override fun visitProperty(property: KtProperty) {
        super.visitProperty(property)
        
        // Проверяем свойства только в State классах
        if (!isStateClass) return
        
        // Определяем тип ключевого слова
        val isVar = property.valOrVarKeyword?.node?.elementType == KtTokens.VAR_KEYWORD
        
        // Если свойство объявлено как var - создаем отчет
        if (isVar) {
            report(
                CodeSmell(
                    issue = issue,
                    entity = Entity.from(property),
                    message = "Свойство `${property.name}` должно быть val. " +
                             "Используйте copy() для изменения состояния."
                )
            )
        }
    }
}

Тесты:

Скрытый текст
package com.example.detekt_rules

import io.gitlab.arturbosch.detekt.api.Config  
import io.gitlab.arturbosch.detekt.test.compileAndLint  
import io.kotest.matchers.collections.shouldHaveSize  
import org.junit.Test  
  
class ImmutableViewStateRuleTest {  
  
    @Test  
    fun `ViewState с var - срабатывает`() {  
        val code = """  
            data class UserViewState(                
	            val name: String,  
                var age: Int  
            )  
        """.trimIndent()  
  
        val findings = ImmutableViewStateRule(Config.empty)  
            .compileAndLint(code)  
  
        findings shouldHaveSize 1  
    }  
  
    @Test  
    fun `ViewState с var в теле - срабатывает`() {  
        val code = """  
            data class UserViewState(                
	            val name: String,  
                val age: Int  
            ) {  
                var email: String = ""  
            }  
        """.trimIndent()  
  
        val findings = ImmutableViewStateRule(Config.empty)  
            .compileAndLint(code)  
  
        findings shouldHaveSize 1  
    }  
  
    @Test  
    fun `ViewState с val - не срабатывает`() {  
        val code = """  
            data class UserViewState(                
	            val name: String,  
                val age: Int  
            )  
        """.trimIndent()  
  
        val findings = ImmutableViewStateRule(Config.empty)  
            .compileAndLint(code)  
  
        findings shouldHaveSize 0  
    }  
  
    @Test  
    fun `Обычный класс с var - не срабатывает`() {  
        val code = """  
            data class User(                
	            val name: String,  
                var age: Int  
            ) {  
                var email: String = ""  
            }  
        """.trimIndent()  
  
        val findings = ImmutableViewStateRule(Config.empty)  
            .compileAndLint(code)  
  
        findings shouldHaveSize 0  
    }  
}

Пример 3: миграция с RxJava на Coroutines

Задача: находить методы, возвращающие RxJava типы (Single, Observable, Completable) и предлагать миграцию на Coroutines.

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

Примечание: проверка по именам типов может давать ложные срабатывания на классы с похожими именами из других библиотек. Более надежный способ – проверять также импорты "io.reactivex.", но для простоты примера мы используем только проверку по именам.

Скрытый текст
package com.example.detekt_rules

import io.gitlab.arturbosch.detekt.api.*
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.KtTypeReference

class RxJavaToCoroutinesMigrationRule(config: Config = Config.empty) : Rule(config) {
    
    override val issue = Issue(
        id = "RxJavaToCoroutinesMigration",
        severity = Severity.Style, // Style = не ошибка, но стоит улучшить
        description = "Метод использует RxJava, рассмотрите миграцию на Coroutines",
        debt = Debt.TWENTY_MINS
    )
    
    // Маппинг RxJava типов на Coroutines аналоги
    private val rxToCoroutines = mapOf(
        "Single" to "suspend fun T", // Single<T> -> suspend fun(): T
        "Observable" to "Flow<T>", // Observable<T> -> Flow<T>
        "Flowable" to "Flow<T>", // Flowable<T> -> Flow<T>
        "Completable" to "suspend fun returning Unit", // Completable -> suspend fun()
        "Maybe" to "suspend fun T?" // Maybe<T> -> suspend fun(): T?
    )
    
    // Вызывается для каждой функции в коде
    override fun visitNamedFunction(function: KtNamedFunction) {
        super.visitNamedFunction(function)
        
        // Получаем тип возвращаемого значения функции
        val returnType = function.typeReference ?: return
        val typeName = extractTypeName(returnType)
        
        // Проверяем является ли возвращаемый тип одним из RxJava типов
        val alternative = rxToCoroutines[typeName] ?: return
        
        // Извлекаем generic параметр (User из Single<User>)
        val genericType = extractGenericType(returnType)
        val suggestion = formatSuggestion(alternative, genericType)
        
        // Создаем отчет с предложением миграции
        report(
            CodeSmell(
                issue = issue,
                entity = Entity.from(function),
                message = "Метод `${function.name}` возвращает RxJava тип `$typeName`. " +
                         "Рассмотрите миграцию на Coroutines: $suggestion"
            )
        )
    }
    
    // Извлекает имя типа без generic параметров (Single<User> -> Single)
    private fun extractTypeName(typeReference: KtTypeReference): String {
        return typeReference.text.substringBefore("<").trim()
    }
    
    // Извлекает generic параметр (Single<User> -> User)
    private fun extractGenericType(typeReference: KtTypeReference): String? {
        val text = typeReference.text
        val start = text.indexOf('<')
        val end = text.lastIndexOf('>')
        
        return if (start != -1 && end != -1 && start < end) {
            text.substring(start + 1, end).trim()
        } else null
    }
    
    // Форматирует предложение по миграции с учетом generic типа
    // "suspend fun" + "User" -> "suspend fun User"
    // "Flow<T>" + "Event" -> "Flow<Event>"
    private fun formatSuggestion(alternative: String, genericType: String?): String {
        return when {
            genericType != null && alternative.contains("T") ->
                alternative.replace("T", genericType)
            genericType != null && alternative.startsWith("suspend") ->
                "$alternative -> $genericType"
            else -> alternative
        }
    }
}

Тесты:

Скрытый текст
package com.example.detekt_rules

import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.test.compileAndLint
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.string.shouldContain
import org.junit.Test

class RxJavaToCoroutinesMigrationRuleTest {

    @Test
    fun `Single возвращаемый тип - срабатывает`() {
        val code = """
            import io.reactivex.Single
            
            class UserRepository {
                fun getUser(): Single<User> {
                    return Single.just(User("123"))
                }
            }
            
            data class User(val id: String)
        """.trimIndent()

        val findings = RxJavaToCoroutinesMigrationRule(Config.empty)
            .compileAndLint(code)

        findings shouldHaveSize 1
        findings.first().message shouldContain "suspend"
    }

    @Test
    fun `Observable возвращаемый тип - срабатывает`() {
        val code = """
            import io.reactivex.Observable
            
            class EventRepository {
                fun observeEvents(): Observable<Event> {
                    return Observable.empty()
                }
            }
            
            data class Event(val name: String)
        """.trimIndent()

        val findings = RxJavaToCoroutinesMigrationRule(Config.empty)
            .compileAndLint(code)

        findings shouldHaveSize 1
        findings.first().message shouldContain "Flow"
    }

    @Test
    fun `Suspend функция - не срабатывает`() {
        val code = """
            class UserRepository {
                suspend fun getUser(): User {
                    return User("123")
                }
            }
            
            data class User(val id: String)
        """.trimIndent()

        val findings = RxJavaToCoroutinesMigrationRule(Config.empty)
            .compileAndLint(code)

        findings shouldHaveSize 0
    }
}

Регистрация правил

Скрытый текст
package com.example.detekt_rules

import io.gitlab.arturbosch.detekt.api.*

class CustomRuleSetProvider : RuleSetProvider {
    override val ruleSetId: String = "custom-rules"
    
    override fun instance(config: Config): RuleSet {
        return RuleSet(
            ruleSetId,
            listOf(
                NoAndroidInViewStateRule(config),
                ImmutableViewStateRule(config),
                RxJavaToCoroutinesMigrationRule(config)
            )
        )
    }
}

Создайте файл detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider, в котором содержится полное имя вашего RuleSetProvider:

com.example.detekt_rules.CustomRuleSetProvider

И конфигурацию config.yml в detekt-rules/src/main/resources/config/config.yml:

Скрытый текст
custom-rules:
  active: true
  NoAndroidInViewState:
    active: true
  ImmutableViewState:
    active: true
  RxJavaToCoroutinesMigration:
    active: true

Запуск: ./gradlew detekt

Практические советы

Отладка правил

Вывод в консоль:

Скрытый текст
// Lint
override fun visitClass(context: JavaContext, declaration: UClass) {
    println("DEBUG: проверяем ${declaration.name}")
}

// Detekt
override fun visitClass(klass: KtClass) {
    println("DEBUG: обрабатываем ${klass.name}")
    super.visitClass(klass)
}

Запуск с логами: ./gradlew :app:lint --info или ./gradlew detekt --info

PSI Viewer для Detekt: установите плагин PSI Viewer в Android Studio для просмотра структуры PSI дерева. Помогает понять какие методы нужно переопределить в Rule.

Аннотации Suppress

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

Скрытый текст
// Lint
@SuppressLint("RepositoryPackageViolation")
class LegacyUserRepositoryImpl {
    // TODO: переместить после рефакторинга (PROJ-123)
}

// Detekt
@Suppress("NoAndroidInViewState")
data class LegacyViewState(
    val context: Context // TODO: убрать после KMP миграции (PROJ-456)
)

Важно: применяйте к минимальной области видимости (метод/класс, не файл).

Файлы Baseline

Baseline фиксирует текущее состояние кода, блокируя новые нарушения. Используйте для:

  • Добавления строгих правил в легаси проект

  • Пошагового решения найденных ошибок

  • Защиты от новых проблем прямо сейчас

Подключение:

Скрытый текст
// app/build.gradle.kts
android {
    lint {
        baseline = file("lint-baseline.xml")
    }
}

detekt {
    baseline = file("detekt-baseline.xml")
}

Создание:

Скрытый текст
# Lint
./gradlew lint --continue

# Detekt
./gradlew detektBaseline

Периодически обновляйте baseline, удаляя исправленные проблемы.

Оптимизация производительности

Ранний выход:

override fun visitClass(context: JavaContext, declaration: UClass) {
    if (!declaration.name?.endsWith("Impl")!!) return
    // логика только для нужных классов
}

Избегайте регулярных выражений:

// Медленно
if (className.matches(Regex(".*Repository.*"))) { }

// Быстро
if (className.contains("Repository")) { }

Специфичные методы:

// Медленно - для каждого элемента
override fun visitElement(element: UElement) { }

// Быстро - только для классов
override fun visitClass(declaration: UClass) { }

Профилируйте правила:

# Lint
./gradlew lint --profile

# Detekt
./gradlew detekt --profile

Gradle создаст HTML-отчет с временем выполнения задач в build/reports/profile/.

Лучшие практики

1. Пишите тесты для всех правил

Тесты – обязательная часть любого правила. Минимальный набор:

  • Позитивный тест – правило находит проблему

  • Негативный тест – правило не срабатывает на правильном коде

  • Граничные случаи – пустые классы, специальные символы

2. Пишите понятные сообщения об ошибках

Сообщение должно отвечать на три вопроса: что не так, почему это проблема, как исправить.

Хорошо:

message = "Repository реализация `UserRepositoryImpl` должна быть в пакете " +
         "`*.data.repository` для соблюдения Clean Architecture. " +
         "Текущий пакет: `com.example.app.domain`"

Плохо:

message = "Wrong package"

3. Правильно выбирайте Severity

Для Android Lint:

  • ERROR – блокирует сборку. Используйте только для критичных проблем: гарантированные runtime ошибки, проблемы безопасности

  • WARNING – не блокирует сборку. Используйте для большинства пользовательских правил: нарушения архитектуры, отсутствие документации

  • INFORMATIONAL – подсказки для улучшения кода

Для Detekt:

Detekt использует категории проблем вместо уровней строгости:

  • Security – проблемы безопасности

  • Defect – ошибки, которые могут привести к неправильному поведению

  • Performance – проблемы производительности

  • CodeSmell / Maintainability – проблемы поддерживаемости кода

  • Style – нарушения стиля кода

  • Warning – неэффективный код, который не ломает работу

  • Minor – незначительные проблемы качества

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

4. Добавляйте QuickFix, где возможно (только Lint)

QuickFix экономит время команды. Добавляйте для:

  • Простых замен (пакеты, импорты)

  • Добавления кода (KDoc, аннотации)

  • Генерации стандартного кода

Избегайте для сложного перепроектирования нескольких файлов.

5. Балансируйте между качеством и удобством

Слишком строгие правила вредят продуктивности. Признаки проблемы:

  • Разработчики постоянно жалуются

  • Половина кода покрыта @Suppress

  • Проверка кода превращается в споры о правилах

Как избежать:

  • Определите 3-5 ключевых принципов

  • Обсуждайте изменения с командой

  • Убирайте то, что перестало работать

  • Слушайте обратную связь

Качество важнее количества – 5 полезных правил лучше 20 спорных.

Типичные проблемы

Проблема: правило не срабатывает

Проверьте:

  1. Правило зарегистрировано в IssueRegistry/RuleSetProvider

  2. Модуль подключен в app/build.gradle.kts через lintChecks() или detektPlugins()

  3. Правило активно в конфигурации (для Detekt в detekt.yml / config.yml)

  4. Severity не слишком низкий

Проблема: ложные срабатывания

Решения:

  1. Уточните условия проверки – используйте точные совпадения вместо содержания

  2. Добавьте исключения для тестовых/mock классов

  3. Проверяйте контекст, не применяйте правила к тестовым пакетам

Проблема: медленная работа правила

Решения:

  1. Профилируйте правило

  2. Кешируйте результаты вычислений

  3. Используйте более специфичные методы (visitClass вместо visitElement)

Когда НЕ нужны пользовательские правила

Не пишите правила если:

  • Можно настроить существующие правила

  • Правило применимо к паре файлов

  • Логику невозможно формализовать

  • Нет времени на поддержку

  • Команда не согласна

Используйте правила для решения реальных проблем команды.

Заключение

Пользовательские правила Lint и Detekt автоматизируют проверку архитектуры и соблюдение конвенций. Они экономят время на проверку кода и помогают новичкам быстрее влиться в проект.

Ключевые принципы:

  1. Начинайте с малого – одно простое правило

  2. Тестируйте все

  3. Слушайте команду

  4. Документируйте правила

Полезные ресурсы

Если хотите углубиться или разобраться с деталями API – ниже собрала ссылки на официальную документацию, примеры и инструменты, которые пригодятся при написании собственных правил.

Помните: цель правил – помогать разработке, а не усложнять её.