Skip to content

iamjosephmj/kdrift

Repository files navigation

kdrift

A Gradle plugin that detects Kotlin metadata version drift in your dependencies — the kind of mismatch that compiles fine but can cause runtime incompatibilities.

The problem

Every Kotlin class compiled to JVM bytecode carries a @kotlin.Metadata annotation with a version field (mv). When a dependency was compiled with a significantly older Kotlin version than your project uses, this drift is a compatibility risk that often escapes compile-time detection. The result can be NoSuchMethodError, IncompatibleClassChangeError, or other failures that only surface at runtime.

This is especially common when:

  • You upgrade to Kotlin 2.x but a transitive dependency was compiled with Kotlin 1.5
  • A vendor SDK bundles an old version of Ktor or another Kotlin library inside a fat JAR
  • An old library hasn't been updated in years and you pull it in through a transitive chain
  • You use Kotlin Multiplatform and dependencies come from different Kotlin versions

What kdrift does and does not prove

What kdrift does:

  • Detects Kotlin metadata version drift in dependency artifacts
  • Drift is a strong risk signal, especially in shaded or vendor-controlled artifacts
  • Findings should be treated as investigation signals and compatibility risks

What kdrift does not prove:

  • Not every finding will cause a runtime failure — metadata version is a proxy for compiler version, not an exact match
  • False positives are possible (e.g., stable internal APIs that work fine across Kotlin versions)
  • kdrift does not analyze bytecode compatibility — it detects metadata version deltas
  • Package attribution uses top-level packages (first two path segments) which can be imprecise

Why existing tools miss this:

  • Gradle dependency resolution sees Maven coordinates, not classes shaded inside vendor artifacts
  • Version resolution strategies cannot upgrade classes baked into fat/shaded JARs
  • Standard dependency tools do not recursively inspect Kotlin metadata inside nested archives

Real-world example

After upgrading to Kotlin 2.2.0, a vendor SDK starts failing with a cryptic error code. No stack trace, no useful diagnostic. After days of investigation and decompiling the SDK, you discover the root cause: the SDK internally bundles a popular Kotlin networking library compiled against Kotlin 1.8.x, carrying metadata that represents a compatibility risk against the Kotlin 2.2.0 project.

kdrift surfaces this at build time. It scans inside fat/shaded JARs, finds the bundled classes, and tells you exactly which internal package drifted:

kdrift: [WARN] vendor-sdk-1.0.jar — compiled with Kotlin 1.8.0, project: 2.2.0
kdrift:   ↳ io.ktor: Kotlin 1.8.0

No decompilation needed. No days of debugging. kdrift surfaces the drift the moment you bump your Kotlin version, giving you an investigation starting point instead of a mystery.

Setup

Kotlin DSL

// build.gradle.kts
plugins {
    id("io.github.iamjosephmj.kdrift") version "0.2.0"
}

Groovy DSL

// build.gradle
plugins {
    id 'io.github.iamjosephmj.kdrift' version '0.2.0'
}

Usage

Run manually

./gradlew kdriftCheck

Automatic

When the Kotlin JVM or Kotlin Android plugin is applied, kdriftCheck automatically runs as part of ./gradlew check. No extra wiring needed.

Sample output

kdrift: [NEW] [WARN] vendor-sdk-1.0.jar — compiled with Kotlin 1.8.0, project: 2.2.0
kdrift:   ↳ io.ktor: Kotlin 1.8.0 (23 classes)
kdrift:   💡 Ktor is a Kotlin-first framework tightly coupled to the Kotlin compiler version.
kdrift:   💡 Check for a newer version of the dependency.
kdrift: [NEW] [ERROR] legacy-utils-0.9.jar — compiled with Kotlin 1.3.72, project: 2.2.0
kdrift: Full report at /path/to/build/reports/kdrift/report.txt
kdrift: JSON report at /path/to/build/reports/kdrift/report.json

Two report files are generated in build/reports/kdrift/:

  • report.txt — human-readable text report
  • report.json — machine-readable JSON for CI (see CI Integration)

