Design: Kotlin Standards Plugin
Design: Kotlin Standards Plugin
Section titled “Design: Kotlin Standards Plugin”Overview
Section titled “Overview”The plugin turns the Kotlin Coding Standards from a document into a gate. It ships twenty-five catalogued rules across two engines, renders their findings against the section of the standard each one enforces, and fails a build according to a posture the consuming repository declares.
Two properties shape everything below, and neither is an implementation detail.
The report is the product, not the rules. A rule is only trustworthy if a reader can tell the difference between it ran and found nothing and it never ran. Every silent miss this apparatus has met came through that gap — including an occasion when a stale service-registration file took an entire ruleset offline while all forty of its unit tests passed, and the survey reported every rule as clean. So the report has three states everywhere, and the design is arranged to make the third one reachable rather than theoretical.
The apparatus is separate from its payload. The catalog, the roster check, the liveness check and the report operate over platform rules ∪ a consuming repository’s rules. Treating the rule set as a closed list would make every consumer-authored rule a fork; treating it as an open collection contributed to from both sides makes adding one ordinary.
Decision summary
Section titled “Decision summary”| # | Decision | Chosen |
|---|---|---|
| DT-001 | The rule engine | detekt, single engine |
| DT-002 | Rules with no subject in the consumer | Carried, not dropped |
| DT-004 | Vocabulary for what a repository is made of | Facet |
| DT-005 | The shared / repository-local seam | One scope dial including none, with an audited configuration |
| DT-006 | Default posture | tree for every rule |
| DT-007 | Liveness | Mandatory fixture, shipped with the plugin |
| DT-009 | Extension surface | Discovered roster; invariants plugin-enforced |
| DT-011 | Packaging | One artifact |
| DT-016 | Rule-list ownership | Plugin ships it; none is the only opt-out |
Full rationale in the decision log.
Platform conventions
Section titled “Platform conventions”| Convention | Applies to |
|---|---|
| Kotlin Coding Standards | Every rule’s claim, guidance and citation; and the plugin’s own source |
| DAG Package Discipline | The claims Tier B judges — layering, cycles, the servers subtree shape, the protocol boundary |
| Data Authority Module Pattern | The Universe-related rules |
| Release Lifecycle | The repository’s changelog-driven versioning and publication |
Deliberate departures
| Departure | Convention | Why | Agreed with |
|---|---|---|---|
The plugin’s rule implementations depend compileOnly on the detekt API rather than declaring it normally | Ordinary dependency declaration | detekt supplies its own API and an embedded Kotlin compiler at runtime. Shipping either in this jar risks two incompatible PSI implementations on one classpath | MP, DT-011 |
| A catalogued rule cannot be disabled through detekt’s own configuration, though detekt supports it | detekt’s documented configuration model | detekt honours active: false silently, and the rule then reports as clean — under the heading that exists to prevent exactly that. none is the single audited expression | MP, DT-016 |
| Rule thresholds are not overridable per repository | Configurable static-analysis thresholds | A departure is stated where it applies and met in a diff, rather than expressed as a number in a file nobody re-reads. It keeps a threshold meaning the same thing everywhere, which is what makes it a standard rather than a default | MP, DT-016 |
Structural design
Section titled “Structural design”The plugin has three parts that meet only at the report: a Gradle front end that declares tasks and reads the repository’s declaration, a Tier A payload that detekt loads onto its own classpath, and a Tier B runner that evaluates structural rules over the package graph. The extension points are StructuralRule for Tier B and detekt’s own provider model for Tier A.
Key types
Section titled “Key types”KotlinStandardsExtension — the ardaStandards { } block. It carries the seam as a type: judgedProjects, repositoryDeclaration, ratchetFrom, the four scope setters, and the three additional* collections through which a repository contributes rules, catalogs and configuration. What it deliberately does not accept is a package prefix or a source root: those are facts about the repository and belong in repository.properties, where a rule may not argue with them. Putting a fact where a decision belongs, or the reverse, is the confusion the split exists to prevent.
Repository — reads repository.properties into the topology every rule needs: owned prefixes, source roots, the segment-to-layer table, the staging area, protocol packages, generated packages, and which segments under servers name a capability definition. Every key is required except where noted, and a missing key fails rather than defaulting — a silently assumed prefix produces an empty package graph, which is indistinguishable from a repository with no cycles.
StructuralRule — the Tier B extension point. Three members: an id matching its catalog entry, evaluate(StructuralContext), and a member answering why this rule has nothing to look at here, or null when it does. That third member is the design’s centre of gravity in miniature. Rules whose subject is absent used to return an empty finding list, and an empty finding list renders as CLEAN — a claim that the rule looked and found the tree fine, made by a rule that never looked. It is the failure the three-state report exists to prevent, arriving through the one door nobody had checked.
One id per rule, so the roster is exactly the set of ids that ran. Where one idea produces two kinds of finding — leaf cycles and aggregate cycles — it is two rules, because a reader has to be able to hold each at its own scope.
ArchRulesRunner — discovers rules rather than listing them. The previous design was a JVM main with a literal roster verified by parsing its own source file’s text, which is not a mechanism a second contributor can reason about.
Catalog and RuleReport — the catalog maps a rule id to its claim, guidance, doc and default scope; the report renders findings from both tiers against it, prints the effective configuration with each override beside the plugin’s default, and lists every rule under one of the three states.
SuppressionSiteListener — a detekt FileProcessListener that records where every @Suppress sits, parsed from the syntax tree rather than matched by regex, so the suppression inventory and the stale-waiver audit agree with the compiler about what a suppression is and where it applies.
Behavioural design
Section titled “Behavioural design”A gate run reads the repository’s declaration once, drives both tiers over the same tree, and renders a single report. The two tiers are independent — neither can see the other’s findings — and they meet only at the catalog, which is what lets one reader present both against the standard.
The contract the report makes
Section titled “The contract the report makes”The report is this component’s public interface, so its guarantees are stated rather than implied.
- Every catalogued rule appears exactly once, in exactly one state. Absence from the report is not a possible outcome.
SKIPPEDalways carries its reason — set tonone, subject absent, or a required declaration missing.- Every scope override is printed with the plugin’s default beside it, so a reader never has to open a build file to know what posture produced a verdict.
- The audit states what a configuration excluded, not merely what it was.
ResultUnwrapping: tree, excludes **/Main.ktis auditable;ResultUnwrapping: treeis not — and an eight-finding drift between two rules that a comment asked to agree would have been visible on sight under the first form.
Scope, applied
Section titled “Scope, applied”tree blocks anywhere. ratchet blocks only in files the branch changed, measured against the merge base — and an unresolvable ratchet base must fail loudly rather than report an empty change set, because an empty change set is a clean gate that judged nothing. advisory reports and never blocks. none does not run the rule and reports SKIPPED.
What 2.0.0 contains
Section titled “What 2.0.0 contains”The template’s files to create is written for work not yet done; this artifact has shipped, so what follows is what it contains.
- Twenty-five catalogued rules — nineteen Tier A, six Tier B — each with a claim, guidance, a citation into this site, and a default scope of
tree. - Four public tasks —
ardaStandardsReport(never fails),ardaStandardsGate(enforces scope),ardaStandardsSuppressions(inventory),ardaStandardsStaleSuppressions(fails on a waiver holding nothing back, and a dependency of the gate). - The repository declaration — seven keys, read once and shared by both tiers, with the prefixes and generated packages written into a generated detekt overlay so Tier A can read them without the consumer restating anything.
- The extension surface —
additionalRuleCatalogs,additionalStructuralRules,additionalDetektConfig, and theardaStandardsRulesconfiguration.
Out of scope
Section titled “Out of scope”- The CI action surface. What the GitHub Actions must do and how a report is posted as a sticky pull-request comment is still open as DT-008.
- Pure rule-authoring repositories that publish rules for others to consume. The surface serves a repository that authors and uses its rules; narrowing to that case is what settled DT-011.
- Kover thresholds and the JUnit platform pin, which stay with PDEV-1304.
Testing strategy
Section titled “Testing strategy”The ordinary per-rule tests matter, but they are not what this design rests on. Two suites carry the guarantees.
The liveness fixture. It lints a fixture violating every rule in the set and fails if any one of them reports nothing. It reads the rule list from the provider rather than repeating it, so a rule added without a fixture fails the test instead of going silently unwatched. It is deliberately weaker than a per-rule test — it asks only whether the rule still speaks — because that is the failure a per-rule test structurally cannot see: those run in the test JVM and cannot inherit a stale daemon classloader. And it ships with the plugin rather than with the consumer, so a repository cannot adopt the rules without adopting the check that proves they run.
The catalog invariants. Every catalogued rule runs, so clean means clean rather than absent; every rule that runs is catalogued, so no finding arrives without its standard. Both are run by the plugin over the union of platform and local rules rather than being tests a consumer copies — because a copied invariant drifts, which is not hypothetical here.
Proving the gate can fail. Behaviour-preservation was shown by a differential run — same tree, before and after, diffed finding by finding — and the gate itself was verified by introducing three probes, confirming each turned it red, and reverting them. In a project about tools that report zero, a tool that has never been seen failing has not been seen working.
A gap, named. Each rule’s doc citation is an absolute URL into this documentation site, and nothing checks it on either side: the site’s link checker resolved pages without looking at fragments until 2026-09-02, and the plugin has no test that resolves a citation at all. Four of the twenty-five had rotted by the time anyone looked. The site now checks fragments; a plugin-side test that resolves every doc key is the other half and is not yet written.
References
Section titled “References”- PDEV-1790 — the ticket.
kotlin-standards-gradle-plugin— the repository, including the generated rule catalog and the version-specific configuration reference.- Decision Log — the reasoning behind each choice above.
- Automated Checks — the consumer-facing how-to.
- Kotlin Standards Plugin — the durable description of the apparatus.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved