CraftBridge.kt exposes 103 @JavascriptInterface methods and Zig serves one.
iOS reached this file's job with 105 unmigrated and is at 13. The ratchet exists now rather than later for the reason the iOS one earned: the second migration is where a name quietly stops matching, and by then nothing remembers what the first one agreed to.
Android has two contracts where iOS has one
The call arrives through JNI, so there are two strings that must agree, not one.
1. The action — @JavascriptInterface fun getDeviceInfo() against the A block of whichever module serves it. Same shape as the iOS scan, anchored on the annotation rather than on fun: CraftBridge.kt is full of private helpers (sendEvent, permission plumbing) that aren't page-callable, which is the same reason the iOS scan is bounded to the dispatcher rather than the whole file.
2. The binding — the name in android_dispatch.natives against the external fun that CraftNative.kt declares.
That second one has no compiler behind it at all. RegisterNatives matches by string at load time: the Zig compiler sees a string literal, the Kotlin compiler sees an external fun it assumes someone will bind, and the two agree right up until one is renamed.
Both directions are checked, and they fail differently:
| what happens | |
|---|---|
| Zig registers a name the class lacks | RegisterNatives refuses the whole batch at load — every action drops to the shim |
| Kotlin declares a name nobody binds | Loads fine; throws UnsatisfiedLinkError the first time a page reaches the feature |
The second is quieter and worse: it survives the app starting.
A third string, also uncompiled
FindClass takes a binary name with slashes (com/craft/runtime/CraftNative); CraftNative.kt declares a package with dots. Two files, two languages, and a prebuilt library that can't find its holder binds nothing at all. The package is derived from the Kotlin and compared against holder_class — and asserted to stay untemplated, since a substitution marker there would make the shipped library app-specific, which is the entire reason the holder is a separate class from CraftBridge.
Verified
| mutation | result |
|---|---|
Zig action typo (getDeviceinfo) | ZigServesAnActionTheSpecDoesNotHave |
| registered native renamed | RegisteredNativeNotDeclared |
holder_class disagrees with the Kotlin package | HolderClassDoesNotMatchPackage |
| ratchet lowered 102 → 101 | MigrationWentBackwards — the count really is 102 |
@JavascriptInterface scan broken | the non-vacuity floor |
The reverse binding direction is covered too (DeclaredNativeNotRegistered), though it can't be mutation-tested from the Zig side alone without editing the Kotlin template.
zig build test:android → 80/80, 11/11 steps.
Next
Tier 0 — clipboardRead/clipboardWrite, log, getMemoryUsage, vibrate, setBadge, openURL. No permissions, no UI, no callbacks; each is a Build static or a single instance call, and the ratchet now makes each one lower a number.