Ultra-fast and low-memory LinkageError checker for JVM.
Catches NoSuchMethodError and friends statically, before you ship.
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.
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.
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.
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.
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 commentuikaResolveClasspath 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.
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"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# 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.jarA 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 (Class.forName), and META-INF/services providers.
It never hides a violation: reachability is an over-approximation, so check --classpath ...) there is nothing to rank from, so the report stays a
single flat list.
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.
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 reachable again falls back
to any rather than passing every violation off as unreachable. Errors always
exit 2 regardless of --fail-on.
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.
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 upgrade-check (the
dumps carry them), not for a bare check --classpath.
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.
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// 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<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 overrideA typical setup involves:
- Dump the base branch and run
uikaResolveClasspathto output the resolved dependency. - Dump the PR branch, compile and run
uikaResolveClasspath. - 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 mergeFor 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.jsonAfter 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"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# 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.jarA 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 (Class.forName), and META-INF/services providers.
It never hides a violation: reachability is an over-approximation, so check --classpath ...) there is nothing to rank from, so the report stays a
single flat list.
- Parse the old/new JARs into full API indexes with class hierarchy.
- 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.
- Pass 2: re-read just the classes that resolution could actually visit (typically under 0.1% of the total) to obtain their member tables.
- 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)$ 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.
Refer PUBLISHING.md.
- 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
--classpathreduces) - Multi-release JARs are analyzed at their base classes only
(
META-INF/versions/is ignored) InvokeDynamicbootstrap 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)