Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

202 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Uika (Unseen Incompatibility, Kick Away)

Maven Central

Ultra-fast and low-memory LinkageError checker for JVM. Catches NoSuchMethodError and friends statically, before you ship.

The problem to fix

When dependency resolution picks conflicting versions, an API that a library was compiled against can vanish from the runtime classpath and fail at runtime with NoSuchMethodError / NoClassDefFoundError.

With modern practice of using Dependabot, Renovate, or Scala Steward bumping versions constantly, auditing transitive dependencies by hand does not scale.

Uika catches such LinkageErrors at PR time by analyzing every class/method reference recorded in the referencing binary's constant pool.

Detection covers:

  • Class/Method removals
  • Visibility narrowing (public -> protected -> private)
  • Static <-> instance mismatches
  • Newly-final classes/members
  • Methods that became abstract
  • new on a class that became abstract or an interface
  • Class <-> interface flips
  • Subclasses left out of a newly sealed type's permits clause
  • Conflict of default methods from two unrelated interfaces at once

Prior art

API diff tools (Revapi, japicmp, roseau, MiMa) report every API change between two versions of one library. They answer "what changed in this library", not "which of those changes break my app".

Classpath validators (Google's Linkage Checker, Spotify's missinglink) scan one fully resolved snapshot. Every run therefore also surfaces pre-existing inconsistencies, so a per-PR upgrade gate built on one tends to need a curated exclusion list.

Uika does both halves in one step. It diffs the changed library old vs new, then resolves each real reference on your classpath the way the JVM links, and reports only breakage the upgrade itself introduced. That keeps a PR gate on Renovate/Dependabot/Scala Steward bumps quiet with no exclusion list.

BENCHMARKS.md has measured head-to-head runs against these tools on the same inputs (wall time, peak memory, and what each one reports).

Usage

Every recipe below drives the Gradle, sbt, or Maven plugin, so declare it in your build first: see Build-tool plugins.

PR gate on GitHub Actions (the main use case)

For Gradle:

name: dependency binary incompatibility check
on: pull_request

jobs:
  upgrade-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      # ... You may need to setup Java/Gradle/Maven/Sbt here ....

      - name: Dump baseline classpath (base branch)
        id: baseline
        continue-on-error: true
        run: |
          git checkout ${{ github.event.pull_request.base.sha }}
          if ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/before.json -PuikaBuildOutputs=false; then
            status=0
          else
            status=1
          fi
          git checkout -
          exit $status

      - name: Dump PR classpath
        run: ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/after.json

      - name: Check broken references
        if: steps.baseline.outcome == 'success'
        run: ./gradlew uikaUpgradeCheck -PuikaBefore=/tmp/before.json -PuikaAfter=/tmp/after.json

sbt and Maven use the same three steps with different commands.

Step sbt Maven
Baseline dump sbt uikaDumpClasspath && cp target/uika/classpath.json /tmp/before.json mvn -q uika:dump-classpath -Duika.output=/tmp/before.json
PR dump sbt compile uikaDumpClasspath && cp target/uika/classpath.json /tmp/after.json mvn -q compile uika:dump-classpath -Duika.output=/tmp/after.json
Check sbt "uikaUpgradeCheck /tmp/before.json /tmp/after.json" mvn uika:upgrade-check -Duika.before=/tmp/before.json -Duika.after=/tmp/after.json

To keep the base-branch resolution off the PR's critical path, dump the baseline once per push instead and cache it as an artifact keyed by SHA: BASELINE-CACHING.md.

Command reference

# List breaking changes between old/new versions of a library.
# Each line opens with the change kind, which is also what --json puts in its "kind" field:
# jq '.breaking_changes[] | select(.kind == "class_became_final")' selects those lines.
$ uika diff guava-22.0.jar guava-23.0-rc1.jar [--json]
CLASS BECAME FINAL     com/google/common/collect/BoundType
FIELD REMOVED          com/google/common/graph/GraphConstants.EDGE_CONNECTING_NOT_IN_GRAPH Ljava/lang/String;
METHOD ACCESS NARROWED com/google/common/collect/Iterators$ConcatenatedIterator.<init> (Ljava/util/Iterator;)V (public -> package-private)

breaking changes: 93 (classes: 26, methods: 61, fields: 6)

# Find usages of breaking changes across classpath JARs / your build output
# (--old/--new may be repeated to check several changed libraries in one run)
# Exit codes: 0 = clean, 1 = violations found, 2 = error
$ uika check --old kotlinx-coroutines-core-jvm-1.7.1.jar \
             --new kotlinx-coroutines-core-jvm-1.11.0.jar \
             --classpath ktor-io-jvm-2.3.13.jar:other-dep.jar \
             --app build/classes/kotlin/main
checked kotlinx-coroutines-core-jvm-1.7.1.jar -> kotlinx-coroutines-core-jvm-1.11.0.jar against 3 scan targets

--------------------------------------------------------------------------------
💥 reachable from the application (likely to break)
--------------------------------------------------------------------------------

❌ kotlinx.coroutines.EventLoopKt.processNextEventInCurrentThread()
    method removed, throws NoSuchMethodError at first call
    used by 1 class:
        io.ktor.utils.io.jvm.javaio.BlockingAdapter  (ktor-io-jvm-2.3.13.jar)

scanned 372 classes: ❌ 1 broken (of which 💥 1 reachable, ⚠️ 0 not proven reachable), ❓ 5 unverified references (hierarchy escapes the analyzed scope)

# Detect broken references caused by every artifact whose version changed.
# When application roots are known (build outputs in the dump, or --app), violations
# are ranked: reachable first, then the ones no static path reaches.
$ uika upgrade-check --before /tmp/before.json --after /tmp/after.json
dependency changes: 1
    CHANGED io.opentelemetry:opentelemetry-sdk-common 1.42.1 -> 1.60.1

per-module check: 2 of 41 modules changed their resolved versions (39 unchanged)
    :app  scanned 84013 classes, ❌ 42 broken, ❓ 118 unverified
    :worker  scanned 61200 classes, ✅ 0 broken, ❓ 87 unverified

--------------------------------------------------------------------------------
💥 reachable from the application (likely to break)
--------------------------------------------------------------------------------

💡 suggestion: align all io.opentelemetry artifacts to one version (e.g. via the matching BOM); otherwise upgrade the sender or pin opentelemetry-sdk-common to 1.42.1
    affected modules: :app
    why: io.opentelemetry:opentelemetry-sdk-common changed 1.42.1 -> 1.60.1, which breaks io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.42.1:
        io.opentelemetry.sdk.internal.DaemonThreadFactory was removed, but io.opentelemetry.exporter.sender.okhttp.internal.OkHttpGrpcSender still uses it
        io.opentelemetry.sdk.internal.DaemonThreadFactory was removed, but io.opentelemetry.exporter.sender.okhttp.internal.OkHttpUtil still uses it

--------------------------------------------------------------------------------
⚠️  not proven reachable (no static path found; may still load via reflection)
--------------------------------------------------------------------------------

💡 suggestion: ...
    why: ...

scanned 145213 classes: ❌ 42 broken (of which 💥 25 reachable, ⚠️ 17 not proven reachable), ❓ 205 unverified references (hierarchy escapes the analyzed scope)

# Debugging aid: dump the extracted API surface of a JAR
$ uika dump some.jar

Violation tiers and --fail-on

A changed library drags in transitive JARs your application never touches, so not every violation is worth the same attention. Each one lands in a tier, and the report prints them in this order:

Tier Meaning
💥 breaks reachable from your application, or not provably unreachable
💤 latent class is reachable, but no scanned code invokes the affected member
⚠️ unproven no static path from your application reaches the class

check and upgrade-check always print the full report; --fail-on only decides the exit code, as a threshold over exactly that split:

  • any (default, strictest): exit 1 on any violation.
  • reachable: exit 1 only on 💥.
  • never: always exit 0, reporting violations as warnings only.

So what fails CI is exactly what the report shows above the warning sections. Errors always exit 2 regardless of --fail-on.

