Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

143 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

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 this at PR time by analyzing every class/method reference recorded in the referencing binary's constant pool.

Prior art

API diff tools

There are many tools to inspect binary incompatibility. These diff two versions of one library and report the API changes between them, and they are excellent at that job.

Each brings its own strengths: Revapi models the API use-chain and extends beyond Java to XML and other configuration. japicmp also advises which semantic-versioning part to bump. roseau builds its API model from either source or bytecode with a strong focus on speed and accuracy. And MiMa supports Scala-specific features.

uika diff covers the same ground more narrowly, and any of these is a good choice a consumer can run against the two versions of a dependency to see what changed. By design they answer "what changed in this library", not "which of those changes break my app": they report every API change whether your code, or another artifact on a flattened classpath, actually depends on it. That second question is the one Uika takes up, and it is complementary to these tools rather than a replacement.

Classpath validators

Other tools scan a fully resolved classpath for references that will not link, which is exactly what you want for auditing a whole dependency tree at a point in time. Both are solid at that: Google's Linkage Checker, and Spotify's missinglink.

Because they analyze a single snapshot rather than an upgrade, every run surfaces all pre-existing inconsistencies, including references in code paths that never execute, so using one as a per-PR upgrade gate tends to need a curated exclusion list.

Uika narrows the same analysis to the breakage the upgrade itself introduces.

Where Uika fits

Uika does both halves in one step: diff the changed library old vs new, then resolve each real reference on your classpath the way the JVM links. Only breakage introduced by the upgrade is reported, which keeps a PR gate on Renovate/Dependabot/Scala Steward bumps quiet with no exclusion list. Gradle, sbt, and Maven plugins produce the classpath dumps (neither validator supports sbt), and detection covers visibility narrowing, static <-> instance mismatches, newly-final classes/members, removals, new on a class that became abstract or an interface, and class<->interface flips. Version lag is covered too: an upgraded artifact subclassing a class that a lagging dependency still declares final is reported. It is also a dependency-free static binary: no JVM.

BENCHMARKS.md has measured head-to-head runs against these tools on the same inputs: wall time, peak memory, and what each one reports, including how uika narrows to the references an upgrade actually broke while a snapshot linkage check also surfaces pre-existing, unrelated errors.

Usage

CI gate on dependency-update PRs (the main use case)

Store a baseline (the resolved-classpath dump of develop) as a build artifact on every push. The PR job only resolves its own side.

# --- On push to develop (baseline generation) ---
$ ./gradlew uikaDumpClasspath -PuikaOutput=classpath.json   # store as artifact keyed by SHA

# --- On the PR job (after the normal build, so build outputs exist and
#     anchor the reachability ranking) ---
$ ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/after.json
$ fetch-artifact <merge-base SHA> classpath.json > /tmp/before.json   # CI-specific retrieval
$ ./gradlew uikaResolveClasspath \
      -PuikaInput=/tmp/before.json -PuikaResolveOutput=/tmp/before-local.json
$ ./gradlew uikaUpgradeCheck -PuikaBefore=/tmp/before-local.json -PuikaAfter=/tmp/after.json
# a violation fails the task and blocks the merge. Post the output as a PR comment

uikaResolveClasspath rewrites a baseline recorded on another machine to local paths, and has Gradle itself fetch any missing old-version JARs using this build's repositories and credentials.

Local check before pushing

After bumping libs.versions.toml, verify with resolution only, no compilation:

$ git stash && ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/before.json && git stash pop
$ ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/after.json
$ ./gradlew uikaUpgradeCheck -PuikaBefore=/tmp/before.json -PuikaAfter=/tmp/after.json

Ad-hoc investigation

"What breaks between these two versions, and who dies?" needs only the JAR files. Uika is a static binary and does not need a JVM:

$ uika diff old.jar new.jar
$ uika check --old old.jar --new new.jar --classpath ~/.gradle/caches/.../suspect.jar

Command reference

# List breaking changes between old/new versions of a library
# (removals, access narrowing, static/instance changes, newly-final/abstract classes/members, class<->interface flips)
$ uika diff old.jar new.jar [--json]

# 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
VIOLATION in ktor-io-jvm-2.3.13.jar
  io/ktor/utils/io/jvm/javaio/BlockingAdapter
    -> method removed: kotlinx/coroutines/EventLoopKt.processNextEventInCurrentThread ()J

scanned 372 classes, 1 broken reference(s), 5 unverified (hierarchy escapes 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 resolution (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)
πŸ’‘ 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
   removed by: io.opentelemetry:opentelemetry-sdk-common 1.42.1 -> 1.60.1
   referenced by: io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.42.1
   modules: :app
   -> io/opentelemetry/exporter/sender/okhttp/internal/OkHttpUtil  class removed: io/opentelemetry/sdk/internal/DaemonThreadFactory
   -> io/opentelemetry/exporter/sender/okhttp/internal/OkHttpGrpcSender  class removed: io/opentelemetry/sdk/internal/DaemonThreadFactory

⚠️  not proven reachable (no static path found; may still load via reflection)
πŸ’‘ ...
   -> ...

scanned 168496 classes, 42 broken reference(s) (πŸ’₯ 25 reachable, ⚠️ 17 not proven reachable)

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

Reachability ranking

A changed library often drags in transitive JARs the application never touches, so a run can report violations in code that is present on the classpath but never loaded. When application roots are available (the module classesDirs in a dump, or --app build outputs), uika walks the class-load graph from them and splits the report into two sections: reachable violations (πŸ’₯, likely to break) first, then the ones it could not prove reachable (⚠️). Edges are constant-pool class references, superclass/interface links, class-name-shaped string constants (an over-approximation of Class.forName), and META-INF/services providers.

It never hides a violation: reachability is an over-approximation, so ⚠️ means "no static path from the application reaches this class" (reflection driven purely by external configuration stays invisible), a signal to deprioritize rather than a guarantee. With no application roots (a bare check --classpath ...) there is nothing to rank from, so the report stays a single flat list.

Per-module checking (upgrade-check)

In a multi-module build, each module's resolved classpath is its own JVM classpath: two modules can legitimately resolve different versions of the same coordinate (one service on netty 4.1, a newer one on 4.2), and no process ever mixes them. upgrade-check therefore checks each module against its own resolution, using the per-module classpaths the build-tool dumps already carry. Only modules whose own resolution lost a version are checked (an unchanged module cannot break from the upgrade), modules with identical inputs share one run, and each violation is attributed to the modules whose classpaths exhibit it (modules: in the text report, a modules array in JSON). A module that was renamed or added between the two dumps is checked against the union's before versions instead of being skipped, and a module whose after-side artifact list vanished (a partial build) is skipped with a warning rather than read as "every dependency removed".

This has two correctness effects over checking the flattened union. It removes a false-positive class: a jar used by one module was judged against the newer version another module resolves, so its self-consistent internal references looked broken. And it removes a false-negative class: an upgrade in one module was invisible to the flat diff whenever a sibling module still resolves the old version, because the old version never left the union.

A project-dependency artifact that was never built (its jar path is in the dump but missing on disk) falls back to the producing module's classesDirs from the same dump instead of being skipped. --merged restores the flat union check, which is also the automatic fallback (with a warning) for dumps written by plugins too old to carry per-module artifact lists.

Exit code policy (--fail-on)

check and upgrade-check always print the full report; --fail-on only controls whether the run exits non-zero (so CI fails). It has three values:

  • any (default, strictest): exit 1 if any violation is found.
  • reachable: exit 1 only when a reachable violation exists (πŸ’₯). Violations that are not proven reachable (⚠️) do not fail the run.
  • never: always exit 0, reporting violations as warnings only.

Because reachability is an over-approximation (reflection driven purely by external configuration is invisible), reachable treats a violation whose reachability could not be determined as reachable, consistent with the report's πŸ’₯ grouping. Two cases feed into that: with no application roots nothing is walked, so reachable behaves like any; and when application roots are supplied but none matched a scanned class (the build outputs were not compiled, so the ⚠️ labels have no basis), reachable again falls back to any rather than passing every violation off as unreachable. Errors always exit 2 regardless of --fail-on.

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 Reachability ranking). 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"

owner/member use the exact JVM internal names shown in the report itself (/-separated, $ for nested classes), so an entry can be copy-pasted straight out of a VIOLATION in ... or πŸ’‘ block. 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.

Actionable suggestions

upgrade-check also attributes each break to the two artifacts involved and proposes a fix. Because one version bump usually breaks many references the same way, the report is suggestion-first: each distinct fix (πŸ’‘) is printed once as a header (with the coordinate whose bump removed the symbol, removed by, and the one holding the reference, referenced by), followed by every reference it covers. When the referencing artifact and the removed one share a group (a version skew inside one library family, like OpenTelemetry core vs its incubator), the advice leads with aligning the whole group via its BOM; otherwise it suggests upgrading the referencer or pinning the removed coordinate back. Grouping happens within each reachability section, so a fix that covers both reachable and not-proven-reachable references appears once under πŸ’₯ and once under ⚠️. This needs coordinates, so it appears only for upgrade-check (the dumps carry them), not for a bare check --classpath.

