kapkan is a ktlint rule set published with sborka, a set of Gradle conventions. A repository that applies sborka.lint gets the formatter and the rules together, and there is no property that turns them on or off.
The rules are not opinions about style: each encodes a class of defect actually found in a working repository and carries the backlog item it was found in. A rule with nothing behind it is not added.
Why it exists
detekt is configurable, which is right for a tool meant to fit any codebase. It also means detekt.yml, thresholds, severities and a baseline — four places to switch a check off where nobody reads the diff — and a default set full of taste: LongMethod, MagicNumber, TooManyFunctions. In a codebase where every number carries a comment naming its source, a rule arguing with that loses inside a week, in @Suppress.
kapkan drops the configuration. A rule is in the set or it is not, and the one knob is a suppression that has to say why.
What it catches
foreign-import-in-common—java.*,javax.*,android.*ororg.w3c.*in acommon…source set. The compiler already answers this for any module with a non-JVM target; the rule answers it forty seconds earlier, and covers the module whose only targets are the JVM and Android.swallowed-failure— arunCatchingwhose failure nothing reads and whose value the language throws away, andcatch (e: Exception)whose body never mentionse.wall-clock—System.currentTimeMillis(),Clock.System.now()and thejava.timeneighbours, anywhere but the place a repository says owns the clock.suppression-needs-a-reason— a kapkan rule switched off without a sentence beside it.kapkanJoins— a Gradle task rather than a rule: declarations nothing outside their own file mentions, and functions nothing calls.
Installation
Published to a snapshot repository, not to Maven Central.
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
maven("https://reposilite.kotlin.website/snapshots") {
content { includeGroupByRegex("ru\\.workinprogress.*") }
}
}
}
plugins { id("ru.workinprogress.sborka.settings") version "0.2.0.28" }
A module then applies id("ru.workinprogress.sborka.lint") beside its Kotlin plugin.
The version is pinned twice, and the two are different halves. settings.gradle.kts names the settings plugin; the project conventions are named wherever the root build puts them on the plugin classpath, usually a version catalog. Bumping one and not the other is silent — a new settings plugin, the old conventions, a green build and no rule running.
How a finding is answered
RiderModule.kt:230:52: Clock.System.now() reads the clock of whatever machine this runs on — a time
that has to agree with somebody else's is a value carried on the wire, not one read here
(kapkan:wall-clock)
Some findings are correct code. A composition root binding a clock is the shape the rule pushes towards, and it is still a wall-clock read:
@Suppress(
"ktlint:kapkan:wall-clock",
"the composition root is where the clock is bound; the view model counts a duration it was handed",
)
public fun riderModule(config: RiderConfig): Module = module { /* … */ }
@Suppress rather than an annotation of kapkan's own, and not for taste: one used in commonMain would need a multiplatform artefact on the compile classpath of every module of every repository — jvm, wasmJs, android, three iOS — with a release cycle of its own. It also makes the suppression a release check, because ktlint validates the id against the rules it loaded and refuses one it does not know: a build where the jar never reached the worker fails on the suppression instead of quietly running fewer rules.
Two rules that were measured and not written
"A @Test that returns a value never runs" is a real defect — JUnit does not collect a non-void method and does not warn — but not a syntactic one. A regular expression for "@Test with an expression body" fires 471 times across four repositories on healthy code, because runTest returns TestResult, which is typealias TestResult = Unit on the JVM. sborka answers it after the run instead, comparing the @Test methods the sources declare against what JUnit reported.
And swallowed-failure shipped asking the wrong question — whether the Result is discarded, rather than whether the failure is read. On the first repository it ran against, five of nine findings were runCatching { … }.onFailure { log.warn(…) } as a statement, which is correct code, against four real ones. A chain mentioning onFailure, getOrElse, fold or recover now ends the question; getOrNull and getOrDefault deliberately do not, because they answer "what is the value" and drop the exception on the way.
kapkanJoins
The one rule no regular expression can find. It reads the constant pool of every module's class files, resolves calls up the class hierarchy, strips the suffix Kotlin mangles onto anything mentioning a value class, and lists two piles: what nothing outside its own file mentions, and what nothing calls at all.
The second pile is the interesting one — four of the most expensive defects in the reference service had that shape, including a payment gateway's capture implemented and called by nobody.
On a 33 000-line repository it reads 1563 class files, considers 333 types and 253 functions, and reports 29 findings of which 24 are "only tests reach it". None of the 29 is a defect, which is why it is a report and not a gate: an inline function leaves no reference behind, a const val is folded into its call site, and declaring a helper beside its only user is Kotlin's own idiom.
What it cost to switch on
Same build box, ./gradlew check --rerun-tasks, three runs per variant, browser suite skipped on both sides: 54.03 / 47.24 / 43.04 s before, 51.43 / 41.55 / 41.54 s after. The spread inside each triple is larger than the difference between them, so the honest statement is that the rules cost less than the noise of the stand — not "zero because the ktlint task was already running", which would be true of the parse and false of the AST walk.
The other price is fourteen suppressions in that repository, each with a reason: four composition roots binding a clock, three defaults of an injectable one, three saga tests that need a clock and assert on something else, two finally { runCatching { close() } }, a crash handler of last resort, and a degradation sink that must not be able to break the screen it reports a hole in.
Limitations
- Pinned against ktlint 1.8.0, ktlint-gradle 14.2.0 and Kotlin 2.4.10, and run on nothing else. The ktlint rule API changed shape between 0.x and 1.x and can again.
kapkanJoinsreads JVM class files only. Kotlin/Native and wasm keep their references inside a serialised IR with no supported reader. Every application here keeps a JVM or desktop target anyway, for screenshot tests, but that is a property of this portfolio and not of the stack.- A Koin registration is a call.
single { SendReceiptUseCase(get()) }compiles to a class whose constant pool names the constructor, so "registered and neverget()-ed" is invisible. Generated code counts as a caller for the same reason. wall-clockmatches receivers by name, so an aliased import defeats it.System.nanoTime()is deliberately absent — a monotonic clock cannot be subtracted from anybody's epoch.- Published with CI build numbers to a snapshot repository. There is no release line yet.