Reachability (💥 vs ⚠️). When application roots are available (the module classesDirs in a dump, or --app build outputs), uika walks the class-load graph from them and labels what it never reaches ⚠️. The walk is a deliberate over-approximation, so ⚠️ is a signal to deprioritize rather than a guarantee, and reflection driven purely by external configuration stays invisible. Anything not provably unreachable stays 💥.

Without usable roots every violation stays 💥, so reachable behaves like any. That covers a bare check --classpath ... (nothing to walk from) and roots that matched no scanned class (build outputs not compiled, which prints a warning naming the cause).

Invocation evidence (💤). AbstractMethodError is the one break that does not fire when the class loads. A concrete class inheriting an unimplemented abstract method loads, verifies, and instantiates without complaint, and throws only when the missing method is actually called. So for method became abstract uika looks for an invocation of the affected member in the scanned bytecode, and calls the violation latent when there is none. That evidence comes from bytecode rather than from application roots, so 💤 survives both degraded cases above. Like ⚠️ it is a confidence tier and not a proof, since a call through reflection or JNI, or from code outside the scan, stays invisible. --exclude-file can drop the whole category with kind = "method_became_abstract".

Per-module checking (upgrade-check)

Each module gets its own JVM classpath at runtime, and two modules may legitimately resolve different versions of one coordinate (e.g. one service on netty 4.1, a newer one on 4.2). So each module is checked against what it actually resolves, not against a flattened union: modules whose versions did not move are skipped, and every violation names the modules that exhibit it (modules: in the text report, a modules array in JSON). --merged restores the flat union check.

Excluding known false positives (--exclude-file)

Some violations are real breaks in the referenced API but never actually matter at runtime, because the only reference resolves through reflection the tool cannot see (see Violation tiers). commons-logging's LogFactoryImpl is the recurring example: it reflectively scans a String[] of class names at init, so a field like classesToDiscover shows up as removed even though no bytecode reference to it survives.

--fail-on reachable already keeps that kind of violation from failing the build, but it is still printed on every run. --exclude-file <path> (repeatable; rules from every file given are merged) drops specific known false positives from the report entirely, with a required reason so the entry documents itself for whoever reads it next:

# uika-exclude.toml
[[exclude]]
owner = "org/apache/commons/logging/impl/LogFactoryImpl"
member = "classesToDiscover"
reason = "reflectively scanned by LogFactoryImpl at init; never referenced from bytecode"

# owner may end with a single trailing '*' to match a whole package/class prefix;
# member is optional, and when set matches by name only (covers every overload).
[[exclude]]
owner = "org/apache/commons/logging/*"
reason = "commons-logging uses reflection-based class discovery throughout"

# add descriptor to pin one overload, so a real break on a sibling overload of
# the same name is still reported.
[[exclude]]
owner = "lib/C"
member = "m"
descriptor = "()V"
reason = "only the no-arg m() is invoked reflectively"

# kind pins a rule to one violation kind. With an owner, both must match:
# this waives only newly-abstract methods from conscrypt, leaving every other
# kind of conscrypt break — and this kind elsewhere — reported.
[[exclude]]
owner = "org/conscrypt/*"
kind = "method_became_abstract"
reason = "conscrypt adds abstract methods its own code never calls; tracked in DEP-142"

# kind alone applies to every owner. Note that exclusion REMOVES a violation from
# the report, it does not merely stop it failing the build, so use this only for a
# category you have decided not to see at all.
[[exclude]]
kind = "extends_final_class"
reason = "we ship a shaded copy of the lagging artifact, so version-lag finals never link"

owner/member/descriptor use raw JVM internal forms (/-separated owner names, $ for nested classes, <init> for constructors, undecoded descriptors like (Ljava/util/Date;)V), so copy entries from the --json output rather than from the text report's dotted signatures (each violation's reference carries the raw owner, member.name, and member.descriptor).

kind is the violation kind in snake_case, for example class_removed or method_became_abstract; an unknown value is rejected at load with the valid list, while the spaced form the reports print under reason is accepted, as is a kind from before it was split by direction and member kind (class_kind_changed still waives both of the flips that replaced it).

