:sharingan (the real debug library) and :sharingan-noop (the inert release
replacement) must expose a signature-for-signature identical public contract.
This document explains why, how the checkApiParity gate enforces it, and — most
importantly for contributors — why a handful of symbols are deliberately excluded
from the comparison so you don't "fix" a false positive by breaking the contract.
The whole point of the two-module design is the release swap: a consumer
compiles against :sharingan in debug builds and substitutes :sharingan-noop in
release builds (typically debugImplementation / releaseImplementation). For
that swap to be safe, every symbol the consumer can reference in debug must also
exist — with an identical signature — in the noop.
The two modules are compiled independently from separate sources. Nothing but human discipline keeps them in lockstep. A drifted default value, a renamed parameter, or a method added to one module and forgotten in the other does not fail either module's own build. It surfaces only later, as a consumer's release-build compile error — the worst possible place to discover it, on someone else's machine, at release time.
checkApiParity turns that latent, human-dependent invariant into an automated
gate that fails fast in CI on every PR.
Binary Compatibility Validator (BCV, wired up in #11) dumps each module's full public surface to committed files:
| Surface | :sharingan |
:sharingan-noop |
|---|---|---|
| Android / JVM | sharingan/api/sharingan.api |
sharingan-noop/api/sharingan-noop.api |
| Native / iOS | sharingan/api/sharingan.klib.api |
sharingan-noop/api/sharingan-noop.klib.api |
The checkApiParity Gradle task (defined in the root build.gradle.kts) reads
those four committed dumps, reduces each to its consumer-facing contract (by
filtering — see below), and asserts the real and noop contracts are identical for
both surfaces. The comparison is bidirectional: it fails if the noop is
missing a symbol the real module has, and if the noop exposes a symbol the
real module lacks. Both are contract breaks.
It is pure text processing over already-committed files — no compilation, no Kotlin/Native link, no Android or iOS SDK — so it runs in a couple of seconds.
./gradlew checkApiParityIn CI it runs as the api-parity job in
.github/workflows/api-check.yml, on a cheap
ubuntu runner (it needs no macOS host).
checkApiParity compares the two committed dumps against each other; it trusts
they are current. It does not verify that a dump still matches its module's real
source — that is apiCheck's
job (the separate api-check job, wired in #11). The two gates are complementary,
not redundant:
apiCheckcatches a stale dump — you changed the public API but forgot./gradlew apiDump, so the committed dump no longer matches the source.checkApiParitycatches the two modules drifting apart — both dumps are current, but:sharinganand:sharingan-noopno longer agree.
A green parity check over stale dumps proves nothing, so always keep both gates running.
A naïve diff of the committed dumps would fail constantly, because :sharingan
legitimately carries symbols the noop neither has nor should have. The check
filters these out before comparing. If you hit a parity failure, first confirm
it is a real contract change and not one of the categories below.
:sharingan ships an in-app log viewer and the Android plumbing to launch it.
None of it is part of the API a consumer calls directly, so the noop correctly
omits it. Excluded from the JVM comparison:
dev/sharingan/ui/**— the Compose viewer screens (SharinganScreenKt, etc.).dev/sharingan/internal/**—SharinganInitProvider(the auto-initContentProvider) andSharinganNotificationReceiver.dev/sharingan/SharinganActivity— the Android Activity that hosts the viewer.- any
ComposableSingletons$*class — synthetic lambda holders generated by the Compose compiler.
On the native surface the only counterpart is the top-level
dev.sharingan.ui/SharinganScreen composable, which is excluded for the same
reason. (SharinganViewController and presentSharingan are not excluded —
those are the iOS entry points the noop must mirror, and it does.)
The Compose compiler adds stability metadata to every class it sees. Because
Compose ships only in :sharingan, the real dumps carry symbols the noop
never will, even for classes that are otherwise part of the shared contract
(e.g. HttpEvent):
- JVM: a
public static final field $stable Iline inside each class. - Native: top-level
…$stablepropvalues and…$stableprop_getterfunctions.
These are compiler bookkeeping, not API a consumer references, so they are stripped before comparing. This is the subtle one — it lives inside classes that are otherwise shared, so a naïve diff would false-fail on every event type.
Each .klib.api begins with a comment block including
// Library unique name: <io.github.mibrahimdev:sharingan> vs …:sharingan-noop.
That line differs by construction. All // header comments are dropped before
comparing.
After filtering, both modules expose the same 19 JVM contract classes (plus the
native top-level SharinganViewController / presentSharingan functions):
Sharingan, HttpLogger (+Companion), MqttLogger, BleLogger,
SharinganStore (+Companion), SharinganEvent (+DefaultImpls), HttpEvent,
MqttEvent, BleEvent, TimingPhase, BleOperation, MqttDirection,
SharinganExport, SharinganAndroidKt, ktor.SharinganKtorConfig,
ktor.SharinganKtorKt.
Anything a consumer can reach lives here, and any change to it must be mirrored in both modules.
- Read the failure. It lists each diverging symbol, qualified by class, under "MISSING from :sharingan-noop" (real has it, noop doesn't) or "NOT in :sharingan" (noop over-exposes it).
- Decide which module is correct. Usually you changed
:sharingan's public API and forgot to make the same change in:sharingan-noop(or vice versa). Make the matching change to the lagging module's source. - Regenerate the dumps and commit them:
./gradlew apiDump
- Re-run
./gradlew checkApiParityto confirm it passes.
If the failure is genuinely one of the excluded categories above leaking through
(e.g. a new debug-only package), update the filter in the checkApiParity task
and document the new exclusion here — never widen the noop to match a debug-only
symbol.