From 93b1fbd5a90aa4b4274662f3ccb972131fa92366 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 22 Aug 2026 14:09:31 -0500 Subject: [PATCH] Give this project the lint and formatting setup LibreMail already has There was none: no .editorconfig, no static analysis, and CI ran only tests. Style was whatever the IDE happened to do, which is fine until two of them disagree. The split is LibreMail's, because it is the one that avoids arguments between tools: ktlint owns formatting, detekt owns static analysis with its formatting ruleset left off. Neither can contradict the other about the same line. Adapted rather than copied. LibreMail is Gradle 9.6 / JDK 21 with the configuration cache off; this is Gradle 9.5 / JDK 17 with it on, and Kotlin lives under src/main/java rather than src/main/kotlin -- so its plugin versions were evidence, not proof. Verified here before committing: both plugins resolve, ktlint reads src/main/java, and the run stores a configuration cache entry rather than tripping over it. detekt.yml carries only what applies. The Compose relaxations transfer intact -- a @Composable function is legitimately long, PascalCase, and full of dp literals no matter which app it is in. LibreMail's ForbiddenImport guard and its LargeClass exclusions do not: they name an AppLog facade and two test files that exist over there and nowhere here, and config that guards nothing is worse than no config, because the next reader has to work out that it is dead. detekt 2.0 is an alpha. That is not a preference: stable 1.23.x stops at Gradle 8.12 and this project is on 9.5, so there is no other line to be on. Coverage is reported, not gated. A floor needs a measured baseline, and the JVM test stack here is still junit-only -- a number picked before measuring would either fail on day one or mean nothing. Android lint gets warningsAsErrors because the other two tools fail on any finding, and a gate that stays green while its report fills up is not a gate. Co-Authored-By: Claude Opus 5 (1M context) --- .editorconfig | 7 ++++ .gitattributes | 28 +++++++++++++ app/build.gradle.kts | 82 +++++++++++++++++++++++++++++++++++++++ config/detekt/detekt.yml | 49 +++++++++++++++++++++++ gradle/libs.versions.toml | 12 ++++++ 5 files changed, 178 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 config/detekt/detekt.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..23bf3b9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.{kt,kts}] +ktlint_code_style = intellij_idea +max_line_length = 120 +# @Composable functions are PascalCase by convention — don't flag them as bad function names. +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..979f0c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +# Normalize line endings across platforms. +* text=auto eol=lf + +# Windows scripts must keep CRLF. +*.bat text eol=crlf + +# The Gradle wrapper shell script must keep LF (it runs under sh on Unix/macOS). +gradlew text eol=lf + +# Treat these as binary. +*.jar binary +*.ttf binary +*.otf binary +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.apk binary +*.aab binary +*.keystore binary +*.jks binary + +# The FFmpeg archive under bin/ and the native libraries inside it. Without these a +# normalizing checkout would rewrite bytes that happen to look like line endings and +# corrupt the archive -- which the "FFmpeg binary" CI job exists to catch, but which +# is far cheaper to prevent here. +*.aar binary +*.so binary diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2b917c..557f7e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,18 @@ +import org.gradle.testing.jacoco.tasks.JacocoReport + plugins { alias(libs.plugins.android.application) // Required even under AGP 9: the Compose compiler plugin is NOT built in. alias(libs.plugins.kotlin.compose) + + // Lint/format. Resolved from the Gradle Plugin Portal, not AGP's buildscript + // classpath -- neither is an Android plugin. + alias(libs.plugins.ktlint) + alias(libs.plugins.detekt) + + // JaCoCo (Gradle built-in) instruments the JVM testDebugUnitTest task. Report only: + // there is deliberately no coverage gate, see the jacocoTestReport block below. + jacoco } android { @@ -62,6 +73,15 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + lint { + // ktlint and detekt both fail the build on any finding. Android lint by default + // aborts on errors only, so warnings would land in the report while the gate stayed + // green -- a gate that passes while the report has content is not a gate. + warningsAsErrors = true + // Already the default. Stated so a later edit cannot turn the gate off by accident. + abortOnError = true + } + packaging { jniLibs { // Uncompressed .so, so the APK zip-aligns them on 16 KB boundaries. @@ -76,6 +96,68 @@ kotlin { compilerOptions {} } +detekt { + // Merge the project overrides in config/detekt onto detekt's bundled defaults, so this + // repo's file only has to carry the rules it actually changes. + buildUponDefaultConfig = true + config.setFrom(rootProject.file("config/detekt/detekt.yml")) +} + +// Pin the coverage agent rather than inheriting whatever Gradle bundles. +jacoco { + toolVersion = libs.versions.jacoco.get() +} + +// --- Unit-test coverage ------------------------------------------------------------------- +// Report only. There is deliberately no coverage gate: a floor is only meaningful against a +// measured baseline, and the JVM test stack here is still junit-only. This task produces the +// number a floor would need; add `jacocoTestCoverageVerification` once it is known. + +// Generated code, stripped from the denominator so the percentage reflects hand-written Kotlin. +// No DI framework is in use, so there are no Hilt/Dagger patterns to exclude. +val jacocoGeneratedExcludes = listOf( + "**/R.class", + "**/R\$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + // Room lands in a later phase; its KSP output comes out the same door as hand-written code. + "**/*_Impl*", + // One per file with @Composable lambdas -- Compose compiler output, not written by anyone. + "**/ComposableSingletons*", +) + +// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the +// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the +// javac output (BuildConfig and R only) is not read at all. +val jacocoDebugKotlinClasses = layout.buildDirectory.dir( + "intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", +) + +// Accept both the base `jacoco` plugin's default exec location and AGP's +// enableUnitTestCoverage one, so the wiring survives either being the source of truth. +val jacocoExecutionData = fileTree(layout.buildDirectory) { + include( + "jacoco/testDebugUnitTest.exec", + "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", + ) +} + +tasks.register("jacocoTestReport") { + // The exec data does not exist until the tests have run. + dependsOn("testDebugUnitTest") + group = "verification" + description = "Generates JaCoCo XML + HTML coverage for the debug JVM unit tests." + + reports { + xml.required.set(true) + html.required.set(true) + } + + classDirectories.setFrom(fileTree(jacocoDebugKotlinClasses) { exclude(jacocoGeneratedExcludes) }) + sourceDirectories.setFrom(files("src/main/java")) + executionData.setFrom(jacocoExecutionData) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..0845e5a --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,49 @@ +# Project overrides merged onto detekt's bundled defaults (buildUponDefaultConfig = true in +# app/build.gradle.kts). Only rules that need tuning for this project appear here. +# +# Guiding principle: Compose UI intentionally breaks several OOP-era metrics, and a few default +# thresholds are stricter than this project's chosen style. Those are relaxed here with a reason. +# Genuine smells (swallowed exceptions, an over-complex condition, a misnamed file) are fixed in +# the code, not silenced. +# +# Formatting is deliberately absent: ktlint owns it. detekt's formatting ruleset is not enabled, +# so the two tools can never disagree about the same line. + +complexity: + LongMethod: + # Declarative @Composable functions are read top-to-bottom and are legitimately long. + ignoreAnnotated: ['Composable'] + CyclomaticComplexMethod: + # Branchy layout code (when/if inside a UI tree) isn't algorithmic complexity. + ignoreAnnotated: ['Composable'] + TooManyFunctions: + # Screen files group many small @Composable helpers next to their screen, and the codec / + # container matrix files are intentionally operation-rich cohesive APIs. detekt's default of + # 11 is far too low for either. Files past ~40 functions still flag as genuinely bloated. + ignoreAnnotated: ['Composable'] + allowedFunctionsPerFile: 40 + allowedFunctionsPerClass: 40 + allowedFunctionsPerInterface: 40 + +naming: + FunctionNaming: + # @Composable functions are PascalCase by Compose convention. + ignoreAnnotated: ['Composable'] + +style: + MagicNumber: + # dp / sp / duration literals are idiomatic inline in Compose. + ignoreAnnotated: ['Composable'] + ignorePropertyDeclaration: true + ignoreNamedArgument: true + ReturnCount: + # Allow guard-clause-style early returns; detekt's default of 2 is overly strict. + max: 4 + LoopWithTooManyJumpStatements: + # Clear early-continue / early-return loops read fine; the default of 1 is strict. + maxJumpCount: 3 + ThrowsCount: + # Guard-clause throws don't count; allow a few more for functions validating several + # preconditions. + excludeGuardClauses: true + max: 3 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdba997..4234142 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,16 @@ androidxJunit = "1.3.0" espressoCore = "3.7.0" smartException = "0.2.1" +# Lint/format. ktlint owns formatting; detekt owns static analysis (its formatting +# ruleset stays off, so the two can never disagree about the same line). +# detekt 2.0 is the only line with Gradle 9 support -- stable 1.23.x tops out at +# Gradle 8.12, and this project is on 9.5. +ktlint = "14.2.0" +detekt = "2.0.0-alpha.5" +# JaCoCo coverage agent. Pinned rather than inheriting whatever Gradle 9.5 bundles, +# so the agent version that reads Kotlin 2.2.10 bytecode is stated, not implied. +jacoco = "0.8.13" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -78,4 +88,6 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +detekt = { id = "dev.detekt", version.ref = "detekt" } # DO NOT add org.jetbrains.kotlin.android — AGP 9 built-in Kotlin makes it a build failure.