Build-tool plugins

The Gradle, sbt, and Maven plugins all write the same dump format: every module's resolved runtime classpath as coordinate-annotated JSON. 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).

The dumps carry each module's classpath separately, which is what lets upgrade-check check every module against its own resolution. Project dependencies are attributed to their producing module ("project" in the dump), so the CLI can fall back to that module's classesDirs when a project jar was never built. The Gradle dump task also builds what the dump refers to by default (project-dependency jars and each module's own classes); opt out with -PuikaBuildOutputs=false for a resolution-only dump. sbt compiles as a side effect of evaluating the dump task; Maven needs a compile phase in the same invocation for module classes to exist.

Each plugin also provides an upgrade-check task that fetches the uika CLI binary itself, as net.exoego.uika:uika-cli:<version>:<platform>@zip through the build's own dependency resolution: repositories, credentials, and cache are reused, and no separate install step is needed. The CLI version defaults to the plugin's own version, so a single coordinate in the build (which Renovate, Dependabot, or Scala Steward bumps) updates both.

The upgrade-check task fails the build on any violation by default. This maps to the CLI's --fail-on policy (never / reachable / any, default any): use reachable to gate only on violations reachable from your own build outputs, or never to report without ever failing the build. Set it in the build file (shown per tool below), or on the command line when it is not fixed there (-PuikaFailOn=, set uikaFailOn :=, -Duika.failOn=).

Known false positives can be suppressed the same way via --exclude-file: excludeFiles (Gradle task DSL, or -PuikaExcludeFile= for a single file), uikaExcludeFiles (sbt), or <excludeFiles> (Maven).

The plugins also enable the CLI's --jdk-release JDK API layer by default: the build runs on a JVM, so a usable ct.sym is at hand and the target release is derivable. Gradle derives it from the root project's Java toolchain or target compatibility, Maven from maven.compiler.release or maven.compiler.target, sbt from the build JVM, and every tool clamps the value to what the build JVM's ct.sym can serve (its own release is not in it; clamping down errs toward not reporting). Override with jdkRelease (Gradle task DSL, or -PuikaJdkRelease=), uikaJdkRelease (sbt), or <jdkRelease> / -Duika.jdkRelease= (Maven); 0 disables the layer.

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: fail only on reachable violations instead of the default `any`, 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: fail only on reachable violations instead of the default `any`,
// 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: fail only on reachable violations instead of the default `any`, 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

PR gate on GitHub Actions

A typical setup involves:

  1. Dump the base branch and run uikaResolveClasspath to output the resolved dependency.
  2. Dump the PR branch, compile and run uikaResolveClasspath.
  3. Compare the two dumps.

The job checks out the base commit to dump it, so the plugin must already be declared there too, except on the PR that introduces this workflow, whose base branch has no plugin yet. That baseline step is expected to fail there, so it's marked continue-on-error and the final check step is skipped instead of failing the PR.

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; then
            status=0
          else
            status=1
          fi
          git checkout -
          exit $status

      - name: Dump PR classpath
        # The dump builds project jars and module classes by default
        # (-PuikaBuildOutputs=false for a resolution-only dump); the built
        # outputs anchor reachability ranking and per-module checking.
        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
        # a violation fails the job and blocks the merge

For sbt:

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

      - name: Dump PR classpath
        # compile first so the build outputs anchor reachability ranking
        run: sbt compile uikaDumpClasspath && cp target/uika/classpath.json /tmp/after.json

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

For Maven:

      - name: Dump baseline classpath (base branch)
        id: baseline
        continue-on-error: true
        run: |
          git checkout ${{ github.event.pull_request.base.sha }}
          if mvn -q uika:dump-classpath -Duika.output=/tmp/before.json; then
            status=0
          else
            status=1
          fi
          git checkout -
          exit $status

      - name: Dump PR classpath
        # compile first so the build outputs anchor reachability ranking
        run: mvn -q compile uika:dump-classpath -Duika.output=/tmp/after.json

      - name: Check broken references
        if: steps.baseline.outcome == 'success'
        run: mvn uika:upgrade-check -Duika.before=/tmp/before.json -Duika.after=/tmp/after.json

As CLI

Local check before pushing

After bumping libs.versions.toml, verify with resolution only, no compilation:

$ git stash && ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/before.json && git stash pop
$ ./gradlew uikaDumpClasspath -PuikaOutput=/tmp/after.json
$ ./gradlew uikaUpgradeCheck -PuikaBefore=/tmp/before.json -PuikaAfter=/tmp/after.json

Ad-hoc investigation

"What breaks between these two versions, and who dies?" needs only the JAR files. Uika is a static binary and does not need a JVM:

$ uika diff old.jar new.jar
$ uika check --old old.jar --new new.jar --classpath ~/.gradle/caches/.../suspect.jar

Command reference

# List breaking changes between old/new versions of a library
# (removals, access narrowing, static/instance changes, newly-final/abstract classes/members, class<->interface flips)
$ uika diff old.jar new.jar [--json]

# 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
VIOLATION in ktor-io-jvm-2.3.13.jar
  io/ktor/utils/io/jvm/javaio/BlockingAdapter
    -> method removed: kotlinx/coroutines/EventLoopKt.processNextEventInCurrentThread ()J

scanned 372 classes, 1 broken reference(s), 5 unverified (hierarchy escapes 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 resolution (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)
πŸ’‘ 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
   removed by: io.opentelemetry:opentelemetry-sdk-common 1.42.1 -> 1.60.1
   referenced by: io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.42.1
   modules: :app
   -> io/opentelemetry/exporter/sender/okhttp/internal/OkHttpUtil  class removed: io/opentelemetry/sdk/internal/DaemonThreadFactory
   -> io/opentelemetry/exporter/sender/okhttp/internal/OkHttpGrpcSender  class removed: io/opentelemetry/sdk/internal/DaemonThreadFactory

⚠️  not proven reachable (no static path found; may still load via reflection)
πŸ’‘ ...
   -> ...

scanned 168496 classes, 42 broken reference(s) (πŸ’₯ 25 reachable, ⚠️ 17 not proven reachable)

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

Reachability ranking

A changed library often drags in transitive JARs the application never touches, so a run can report violations in code that is present on the classpath but never loaded. When application roots are available (the module classesDirs in a dump, or --app build outputs), uika walks the class-load graph from them and splits the report into two sections: reachable violations (πŸ’₯, likely to break) first, then the ones it could not prove reachable (⚠️). Edges are constant-pool class references, superclass/interface links, class-name-shaped string constants (an over-approximation of Class.forName), and META-INF/services providers.

It never hides a violation: reachability is an over-approximation, so ⚠️ means "no static path from the application reaches this class" (reflection driven purely by external configuration stays invisible), a signal to deprioritize rather than a guarantee. With no application roots (a bare check --classpath ...) there is nothing to rank from, so the report stays a single flat list.

How it works

  1. Parse the old/new JARs into full API indexes with class hierarchy.
  2. Pass 1: stream the consumer classpath, keeping only a class-hierarchy graph (a few dozen bytes per class) and the references whose owner exists in the old index.
  3. Pass 2: re-read just the classes that resolution could actually visit (typically under 0.1% of the total) to obtain their member tables.
  4. Resolve each reference against "new JARs + re-read classes", walking the inheritance hierarchy, and report references that resolved under old but break under new: removals, visibility narrowing, static<->instance changes, writes to newly-final fields, and subclassing/overriding of newly-final classes/methods.

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 reference(s), 16 unverified (hierarchy escapes 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 reference(s)

Development

$ make check   # cargo fmt --check + cargo test + Gradle/sbt/Maven plugin checks
$ make test    # cargo test + Gradle/sbt/Maven plugin tests
$ make build   # cargo build + Gradle/sbt/Maven plugin builds

$ cargo build --release                       # for benchmarks
$ cargo build --release --features memstats   # memory breakdown (counting allocator, slower)

The integration tests replay five real incidents (ktor-io/coroutines, OpenTelemetry, Selenium/Guava, okhttp-digest/OkHttp, Koin) against unmodified JARs from Maven Central, vendored under cli/tests/fixtures/ (see its README for coordinates, checksums, and licensing).

Golden tests pin the full check JSON for those fixture scenarios (cli/tests/golden/), so any detection shift fails cargo test before it ships. After verifying a diff is an intended semantic change, re-bless with UIKA_BLESS=1 cargo test --test golden. The scenario table is single-sourced in cli/tests/scenarios.tsv, shared with the probe harness below.

make probe answer-checks the same scenarios against a real JVM. check --verdicts-json <path> (also available on upgrade-check) streams every reference verdict (ok/unknown/broken) as JSON Lines, and tools/jvm-probe/Probe.java resolves each reference with MethodHandles.Lookup on the new-side and old-side classpaths. A verdict uika calls broken that the JVM links fine fails the run as a false positive; ok/unknown verdicts that fail on the new side but linked on the old side are listed as false-negative candidates for triage. Graph-walk violations (newly final classes/methods and version-lag extends-final, as in the koin and pact scenarios) never enter the verdict stream, so those breaks are covered by the integration tests rather than the probe.

Publishing

Refer 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

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages