Skip to content

v1.3.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 11:40
· 57 commits to main since this release

jdx v1.3.0 — jdx diff: public-API diff with binary-compat warnings

Minor release: one new command, and nothing that already shipped changes
behaviour. jdx diff answers the question a dependency upgrade poses — what
changed, and can I still compile and link against it?
— with every difference
named by a rule code an agent can filter on, most breaking first.

A real upgrade, on the surface a consumer compiles against — this one is clean, so
the CI gate passes and the answer is that you can take the upgrade:

$ jdx diff gson-2.11.0.jar gson-2.14.0.jar --fail-on breaking
api diff gson-2.11.0.jar -> gson-2.14.0.jar (public surface)
info  TYPE_ADDED  com.google.gson.internal.GsonTypes
info  TYPE_ADDED  com.google.gson.internal.bind.JsonElementTypeAdapter
info  MEMBER_ADDED  com.google.gson.FieldNamingStrategy#alternateNames(java.lang.reflect.Field)
…
0 breaking · 0 suspicious · 17 informational · 2 types added · 0 types removed
fail-on breaking: no breaking changes (exit 0)

The same two jars on the all surface, where the internal churn a consumer
never sees is also reported — 118 breaking findings, which is the point of the
flag for refactors and mod/mixin work:

$ jdx diff gson-2.11.0.jar gson-2.14.0.jar --visibility all --severity breaking
api diff gson-2.11.0.jar -> gson-2.14.0.jar (all surface)
breaking  TYPE_REMOVED  com.google.gson.Gson$1
breaking  TYPE_REMOVED  com.google.gson.Gson$2
…
118 breaking · 11 suspicious · 101 informational · 32 types added · 29 types removed

Both sides may be a jar, a class directory, a glob, or a Maven coordinate
(--fetch to download; ~/.gradle/caches and ~/.m2 are used without it). No
workspace is needed and no index is built — a diff names its own two artifacts.
Warm, two real jars compare in 230–250 ms.

Install

curl -fsSL https://raw.githubusercontent.com/MohammadMD1383/jdx/main/install-release.sh | bash

Windows users who prefer an Explorer-first bundle: download
jdx-1.3.0-windows.zip from the assets below and run
sh install-release.sh --tarball jdx-1.3.0-windows.zip.
Release installs self-update with jdx upgrade.

Releases are unsigned: expect a first-run warning — Unblock-File on
Windows (PowerShell), xattr -d com.apple.quarantine <install-dir>/jdx on
macOS.

What jdx diff reports

47 rules at three severities (16 breaking, 11 suspicious, 20
informational). The rule id is the CompatRule enum name, so a caller filters
on a code and never on prose — MEMBER_REMOVED, RETURN_TYPE_CHANGED,
STATIC_TO_INSTANCE, ENUM_CONSTANT_REMOVED, KOTLIN_SUSPEND_CHANGED, and so
on. Full set in docs/PROPOSAL.md §7.7.

A member's identity is its name plus the erased parameter list, so a
covariant return is one RETURN_TYPE_CHANGED rather than a removal plus an
addition, and a field's type is part of its identity. Two pairing passes keep
the report free of remove/add avalanches: shape (name + arity) first, then
Kotlin name and Kotlin arity for JVM renames and suspend changes. An
ambiguous bucket is never paired
— with two removals of one shape, guessing
which addition replaced which is worse than reporting both.

The inheritance check refuses to guess. A member gone from its type but
declared by one of the type's new supertypes is MEMBER_MOVED_TO_SUPERTYPE,
not a removal — the caller's invokevirtual still resolves through the chain.
When the walk reaches a supertype the artifact does not hold, absence proves
nothing, so the removal stays MEMBER_REMOVED and the detail names the
supertype it could not read. A diff never downgrades a breaking change on a
maybe. The universal roots (java.lang.Object and friends) are excluded from
that walk, because counting java.lang.Object as "missing from the artifact"
put an "inheritance not checked" caveat on every removal in every jar.

Structure comes from bytecode alone — no sources jar, no decompilation, no
classpath — because a diff has no "flesh" to recover.

Exit codes — one documented exception

A comparison that ran exits 0 whatever it found. The answer is in the
report, not the process status, because "no differences" and "differences that
do not matter to you" are the same outcome and only you can tell them apart.
--fail-on breaking (or any) is the CI gate and the only way a successful
diff exits non-zero:

jdx diff old.jar new.jar --fail-on breaking   # exit 1 when a breaking change is present

The verdict is also in the envelope as result.gate, so a consumer that cannot
read exit codes still gets the answer. This is the single documented exception
to the exit-code table in AGENTS.md §6 / PROPOSAL.md §16; everything else is
unchanged (3 for a bad spec or flag, 5 for an unreadable artifact).

Flags

Flag Meaning
--visibility public|all public (default) is the surface a consumer compiles against; all compares every declared member, for refactors and mod/mixin work.
--severity all|suspicious|breaking Cumulative: suspicious = breaking + suspicious. The per-severity tally always describes the whole comparison, never only what was printed.
--fail-on none|breaking|any Turn the report into a process status.
--limit <int> Maximum findings shown (default 200); the tail this cuts is by construction the least severe.
--include-synthetic Compare bridge/synthetic members too, hidden by default.
--fetch, --repo Maven coordinate resolution, as on every other command.

All four interfaces

CLI, MCP (jdx_diff), the daemon wire, and HTTP (GET /v1/diff?query=<old>&new=<new>)
— the same JdxService.diff call, verified byte-identical between the CLI and
HTTP adapters.

jdx diff is deliberately not forwarded to the daemon, and there is
deliberately no --no-daemon flag: a daemon warm on one workspace's
classpath holds nothing a two-artifact comparison can reuse, and a --fail-on
verdict has no way back over the v1 wire. A flag that does nothing is a lie, so
it is absent rather than inert — jdx --no-daemon diff a b parses,
jdx diff --no-daemon a b does not. The daemon still serves diff.

Fixes that shipped with it

Found by the new test families, not by inspection — each is a case where the
report was confidently wrong:

  • A spurious caveat on every removal. MEMBER_REMOVED carried "supertype
    java.lang.Object is not in new.jar; inheritance not checked" on every
    removal in every jar, because every class extends java.lang.Object. The
    universal roots are now skipped in the inheritance walk, and a private removal
    skips the walk entirely (JLS §8.2).
  • SUPERTYPE_REMOVED java.lang.Object. A class that becomes an interface, or
    that now names a real superclass, drops java.lang.Object from its header —
    reported as a lost supertype, on a change already reported as
    TYPE_KIND_CHANGED or SUPERTYPE_ADDED.
  • FIELD_TYPE_CHANGED printed its ref twice. A field's canonical ref carries
    no type, so the detail read now: pkg.Type#field on both sides of the arrow.
    It now reads int -> long, which is the whole news.
  • ANNOTATION_VALUES_CHANGED kotlin.Metadata on every Kotlin edit. The
    compiler's own bookkeeping changed value on every declaration touched, so
    every Kotlin diff carried a line that is always true, burying the real
    findings. The internal annotations are now excluded; a JetBrains @Nullable is
    still compared, because that one is real declared API.
  • Every degradation warning was printed twice on a self-diff.
  • macOS and Windows could not build the new test fixtures. The Kotlin plugin
    contributes a default src/diffV1/kotlin for source set diffV1; an added
    src/diffv1/kotlin is a different path on Linux and the same directory on a
    case-insensitive filesystem, so every fixture reached the compiler twice
    (CLASSIFIER_REDECLARATION). Fixed with setSrcDirs and source-set-cased
    directory names.
  • Windows could not run the glob tests. * is not a legal character in a
    Windows path, so four tests that built a glob argument with
    Path.resolve("*.jar") threw before their first assertion. A spec is matched,
    never opened — a glob is a perfectly good argument.

Known limitations

  • A public member that is no longer declared but was an override of a
    universal root's method (toString, equals, …) still links through that
    root, and is reported as MEMBER_REMOVED. Resolving it needs the roots' member
    names, which is a table, i.e. a guess — so the case stays conservative and
    documented on the rule.
  • A degradation warning does not yet say which of the two artifacts produced it
    (#64); a multi-release jar
    trips MULTI_RELEASE_VARIANT for the side that carries it.
  • VARARGS_CHANGED is only reachable from hand-modified bytecode: javac gives
    f(String) and f(String...) different descriptors, so that change is
    reported as PARAMETER_TYPE_CHANGED. Pinned by unit tests and listed as
    unreachable-from-javac in the coverage test, so the gap is deliberate rather
    than invisible.
  • Unrelated to diff, and pre-existing in every release so far: a Clikt-level
    usage error — a missing argument, an unknown command, an unknown flag — prints
    the right message but exits 0
    (#65). Service-level exits
    are correct.
  • The core mutation gate cannot currently run in CI
    (#66): the PIT pre-scan
    needs the fixture corpus, which three modules do not hand to the minion JVM, so
    PIT aborts before mutating anything on a clean checkout. Pre-existing since at
    least v1.0.0.

Testing

206 tests and 22 goldens for this feature alone: 73 per-rule examples and 12
property-based families in core (self-identity, total order, determinism, the
truncation law, text ⊆ JSON, hostile names), 19 snapshot tests, 21 renderer
tests, 44 service/dispatch/rule-coverage/golden tests in index, and 37
adapter tests in cli.

Plus a tier-3 javap differential over six real version pairs in the local
corpus, which pins two things nothing else can: the snapshot's member sets equal
javap -p -s exactly, and the differ is neither blind to a javap-visible
change nor noisy on an unchanged class.

core's mutation gate is 84.0% module-wide against a ≥ 80% threshold, with the
new core/diff package at 82.2% — the six remaining gaps in that package were
real unexercised branches (the enum-constant access flags, the ambiguous-bucket
guard's mirror case, field Signature/ConstantValue/annotations, a
non-stripAppliesTo Kotlin view, and each comparator tiebreak), not incidental
equivalent mutants.

New corpus: testfixtures/src/diffV1 and src/diffV2 — two versions of the
same package, real javac and kotlinc output, byte-deterministic across a
clean rebuild. It self-describes through a rule-coverage test rather than
per-type annotations: the property worth pinning belongs to the pair — that
every rule fires against real compiler output. A rule with no fixture fails the
build, and a fixture with no declared expectation fails it too, so neither can
grow silently. The Kotlin rules are proven against real @Metadata there, which
matters because KOTLIN_SUSPEND_CHANGED depends on a reader flag
(stripTrailingContinuation) that nothing else would catch if it stopped being
set.

Docs

docs/PROPOSAL.md §7.7 (the full rule set and the exit-code reasoning),
Appendix B, §16, §21 · docs/COMMANDS.md · docs/TESTING.md §11.2 · README.md
· and the core, index, cli, server and testfixtures module notes.

Full Changelog: v1.2.1...v1.3.0