# kapkan: ktlint rules with a defect behind each one

> A ktlint rule set with no configuration, where every rule encodes one class of defect this stack paid to find — and the two that did not survive being measured.

Page: https://kotlin.website/blog/kapkan · Published: 2026-09-03

[kapkan](https://github.com/youndie/sborka) is a ktlint rule set published with
[sborka](https://github.com/youndie/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.*` or `org.w3c.*` in a `common…`
  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`** — a `runCatching` whose failure nothing reads and whose value the language
  throws away, and `catch (e: Exception)` whose body never mentions `e`.
* **`wall-clock`** — `System.currentTimeMillis()`, `Clock.System.now()` and the `java.time`
  neighbours, 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.

```kotlin
// 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

```text
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:

```kotlin
@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.
* `kapkanJoins` reads **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 never `get()`-ed" is invisible. Generated
  code counts as a caller for the same reason.
* `wall-clock` matches 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.
