Automated Checks
Most of the Kotlin Coding Standards are enforced mechanically rather than by review. This page is for the engineer on the receiving end: how to switch the checks on in a repository, how to read what comes back, how to change the posture a rule is held at, and how to add rules of your own.
The apparatus is the Kotlin Standards plugin, which describes what it is and how it is built. This page is about using it.
What it judges, and how
Section titled “What it judges, and how”Two tiers, because the two kinds of claim need different instruments.
| Engine | Judges | Example | |
|---|---|---|---|
| Tier A | detekt, on its own classpath | one file at a time | !!, getOrThrow(), a naked UUID field |
| Tier B | ArchUnit and the package graph | the whole program | package cycles, layer direction, the protocol boundary |
Both write the same report shape, and one reader renders them against the standard that defines them. That is why a finding cites a section you can open rather than repeating a fixed tool message.
Adopting it in a repository
Section titled “Adopting it in a repository”The plugin is applied to the root project only — it reaches the judged subprojects itself.
// settings.gradle.kts — resolves from GitHub PackagespluginManagement { repositories { gradlePluginPortal() maven("https://maven.pkg.github.com/Arda-cards/kotlin-standards-gradle-plugin") { /* credentials */ } }}// build.gradle.kts, ROOT projectplugins { // Required for a MULTI-PROJECT consumer. Gradle loads a plugin into the classloader scope of // the first project that applies it; this plugin lives at the root and applies detekt to the // judged subprojects, and detekt needs Kotlin classes from a scope the root can see. // `apply false` puts Kotlin in the shared root scope without applying it to the root itself. // Subprojects keep their own `plugins { kotlin("jvm") }` unchanged. kotlin("jvm") version "<version>" apply false
id("cards.arda.standards.kotlin") version "<version>"}
ardaStandards { // Empty means the root project holds the source, which is the shape a single-module service has. judgedProjects.set(listOf("print-v1", "component", "harness"))}The plugin checks the classloader condition itself and says so if it is unmet, rather than failing somewhere less legible.
Pin an exact version. A minor release of this plugin can add a rule, and a catalogued rule blocks by default — so a floating pin means a new rule can start failing builds with no commit in the repository and no pull request anywhere. Take the bump as a reviewable change.
Declaring what the repository is
Section titled “Declaring what the repository is”config/standards/repository.properties is a description of the repository — the facts a rule may not argue with. Every key is required except where noted; a missing key fails the build rather than defaulting, because a silently assumed prefix produces an empty package graph, which is indistinguishable from a repository with no cycles.
| Key | Answers |
|---|---|
prefixes | Which packages this repository owns — see why the compiler cannot answer this below |
sourceRoots | Where the hand-written Kotlin is, relative to the repository root |
layer.<segment> | The segment-to-layer table. The layer rule judges nothing at all if this is empty, so an empty table is an error rather than a permissive default |
staging | Optional. The common staging area every layer may import |
protocolPackages | Optional. Where protocol technologies are allowed, because that is where this Module implements an Endpoint |
generatedPackages | Optional. Roots a generator writes and nobody hand-edits. These sit inside the repository’s own prefixes — protobuf bindings do — so ownership alone cannot tell them apart |
serverDefinitionSegments | Optional. Which segments under servers name a capability definition rather than a vendor. See the shape of a servers subtree |
The layer table is why the same rule reads differently in two repositories: one names its protocol layer api and another names it grpc, and both are correct descriptions of themselves.
Why ownership is declared rather than inferred
Section titled “Why ownership is declared rather than inferred”It is reasonable to expect prefixes to be unnecessary: the rules that care about ownership
resolve the declaration they are judging, and the compiler already reports whether a symbol came
from a library. That is not sufficient, and the gap is easy to miss.
KaSymbolOrigin.LIBRARY means not in the source set currently being analysed — which is not
the same as not ours. print-module’s own PdfRenderer resolves as SOURCE under detektMain
and as LIBRARY under detektTest: one declaration, two answers, because the module boundary moved
between the two runs. The same happens across subprojects, where both sides are ours and both are
bound by the same standard. A rule trusting origin alone would exempt a repository’s own interfaces
from its own rules — in exactly the places where the signature is ours to change, which is where
the exemption does the most damage.
So origin answers “is this outside the current compilation” and prefixes answers “is this
ours”. Those coincide only in a single-source-set repository, and neither of the two consumers is
one.
generatedPackages is then the exception inside the exception: protobuf bindings land under
the repository’s own prefixes, so ownership would claim them, and a signature nobody hand-writes is
not a signature anyone can change.
prefixes is also what the structural rules build the package graph from, which has nothing to do
with symbol origin at all.
Reading a report
Section titled “Reading a report”Four tasks, of which two are the ones you run:
| Task | Does | Fails the build |
|---|---|---|
ardaStandardsReport | Surveys the whole tree | never |
ardaStandardsGate | The same survey, enforcing scope | on a blocking finding |
ardaStandardsSuppressions | Inventories every @Suppress, and which name an issue | never |
ardaStandardsStaleSuppressions | Checks every waiver is holding back a real finding | on a waiver that waives nothing |
Every rule appears in every report, in exactly one of three states.
BLOCKING / CARRIED / ADVISORY — findingsCLEAN — ran, found nothingSKIPPED — did not run, and whyThat is the property the whole design is built around: a rule that ran and found nothing and a rule that never ran must never look alike. Every silent miss this apparatus has met came through that gap — including a case where an entire ruleset went offline while its unit tests passed, and the report called all of it clean.
So read SKIPPED as carefully as you read a finding. It means one of: the rule is set to none here; its subject is absent because the repository declared no such facet; or a required declaration is missing — ServersCapabilityShape reports SKIPPED rather than clean when serverDefinitionSegments is unset, because a rule with nothing to look at has not looked.
The three finding states differ only in what they do to the build. BLOCKING fails it. CARRIED is a finding in the standing backlog, in a file this branch did not touch — reported, not blocking. ADVISORY never blocks. The CONFIGURATION section of every report prints the scope each rule was judged at, with the plugin’s default beside it, so you never have to open a build file to know what posture produced the verdict.
Tuning, in order of preference
Section titled “Tuning, in order of preference”Three axes, and none of them is a way to be quietly excused from anything. That is the property the design has, not a claim it makes.
| Axis | Question | Who declares | How it is audited |
|---|---|---|---|
| Facets | What exists here? | The repository, in repository.properties | The declaration is exhaustive, and the plugin checks it against the code |
| Scope | How hard is it held? | Plugin default, repository override | Printed in every report |
| Site departures | Why this line? | The author, in a @Suppress | Visible in the diff, inventoried, and failed if it waives nothing |
Scope — the first thing to reach for
Section titled “Scope — the first thing to reach for”ardaStandards { ratchetFrom.set("origin/main") // what `ratchet` measures against ratchet("ResultUnwrapping", "LargeSourceFile") advisory("ProtocolBoundary") none("ServersCapabilityShape")}| Scope | Blocks | Choose it when |
|---|---|---|
tree | anywhere | the rule is already at zero here, so any finding is by construction new |
ratchet | only in files the branch changed | there is a standing backlog you are not fixing today, but new code must be clean |
advisory | never | the standard itself declines to gate the rule, or it has a known false positive here |
none | never — and the rule does not run | the repository does not have the rule’s subject at all |
Scope is repository-local, and deliberately so. One repository ratchets a rule because it has a backlog; another holds the same rule at tree because its baseline is empty. Same rule, same citation, opposite posture, both correct.
Prefer moving a scope over widening an exemption, and the reason is worth internalising: a scope acts on findings, an exemption acts on whether the rule looks. An advisory rule still prints everything it found — the knowledge is preserved and deferred. An exemption produces no finding at all, so nobody can count what it hid, because there is nothing left to count.
none is the only way to switch a catalogued rule off. Setting active: false in a detekt overlay fails the build with a message pointing here. detekt would honour it silently, and the rule would then report as clean — under the heading that exists to prevent exactly that.
Site departures — one line, with its reason in the diff
Section titled “Site departures — one line, with its reason in the diff”@Suppress("RuleId") at the declaration, or @file:Suppress("RuleId") for a file, waives a Tier A rule at one site. Put the reason immediately above it: a reviewer meets the departure in the diff, which is the whole point of expressing it here rather than in a configuration file nobody re-reads.
A waiver that waives nothing fails the gate. ardaStandardsStaleSuppressions is a dependency of ardaStandardsGate, so a @Suppress left behind after the code it excused was fixed is a build failure rather than sediment. A suppression is a claim that a real finding exists at that line; when the claim stops being true the annotation stops being a departure and becomes a lie about the tree.
Facets — when the declaration is what is wrong
Section titled “Facets — when the declaration is what is wrong”If a rule fires across a whole area and every finding looks wrong, check repository.properties before reaching for a scope. A layer table that names the wrong segment, or a prefixes entry that misses a source root, produces findings that are faithful to a description that is false. The fix is the description.
Rules this repository writes
Section titled “Rules this repository writes”A repository will want rules that are its own — a Component-specific convention, a rule about a vendor integration, a rule not yet ready to be everyone’s. Nothing about that is a fork: the catalog, the roster check, the liveness check and the report all operate over platform rules ∪ this repository’s rules.
Decide first whether the rule is local or platform. A rule that stays local is a claim about one repository. A rule going up is a claim about the platform, and it needs no extension point at all — it is a pull request against the plugin, because it deserves review.
A Tier A rule — file-local, on detekt
Section titled “A Tier A rule — file-local, on detekt”Keep them in their own subproject. It stays local and unpublished; nothing needs to be released for the build to use it.
dependencies { // compileOnly: detekt supplies its own API and compiler at runtime. Shipping either in this // jar risks two incompatible PSI implementations on one classpath. compileOnly(libs.arda.standards)}// build.gradle.kts, rootdependencies { add("ardaStandardsRules", project(":standards-rules"))}Declare the dependency from the same version reference as the plugin itself. The rules compile against the API the plugin runs, and the two cannot be allowed to drift.
A Tier B rule — structural, over the whole program
Section titled “A Tier B rule — structural, over the whole program”Implement StructuralRule and register it with a ServiceLoader entry in META-INF/services/cards.arda.standards.kotlin.arch.StructuralRule.
class HarnessIsolation : StructuralRule { override val id = "HarnessIsolation" override fun evaluate(context: StructuralContext): List<Finding> = …}The roster is discovered rather than listed, which is what makes this an extension point instead of an edit to somebody else’s source.
Implement the “why this rule has nothing to look at here” member as well. It is what produces SKIPPED when the rule is present and working and its subject is absent — the third state exists precisely so that case cannot render as clean. It is not for “the subject happens not to occur”: a repository with no !! and a repository with no way to recognise one are different states and must read differently.
Wire them up
Section titled “Wire them up”ardaStandards { additionalRuleCatalogs.from(file("config/standards/local-rules.properties")) additionalStructuralRules.from(project(":standards-rules")) additionalDetektConfig.from(file("config/standards/local-detekt.yml"))}Two things the plugin will fail your build over
Section titled “Two things the plugin will fail your build over”A rule with no catalog entry. The entry supplies the rule’s claim, guidance, doc and default scope — in the same five-key shape as the platform catalog. Without it the rule still runs and still reports, but it cites nothing, and a finding that cites nothing leaves the reader with no standard to open.
A rule with no liveness fixture. This is the one worth taking seriously. A rule that silently stops working reports zero findings, which is indistinguishable from a clean tree — and if its subject does not occur in your repository yet, dead and clean are the same number permanently. The fixture asks only whether the rule still speaks, not whether it says the right thing; that is the failure a per-rule unit test structurally cannot see.
Both checks are run by the plugin over the union of platform and local rules, rather than being tests you copy. A copied invariant drifts — two rules once carried two exclusion lists that a comment asked to agree, in two repositories, for as long as both had existed, and they did not.
Developing against a consumer
Section titled “Developing against a consumer”./gradlew -PardaStandardsPlugin=/path/to/kotlin-standards-gradle-plugin ardaStandardsGateThe path comes from a property rather than a tracked includeBuild, so no machine-local path reaches a shared file. A composite build proves the behaviour and nothing about the packaging — before calling a change done, publish and run once against the published artifact.
Related
Section titled “Related”- Kotlin Standards Plugin — what the apparatus is and how it is built.
- Kotlin Coding Standards — the standards these checks enforce.
- DAG Package Discipline — the structural claims Tier B judges.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved