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) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:09:31 -05:00
co-authored by Claude Opus 5
parent 20a8049719
commit 93b1fbd5a9
5 changed files with 178 additions and 0 deletions
+7
View File
@@ -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
+28
View File
@@ -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
+82
View File
@@ -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<JacocoReport>("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)
+49
View File
@@ -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
+12
View File
@@ -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.