
Enforces structural acyclicity in source code: compiler-plugin family detecting semantic file cycles, rejecting same-file declaration recursion, enforcing declaration order, and supporting explicit all-participant opt-out annotations.
kotlin-acyclic is a Kotlin compiler-plugin family for enforcing structural acyclicity rules in source code.
For a JVM project, the smallest useful setup is:
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = "acyclic-quickstart"// build.gradle.kts
import one.wabbit.acyclic.gradle.AcyclicDeclarationOrderMode
import one.wabbit.acyclic.gradle.AcyclicEnforcementMode
plugins {
kotlin("jvm") version "2.3.10"
application
id("one.wabbit.acyclic") version "0.1.0"
}
dependencies {
implementation("one.wabbit:kotlin-acyclic:0.1.0")
}
kotlin {
jvmToolchain(21)
}
application {
mainClass = "sample.MainKt"
}
acyclic {
compilationUnits.set(AcyclicEnforcementMode.OPT_IN)
declarations.set(AcyclicEnforcementMode.ENABLED)
declarationOrder.set(AcyclicDeclarationOrderMode.TOP_DOWN)
}Then add src/main/kotlin/sample/Main.kt:
package sample
fun main() {
println(render())
}
fun render(): String = helper()
fun helper(): String = "acyclic"Run it:
./gradlew runExpected output:
acyclic
This example is ordered for TOP_DOWN: earlier declarations depend only on later declarations. In most builds, the Gradle plugin resolves the Kotlin-matched compiler-plugin artifact automatically.
It is built for teams that want compile-time guardrails around declaration recursion, file-to-file dependency cycles, and source-order conventions without relying on lint-only heuristics or import-string analysis.
This repository is pre-1.0 and K2-only.
supportedKotlinVersions in gradle.properties. The current matrix is 2.3.10 and 2.4.0-Beta1.2025.3.This project is trying to be strict without becoming magical.
Kotlin makes it easy to write elegant recursive code and to spread definitions across files. That is usually a strength. In larger codebases, it can also hide structural problems:
kotlin-acyclic makes those rules explicit and compile-time enforced.
| Module | Gradle project | Purpose |
|---|---|---|
library/ |
:kotlin-acyclic |
Public annotation API: @Acyclic, recursion opt-outs, and AcyclicOrder
|
gradle-plugin/ |
:kotlin-acyclic-gradle-plugin |
Gradle integration for one.wabbit.acyclic
|
compiler-plugin/ |
:kotlin-acyclic-plugin |
K2/FIR compiler plugin: semantic dependency collection, cycle detection, and order enforcement |
ij-plugin/ |
:kotlin-acyclic-ij-plugin |
IntelliJ IDEA helper plugin for external compiler-plugin loading |
The project enforces three related rule families.
| Rule family | What it reports | Current scope |
|---|---|---|
| Compilation-unit acyclicity | semantic cycles between Kotlin source files | whole compilation |
| Declaration acyclicity | recursive dependency structure between tracked declarations | same file |
| Declaration order | wrong-direction declaration dependencies under TOP_DOWN or BOTTOM_UP
|
same file |
Current boundary:
These are current product boundaries, not hidden surprises:
The Gradle plugin defaults are conservative:
compilationUnits = OPT_INdeclarations = DISABLEDdeclarationOrder = NONEIn practice:
The effective policy is resolved from broadest scope to narrowest scope:
acyclic {} or direct compiler-plugin options@file:Acyclic and @file:AllowCompilationUnitCycles
@Acyclic, @AllowSelfRecursion, and @AllowMutualRecursion
@Acyclic(order = DEFAULT|NONE|TOP_DOWN|BOTTOM_UP) for per-declaration order policyPractical reading:
@Acyclic(order = DEFAULT) resets one declaration back to the build-level order policy, not the file-level overrideLexical containment and nominal self-reference are not treated as declaration cycles:
package sample
sealed interface Token {
class Word(val text: String) : Token
}
class Box {
fun self(): Box = this
}With declaration checking enabled, same-file mutual recursion is rejected:
package sample
fun a(): Int = b()
fun b(): Int = a()With compilation-unit checking enabled, semantic cross-file cycles are rejected:
// sample/A.kt
package sample
class A(val b: B)// sample/B.kt
package sample
class B(val a: A)With declarationOrder = BOTTOM_UP, later declarations may depend on earlier ones, but not the reverse:
package sample
fun use(): Int = helper()
fun helper(): Int = 1That file is valid under TOP_DOWN and rejected under BOTTOM_UP.
If an edge is already part of a reported declaration cycle, the cycle diagnostic wins and the redundant declaration-order diagnostic for that same edge is suppressed.
Opt-outs are narrow. A cycle is exempt only when every participant opts out:
package sample
import one.wabbit.acyclic.AllowMutualRecursion
@AllowMutualRecursion
fun even(n: Int): Boolean =
if (n == 0) true else odd(n - 1)
@AllowMutualRecursion
fun odd(n: Int): Boolean =
if (n == 0) false else even(n - 1)The same all-participants rule applies at file scope with @file:AllowCompilationUnitCycles.
Most consumers only need the annotations library and the Gradle plugin.
| Module | Gradle project | Coordinates | Role |
|---|---|---|---|
| Annotations library | :kotlin-acyclic |
one.wabbit:kotlin-acyclic |
source-retained annotations and enums used from normal Kotlin code |
| Gradle plugin | :kotlin-acyclic-gradle-plugin |
one.wabbit:kotlin-acyclic-gradle-plugin |
typed Gradle DSL and compiler-plugin wiring |
| Compiler plugin | :kotlin-acyclic-plugin |
one.wabbit:kotlin-acyclic-plugin:<baseVersion>-kotlin-<kotlinVersion> |
Kotlin-line-specific K2/FIR implementation |
| IntelliJ plugin | :kotlin-acyclic-ij-plugin |
local/plugin distribution | enables IDE-side loading of the external compiler plugin for trusted projects |
The compiler plugin is published per Kotlin compiler line, using a version suffix of the form:
one.wabbit:kotlin-acyclic-plugin:<baseVersion>-kotlin-<kotlinVersion>For the current release train, the repository is configured to publish compiler-plugin variants for:
2.3.102.4.0-Beta1The Gradle plugin chooses the matching compiler-plugin artifact automatically. If you integrate the compiler plugin directly, choose the artifact whose -kotlin-<kotlinVersion> suffix matches your compiler.
If you are not using Gradle, wire the compiler plugin directly:
-Xplugin=/path/to/kotlin-acyclic-plugin.jar
-P plugin:one.wabbit.acyclic:compilationUnits=disabled|opt-in|enabled
-P plugin:one.wabbit.acyclic:declarations=disabled|opt-in|enabled
-P plugin:one.wabbit.acyclic:declarationOrder=none|top-down|bottom-up
If source code uses one.wabbit.acyclic.*, the annotations library still needs to be present on the compilation classpath.
Before publication, or when testing locally across repositories, consumers should use both forms of composite-build wiring:
pluginManagement {
includeBuild("../kotlin-acyclic")
}
includeBuild("../kotlin-acyclic")The first resolves the Gradle plugin ID. The second lets Gradle substitute the annotations and compiler-plugin artifacts.
The IntelliJ plugin in this repository does not implement separate IDE-native inspections yet. Its current job is to help the bundled Kotlin IDE plugin load the external compiler plugin registrar for trusted projects that already apply kotlin-acyclic.
IntelliJ only exposes a coarse registry switch here, so enabling support for kotlin-acyclic enables all non-bundled K2 compiler plugins for the current trusted project session, not just this one.
Common commands from the repo root:
./gradlew build
./gradlew projects
./gradlew :kotlin-acyclic:compileKotlinMetadata
./gradlew :kotlin-acyclic-plugin:test
./gradlew :kotlin-acyclic-gradle-plugin:test
./gradlew :kotlin-acyclic-ij-plugin:testTo run the compiler plugin against a specific supported Kotlin line:
./gradlew -PkotlinVersion=2.3.10 :kotlin-acyclic-plugin:test
./gradlew -PkotlinVersion=2.4.0-Beta1 :kotlin-acyclic-plugin:testhttps://wabbit-corp.github.io/kotlin-acyclic/
If you are new to the repository, this order usually works well:
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) for open source use.
Before contributions can be merged, contributors need to agree to the repository CLA.
kotlin-acyclic is a Kotlin compiler-plugin family for enforcing structural acyclicity rules in source code.
For a JVM project, the smallest useful setup is:
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = "acyclic-quickstart"// build.gradle.kts
import one.wabbit.acyclic.gradle.AcyclicDeclarationOrderMode
import one.wabbit.acyclic.gradle.AcyclicEnforcementMode
plugins {
kotlin("jvm") version "2.3.10"
application
id("one.wabbit.acyclic") version "0.1.0"
}
dependencies {
implementation("one.wabbit:kotlin-acyclic:0.1.0")
}
kotlin {
jvmToolchain(21)
}
application {
mainClass = "sample.MainKt"
}
acyclic {
compilationUnits.set(AcyclicEnforcementMode.OPT_IN)
declarations.set(AcyclicEnforcementMode.ENABLED)
declarationOrder.set(AcyclicDeclarationOrderMode.TOP_DOWN)
}Then add src/main/kotlin/sample/Main.kt:
package sample
fun main() {
println(render())
}
fun render(): String = helper()
fun helper(): String = "acyclic"Run it:
./gradlew runExpected output:
acyclic
This example is ordered for TOP_DOWN: earlier declarations depend only on later declarations. In most builds, the Gradle plugin resolves the Kotlin-matched compiler-plugin artifact automatically.
It is built for teams that want compile-time guardrails around declaration recursion, file-to-file dependency cycles, and source-order conventions without relying on lint-only heuristics or import-string analysis.
This repository is pre-1.0 and K2-only.
supportedKotlinVersions in gradle.properties. The current matrix is 2.3.10 and 2.4.0-Beta1.2025.3.This project is trying to be strict without becoming magical.
Kotlin makes it easy to write elegant recursive code and to spread definitions across files. That is usually a strength. In larger codebases, it can also hide structural problems:
kotlin-acyclic makes those rules explicit and compile-time enforced.
| Module | Gradle project | Purpose |
|---|---|---|
library/ |
:kotlin-acyclic |
Public annotation API: @Acyclic, recursion opt-outs, and AcyclicOrder
|
gradle-plugin/ |
:kotlin-acyclic-gradle-plugin |
Gradle integration for one.wabbit.acyclic
|
compiler-plugin/ |
:kotlin-acyclic-plugin |
K2/FIR compiler plugin: semantic dependency collection, cycle detection, and order enforcement |
ij-plugin/ |
:kotlin-acyclic-ij-plugin |
IntelliJ IDEA helper plugin for external compiler-plugin loading |
The project enforces three related rule families.
| Rule family | What it reports | Current scope |
|---|---|---|
| Compilation-unit acyclicity | semantic cycles between Kotlin source files | whole compilation |
| Declaration acyclicity | recursive dependency structure between tracked declarations | same file |
| Declaration order | wrong-direction declaration dependencies under TOP_DOWN or BOTTOM_UP
|
same file |
Current boundary:
These are current product boundaries, not hidden surprises:
The Gradle plugin defaults are conservative:
compilationUnits = OPT_INdeclarations = DISABLEDdeclarationOrder = NONEIn practice:
The effective policy is resolved from broadest scope to narrowest scope:
acyclic {} or direct compiler-plugin options@file:Acyclic and @file:AllowCompilationUnitCycles
@Acyclic, @AllowSelfRecursion, and @AllowMutualRecursion
@Acyclic(order = DEFAULT|NONE|TOP_DOWN|BOTTOM_UP) for per-declaration order policyPractical reading:
@Acyclic(order = DEFAULT) resets one declaration back to the build-level order policy, not the file-level overrideLexical containment and nominal self-reference are not treated as declaration cycles:
package sample
sealed interface Token {
class Word(val text: String) : Token
}
class Box {
fun self(): Box = this
}With declaration checking enabled, same-file mutual recursion is rejected:
package sample
fun a(): Int = b()
fun b(): Int = a()With compilation-unit checking enabled, semantic cross-file cycles are rejected:
// sample/A.kt
package sample
class A(val b: B)// sample/B.kt
package sample
class B(val a: A)With declarationOrder = BOTTOM_UP, later declarations may depend on earlier ones, but not the reverse:
package sample
fun use(): Int = helper()
fun helper(): Int = 1That file is valid under TOP_DOWN and rejected under BOTTOM_UP.
If an edge is already part of a reported declaration cycle, the cycle diagnostic wins and the redundant declaration-order diagnostic for that same edge is suppressed.
Opt-outs are narrow. A cycle is exempt only when every participant opts out:
package sample
import one.wabbit.acyclic.AllowMutualRecursion
@AllowMutualRecursion
fun even(n: Int): Boolean =
if (n == 0) true else odd(n - 1)
@AllowMutualRecursion
fun odd(n: Int): Boolean =
if (n == 0) false else even(n - 1)The same all-participants rule applies at file scope with @file:AllowCompilationUnitCycles.
Most consumers only need the annotations library and the Gradle plugin.
| Module | Gradle project | Coordinates | Role |
|---|---|---|---|
| Annotations library | :kotlin-acyclic |
one.wabbit:kotlin-acyclic |
source-retained annotations and enums used from normal Kotlin code |
| Gradle plugin | :kotlin-acyclic-gradle-plugin |
one.wabbit:kotlin-acyclic-gradle-plugin |
typed Gradle DSL and compiler-plugin wiring |
| Compiler plugin | :kotlin-acyclic-plugin |
one.wabbit:kotlin-acyclic-plugin:<baseVersion>-kotlin-<kotlinVersion> |
Kotlin-line-specific K2/FIR implementation |
| IntelliJ plugin | :kotlin-acyclic-ij-plugin |
local/plugin distribution | enables IDE-side loading of the external compiler plugin for trusted projects |
The compiler plugin is published per Kotlin compiler line, using a version suffix of the form:
one.wabbit:kotlin-acyclic-plugin:<baseVersion>-kotlin-<kotlinVersion>For the current release train, the repository is configured to publish compiler-plugin variants for:
2.3.102.4.0-Beta1The Gradle plugin chooses the matching compiler-plugin artifact automatically. If you integrate the compiler plugin directly, choose the artifact whose -kotlin-<kotlinVersion> suffix matches your compiler.
If you are not using Gradle, wire the compiler plugin directly:
-Xplugin=/path/to/kotlin-acyclic-plugin.jar
-P plugin:one.wabbit.acyclic:compilationUnits=disabled|opt-in|enabled
-P plugin:one.wabbit.acyclic:declarations=disabled|opt-in|enabled
-P plugin:one.wabbit.acyclic:declarationOrder=none|top-down|bottom-up
If source code uses one.wabbit.acyclic.*, the annotations library still needs to be present on the compilation classpath.
Before publication, or when testing locally across repositories, consumers should use both forms of composite-build wiring:
pluginManagement {
includeBuild("../kotlin-acyclic")
}
includeBuild("../kotlin-acyclic")The first resolves the Gradle plugin ID. The second lets Gradle substitute the annotations and compiler-plugin artifacts.
The IntelliJ plugin in this repository does not implement separate IDE-native inspections yet. Its current job is to help the bundled Kotlin IDE plugin load the external compiler plugin registrar for trusted projects that already apply kotlin-acyclic.
IntelliJ only exposes a coarse registry switch here, so enabling support for kotlin-acyclic enables all non-bundled K2 compiler plugins for the current trusted project session, not just this one.
Common commands from the repo root:
./gradlew build
./gradlew projects
./gradlew :kotlin-acyclic:compileKotlinMetadata
./gradlew :kotlin-acyclic-plugin:test
./gradlew :kotlin-acyclic-gradle-plugin:test
./gradlew :kotlin-acyclic-ij-plugin:testTo run the compiler plugin against a specific supported Kotlin line:
./gradlew -PkotlinVersion=2.3.10 :kotlin-acyclic-plugin:test
./gradlew -PkotlinVersion=2.4.0-Beta1 :kotlin-acyclic-plugin:testhttps://wabbit-corp.github.io/kotlin-acyclic/
If you are new to the repository, this order usually works well:
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) for open source use.
Before contributions can be merged, contributors need to agree to the repository CLA.