Text report example:

=== kdrift Report ===
Project Kotlin version: 2.2.0
Scan date: 2026-03-07

Found 2 dependencies with potential incompatibilities:

[ERROR] legacy-utils-0.9.jar
       Compiled with: Kotlin 1.3.72
       Project:       Kotlin 2.2.0
       Classes:       47
       Hints:
         - Check for a newer version of the dependency.

[WARN]  vendor-sdk-1.0.jar
       Compiled with: Kotlin 1.8.0
       Project:       Kotlin 2.2.0
       Classes:       150
       Package breakdown:
         - io.ktor: Kotlin 1.8.0 (23 classes)
           Samples: io.ktor.client.HttpClient, io.ktor.client.engine.OkHttpEngine
         - com.vendor: Kotlin 2.0.0 (127 classes)
           Samples: com.vendor.sdk.Auth, com.vendor.sdk.Config
       Hints:
         - This artifact contains packages compiled with different Kotlin versions, indicating a fat/shaded JAR with bundled dependencies.
         - Ktor is a Kotlin-first framework tightly coupled to the Kotlin compiler version.
         - Check for a newer version of the dependency.

The package breakdown appears when a single artifact contains classes from different packages compiled with different Kotlin versions — the telltale sign of a fat/shaded JAR bundling an old dependency. It tells you exactly which internal library is the problem, including class counts and sample class names per package.

When a baseline is active, findings are labeled [NEW], [WORSENED], or [BASELINE] to distinguish new issues from accepted ones.

What gets scanned

kdrift performs a deep recursive scan of every dependency artifact:

Structure Scanned?
JAR → .class files Yes
JAR → nested JAR → .class files Yes (recursive)
JAR → nested JAR → nested JAR → ... Yes (any depth)
AAR → embedded JAR → .class files Yes
AAR → embedded JAR → nested JAR → .class files Yes

This means kdrift catches version drift in:

  • Regular dependencies (direct and transitive)
  • Fat/shaded JARs that bundle dependencies inline (classes repackaged into the JAR)
  • Nested JARs inside fat JARs (e.g., Spring Boot executable JARs with BOOT-INF/lib/*.jar)
  • Vendor SDKs that bundle old Kotlin libraries internally
  • Android AARs with embedded JARs

Configuration

kdrift {
    // Policy mode controls when the build fails.
    // ADVISORY (default): never fails, only reports
    // FAIL_ON_ERROR: fails on ERROR severity
    // FAIL_ON_WARNING: fails on WARNING or ERROR
    policyMode.set("FAIL_ON_ERROR")

    // Legacy option — equivalent to policyMode = FAIL_ON_ERROR.
    // Use policyMode instead for new projects.
    failOnIncompatible.set(true)

    // Major version gap to start flagging.
    // Default: 1 (flags when dependency is 1.x and project is 2.x)
    // Gap == threshold  -> WARNING
    // Gap >  threshold  -> ERROR
    majorVersionGapThreshold.set(1)

    // Minor version gap to flag within the same major version.
    // Default: not set (disabled)
    // Useful for catching 1.5 vs 1.9 drift where metadata formats changed.
    minorVersionGapThreshold.set(3)

    // Dependencies to skip. Use exact artifact names or group:artifact coordinates.
    ignoredDependencies.set(listOf(
        "io.ktor:ktor-client-core",
        "legacy-internal-sdk-1.0"
    ))

    // Baseline file. When present, kdriftCheck only fails on new or worsened findings.
    // Generate with: ./gradlew kdriftBaseline
    baselineFile.set(file("kdrift-baseline.json"))

    // Suppressions file. Suppressed artifacts are logged but do not cause failures.
    suppressionsFile.set(file("kdrift-suppressions.json"))

    // Override which Gradle configurations to scan.
    // Default: auto-detects (releaseRuntimeClasspath, runtimeClasspath, or debugRuntimeClasspath)
    configurations.set(listOf("runtimeClasspath"))

    // Override the project Kotlin version if auto-detection doesn't work.
    // Normally detected from the Kotlin plugin automatically.
    projectKotlinVersion.set("2.2.0")
}

Configuration reference

Property Type Default Description
policyMode String "ADVISORY" When to fail: ADVISORY, FAIL_ON_ERROR, or FAIL_ON_WARNING
failOnIncompatible Boolean false Legacy: maps to FAIL_ON_ERROR when true
majorVersionGapThreshold Int 1 Major version gap that triggers warnings/errors
minorVersionGapThreshold Int not set Minor version gap threshold (disabled by default)
ignoredDependencies List<String> [] Artifact names or group:artifact coordinates to skip
baselineFile File not set Path to baseline JSON (enables incremental adoption)
suppressionsFile File not set Path to suppressions JSON (skip specific artifacts with reasons)
configurations List<String> auto-detect Gradle configurations to scan
projectKotlinVersion String auto-detect Your project's Kotlin version

Baseline support

Baselines let teams adopt kdrift gradually without blocking builds on existing drift.

# Generate a baseline from current findings
./gradlew kdriftBaseline

This creates kdrift-baseline.json in your project root. Once a baseline exists and is configured via baselineFile, kdriftCheck will:

  • Label existing findings as [BASELINE] and skip them for failure decisions
  • Fail only on [NEW] findings (artifacts not in the baseline)
  • Fail on [WORSENED] findings (severity increased since the baseline was captured)

Suppressions

For artifacts where drift is known and accepted, use a suppressions file:

{
  "suppressions": [
    {
      "artifact": "vendor-sdk-1.0.jar",
      "reason": "Vendor confirmed no runtime impact; awaiting SDK update Q3 2026"
    }
  ]
}

Suppressed artifacts are skipped during scanning but logged at info level with their reason. Run with --info to see suppression messages.

CI Integration

JSON report

Every run generates a machine-readable JSON report at build/reports/kdrift/report.json. No extra configuration needed.

{
  "projectKotlinVersion": "2.2.0",
  "scanDate": "2026-03-07",
  "totalDependenciesScanned": 48,
  "incompatibleCount": 2,
  "errorCount": 1,
  "warningCount": 1,
  "dependencies": [
    {
      "artifact": "vendor-sdk-1.0.jar",
      "compiledWithKotlin": "1.8.0",
      "severity": "WARNING",
      "classCount": 150,
      "packageBreakdown": {
        "io.ktor": "1.8.0",
        "com.vendor": "2.0.0"
      },
      "sampleClasses": {
        "io.ktor": ["io.ktor.client.HttpClient", "io.ktor.client.engine.OkHttpEngine"],
        "com.vendor": ["com.vendor.sdk.Auth", "com.vendor.sdk.Config"]
      },
      "packageClassCounts": {
        "io.ktor": 23,
        "com.vendor": 127
      },
      "nestedPaths": ["vendor-sdk-1.0.jar!/libs/ktor-client.jar"],
      "oldestClassVersion": "1.8.0",
      "newestClassVersion": "2.0.0",
      "hints": [
        "Classes found inside nested archives — this dependency bundles other libraries internally.",
        "Ktor is a Kotlin-first framework tightly coupled to the Kotlin compiler version.",
        "Check for a newer version of the dependency."
      ]
    }
  ]
}

Key JSON fields:

  • packageBreakdown — top-level packages mapped to their oldest Kotlin metadata version, pinpointing which bundled library drifted
  • sampleClasses — up to 3 representative class names per package for concrete evidence
  • packageClassCounts — number of Kotlin classes per package
  • nestedPaths — archive paths where drifted classes were found (e.g., sdk.jar!/libs/inner.jar)
  • oldestClassVersion / newestClassVersion — version range across all packages
  • hints — dynamically generated remediation hints based on structural analysis and known package families

You can consume this in CI to post PR comments, feed dashboards, or gate merges:

# GitHub Actions example — fail if any errors
./gradlew kdriftCheck
ERRORS=$(jq '.errorCount' build/reports/kdrift/report.json)
if [ "$ERRORS" -gt 0 ]; then
  echo "::error::kdrift found $ERRORS dependencies with incompatible Kotlin metadata"
  jq -r '.dependencies[] | select(.severity == "ERROR") | "::error::\(.artifact) compiled with Kotlin \(.compiledWithKotlin)"' build/reports/kdrift/report.json
  exit 1
fi

onResult callback

For programmatic access directly in your build script, use the onResult callback. The receiver is a KDriftResult — use this to access properties (Gradle Action receiver style):

kdrift {
    onResult {
        // 'this' is a KDriftResult
        // Write JSON to a custom location
        file("build/kdrift-ci.json").writeText(toJson())

        // Or use the structured data directly
        if (errorCount > 0) {
            dependencies.filter { it.severity == "ERROR" }.forEach {
                println("BLOCKED: ${it.artifact} uses Kotlin ${it.compiledWithKotlin}")
                it.packageBreakdown.forEach { (pkg, ver) ->
                    println("$pkg: $ver")
                }
            }
        }
    }
}

The KDriftResult object contains:

Field Type Description
projectKotlinVersion String Your project's Kotlin version
scanDate String ISO date of the scan
totalDependenciesScanned Int Total Kotlin dependencies scanned
incompatibleCount Int Number of flagged dependencies
errorCount Int Number of ERROR-level issues
warningCount Int Number of WARNING-level issues
dependencies List See dependency entry fields below

Each dependency entry contains:

Field Type Description
artifact String Artifact file name
compiledWithKotlin String Oldest Kotlin metadata version found
severity String "WARNING" or "ERROR"
classCount Int Number of Kotlin classes in the artifact
packageBreakdown Map<String, String> Top-level packages with their oldest version
sampleClasses Map<String, List<String>> Up to 3 representative class names per package
packageClassCounts Map<String, Int> Number of classes per package
nestedPaths List<String> Archive paths where drifted classes were found
oldestClassVersion String? Oldest version across all packages
newestClassVersion String? Newest version across all packages
hints List<String> Remediation hints for this finding

Call toJson() on the result to get the JSON string shown above.

How it works

  1. Resolves the specified (or auto-detected) Gradle configuration to get all dependency JARs and AARs
  2. Skips suppressed artifacts (if a suppressions file is configured)
  3. For each JAR, reads every .class file using ASM and looks for the @kotlin.Metadata annotation
  4. Recursively scans nested JARs inside fat/shaded JARs (any depth), tracking the full archive path (e.g., sdk.jar!/libs/inner.jar!/com/example/Foo.class)
  5. For AARs, extracts embedded JARs and scans them the same way
  6. Extracts the mv (metadata version) array — this tells you which Kotlin version compiled that class
  7. Groups classes by top-level package, collecting the oldest version, class count, and up to 3 sample class names per package
  8. Compares the oldest metadata version found in each dependency against your project's Kotlin version
  9. Reports WARNING or ERROR based on how far the versions have drifted
  10. Generates remediation hints dynamically based on structural signals (nesting, mixed versions, version gap) and known package families (Ktor, Compose, coroutines, stdlib, etc.)
  11. If a baseline exists, classifies findings as [NEW], [WORSENED], or [BASELINE] — only new or worsened findings trigger build failures

Severity levels

Level Meaning Example (threshold=1)
OK Same major version, within tolerance Project 2.2, dep 2.0
WARNING Gap equals the threshold Project 2.0, dep 1.9
ERROR Gap exceeds the threshold Project 3.0, dep 1.5

When minorVersionGapThreshold is set, the same WARNING/ERROR logic applies to minor versions within the same major.

Requirements

  • Gradle 7.0+
  • Works with Kotlin JVM and Kotlin Android projects
  • No runtime dependencies added to your project — kdrift only runs at build time

License

Apache License 2.0

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages