Skip to content

Latest commit

 

History

History
148 lines (113 loc) · 7.19 KB

File metadata and controls

148 lines (113 loc) · 7.19 KB

Cross-module API parity

: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.

Why parity matters

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.

How the check works

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 checkApiParity

In 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 complements apiCheck — keep both

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:

  • apiCheck catches a stale dump — you changed the public API but forgot ./gradlew apiDump, so the committed dump no longer matches the source.
  • checkApiParity catches the two modules drifting apart — both dumps are current, but :sharingan and :sharingan-noop no longer agree.

A green parity check over stale dumps proves nothing, so always keep both gates running.

Why it is NOT a whole-file diff

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.

1. Debug-only UI / platform classes (real module only)

: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-init ContentProvider) and SharinganNotificationReceiver.
  • 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.)

2. The Compose $stable / $stableprop synthetics

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 I line inside each class.
  • Native: top-level …$stableprop values and …$stableprop_getter functions.

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.

3. The klib header line

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.

The contract that MUST stay in lockstep

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.

What to do when checkApiParity fails

  1. 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).
  2. 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.
  3. Regenerate the dumps and commit them:
    ./gradlew apiDump
  4. Re-run ./gradlew checkApiParity to 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.