A rule needs an owner, a kind, or both. The summary line reports how many violations were suppressed (N suppressed by --exclude-file), and a rule that matched nothing prints a warning, so stale entries do not go unnoticed as the checked libraries change.

This is for false positives you have actually investigated, not a shortcut around triaging ⚠️ not proven reachable violations wholesale; use --fail-on reachable for that instead.

Build-tool plugins

The Gradle, sbt, and Maven plugins write the same dump format: every module's resolved runtime classpath as coordinate-annotated JSON, kept per module so upgrade-check can check each against its own resolution. Feed two dumps to uika upgrade-check, or one to uika check --classpath-file (more accurate than a hand-assembled classpath, and reduces unverified references).

A dump also refers to build outputs, so the Gradle task builds them by default (-PuikaBuildOutputs=false for a resolution-only dump); sbt compiles as a side effect of the dump task, and Maven needs a compile phase in the same invocation.

The upgrade-check task fetches the CLI itself as net.exoego.uika:uika-cli:<version>:<platform>@zip through the build's own dependency resolution, reusing its repositories, credentials, and cache, so there is no separate install step. The version defaults to the plugin's own, so one coordinate bump updates both.

The settings shown per tool below also have command-line forms: failOn (-PuikaFailOn=, set uikaFailOn :=, -Duika.failOn=) and excludeFiles (-PuikaExcludeFile= for a single file).

--jdk-release needs no setting at all. The build runs on a JVM, so the release is derived from the Gradle toolchain, maven.compiler.release/target, or the sbt build JVM, clamped to what that JVM's ct.sym serves. Override with jdkRelease / uikaJdkRelease / <jdkRelease> (-PuikaJdkRelease=, -Duika.jdkRelease=), or set 0 to disable it.

Gradle (gradle-plugin/) Maven Central

Works with Groovy and Kotlin DSL builds (Gradle 9 / JVM 17+).

// settings.gradle.kts
pluginManagement {
    repositories {
        gradlePluginPortal()
        mavenCentral()
    }
}
// build.gradle.kts
import net.exoego.uika.gradle.UpgradeCheckTask

plugins {
    id("net.exoego.uika") version "VERSION_PLACEHOLDER"
}

// Optional: gate only on reachable violations, and suppress known false positives.
tasks.withType<UpgradeCheckTask>().configureEach {
    failOn.set("reachable")
    excludeFiles.from("uika-exclude.toml")
}
$ ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/after.json
$ ./gradlew uikaUpgradeCheck \
      -PuikaBefore=/tmp/before.json -PuikaAfter=/tmp/after.json   # -PuikaCliVersion=x.y.z to override

sbt (sbt-plugin/) Maven Central

// project/plugins.sbt
addSbtPlugin("net.exoego.uika" % "sbt-uika" % "VERSION_PLACEHOLDER")
// build.sbt — optional: gate only on reachable violations, and suppress known false positives.
ThisBuild / uikaFailOn := "reachable"
ThisBuild / uikaExcludeFiles := Seq(baseDirectory.value / "uika-exclude.toml")
$ sbt uikaDumpClasspath   # writes target/uika/classpath.json (override via the uikaOutput setting)
$ sbt "uikaUpgradeCheck /tmp/before.json /tmp/after.json"   # uikaCliVersion setting to override

Maven (maven-plugin/) Maven Central

<build>
  <plugins>
    <plugin>
      <groupId>net.exoego.uika</groupId>
      <artifactId>uika-maven-plugin</artifactId>
      <version>VERSION_PLACEHOLDER</version>
      <!-- Optional: gate only on reachable violations, and suppress known false positives. -->
      <configuration>
        <failOn>reachable</failOn>
        <excludeFiles>
          <excludeFile>${project.basedir}/uika-exclude.toml</excludeFile>
        </excludeFiles>
      </configuration>
    </plugin>
  </plugins>
</build>
$ mvn uika:dump-classpath -Duika.output=/tmp/classpath.json
$ mvn uika:upgrade-check \
      -Duika.before=/tmp/before.json -Duika.after=/tmp/after.json   # -Duika.cliVersion to override

How it works

  1. Parse the old/new JARs into full API indexes with class hierarchy.
  2. Pass 1 streams the consumer classpath, keeping only a class-hierarchy graph and the references whose owner exists in the old index.
  3. Pass 2 re-reads just the classes resolution could actually visit (typically under 0.1% of the total) for their member tables.
  4. Resolve each reference against "new JARs + re-read classes", walking the inheritance hierarchy, and report the ones that resolved under old but break under new.

Linkage is checked the way the JVM links, against the flattened runtime classpath. Members moved to a superclass, classes relocated to another artifact, and copies bundled inside fat JARs are not false positives. References that escape into unanalyzed classes are counted as "unverified" rather than silently ignored.

Most escapes lead into the JDK. Passing --jdk-release N (on check and upgrade-check) layers the JDK API of release N under the resolution scope, read from the ct.sym file of the JDK named by UIKA_JDK (checked first, authoritative when set), else JAVA_HOME, so those references conclude as OK or broken instead of unverified. N must be older than the installed JDK (its own release is not in ct.sym). The layer sits under both the old and the new side, so gaps in ct.sym cancel out instead of producing false positives from missing stubs. Without the flag nothing changes, and uika still needs no JVM to run.

$ uika check --old guava-22.0.jar --new guava-23.0-rc1.jar \
             --classpath selenium-remote-driver-3.4.0.jar
...
scanned 205 classes: ❌ 2 broken, ❓ 16 unverified references (hierarchy escapes the analyzed scope)

$ uika check --old guava-22.0.jar --new guava-23.0-rc1.jar \
             --classpath selenium-remote-driver-3.4.0.jar --jdk-release 17
...
scanned 205 classes: ❌ 2 broken

Checking a JDK upgrade

--jdk-release-old N --jdk-release-new M makes the JDK upgrade itself the compared pair, so --old and --new become optional. A JDK API your classpath still references and release M dropped is then reported like any other removal.

$ uika check --jdk-release-old 11 --jdk-release-new 17 --classpath app.jar
checked JDK 11 -> JDK 17 against 1 scan target

❌ java.rmi.activation.ActivationGroup
    class removed, throws NoClassDefFoundError at first use
    used by 1 class:
        UsesRemoved  (app.jar)

Releases below the installed JDK come from its ct.sym; the installed JDK's own release comes from its jmods/, which ct.sym never carries. Checking an upgrade to the JDK you now run therefore needs only that one JDK. Sealing changes are invisible here, because ct.sym stubs do not carry PermittedSubclasses, and reporting them from the jmods side alone would be a false positive.

From the build-tool plugins this needs no flag. The classpath dump records the release of the JVM that wrote it, so bumping your toolchain and re-running the dump is enough — upgrade-check sees the two dumps disagree and checks the JDK move alongside the dependency moves, in one report.

$ uika upgrade-check --before before.json --after after.json
dependency changes: none

per-module check: 0 of 1 modules changed their resolved versions (1 unchanged)
    JDK 11 -> 17  scanned 2 classes, ❌ 1 broken, 0 unverified
...
        UsesRemoved  (app.jar) [JDK 11 -> 17]

Dumps written before the plugins recorded the release carry no value, and a missing value on either side is never read as a JDK move.

Development

make check runs fmt, clippy, and the Rust and plugin test suites. CONTRIBUTING.md covers the rest, including the vendored real-incident fixtures, the golden-bless workflow, and the JVM probe harness. Releases are described in PUBLISHING.md.

Known limitations (PoC)

  • References whose hierarchy escapes into unanalyzed classes are conservatively treated as OK (reported only as an "unverified" count, which passing the complete runtime classpath via --classpath reduces)
  • Multi-release JARs are analyzed at their base classes only (META-INF/versions/ is ignored)
  • InvokeDynamic bootstrap synthetic names are excluded
  • A constant-pool reference does not guarantee the code path executes (optional integrations guarded by try/catch may be reported yet never run)

About

Ultra-fast and low-memory linkage checker for JVM. Catch NoSuchMethodError before you ship

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages