Skip to content

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.

Two tiers, because the two kinds of claim need different instruments.

EngineJudgesExample
Tier Adetekt, on its own classpathone file at a time!!, getOrThrow(), a naked UUID field
Tier BArchUnit and the package graphthe whole programpackage 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.

The plugin is applied to the root project only — it reaches the judged subprojects itself.

// settings.gradle.kts — resolves from GitHub Packages
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://maven.pkg.github.com/Arda-cards/kotlin-standards-gradle-plugin") { /* credentials */ }
}
}
// build.gradle.kts, ROOT project
plugins {
// 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.

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.

KeyAnswers
prefixesWhich packages this repository owns — see why the compiler cannot answer this below
sourceRootsWhere 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
stagingOptional. The common staging area every layer may import
protocolPackagesOptional. Where protocol technologies are allowed, because that is where this Module implements an Endpoint
generatedPackagesOptional. 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
serverDefinitionSegmentsOptional. 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.

Four tasks, of which two are the ones you run:

TaskDoesFails the build
ardaStandardsReportSurveys the whole treenever
ardaStandardsGateThe same survey, enforcing scopeon a blocking finding
ardaStandardsSuppressionsInventories every @Suppress, and which name an issuenever
ardaStandardsStaleSuppressionsChecks every waiver is holding back a real findingon a waiver that waives nothing

Every rule appears in every report, in exactly one of three states.

BLOCKING / CARRIED / ADVISORY — findings
CLEAN — ran, found nothing
SKIPPED — did not run, and why

That 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.

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.

AxisQuestionWho declaresHow it is audited
FacetsWhat exists here?The repository, in repository.propertiesThe declaration is exhaustive, and the plugin checks it against the code
ScopeHow hard is it held?Plugin default, repository overridePrinted in every report
Site departuresWhy this line?The author, in a @SuppressVisible in the diff, inventoried, and failed if it waives nothing
ardaStandards {
ratchetFrom.set("origin/main") // what `ratchet` measures against
ratchet("ResultUnwrapping", "LargeSourceFile")
advisory("ProtocolBoundary")
none("ServersCapabilityShape")
}
ScopeBlocksChoose it when
treeanywherethe rule is already at zero here, so any finding is by construction new
ratchetonly in files the branch changedthere is a standing backlog you are not fixing today, but new code must be clean
advisoryneverthe standard itself declines to gate the rule, or it has a known false positive here
nonenever — and the rule does not runthe 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.

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.

Keep them in their own subproject. It stays local and unpublished; nothing needs to be released for the build to use it.

standards-rules/build.gradle.kts
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, root
dependencies {
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.

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.

./gradlew -PardaStandardsPlugin=/path/to/kotlin-standards-gradle-plugin ardaStandardsGate

The 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.


Copyright: (c) Arda Systems 2025-2026, All rights reserved